mateclaw/mateclaw-server/src/main/java/vip/mate/approval/ResolveOutcome.java
matevip 2f93d53737 refactor(approval): unify state machine across DB/metadata/memory
Foundation for the ghost-approval root-cause fix.

Adds ResolveOutcome / MetadataDecision; rewrites ApprovalWorkflowService so
every resolve / consume / timeout / supersede transitions through one
two-phase contract: snapshot → DB UPDATE conditional on status=PENDING →
metadata reconciliation → afterCommit memory mutation. ChatController,
ChannelMessageRouter, and ApprovalController all switch to the workflow;
ApprovalService.resolve / resolveAndConsume / consumeApproved /
cancelStalePending / denyAllByConversation are physically removed so
DB-bypass is no longer reachable at compile time.

Specific fixes:
- recoverFromDb preserves DB pendingId + createdAt (was generating fresh
  random ids, breaking every later DB sync)
- effectiveExpireAt = expireAt ?? createdAt + PENDING_TTL: legacy rows
  with NULL expireAt no longer resurrect as live PENDING after restart
- markPendingApprovalsResolved flips pendingApproval.status + currentPhase
  + MessageEntity.status atomically (was only flipping the first field;
  message.status uses existing completed/stopped, not approved/denied,
  to stay within the frontend Message.status union)
- GC scheduler moves to ApprovalWorkflowService; timeouts and overflow
  evictions now sync DB + metadata + memory through markTimeout
- DB UPDATE rows=0 returns alreadyResolved (concurrent-resolve safe);
  exception propagates so @Transactional rolls back; memory stays untouched
- expireRecoveredRow gates metadata write on DB success (was writing
  metadata even when DB update failed, producing the worst-case ghost)
- Mockito JDK 21 agent attach fixed via maven-dependency-plugin properties
  + surefire argLine (no more flaky self-attach across machines)

Tests: 34 new across 4 classes (recovery, resolve, GC, metadata sync).
Full suite: 788 / 788.
2026-04-27 19:30:52 +08:00

80 lines
3.3 KiB
Java

package vip.mate.approval;
/**
* Result of an {@link ApprovalWorkflowService} state-change operation.
* <p>
* Returned by {@code resolve}, {@code resolveAndConsume}, {@code consumeApproved},
* {@code cancelStalePending}, and (PR 3) {@code denyAllByConversation} so the caller can:
* <ul>
* <li>broadcast a {@code tool_approval_resolved} SSE event AFTER the DB transaction
* commits — SSE is not a rollback-capable resource and must not live inside the
* persistence transaction (RFC-067 §4.2);</li>
* <li>distinguish "did anything happen" from "nothing to do, idempotent return"
* so noisy log spam / repeated UI events stay suppressed when two paths race
* to resolve the same approval;</li>
* <li>reach the consumed payload (tool call JSON) for replay without going back to
* the memory map a second time.</li>
* </ul>
*
* @param pendingId the approval that was operated on (always populated)
* @param conversationId conversation owning the approval (null on alreadyResolved
* when only the id was given)
* @param toolName tool that was awaiting approval (null on alreadyResolved)
* @param decision one of: {@code approved}, {@code denied}, {@code consumed},
* {@code superseded}, {@code timeout}, {@code already_resolved}
* @param consumedSnapshot the in-memory record at the moment of consume; non-null
* only when {@code decision == "consumed"} (used by replay)
* @param dbSynced {@code true} iff the DB row's status flipped successfully
* in this call (false on already_resolved or DB failure)
* @param messagesRewritten how many {@code mate_message} rows had their
* {@code metadata.pendingApproval.status} reconciled
*/
public record ResolveOutcome(
String pendingId,
String conversationId,
String toolName,
String decision,
PendingApproval consumedSnapshot,
boolean dbSynced,
int messagesRewritten
) {
public static ResolveOutcome alreadyResolved(String pendingId) {
return new ResolveOutcome(pendingId, null, null, "already_resolved", null, false, 0);
}
public static ResolveOutcome resolved(PendingApproval snapshot, String decision,
boolean dbSynced, int messagesRewritten) {
return new ResolveOutcome(
snapshot.getPendingId(),
snapshot.getConversationId(),
snapshot.getToolName(),
decision,
null,
dbSynced,
messagesRewritten
);
}
public static ResolveOutcome consumed(PendingApproval snapshot,
boolean dbSynced, int messagesRewritten) {
return new ResolveOutcome(
snapshot.getPendingId(),
snapshot.getConversationId(),
snapshot.getToolName(),
"consumed",
snapshot,
dbSynced,
messagesRewritten
);
}
public boolean isAlreadyResolved() {
return "already_resolved".equals(decision);
}
public boolean isConsumed() {
return "consumed".equals(decision);
}
}