mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(goal): persistent cross-turn goal with self-evaluation scaffolding
This commit is contained in:
parent
51e6542a5a
commit
ac6c8a18e3
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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() {}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package vip.mate.goal.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
|
||||
/**
|
||||
* Persistent goal lifecycle states.
|
||||
*
|
||||
* <p>State machine:
|
||||
* <pre>
|
||||
* create -> active
|
||||
* active <-> paused
|
||||
* active -> { 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
@ -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;
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user