feat(goal): persistent cross-turn goal with self-evaluation scaffolding

This commit is contained in:
matevip 2026-05-21 14:43:01 +08:00
parent 51e6542a5a
commit ac6c8a18e3
18 changed files with 1989 additions and 0 deletions

View File

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

View File

@ -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.
*
* <p>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.
*
* <p>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<GoalEntity> 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<GoalEntity> 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<GoalEntity> 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<List<GoalEventEntity>> 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<GoalEntity>> 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<GoalEntity> 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<GoalEntity> 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<GoalEntity> 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<GoalEntity> 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<GoalEntity> addCriterion(@PathVariable Long id,
@RequestBody Map<String, String> 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);
}
}
}

View File

@ -0,0 +1,33 @@
package vip.mate.goal.model;
import lombok.Data;
/**
* Request body for {@code POST /api/v1/goals}.
*
* <p>Only {@code conversationId}, {@code agentId}, {@code workspaceId} and
* {@code title} are mandatory. Budgets default to the values in
* {@link vip.mate.goal.config.GoalProperties}.
*
* <p>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;
}

View File

@ -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.
*
* <p>Lifecycle and DB uniqueness are documented in the V120 migration and
* the {@link GoalStatus} enum. Concurrency safety relies on:
* <ul>
* <li>The DB-level unique index on the active-state subset (H2 predicate
* index / MySQL generated column).</li>
* <li>The {@link #version} column updated via explicit
* {@code WHERE version = ?} in the service layer's update wrappers.
* No global OptimisticLockerInnerInterceptor is wired, so the
* service must opt in per-write.</li>
* </ul>
*
* <p>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;
}
}

View File

@ -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}.
*
* <p>Defined in PR1 so the service-layer signature is stable; the actual
* evaluator implementation lands in PR2.
*
* <p>{@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).
*
* <p>{@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<String, Object> toMap() {
Map<String, Object> 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;
}
}

View File

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

View File

@ -0,0 +1,41 @@
package vip.mate.goal.model;
/**
* String constants for {@link GoalEventEntity#getEventType()}.
*
* <p>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() {}
}

View File

@ -0,0 +1,47 @@
package vip.mate.goal.model;
import com.baomidou.mybatisplus.annotation.EnumValue;
/**
* Persistent goal lifecycle states.
*
* <p>State machine:
* <pre>
* create -&gt; active
* active &lt;-&gt; paused
* active -&gt; { completed | abandoned | exhausted } (terminal states)
* </pre>
*
* <p>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;
}
}

View File

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

View File

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

View File

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

View File

@ -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).
*
* <p>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<GoalEntity> 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<GoalEventEntity> 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);
}

View File

@ -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:
* <ol>
* <li>Service-level pre-check + DB-level unique index for "at most one
* active goal per conversation" — see V120 migration.</li>
* <li>Per-write optimistic lock via {@code WHERE version=?} on
* state-mutating updates; retried up to 3 times on contention.</li>
* <li>{@code @Transactional} on every write so the goal row update and
* the matching event-log insert succeed or fail together.</li>
* </ol>
*/
@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<GoalEntity>()
.eq(GoalEntity::getConversationId, conversationId)
.eq(GoalEntity::getStatus, GoalStatus.ACTIVE)
.last("LIMIT 1"));
}
@Override
public List<GoalEntity> list(String status, String username, int limit) {
LambdaQueryWrapper<GoalEntity> w = new LambdaQueryWrapper<GoalEntity>()
.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<GoalEntity> 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<GoalEventEntity> listEvents(Long goalId, int limit) {
return eventMapper.selectList(new LambdaQueryWrapper<GoalEventEntity>()
.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<GoalEntity> 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<GoalEntity> 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<String, Object> 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<GoalEntity> w = baseLockedUpdate(g)
.set(GoalEntity::getStatus, GoalStatus.EXHAUSTED);
bumpVersionAndTime(w);
retryOptimistic(() -> goalMapper.update(null, w), "markExhausted");
Map<String, Object> 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<GoalEntity> 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<String, Object> 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<GoalEntity> 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<GoalEntity> 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<GoalEntity> baseLockedUpdate(GoalEntity g) {
return new LambdaUpdateWrapper<GoalEntity>()
.eq(GoalEntity::getId, g.getId())
.eq(GoalEntity::getVersion, g.getVersion())
.eq(GoalEntity::getDeleted, 0);
}
private static void bumpVersionAndTime(LambdaUpdateWrapper<GoalEntity> 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<GoalEntity> 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<String, Object> 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<String, Object> 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<String, Object> 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());
}
}

View File

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

View File

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

View File

@ -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:
*
* <ol>
* <li>{@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.</li>
* <li>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.</li>
* </ol>
*
* <p>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());
}
}

View File

@ -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}.
*
* <p>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<GoalEntity> 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<GoalEntity> 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");
}
}

View File

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