feat(execution): add persistent execution evidence in observe mode

This commit is contained in:
matevip 2026-09-07 06:38:58 -04:00
parent 26150b68e8
commit 7406fc99bf
54 changed files with 2393 additions and 25 deletions

View File

@ -18,6 +18,7 @@ import org.springframework.stereotype.Component;
import vip.mate.agent.graph.StateGraphReActAgent;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
import vip.mate.execution.evidence.service.ExecutionEvidenceRecorder;
import vip.mate.agent.graph.edge.ObservationDispatcher;
import vip.mate.agent.graph.edge.ReasoningDispatcher;
import vip.mate.agent.graph.lifecycle.ReActLifecycleListener;
@ -103,6 +104,13 @@ public class AgentGraphBuilder {
"${mateclaw.skill.disclosure.load-skill-tool.enabled:true}")
private boolean loadSkillToolEnabled;
private ExecutionEvidenceRecorder executionEvidenceRecorder;
@Autowired
public void setExecutionEvidenceRecorder(ExecutionEvidenceRecorder recorder) {
this.executionEvidenceRecorder = recorder;
}
/** Escape hatch: when false, the final answer is sent verbatim without Markdown normalization. */
@org.springframework.beans.factory.annotation.Value(
"${mate.agent.markdown-normalize-enabled:true}")
@ -675,6 +683,7 @@ public class AgentGraphBuilder {
executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
executor.setProgressContext(progressContext);
executor.setExecutionEvidenceRecorder(executionEvidenceRecorder);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {
@ -998,6 +1007,7 @@ public class AgentGraphBuilder {
executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
executor.setProgressContext(progressContext);
executor.setExecutionEvidenceRecorder(executionEvidenceRecorder);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {

View File

@ -5,6 +5,7 @@ import org.springframework.ai.chat.model.ToolContext;
import org.springframework.lang.Nullable;
import java.util.Map;
import java.util.Objects;
/**
* Immutable value object that travels alongside an agent invocation describing
@ -76,9 +77,22 @@ public record ChatOrigin(
* from "this is an external/anonymous identifier" (RFC: identity typing).
*/
@Nullable Long requesterUserId,
@Nullable Long originMessageId
@Nullable Long originMessageId,
@Nullable ExecutionAttribution executionAttribution
) {
public ChatOrigin(@Nullable Long agentId, @Nullable String conversationId,
@Nullable String requesterId, @Nullable Long workspaceId,
@Nullable String workspaceBasePath, @Nullable Long channelId,
@Nullable ChannelTarget channelTarget, boolean cronOrigin,
@Nullable String senderName, @Nullable String channelType,
@Nullable String chatId, @Nullable String baseUrl,
@Nullable Long requesterUserId, @Nullable Long originMessageId) {
this(agentId, conversationId, requesterId, workspaceId, workspaceBasePath,
channelId, channelTarget, cronOrigin, senderName, channelType,
chatId, baseUrl, requesterUserId, originMessageId, null);
}
public ChatOrigin(@Nullable Long agentId, @Nullable String conversationId,
@Nullable String requesterId, @Nullable Long workspaceId,
@Nullable String workspaceBasePath, @Nullable Long channelId,
@ -149,27 +163,27 @@ public record ChatOrigin(
public ChatOrigin withAgent(@Nullable Long newAgentId) {
return new ChatOrigin(newAgentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, executionAttribution);
}
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
@Nullable String newWorkspaceBasePath) {
return new ChatOrigin(agentId, conversationId, requesterId,
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, executionAttribution);
}
public ChatOrigin withConversationId(@Nullable String newConversationId) {
return new ChatOrigin(agentId, newConversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, Objects.equals(conversationId, newConversationId) ? executionAttribution : null);
}
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, newBaseUrl, requesterUserId, originMessageId);
senderName, channelType, chatId, newBaseUrl, requesterUserId, originMessageId, executionAttribution);
}
/**
@ -183,13 +197,26 @@ public record ChatOrigin(
@Nullable String newChatId) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId);
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId, executionAttribution);
}
public ChatOrigin withOriginMessageId(@Nullable Long newOriginMessageId) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, newOriginMessageId);
senderName, channelType, chatId, baseUrl, requesterUserId, newOriginMessageId, executionAttribution);
}
public ChatOrigin withApprovalId(String pendingId) {
ExecutionAttribution attribution = executionAttribution == null
? new ExecutionAttribution(null, null, null, pendingId, null)
: executionAttribution.withApproval(pendingId);
return withExecutionAttribution(attribution);
}
public ChatOrigin withExecutionAttribution(ExecutionAttribution attribution) {
return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath,
channelId, channelTarget, cronOrigin, senderName, channelType, chatId, baseUrl,
requesterUserId, originMessageId, attribution);
}
// ---------------- Spring AI ToolContext interop ----------------

View File

@ -0,0 +1,12 @@
package vip.mate.agent.context;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/** Server-issued execution linkage. This record is never a tool argument or an HTTP request body. */
@JsonIgnoreProperties(ignoreUnknown = true)
public record ExecutionAttribution(Long goalId, String goalAttemptId, Long cronRunId,
String approvalId, String ownerFence) {
public ExecutionAttribution withApproval(String pendingId) {
return new ExecutionAttribution(goalId, goalAttemptId, cronRunId, pendingId, ownerFence);
}
}

View File

@ -15,6 +15,7 @@ import vip.mate.tool.mcp.runtime.ProgressAwareMcpToolCallback;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.execution.evidence.service.ExecutionEvidenceRecorder;
import vip.mate.agent.context.StructuredTruncator;
import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
@ -483,6 +484,9 @@ public class ToolExecutionExecutor {
ChatOrigin origin,
Set<String> loadedSkills) {
ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY;
if (!isReplay && safeOrigin.executionAttribution() != null) {
safeOrigin = safeOrigin.withApprovalId(null);
}
if (isBlank(safeOrigin.conversationId()) && !isBlank(conversationId)) {
safeOrigin = safeOrigin.withConversationId(conversationId);
}
@ -701,7 +705,7 @@ public class ToolExecutionExecutor {
// 4. 分类: concurrencySafe
boolean safe = isConcurrencySafe(toolName);
preparedCalls.add(new PreparedToolCall(toolCall, responseName, callback, arguments, safe, allResponses.size(),
conversationId, requesterId, workspaceBasePath, safeOrigin, rawEvidenceRef));
conversationId, requesterId, workspaceBasePath, safeOrigin, UUID.randomUUID().toString(), rawEvidenceRef));
// 占位Phase 2 填充
allResponses.add(null);
}
@ -791,6 +795,15 @@ public class ToolExecutionExecutor {
List<GraphEventPublisher.GraphEvent> events,
String conversationId, String workspaceBasePath,
List<DirectToolOutput> directOutputs) {
return executePreApproved(toolCall, storedArguments, events, conversationId, workspaceBasePath,
directOutputs, ChatOrigin.EMPTY);
}
public ToolResponseMessage.ToolResponse executePreApproved(
AssistantMessage.ToolCall toolCall, String storedArguments,
List<GraphEventPublisher.GraphEvent> events,
String conversationId, String workspaceBasePath,
List<DirectToolOutput> directOutputs, ChatOrigin origin) {
String toolName = resolveToolName(toolCall.name());
String callArguments = storedArguments != null ? storedArguments : toolCall.arguments();
@ -826,10 +839,11 @@ public class ToolExecutionExecutor {
// Origin is method-local (see thread-safety note on execute());
// the legacy ThreadLocal that used to carry it across executePreApproved
// calls was a cross-conversation footgun and has been removed.
ChatOrigin replayOrigin = ChatOrigin.EMPTY
.withConversationId(conversationId)
.withWorkspace(null, workspaceBasePath);
String result = callback.call(callArguments, toolContextWithScopedCatalog(replayOrigin));
ChatOrigin replayOrigin = (origin == null ? ChatOrigin.EMPTY : origin)
.withConversationId(conversationId);
replayOrigin = replayOrigin.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
String result = invokeObserved(callback, callArguments, toolContextWithScopedCatalog(replayOrigin),
UUID.randomUUID().toString(), toolCall.id());
throwIfStopRequested(conversationId);
int rawLen = result != null ? result.length() : 0;
@ -1077,7 +1091,7 @@ public class ToolExecutionExecutor {
toolContext = new ToolContext(ctxMap);
}
result = pc.callback.call(pc.arguments, toolContext);
result = invokeObserved(pc.callback, pc.arguments, toolContext, pc.invocationKey, pc.toolCall.id());
throwIfStopRequested(pc.conversationId);
} finally {
if (progressToken != null) {
@ -1772,6 +1786,18 @@ public class ToolExecutionExecutor {
return new ToolContext(context);
}
private ExecutionEvidenceRecorder executionEvidenceRecorder;
public void setExecutionEvidenceRecorder(ExecutionEvidenceRecorder recorder) {
this.executionEvidenceRecorder = recorder;
}
private String invokeObserved(ToolCallback callback, String arguments, ToolContext context,
String invocationKey, String providerCallId) {
return executionEvidenceRecorder == null ? callback.call(arguments, context)
: executionEvidenceRecorder.invoke(callback, arguments, context, invocationKey, providerCallId);
}
// ==================== 内部数据类 ====================
private record PreparedToolCall(
@ -1785,6 +1811,7 @@ public class ToolExecutionExecutor {
String requesterId,
String workspaceBasePath,
ChatOrigin origin,
String invocationKey,
/**
* Shared reference (one per execute() invocation) where each
* concurrent {@code executeSingleTool} merges a {@link SourceEvidenceLedger}

View File

@ -362,7 +362,7 @@ public class StepExecutionNode implements NodeAction {
// (instead of leaking into the next LLM round).
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
toolCall, storedArguments, events, conversationId, workspaceBasePath,
stepDirectOutputs);
stepDirectOutputs, chatOrigin);
toolResponses.add(response);
preApprovedPayload = ""; // 只消费一次
} else {

View File

@ -1469,6 +1469,7 @@ public class ChannelMessageRouter {
replayOrigin = chatOriginFactory.from(
channelEntity, triggerMessage, conversationId, /* workspaceBasePath */ null);
}
replayOrigin = replayOrigin.withApprovalId(consumed.getPendingId());
AgentService.ChatResult replayResult = agentService.chatWithReplayWithUsage(
agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
String reply = replayResult.content();

View File

@ -346,7 +346,7 @@ public class ChatController {
}
// Carry the request-thread base URL so any file a replayed
// tool generates gets an absolute download link.
replayOrigin = replayOrigin.withBaseUrl(requestBaseUrl);
replayOrigin = replayOrigin.withBaseUrl(requestBaseUrl).withApprovalId(finalConsumed.getPendingId());
Disposable disposable = agentService.chatWithReplayStream(
replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username, replayOrigin)
.doOnNext(delta -> {

View File

@ -1362,6 +1362,8 @@ public class WebChatController {
conversationId, actor, wsId, null).withSender(null, "api", null);
}
replayOrigin = replayOrigin.withApprovalId(snapshot.getPendingId());
// Neutral replay prompt (aligned with IM + web channels naming a
// tool here can mislead the LLM on fallthrough).
String replayPrompt = "继续执行已批准的工具调用。";

View File

@ -8,6 +8,7 @@ import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.stereotype.Component;
import vip.mate.agent.AgentService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ExecutionAttribution;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.cron.CronChatOriginFactory;
import vip.mate.cron.model.CronJobEntity;
@ -146,6 +147,8 @@ public class CronJobRunner {
try {
ChatOrigin origin = originFactory.from(
job, conversationId, started.originMessageId());
origin = origin.withExecutionAttribution(new ExecutionAttribution(null, null, run.getId(), null,
"cron:" + run.getId()));
try (CronRunHeartbeatService.Lease ignored = heartbeat.begin(run.getId())) {
chatResult = runAgent(job, userMessage, origin, conversationId);
}

View File

@ -0,0 +1,31 @@
package vip.mate.execution.evidence;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "mateclaw.execution-evidence")
public class ExecutionEvidenceProperties {
public enum Mode { OFF, OBSERVE, ENFORCE }
private Mode mode = Mode.OBSERVE;
private int retentionDays = 90;
private int cleanupMaxBatches = 10;
public int getCleanupMaxBatches() { return cleanupMaxBatches; }
public void setCleanupMaxBatches(int value) { cleanupMaxBatches = Math.clamp(value, 1, 100); }
private int maxObservations = 32;
public int getMaxObservations() { return maxObservations; }
public void setMaxObservations(int value) { maxObservations = Math.clamp(value, 1, 99); }
private int maxSummaryBytes = 2048;
private int defaultListLimit = 20;
private int maxListLimit = 100;
public Mode getMode() { return mode; }
public void setMode(Mode mode) { this.mode = mode; }
public int getRetentionDays() { return retentionDays; }
public void setRetentionDays(int value) { retentionDays = Math.max(1, value); }
public int getMaxSummaryBytes() { return maxSummaryBytes; }
public void setMaxSummaryBytes(int value) { maxSummaryBytes = Math.clamp(value, 1, 2048); }
public int getDefaultListLimit() { return defaultListLimit; }
public void setDefaultListLimit(int value) { defaultListLimit = Math.clamp(value, 1, 100); }
public int getMaxListLimit() { return maxListLimit; }
public void setMaxListLimit(int value) { maxListLimit = Math.clamp(value, 1, 100); }
}

View File

@ -0,0 +1,39 @@
package vip.mate.execution.evidence.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
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.execution.evidence.service.ExecutionEvidenceQueryService;
/** Source-authorized, read-only observations. There is deliberately no evidence write endpoint. */
@RestController
@RequestMapping("/api/v1/execution-evidence")
@RequiredArgsConstructor
public class ExecutionEvidenceController {
private final ExecutionEvidenceQueryService queries;
@GetMapping
public R<ExecutionEvidenceQueryService.Page> list(Authentication auth,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestParam String conversationId,
@RequestParam(required = false) String cursor,
@RequestParam(required = false) Integer limit,
@RequestParam(required = false) Long goalId,
@RequestParam(required = false) Long teamTaskId) {
return R.ok(queries.list(auth == null ? null : auth.getName(), workspaceId, conversationId,
cursor, limit, goalId, teamTaskId));
}
@GetMapping("/{id}")
public R<ExecutionEvidenceQueryService.View> detail(Authentication auth,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@PathVariable Long id) {
return R.ok(queries.detail(auth == null ? null : auth.getName(), workspaceId, id));
}
}

View File

@ -0,0 +1,3 @@
package vip.mate.execution.evidence.model;
public enum AttemptState { STARTED, SUCCEEDED, FAILED, CANCELLED, UNKNOWN, BLOCKED }

View File

@ -0,0 +1,4 @@
package vip.mate.execution.evidence.model;
/** Only the successful inserter may initiate a new execution. */
public record BeginResult(ExecutionAttempt attempt, boolean created) { }

View File

@ -0,0 +1,3 @@
package vip.mate.execution.evidence.model;
public enum EffectOutcome { NONE, CONFIRMED, UNCERTAIN }

View File

@ -0,0 +1,3 @@
package vip.mate.execution.evidence.model;
public enum EvidenceKind { TOOL_RETURNED, COMMAND_EXIT, CHECK_RESULT, ARTIFACT_SNAPSHOT }

View File

@ -0,0 +1,15 @@
package vip.mate.execution.evidence.model;
import java.time.Instant;
/** Allowlisted execution metadata; raw invocation parameters are never accepted. */
public record EvidenceObservation(String sourceKey, EvidenceKind kind, EvidenceResult result,
SourceLevel sourceLevel, Long scopeId, Long generation, String inputFingerprint,
String recipeId, Long recipeRevision, String checkScope, String artifactRef,
String artifactDigest, String summary, String payloadRef, Instant observedAt, Instant expiresAt) {
public EvidenceObservation(String sourceKey, EvidenceKind kind, EvidenceResult result,
SourceLevel sourceLevel, String summary) {
this(sourceKey, kind, result, sourceLevel, null, null, null, null, null, null,
null, null, summary, null, null, null);
}
}

View File

@ -0,0 +1,3 @@
package vip.mate.execution.evidence.model;
public enum EvidenceResult { OBSERVED, PASS, FAIL, UNKNOWN }

View File

@ -0,0 +1,5 @@
package vip.mate.execution.evidence.model;
/** Persisted resource identity reserved for managed validation scopes. */
public record EvidenceScope(Long id, Long workspaceId, String resourceKey, String hostId,
String rootId, long generation, int activeMutations, boolean tainted) { }

View File

@ -0,0 +1,6 @@
package vip.mate.execution.evidence.model;
import java.time.Instant;
public record ExecutionAttempt(Long id, ExecutionIdentity identity, AttemptState state, EffectOutcome effectOutcome,
Instant startedAt, Instant finishedAt) { }

View File

@ -0,0 +1,3 @@
package vip.mate.execution.evidence.model;
public record ExecutionEvidence(Long id, Long workspaceId, Long attemptId, String conversationId, EvidenceObservation observation) { }

View File

@ -0,0 +1,6 @@
package vip.mate.execution.evidence.model;
public record ExecutionIdentity(Long workspaceId, String conversationId, String runtimeKind, String runtimeSessionId,
String invocationKey, String logicalCallId, int attemptNo, String providerToolCallId,
String toolName, Long goalId, String goalAttemptId, Long teamRunId, Long teamTaskId,
Long cronRunId, String approvalId, String ownerFence) { }

View File

@ -0,0 +1,7 @@
package vip.mate.execution.evidence.model;
import java.time.Instant;
/** Versioned binding metadata; creating a binding requires separate source authorization. */
public record GoalCriterionEvidence(Long id, Long workspaceId, Long goalId, String criterionId,
long criterionRevision, Long evidenceId, Instant boundAt) { }

View File

@ -0,0 +1,3 @@
package vip.mate.execution.evidence.model;
public enum SourceLevel { PLATFORM_OBSERVED, ADAPTER_ATTESTED, EXTERNAL_REPORTED, LEGACY_TEXT }

View File

@ -0,0 +1,35 @@
package vip.mate.execution.evidence.service;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import vip.mate.workspace.conversation.event.ConversationDeletedEvent;
import vip.mate.execution.evidence.ExecutionEvidenceProperties;
import java.time.Instant;
/** Metadata retention is independent of source-file retention and respects conversation deletion. */
@Component
public class ExecutionEvidenceLifecycle {
private final ExecutionEvidenceStore store;
private final ExecutionEvidenceProperties properties;
public ExecutionEvidenceLifecycle(ExecutionEvidenceStore store, ExecutionEvidenceProperties properties) {
this.store = store;
this.properties = properties;
}
@EventListener
public void onConversationDeleted(ConversationDeletedEvent event) {
store.purgeConversation(event.conversationId());
}
@Scheduled(fixedDelayString = "${mateclaw.execution-evidence.cleanup-interval-ms:60000}")
public void cleanup() {
Instant now = Instant.now();
for (int batch = 0; batch < properties.getCleanupMaxBatches(); batch++) {
if (store.purgeExpiredMetadata(now, 100) < 100) break;
}
}
}

View File

@ -0,0 +1,147 @@
package vip.mate.execution.evidence.service;
import org.springframework.stereotype.Service;
import vip.mate.execution.evidence.ExecutionEvidenceProperties;
import vip.mate.execution.evidence.model.AttemptState;
import vip.mate.execution.evidence.model.EffectOutcome;
import vip.mate.execution.evidence.model.EvidenceKind;
import vip.mate.execution.evidence.model.EvidenceResult;
import vip.mate.execution.evidence.model.SourceLevel;
import vip.mate.auth.service.AuthService;
import vip.mate.team.service.TeamWorkerConversationGovernanceService;
import vip.mate.tool.document.GeneratedFileCache;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.core.service.WorkspaceService;
import io.micrometer.core.instrument.MeterRegistry;
import vip.mate.exception.MateClawException;
import vip.mate.execution.evidence.model.ExecutionEvidence;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@Service
public class ExecutionEvidenceQueryService {
public record View(Long id, Long attemptId, String conversationId, String toolName, AttemptState state,
EffectOutcome effectOutcome, EvidenceKind kind, EvidenceResult result, SourceLevel sourceLevel,
String validity, String summary, Instant observedAt, Instant expiresAt,
String artifactRef, String artifactDigest, String checkScope) { }
public record Page(List<View> items, String nextCursor) { }
private record Cursor(Instant observedAt, Long id) { }
private final ExecutionEvidenceStore store;
private final ConversationService conversations;
private final TeamWorkerConversationGovernanceService teams;
private final GeneratedFileCache files;
private final AuthService auth;
private final WorkspaceService workspaces;
private final ExecutionEvidenceProperties properties;
private final MeterRegistry metrics;
public ExecutionEvidenceQueryService(ExecutionEvidenceStore store, ConversationService conversations,
TeamWorkerConversationGovernanceService teams, GeneratedFileCache files,
AuthService auth, WorkspaceService workspaces, ExecutionEvidenceProperties properties, MeterRegistry metrics) {
this.store = store;
this.conversations = conversations;
this.teams = teams;
this.files = files;
this.auth = auth;
this.workspaces = workspaces;
this.properties = properties;
this.metrics = metrics;
}
public Page list(String username, Long workspaceId, String conversationId, String cursor, Integer limit,
Long goalId, Long teamTaskId) {
long started = System.nanoTime();
try {
Long canonicalWorkspace = authorize(username, workspaceId, conversationId);
int bounded = Math.clamp(limit == null ? properties.getDefaultListLimit() : limit, 1, properties.getMaxListLimit());
Cursor before = decode(cursor);
List<ExecutionEvidence> rows = store.list(canonicalWorkspace, conversationId, before.observedAt(),
before.id(), bounded + 1, goalId, teamTaskId);
boolean hasMore = rows.size() > bounded;
List<ExecutionEvidence> page = rows.stream().limit(bounded).toList();
return new Page(page.stream().map(row -> view(username, row)).toList(),
hasMore ? encode(page.getLast()) : null);
} finally {
metrics.timer("mateclaw.execution.evidence.query.latency").record(
System.nanoTime() - started, TimeUnit.NANOSECONDS);
}
}
public View detail(String username, Long workspaceId, Long id) {
if (username == null || username.isBlank() || id == null) throw hidden();
ExecutionEvidence row = store.findById(id).orElseThrow(this::hidden);
Long canonicalWorkspace = authorize(username, workspaceId, row.conversationId());
if (!canonicalWorkspace.equals(row.workspaceId())) throw hidden();
return view(username, row);
}
private Long authorize(String username, Long workspaceId, String conversationId) {
if (username == null || username.isBlank() || conversationId == null || conversationId.isBlank()) throw hidden();
var conversation = conversations.findByConversationId(conversationId);
if (conversation == null || conversation.getWorkspaceId() == null || Integer.valueOf(1).equals(conversation.getDeleted())
|| workspaceId != null && !workspaceId.equals(conversation.getWorkspaceId())) throw hidden();
if (!conversations.isConversationOwner(conversationId, username)
&& !teams.canReadTranscript(conversationId, null, null, username)) throw hidden();
return conversation.getWorkspaceId();
}
private View view(String username, ExecutionEvidence row) {
var attempt = store.findAttempt(row.attemptId()).orElseThrow(this::hidden);
if (!Objects.equals(attempt.identity().workspaceId(), row.workspaceId())
|| !Objects.equals(attempt.identity().conversationId(), row.conversationId())) throw hidden();
var evidence = row.observation();
String validity = "UNKNOWN";
String artifact = evidence.artifactRef();
String digest = evidence.artifactDigest();
String summary = evidence.summary();
if (evidence.expiresAt() != null && !evidence.expiresAt().isAfter(Instant.now())) validity = "UNAVAILABLE";
if (evidence.kind() == EvidenceKind.ARTIFACT_SNAPSHOT) {
var user = auth.findByUsername(username);
boolean canReadFile = user != null && ("admin".equalsIgnoreCase(user.getRole())
|| workspaces.hasPermissionCached(row.workspaceId(), user.getId(), "viewer"));
if (!canReadFile) {
artifact = null;
digest = null;
summary = null;
validity = "UNAVAILABLE";
} else if (!files.isDurablyAvailable(artifact, row.workspaceId(), row.conversationId())) {
validity = "UNAVAILABLE";
artifact = null;
}
}
metrics.counter("mateclaw.execution.evidence.validity", "status", validity).increment();
// An available observation is not a freshness or correctness certificate.
return new View(row.id(), row.attemptId(), row.conversationId(), attempt.identity().toolName(), attempt.state(),
attempt.effectOutcome(), evidence.kind(), evidence.result(), evidence.sourceLevel(), validity,
summary, evidence.observedAt(), evidence.expiresAt(), artifact, digest, evidence.checkScope());
}
private Cursor decode(String cursor) {
if (cursor == null || cursor.isBlank()) return new Cursor(null, null);
try {
if (cursor.length() > 256) throw new IllegalArgumentException();
String[] parts = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8).split("\\|", -1);
if (parts.length != 2) throw new IllegalArgumentException();
long id = Long.parseLong(parts[1]);
if (id <= 0) throw new IllegalArgumentException();
return new Cursor(Instant.parse(parts[0]), id);
} catch (RuntimeException invalid) {
throw new MateClawException(400, "Invalid execution evidence cursor");
}
}
private String encode(ExecutionEvidence row) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(
(row.observation().observedAt() + "|" + row.id()).getBytes(StandardCharsets.UTF_8));
}
private MateClawException hidden() {
metrics.counter("mateclaw.execution.evidence.query.denied").increment();
return new MateClawException(404, "Execution evidence not found");
}
}

View File

@ -0,0 +1,127 @@
package vip.mate.execution.evidence.service;
import io.micrometer.core.instrument.MeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.stereotype.Service;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.execution.evidence.ExecutionEvidenceProperties;
import vip.mate.execution.evidence.model.AttemptState;
import vip.mate.execution.evidence.model.EffectOutcome;
import vip.mate.execution.evidence.model.EvidenceKind;
import vip.mate.execution.evidence.model.EvidenceObservation;
import vip.mate.execution.evidence.model.EvidenceResult;
import vip.mate.execution.evidence.model.ExecutionAttempt;
import vip.mate.execution.evidence.model.SourceLevel;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.concurrent.CancellationException;
import java.util.concurrent.TimeUnit;
/** Observes the actual callback boundary. It never interprets returned text as a check result. */
@Service
public class ExecutionEvidenceRecorder {
private static final Logger log = LoggerFactory.getLogger(ExecutionEvidenceRecorder.class);
private final ExecutionEvidenceStore store;
private final ExecutionIdentityResolver identities;
private final ExecutionEvidenceProperties properties;
private final MeterRegistry metrics;
public ExecutionEvidenceRecorder(ExecutionEvidenceStore store, ExecutionIdentityResolver identities,
ExecutionEvidenceProperties properties, MeterRegistry metrics) {
this.store = store;
this.identities = identities;
this.properties = properties;
this.metrics = metrics;
metrics.gauge("mateclaw.execution.evidence.attempts.unresolved", store, ExecutionEvidenceStore::countUnresolved);
if (properties.getMode() == ExecutionEvidenceProperties.Mode.ENFORCE) {
throw new IllegalStateException("Execution evidence enforcement requires managed verification scopes; use observe or off");
}
}
public String invoke(ToolCallback callback, String arguments, ToolContext context,
String invocationKey, String providerCallId) {
if (properties.getMode() == ExecutionEvidenceProperties.Mode.OFF) return callback.call(arguments, context);
ExecutionAttempt attempt = null;
boolean duplicate = false;
long began = System.nanoTime();
try {
var identity = identities.resolve(ChatOrigin.from(context), invocationKey, providerCallId,
callback.getToolDefinition().name());
if (identity != null) {
var reservation = store.reserve(identity);
attempt = reservation.attempt();
duplicate = !reservation.created();
}
else metrics.counter("mateclaw.execution.evidence.unattributed").increment();
} catch (IllegalStateException conflict) {
throw conflict;
} catch (RuntimeException failure) {
failure("begin");
} finally {
metrics.timer("mateclaw.execution.evidence.capture.latency", "phase", "begin")
.record(System.nanoTime() - began, TimeUnit.NANOSECONDS);
}
// An existing receipt is not a license to repeat an approved side effect.
if (duplicate) {
throw new IllegalStateException("Execution already observed; recover the existing approval result");
}
boolean direct = callback.getToolMetadata() != null && callback.getToolMetadata().returnDirect();
var sink = new ExecutionObservationSink(direct, properties.getMaxObservations());
try {
ToolContext observedContext = context;
if (attempt != null) {
var values = new HashMap<String, Object>(context.getContext());
ChatOrigin canonical = ChatOrigin.from(context).withWorkspace(attempt.identity().workspaceId(),
ChatOrigin.from(context).workspaceBasePath());
values.put(ChatOrigin.CTX_KEY, canonical);
observedContext = sink.attach(new ToolContext(values));
}
String result = callback.call(arguments, observedContext);
finish(attempt, sink, sink.state(), "Tool callback returned");
return result;
} catch (RuntimeException | Error error) {
AttemptState state = error instanceof CancellationException || Thread.currentThread().isInterrupted()
? AttemptState.CANCELLED : AttemptState.FAILED;
finish(attempt, sink, state, "Tool callback did not complete normally");
throw error;
}
}
private void finish(ExecutionAttempt attempt, ExecutionObservationSink sink, AttemptState state, String summary) {
sink.seal();
if (attempt == null) return;
long began = System.nanoTime();
try {
if (!identities.isCurrent(attempt.identity())) {
failure("owner_lost");
return;
}
var observations = new ArrayList<>(sink.observations());
observations.add(new EvidenceObservation("callback", EvidenceKind.TOOL_RETURNED,
state == AttemptState.SUCCEEDED ? EvidenceResult.OBSERVED
: state == AttemptState.UNKNOWN || state == AttemptState.CANCELLED
? EvidenceResult.UNKNOWN : EvidenceResult.FAIL,
SourceLevel.PLATFORM_OBSERVED, summary));
store.finish(attempt.id(), attempt.identity().ownerFence(), state,
state == AttemptState.BLOCKED ? EffectOutcome.NONE : EffectOutcome.UNCERTAIN, observations);
for (var observation : observations) {
metrics.counter("mateclaw.execution.evidence.observations", "kind", observation.kind().name()).increment();
}
} catch (RuntimeException failure) {
// Preserve STARTED as uncertain; the existing recovery authority owns any retry.
failure("finish");
} finally {
metrics.timer("mateclaw.execution.evidence.capture.latency", "phase", "finish")
.record(System.nanoTime() - began, TimeUnit.NANOSECONDS);
}
}
private void failure(String phase) {
metrics.counter("mateclaw.execution.evidence.capture.failures", "phase", phase).increment();
log.warn("Execution evidence capture unavailable (phase={}); consult execution recovery state", phase);
}
}

View File

@ -0,0 +1,319 @@
package vip.mate.execution.evidence.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import vip.mate.common.text.SecretRedactor;
import vip.mate.execution.evidence.ExecutionEvidenceProperties;
import vip.mate.execution.evidence.model.AttemptState;
import vip.mate.execution.evidence.model.BeginResult;
import vip.mate.execution.evidence.model.EffectOutcome;
import vip.mate.execution.evidence.model.EvidenceKind;
import vip.mate.execution.evidence.model.EvidenceObservation;
import vip.mate.execution.evidence.model.EvidenceResult;
import vip.mate.execution.evidence.model.ExecutionAttempt;
import vip.mate.execution.evidence.model.ExecutionEvidence;
import vip.mate.execution.evidence.model.ExecutionIdentity;
import vip.mate.execution.evidence.model.SourceLevel;
import java.nio.charset.StandardCharsets;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/** Short, independent transactions never span tool execution. No public write API exists. */
@Service
public class ExecutionEvidenceStore {
private final JdbcTemplate jdbc;
private final TransactionTemplate transaction;
private final ExecutionEvidenceProperties properties;
private static final String EVIDENCE_QUERY = "SELECT e.*, a.conversation_id FROM mate_execution_evidence e JOIN mate_execution_attempt a ON a.id=e.attempt_id WHERE e.deleted=0 AND a.deleted=0";
public ExecutionEvidenceStore(JdbcTemplate jdbc, PlatformTransactionManager manager,
ExecutionEvidenceProperties properties) {
this.jdbc = jdbc;
this.properties = properties;
transaction = new TransactionTemplate(manager);
transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
private ExecutionIdentityResolver ownershipValidator;
@Autowired
public void setOwnershipValidator(ExecutionIdentityResolver validator) {
this.ownershipValidator = validator;
}
public ExecutionAttempt begin(ExecutionIdentity identity) {
return reserve(identity).attempt();
}
public BeginResult reserve(ExecutionIdentity identity) {
validate(identity);
try {
return transaction.execute(status -> {
if (ownershipValidator != null) ownershipValidator.lockCurrentForUpdate(identity, false);
var existing = byInvocation(identity);
if (existing.isPresent()) return new BeginResult(sameIdentity(existing.get(), identity), false);
long id = IdWorker.getId();
Instant now = now();
jdbc.update("""
INSERT INTO mate_execution_attempt
(id,workspace_id,conversation_id,runtime_kind,runtime_session_id,invocation_key,
logical_call_id,attempt_no,provider_tool_call_id,tool_name,goal_id,goal_attempt_id,
team_run_id,team_task_id,cron_run_id,approval_id,owner_fence,state,effect_outcome,started_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'STARTED','UNCERTAIN',?)
""", id, identity.workspaceId(), identity.conversationId(), identity.runtimeKind(),
identity.runtimeSessionId(), identity.invocationKey(), identity.logicalCallId(),
identity.attemptNo(), identity.providerToolCallId(), identity.toolName(), identity.goalId(),
identity.goalAttemptId(), identity.teamRunId(), identity.teamTaskId(), identity.cronRunId(),
identity.approvalId(), identity.ownerFence(), timestamp(now));
return new BeginResult(new ExecutionAttempt(id, identity, AttemptState.STARTED, EffectOutcome.UNCERTAIN, now, null), true);
});
} catch (DuplicateKeyException conflict) {
return new BeginResult(sameIdentity(byInvocation(identity).orElseThrow(() ->
new IllegalStateException("Logical execution attempt already exists")), identity), false);
}
}
public List<ExecutionEvidence> finish(Long attemptId, String ownerFence, AttemptState state,
EffectOutcome effect, List<EvidenceObservation> observations) {
if (state == null || state == AttemptState.STARTED || effect == null || observations == null)
throw new IllegalArgumentException("A terminal execution outcome is required");
if (observations.size() > 100) throw new IllegalArgumentException("Too many observations");
return transaction.execute(status -> {
var snapshot = findAttempt(attemptId).orElseThrow(() -> new IllegalStateException("Execution attempt unavailable"));
boolean currentOwner = ownershipValidator == null || ownershipValidator.lockCurrentForUpdate(snapshot.identity(), true);
var rows = jdbc.query("SELECT * FROM mate_execution_attempt WHERE id=? AND deleted=0 FOR UPDATE",
this::attempt, attemptId);
if (rows.isEmpty()) throw new IllegalStateException("Execution attempt unavailable");
var attempt = rows.getFirst();
if (!Objects.equals(attempt.identity().ownerFence(), ownerFence))
throw new IllegalStateException("Execution owner fence rejected");
var existing = jdbc.query(EVIDENCE_QUERY + " AND e.attempt_id=? ORDER BY e.id", this::evidence, attemptId);
var bySource = new LinkedHashMap<String, ExecutionEvidence>();
existing.forEach(row -> bySource.put(row.observation().sourceKey(), row));
var normalized = new LinkedHashMap<String, EvidenceObservation>();
for (var observation : observations) {
var prior = bySource.get(observation.sourceKey());
var baseline = prior == null ? normalized.get(observation.sourceKey()) : prior.observation();
var clean = normalize(observation, baseline);
var duplicate = normalized.putIfAbsent(clean.sourceKey(), clean);
if (duplicate != null && !duplicate.equals(clean)) throw conflict();
if (prior != null && !prior.observation().equals(clean)) throw conflict();
}
if (attempt.state() != AttemptState.STARTED) {
if (attempt.state() != state || attempt.effectOutcome() != effect
|| bySource.size() != normalized.size() || !bySource.keySet().equals(normalized.keySet()))
throw conflict();
return existing;
}
if (!currentOwner) throw new IllegalStateException("Execution owner fence rejected");
int updated = jdbc.update("""
UPDATE mate_execution_attempt SET state=?,effect_outcome=?,finished_at=?,update_time=?
WHERE id=? AND owner_fence=? AND state='STARTED' AND deleted=0
""", state.name(), effect.name(), timestamp(now()), timestamp(now()), attemptId, ownerFence);
if (updated != 1) throw new IllegalStateException("Execution owner fence rejected");
for (var observation : normalized.values()) {
long id = IdWorker.getId();
jdbc.update("""
INSERT INTO mate_execution_evidence
(id,workspace_id,attempt_id,source_key,kind,result,source_level,scope_id,generation,
input_fingerprint,recipe_id,recipe_revision,check_scope,artifact_ref,artifact_digest,
summary,payload_ref,observed_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", id, attempt.identity().workspaceId(), attemptId, observation.sourceKey(),
observation.kind().name(), observation.result().name(), observation.sourceLevel().name(),
observation.scopeId(), observation.generation(), observation.inputFingerprint(),
observation.recipeId(), observation.recipeRevision(), observation.checkScope(),
observation.artifactRef(), observation.artifactDigest(), observation.summary(),
observation.payloadRef(), timestamp(observation.observedAt()), timestamp(observation.expiresAt()));
}
return jdbc.query(EVIDENCE_QUERY + " AND e.attempt_id=? ORDER BY e.id", this::evidence, attemptId);
});
}
/** Delete bounded, unreferenced terminal metadata; unresolved executions and bindings remain pinned. */
public int purgeExpiredMetadata(Instant now, int batchLimit) {
Objects.requireNonNull(now, "Retention time required");
if (batchLimit < 1) throw new IllegalArgumentException("Positive cleanup batch required");
var cutoff = timestamp(now.minus(properties.getRetentionDays(), ChronoUnit.DAYS));
return transaction.execute(status -> {
var ids = jdbc.queryForList("""
SELECT a.id FROM mate_execution_attempt a
WHERE a.state IN ('SUCCEEDED','FAILED','CANCELLED','BLOCKED') AND a.update_time<?
AND NOT EXISTS (SELECT 1 FROM mate_execution_evidence e
WHERE e.attempt_id=a.id AND e.observed_at>=?)
AND NOT EXISTS (SELECT 1 FROM mate_execution_evidence e
JOIN mate_goal_criterion_evidence b ON b.evidence_id=e.id WHERE e.attempt_id=a.id)
ORDER BY a.id LIMIT ? FOR UPDATE
""", Long.class, cutoff, cutoff, Math.min(batchLimit,100));
for (var id : ids) {
// Foreign keys also prevent deletion if a new binding races the candidate selection.
jdbc.update("DELETE FROM mate_execution_evidence WHERE attempt_id=?", id);
jdbc.update("DELETE FROM mate_execution_attempt WHERE id=?", id);
}
return ids.size();
});
}
/** Erase copied content while retaining non-content tombstones for existing evidence bindings. */
public int purgeConversation(String conversationId) {
required(conversationId,128);
return transaction.execute(status -> {
// Lock attempts before receipts, matching finish, so a late writer cannot restore deleted content.
jdbc.update("""
UPDATE mate_execution_attempt SET
effect_outcome=CASE WHEN state='STARTED' THEN 'UNCERTAIN' ELSE effect_outcome END,
finished_at=CASE WHEN state='STARTED' THEN ? ELSE finished_at END,
state=CASE WHEN state='STARTED' THEN 'UNKNOWN' ELSE state END,
failure_reason=NULL,deleted=1,update_time=?
WHERE conversation_id=? AND deleted=0
""", timestamp(now()), timestamp(now()), conversationId);
return jdbc.update("""
UPDATE mate_execution_evidence SET summary=NULL,input_fingerprint=NULL,check_scope=NULL,
artifact_ref=NULL,artifact_digest=NULL,payload_ref=NULL,recipe_id=NULL,
deleted=1,update_time=? WHERE deleted=0 AND attempt_id IN
(SELECT id FROM mate_execution_attempt WHERE conversation_id=?)
""", timestamp(now()), conversationId);
});
}
public long countUnresolved() {
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM mate_execution_attempt WHERE deleted=0 AND state IN ('STARTED','UNKNOWN')", Long.class);
return count == null ? 0 : count;
}
public Optional<ExecutionAttempt> findAttempt(Long id) {
return jdbc.query("SELECT * FROM mate_execution_attempt WHERE id=? AND deleted=0", this::attempt, id).stream().findFirst();
}
/** Internal lookup for source authorization; callers must authorize before exposing the result. */
public Optional<ExecutionEvidence> findById(Long id) {
return jdbc.query(EVIDENCE_QUERY + " AND e.id=?", this::evidence, id).stream().findFirst();
}
public Optional<ExecutionEvidence> find(Long workspaceId, String conversationId, Long id) {
scope(workspaceId, conversationId);
return jdbc.query(EVIDENCE_QUERY + " AND e.workspace_id=? AND a.conversation_id=? AND e.id=?",
this::evidence, workspaceId, conversationId, id).stream().findFirst();
}
public List<ExecutionEvidence> list(Long workspaceId, String conversationId, Instant beforeObservedAt,
Long beforeId, int limit) {
return list(workspaceId, conversationId, beforeObservedAt, beforeId, limit, null, null);
}
public List<ExecutionEvidence> list(Long workspaceId, String conversationId, Instant beforeObservedAt,
Long beforeId, int limit, Long goalId, Long teamTaskId) {
scope(workspaceId, conversationId);
if ((beforeObservedAt == null) != (beforeId == null))
throw new IllegalArgumentException("Both cursor components are required");
int bounded = Math.min(properties.getMaxListLimit() + 1,
limit <= 0 ? properties.getDefaultListLimit() : limit);
var args = new ArrayList<Object>(List.of(workspaceId, conversationId));
String query = EVIDENCE_QUERY + " AND e.workspace_id=? AND a.conversation_id=?";
if (goalId != null) { query += " AND a.goal_id=?"; args.add(goalId); }
if (teamTaskId != null) { query += " AND a.team_task_id=?"; args.add(teamTaskId); }
if (beforeObservedAt != null) {
query += " AND (e.observed_at<? OR (e.observed_at=? AND e.id<?))";
args.add(timestamp(beforeObservedAt)); args.add(timestamp(beforeObservedAt)); args.add(beforeId);
}
args.add(bounded);
return jdbc.query(query + " ORDER BY e.observed_at DESC,e.id DESC LIMIT ?", this::evidence, args.toArray());
}
private Optional<ExecutionAttempt> byInvocation(ExecutionIdentity identity) {
return jdbc.query("SELECT * FROM mate_execution_attempt WHERE workspace_id=? AND invocation_key=? AND deleted=0",
this::attempt, identity.workspaceId(), identity.invocationKey()).stream().findFirst();
}
private ExecutionAttempt sameIdentity(ExecutionAttempt attempt, ExecutionIdentity identity) {
if (!attempt.identity().equals(identity)) throw conflict();
return attempt;
}
private EvidenceObservation normalize(EvidenceObservation value, EvidenceObservation prior) {
Objects.requireNonNull(value.kind(), "Evidence kind required");
Objects.requireNonNull(value.result(), "Evidence result required");
Objects.requireNonNull(value.sourceLevel(), "Evidence source required");
required(value.sourceKey(), 191);
Instant observed = value.observedAt() == null ? (prior == null ? now() : prior.observedAt())
: value.observedAt().truncatedTo(ChronoUnit.MICROS);
return new EvidenceObservation(value.sourceKey(), value.kind(), value.result(), value.sourceLevel(),
value.scopeId(), value.generation(), bounded(value.inputFingerprint(),128), bounded(value.recipeId(),191),
value.recipeRevision(), bounded(value.checkScope(),2048), bounded(value.artifactRef(),512),
bounded(value.artifactDigest(),128), bounded(value.summary(),properties.getMaxSummaryBytes()),
bounded(value.payloadRef(),512), observed,
value.expiresAt() == null ? null : value.expiresAt().truncatedTo(ChronoUnit.MICROS));
}
private String bounded(String value, int bytes) {
String clean = SecretRedactor.redact(value);
if (clean == null) return null;
int end = 0, used = 0;
while (end < clean.length()) {
int cp = clean.codePointAt(end);
int length = new String(Character.toChars(cp)).getBytes(StandardCharsets.UTF_8).length;
if (used + length > bytes) break;
used += length; end += Character.charCount(cp);
}
return clean.substring(0,end);
}
private void validate(ExecutionIdentity identity) {
Objects.requireNonNull(identity, "Execution identity required");
scope(identity.workspaceId(), identity.conversationId());
required(identity.runtimeKind(),40); required(identity.invocationKey(),191);
required(identity.logicalCallId(),191); required(identity.toolName(),191); required(identity.ownerFence(),191);
if (identity.attemptNo() < 1) throw new IllegalArgumentException("Attempt number must be positive");
}
private void scope(Long workspaceId, String conversationId) {
if (workspaceId == null) throw new IllegalArgumentException("Workspace required");
required(conversationId,128);
}
private void required(String value, int max) {
if (value == null || value.isBlank() || value.length() > max)
throw new IllegalArgumentException("Missing or oversized execution identity");
}
private IllegalStateException conflict() { return new IllegalStateException("Immutable execution evidence conflict"); }
private static Instant now() { return Instant.now().truncatedTo(ChronoUnit.MICROS); }
private static Timestamp timestamp(Instant instant) { return instant == null ? null : Timestamp.from(instant); }
private static Instant instant(ResultSet row, String column) throws SQLException {
var value = row.getTimestamp(column); return value == null ? null : value.toInstant();
}
private ExecutionAttempt attempt(ResultSet row, int number) throws SQLException {
var identity = new ExecutionIdentity(row.getObject("workspace_id",Long.class),row.getString("conversation_id"),
row.getString("runtime_kind"),row.getString("runtime_session_id"),row.getString("invocation_key"),
row.getString("logical_call_id"),row.getInt("attempt_no"),row.getString("provider_tool_call_id"),
row.getString("tool_name"),row.getObject("goal_id",Long.class),row.getString("goal_attempt_id"),
row.getObject("team_run_id",Long.class),row.getObject("team_task_id",Long.class),
row.getObject("cron_run_id",Long.class),row.getString("approval_id"),row.getString("owner_fence"));
return new ExecutionAttempt(row.getLong("id"), identity,AttemptState.valueOf(row.getString("state")),
EffectOutcome.valueOf(row.getString("effect_outcome")),instant(row,"started_at"),instant(row,"finished_at"));
}
private ExecutionEvidence evidence(ResultSet row, int number) throws SQLException {
var observation = new EvidenceObservation(row.getString("source_key"),EvidenceKind.valueOf(row.getString("kind")),
EvidenceResult.valueOf(row.getString("result")),SourceLevel.valueOf(row.getString("source_level")),
row.getObject("scope_id",Long.class),row.getObject("generation",Long.class),row.getString("input_fingerprint"),
row.getString("recipe_id"),row.getObject("recipe_revision",Long.class),row.getString("check_scope"),
row.getString("artifact_ref"),row.getString("artifact_digest"),row.getString("summary"),row.getString("payload_ref"),
instant(row,"observed_at"),instant(row,"expires_at"));
return new ExecutionEvidence(row.getLong("id"),row.getLong("workspace_id"),row.getLong("attempt_id"),
row.getString("conversation_id"),observation);
}
}

View File

@ -0,0 +1,100 @@
package vip.mate.execution.evidence.service;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ExecutionAttribution;
import vip.mate.execution.evidence.model.ExecutionIdentity;
import vip.mate.team.service.TeamWorkerConversationGovernanceService;
import java.util.Objects;
import java.time.LocalDateTime;
/** Resolves business linkage from persisted rows, never from tool arguments. */
@Service
public class ExecutionIdentityResolver {
private final JdbcTemplate jdbc;
private final TeamWorkerConversationGovernanceService teamGovernance;
public ExecutionIdentityResolver(JdbcTemplate jdbc, TeamWorkerConversationGovernanceService teamGovernance) {
this.jdbc = jdbc;
this.teamGovernance = teamGovernance;
}
public ExecutionIdentity resolve(ChatOrigin origin, String invocationKey, String providerCallId, String toolName) {
if (origin == null || origin.conversationId() == null) return null;
var workspaces = jdbc.queryForList("SELECT workspace_id FROM mate_conversation WHERE conversation_id=? AND deleted=0",
Long.class, origin.conversationId());
if (workspaces.size() != 1 || workspaces.getFirst() == null) return null;
Long workspaceId = workspaces.getFirst();
if (origin.workspaceId() != null && !workspaceId.equals(origin.workspaceId())) return null;
ExecutionAttribution source = origin.executionAttribution();
Long goalId = source == null ? null : source.goalId();
String goalAttemptId = source == null ? null : source.goalAttemptId();
Long cronRunId = source == null ? null : source.cronRunId();
String approvalId = source == null ? null : source.approvalId();
if (goalId == null && cronRunId == null) {
var activeGoals = jdbc.queryForList("SELECT id FROM mate_agent_goal WHERE conversation_id=? AND workspace_id=? AND status='active' AND deleted=0",
Long.class, origin.conversationId(), workspaceId);
if (activeGoals.size() == 1) goalId = activeGoals.getFirst();
}
if (goalId != null && !exists("SELECT COUNT(*) FROM mate_agent_goal WHERE id=? AND conversation_id=? AND workspace_id=? AND deleted=0",
goalId, origin.conversationId(), workspaceId)) return null;
if (goalAttemptId != null && !exists("SELECT COUNT(*) FROM mate_goal_attempt WHERE attempt_id=? AND goal_id=? AND conversation_id=? AND lease_token=?",
goalAttemptId, goalId, origin.conversationId(), source.ownerFence())) return null;
if (cronRunId != null && !exists("SELECT COUNT(*) FROM mate_cron_job_run WHERE id=? AND conversation_id=?",
cronRunId, origin.conversationId())) return null;
if (approvalId != null && !exists("SELECT COUNT(*) FROM mate_tool_approval WHERE pending_id=? AND conversation_id=?",
approvalId, origin.conversationId())) return null;
var team = teamGovernance.resolve(origin.conversationId(), null, null).orElse(null);
String fence = source != null && source.ownerFence() != null ? source.ownerFence() : invocationKey;
String key = approvalId == null ? invocationKey : "approval:" + approvalId;
return new ExecutionIdentity(workspaceId, origin.conversationId(), "native", goalAttemptId,
key, key, 1, approvalId == null ? providerCallId : null, toolName, goalId, goalAttemptId,
team == null ? null : team.runId(), team == null ? null : team.taskId(), cronRunId,
approvalId, goalAttemptId != null ? fence : approvalId == null ? fence : "approval:" + approvalId);
}
/** An expired business owner may leave historical observations, but cannot publish a new terminal result. */
public boolean isCurrent(ExecutionIdentity identity) {
if (identity.goalAttemptId() != null && !exists("""
SELECT COUNT(*) FROM mate_goal_attempt WHERE attempt_id=? AND goal_id=? AND conversation_id=?
AND state IN ('claimed','running') AND lease_until>CURRENT_TIMESTAMP
""", identity.goalAttemptId(), identity.goalId(), identity.conversationId())) return false;
return identity.cronRunId() == null || exists("SELECT COUNT(*) FROM mate_cron_job_run WHERE id=? AND conversation_id=? AND status='running'",
identity.cronRunId(), identity.conversationId());
}
/** Lock order: conversation, business owner, then execution attempt. Called inside the store transaction. */
public boolean lockCurrentForUpdate(ExecutionIdentity identity, boolean terminal) {
var conversations = jdbc.queryForList("SELECT workspace_id,deleted FROM mate_conversation WHERE conversation_id=? FOR UPDATE",
identity.conversationId());
if (conversations.size() != 1 || !Objects.equals(((Number) conversations.getFirst().get("workspace_id")).longValue(), identity.workspaceId())
|| ((Number) conversations.getFirst().get("deleted")).intValue() != 0) {
throw new IllegalStateException("Execution conversation unavailable");
}
if (!terminal) return true;
if (identity.goalAttemptId() != null) {
var owners = jdbc.query("SELECT goal_id,conversation_id,lease_token,state,lease_until FROM mate_goal_attempt WHERE attempt_id=? FOR UPDATE",
(row, index) -> Objects.equals(row.getLong("goal_id"), identity.goalId())
&& Objects.equals(row.getString("conversation_id"), identity.conversationId())
&& Objects.equals(row.getString("lease_token"), identity.ownerFence())
&& ("claimed".equals(row.getString("state")) || "running".equals(row.getString("state")))
&& row.getTimestamp("lease_until") != null
&& row.getTimestamp("lease_until").toLocalDateTime().isAfter(LocalDateTime.now()),
identity.goalAttemptId());
if (owners.size() != 1 || !owners.getFirst()) return false;
}
if (identity.cronRunId() != null) {
var owners = jdbc.query("SELECT conversation_id,status FROM mate_cron_job_run WHERE id=? FOR UPDATE",
(row, index) -> Objects.equals(row.getString("conversation_id"), identity.conversationId())
&& "running".equals(row.getString("status")), identity.cronRunId());
if (owners.size() != 1 || !owners.getFirst()) return false;
}
return true;
}
private boolean exists(String sql, Object... values) {
return Objects.equals(1L, jdbc.queryForObject(sql, Long.class, values));
}
}

View File

@ -0,0 +1,79 @@
package vip.mate.execution.evidence.service;
import org.springframework.ai.chat.model.ToolContext;
import vip.mate.execution.evidence.model.AttemptState;
import vip.mate.execution.evidence.model.EvidenceObservation;
import vip.mate.execution.evidence.model.EvidenceKind;
import vip.mate.execution.evidence.model.EvidenceResult;
import vip.mate.execution.evidence.model.SourceLevel;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
/** Typed, invocation-local channel for trusted implementations to report observations. */
public final class ExecutionObservationSink {
private static final String KEY = "mateclaw.executionObservationSink";
private final boolean metadataOnly;
private final int maxObservations;
private final List<EvidenceObservation> observations = new ArrayList<>();
private AttemptState state = AttemptState.SUCCEEDED;
private boolean sealed;
public ExecutionObservationSink(boolean metadataOnly) { this(metadataOnly, 32); }
public ExecutionObservationSink(boolean metadataOnly, int maxObservations) {
this.metadataOnly = metadataOnly;
this.maxObservations = Math.clamp(maxObservations, 1, 99);
}
public ToolContext attach(ToolContext context) {
var values = new HashMap<String, Object>(context.getContext());
values.put(KEY, this);
return new ToolContext(values);
}
public static ExecutionObservationSink from(ToolContext context) {
if (context == null) return null;
Object sink = context.getContext().get(KEY);
return sink instanceof ExecutionObservationSink typed ? typed : null;
}
public boolean metadataOnly() { return metadataOnly; }
public synchronized AttemptState state() { return state; }
public synchronized List<EvidenceObservation> observations() { return List.copyOf(observations); }
public synchronized void seal() { sealed = true; }
/** Called by the process adapter, never by parsing a tool's returned text. */
public void command(Integer exitCode, boolean timedOut, boolean cancelled, boolean blocked) {
command(exitCode, timedOut, cancelled, blocked, null);
}
public synchronized void command(Integer exitCode, boolean timedOut, boolean cancelled, boolean blocked, String workingDirectory) {
if (sealed) return;
state = cancelled ? AttemptState.CANCELLED : timedOut || exitCode == null ? AttemptState.UNKNOWN
: blocked ? AttemptState.BLOCKED : exitCode == 0 ? AttemptState.SUCCEEDED : AttemptState.FAILED;
EvidenceResult result = state == AttemptState.SUCCEEDED ? EvidenceResult.OBSERVED
: state == AttemptState.UNKNOWN || state == AttemptState.CANCELLED
? EvidenceResult.UNKNOWN : EvidenceResult.FAIL;
append(new EvidenceObservation("command", EvidenceKind.COMMAND_EXIT, result,
SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null, workingDirectory,
null, null, "exit=" + exitCode + "; timedOut=" + timedOut
+ "; cancelled=" + cancelled + "; blocked=" + blocked, null, null, null));
}
/** Called only after file bytes and owner metadata have survived durable read-back. */
public synchronized void artifact(String id, String digest, long length, String mimeType, Instant expiresAt) {
append(new EvidenceObservation("artifact:" + id, EvidenceKind.ARTIFACT_SNAPSHOT,
EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null,
null, id, digest, "bytes=" + length + "; mime=" + mimeType, null, null, expiresAt));
}
private void append(EvidenceObservation observation) {
if (!sealed && !metadataOnly && observations.size() < maxObservations
&& observations.stream().noneMatch(e -> e.sourceKey().equals(observation.sourceKey()))) {
observations.add(observation);
}
}
}

View File

@ -5,6 +5,7 @@ import org.springframework.stereotype.Component;
import reactor.core.Disposable;
import vip.mate.agent.AgentService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ExecutionAttribution;
import vip.mate.agent.context.GoalContinuationContext;
import vip.mate.agent.runtime.ConversationTurnGate;
import vip.mate.approval.ApprovalWorkflowService;
@ -126,7 +127,10 @@ public class GoalSegmentRunner {
String guidance=recovered ? "The previous execution was interrupted by a runtime restart. "
+ "Inspect the workspace, progress ledger and existing async handles before acting. "
+ "Do not replay side effects whose outcome is unknown; request review if their outcome cannot be verified.\n" : "";
ChatOrigin origin=ChatOrigin.web(convId,goal.getCreatedBy(),goal.getWorkspaceId(),null).withAgent(goal.getAgentId());
ChatOrigin origin=ChatOrigin.web(convId,goal.getCreatedBy(),goal.getWorkspaceId(),null).withAgent(goal.getAgentId())
.withExecutionAttribution(new ExecutionAttribution(goal.getId(),
claimedRun == null ? null : claimedRun.attempt().id(), null, null,
claimedRun == null ? null : claimedRun.attempt().leaseToken()));
SegmentResult result;
ConversationInputQueueStore.QueuedInput queued=claimNextInput(convId,claimedRun);
do {

View File

@ -61,7 +61,7 @@ public class TeamWorkerInterventionService {
}
heartbeat = dispatchService.startLeaseHeartbeat(taskId);
conversationService.removeApprovalPlaceholders(intervention.conversationId());
ChatOrigin origin = approvalService.restoreChatOrigin(claimedPending.getChatOrigin());
ChatOrigin origin = approvalService.restoreChatOrigin(claimedPending.getChatOrigin()).withApprovalId(claimedPending.getPendingId());
AgentService.ChatResult result;
try {
result = turnGate.withPermit(permit, () -> agentService.chatWithReplayWithUsage(

View File

@ -9,6 +9,7 @@ import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.tool.document.GeneratedFileCache;
import vip.mate.execution.evidence.service.ExecutionObservationSink;
import vip.mate.tool.document.WorkspaceArtifactSurfacer;
import java.io.IOException;
@ -63,6 +64,7 @@ public class ShellExecuteTool {
// ChatOrigin so the workspace boundary check honors per-agent basePath.
@Nullable ToolContext ctx) {
ExecutionObservationSink evidence = ExecutionObservationSink.from(ctx);
int timeout = (timeoutSeconds != null && timeoutSeconds > 0) ? timeoutSeconds : DEFAULT_TIMEOUT_SECONDS;
// 硬上限不允许超过 300
timeout = Math.min(timeout, 300);
@ -80,6 +82,7 @@ public class ShellExecuteTool {
vip.mate.tool.guard.WorkspacePathGuard.validateShellCommand(command, ctx);
} catch (IllegalArgumentException e) {
log.warn("[ShellExecute] Sandbox rejected command: {}", e.getMessage());
if (evidence != null) evidence.command(-1, false, false, true);
result.set("exitCode", -1);
result.set("stdout", "");
result.set("stderr", e.getMessage());
@ -91,6 +94,7 @@ public class ShellExecuteTool {
Path stdoutFile = null;
Path stderrFile = null;
Process process = null;
String observedDirectory = null;
try {
// 处理命令中的嵌入换行符LLM 生成的 JSON 解码后可能包含真实换行
@ -98,6 +102,7 @@ public class ShellExecuteTool {
String sanitizedCommand = collapseEmbeddedNewlines(command);
ProcessBuilder pb = buildShellProcess(sanitizedCommand, ctx);
observedDirectory = (pb.directory() == null ? Path.of("") : pb.directory().toPath()).toAbsolutePath().normalize().toString();
// 不继承环境变量中的敏感信息
pb.environment().keySet().removeIf(key ->
key.contains("KEY") || key.contains("SECRET") || key.contains("TOKEN")
@ -121,6 +126,7 @@ public class ShellExecuteTool {
if (!completed) {
// 超时强制终止进程
killProcessTree(process);
if (evidence != null) evidence.command(null, true, false, false, observedDirectory);
log.warn("[ShellExecute] Command timed out after {}s: {}", timeout, truncateForLog(command));
result.set("exitCode", -1);
result.set("stdout", readFileTruncated(stdoutFile, MAX_OUTPUT_BYTES));
@ -129,6 +135,7 @@ public class ShellExecuteTool {
result.set("message", i18n.msg("tool.shell.error.timeout", timeout));
} else {
int exitCode = process.exitValue();
if (evidence != null) evidence.command(exitCode, false, false, false, observedDirectory);
String stdout = readFileTruncated(stdoutFile, MAX_OUTPUT_BYTES);
String stderr = readFileTruncated(stderrFile, MAX_OUTPUT_BYTES);
log.info("[ShellExecute] Command completed: exitCode={}, stdout={}chars, stderr={}chars",
@ -155,6 +162,7 @@ public class ShellExecuteTool {
killProcessTree(process);
}
Thread.currentThread().interrupt();
if (evidence != null) evidence.command(null, false, true, false, observedDirectory);
log.info("[ShellExecute] Command interrupted by cancellation");
result.set("exitCode", -1);
result.set("stdout", "");
@ -162,6 +170,7 @@ public class ShellExecuteTool {
result.set("timedOut", false);
result.set("cancelled", true);
} catch (Exception e) {
if (evidence != null) evidence.command(null, false, false, false, observedDirectory);
log.error("[ShellExecute] Command execution failed: {}", e.getMessage(), e);
result.set("exitCode", -1);
result.set("stdout", "");

View File

@ -10,6 +10,13 @@ import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.execution.evidence.service.ExecutionObservationSink;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Objects;
import java.util.HexFormat;
import java.util.Arrays;
import java.time.Instant;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@ -163,7 +170,25 @@ public class GeneratedFileCache {
}
public String put(byte[] bytes, String filename, String mimeType, @Nullable ToolContext ctx) {
return put(bytes, filename, mimeType, Owner.from(ctx));
String id = put(bytes, filename, mimeType, Owner.from(ctx));
ExecutionObservationSink sink = ExecutionObservationSink.from(ctx);
if (sink != null && !sink.metadataOnly()) {
Entry durable = loadFromDisk(id);
Owner owner = Owner.from(ctx);
if (durable != null && owner.workspaceId() != null && owner.conversationId() != null
&& owner.workspaceId().equals(durable.workspaceId())
&& owner.conversationId().equals(durable.conversationId())
&& Objects.equals(owner.ownerUserId(), durable.ownerUserId())
&& Arrays.equals(bytes, durable.bytes())) {
try {
String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(durable.bytes()));
sink.artifact(id, digest, durable.bytes().length, durable.mimeType(), Instant.ofEpochMilli(durable.expireAt()));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is unavailable", e);
}
}
}
return id;
}
public String put(byte[] bytes, String filename, String mimeType, @Nullable Owner owner) {
@ -360,6 +385,23 @@ public class GeneratedFileCache {
}
}
/** Bounded metadata-only probe. Availability does not imply that current content is verified. */
public boolean isDurablyAvailable(String id, Long workspaceId, String conversationId) {
if (id == null || !ID_RE.matcher(id).matches()) return false;
Path bin = storageDir.resolve(id).normalize();
Path meta = storageDir.resolve(id + META_SUFFIX).normalize();
try {
if (!bin.startsWith(storageDir) || !Files.isRegularFile(bin)
|| !Files.isRegularFile(meta) || Files.size(meta) > 16_384) return false;
Metadata stored = parseMeta(Files.readString(meta), id);
return stored.expireAt() > System.currentTimeMillis()
&& Objects.equals(workspaceId, stored.workspaceId())
&& Objects.equals(conversationId, stored.conversationId());
} catch (Exception unavailable) {
return false;
}
}
private Entry loadFromDisk(String id) {
Path bin = storageDir.resolve(id).normalize();
Path meta = storageDir.resolve(id + META_SUFFIX).normalize();

View File

@ -131,6 +131,16 @@ springdoc:
# MateClaw 自定义配置
mateclaw:
execution-evidence:
# Observe receipts only. Enforcement requires managed verification scopes and is not available yet.
mode: observe
retention-days: 90
max-summary-bytes: 2048
max-observations: 32
default-list-limit: 20
max-list-limit: 100
cleanup-interval-ms: 60000
cleanup-max-batches: 10
a2a:
enabled: ${MATECLAW_A2A_ENABLED:false}
# Public base URL for Agent Cards. Production deployments should set this

View File

@ -0,0 +1,89 @@
-- Bounded execution facts, independent of runtime recovery state.
CREATE TABLE mate_execution_attempt (
id BIGINT PRIMARY KEY,
workspace_id BIGINT NOT NULL,
conversation_id VARCHAR(128) NOT NULL,
runtime_kind VARCHAR(40) NOT NULL,
runtime_session_id VARCHAR(128),
invocation_key VARCHAR(191) NOT NULL,
logical_call_id VARCHAR(191) NOT NULL,
attempt_no INTEGER NOT NULL,
provider_tool_call_id VARCHAR(191),
tool_name VARCHAR(191) NOT NULL,
goal_id BIGINT,
goal_attempt_id VARCHAR(128),
team_run_id BIGINT,
team_task_id BIGINT,
cron_run_id BIGINT,
approval_id VARCHAR(128),
owner_fence VARCHAR(191) NOT NULL,
state VARCHAR(20) NOT NULL,
effect_outcome VARCHAR(20) NOT NULL,
started_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP,
failure_reason VARCHAR(2048),
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted INTEGER NOT NULL DEFAULT 0,
CONSTRAINT uk_execution_invocation UNIQUE(workspace_id, invocation_key),
CONSTRAINT uk_execution_logical_attempt UNIQUE(workspace_id, logical_call_id, attempt_no)
);
CREATE INDEX idx_execution_conversation ON mate_execution_attempt(workspace_id, conversation_id, started_at, id);
CREATE INDEX idx_execution_state ON mate_execution_attempt(state, update_time);
CREATE INDEX idx_execution_goal ON mate_execution_attempt(workspace_id, goal_id);
CREATE TABLE mate_execution_evidence (
id BIGINT PRIMARY KEY,
workspace_id BIGINT NOT NULL,
attempt_id BIGINT NOT NULL,
source_key VARCHAR(191) NOT NULL,
kind VARCHAR(40) NOT NULL,
result VARCHAR(20) NOT NULL,
source_level VARCHAR(40) NOT NULL,
scope_id BIGINT,
generation BIGINT,
input_fingerprint VARCHAR(128),
recipe_id VARCHAR(191),
recipe_revision BIGINT,
check_scope VARCHAR(2048),
artifact_ref VARCHAR(512),
artifact_digest VARCHAR(128),
summary VARCHAR(2048),
payload_ref VARCHAR(512),
observed_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted INTEGER NOT NULL DEFAULT 0,
CONSTRAINT uk_execution_evidence_source UNIQUE(attempt_id, source_key),
CONSTRAINT fk_execution_evidence_attempt FOREIGN KEY(attempt_id) REFERENCES mate_execution_attempt(id)
);
CREATE INDEX idx_evidence_workspace_observed ON mate_execution_evidence(workspace_id, observed_at, id);
CREATE TABLE mate_evidence_scope (
id BIGINT PRIMARY KEY,
workspace_id BIGINT NOT NULL,
resource_key VARCHAR(191) NOT NULL,
host_id VARCHAR(191) NOT NULL,
root_id VARCHAR(191) NOT NULL,
generation BIGINT NOT NULL DEFAULT 0,
active_mutations INTEGER NOT NULL DEFAULT 0,
tainted BOOLEAN NOT NULL DEFAULT FALSE,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted INTEGER NOT NULL DEFAULT 0,
CONSTRAINT uk_evidence_scope_resource UNIQUE(workspace_id, resource_key)
);
CREATE TABLE mate_goal_criterion_evidence (
id BIGINT PRIMARY KEY,
workspace_id BIGINT NOT NULL,
goal_id BIGINT NOT NULL,
criterion_id VARCHAR(191) NOT NULL,
criterion_revision BIGINT NOT NULL,
evidence_id BIGINT NOT NULL,
bound_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted INTEGER NOT NULL DEFAULT 0,
CONSTRAINT uk_goal_criterion_evidence UNIQUE(goal_id, criterion_id, criterion_revision, evidence_id),
CONSTRAINT fk_goal_criterion_evidence FOREIGN KEY(evidence_id) REFERENCES mate_execution_evidence(id)
);
CREATE INDEX idx_criterion_evidence_goal ON mate_goal_criterion_evidence(workspace_id, goal_id, criterion_id);

View File

@ -0,0 +1,89 @@
-- Bounded execution facts, independent of runtime recovery state.
CREATE TABLE mate_execution_attempt (
id BIGINT PRIMARY KEY,
workspace_id BIGINT NOT NULL,
conversation_id VARCHAR(128) NOT NULL,
runtime_kind VARCHAR(40) NOT NULL,
runtime_session_id VARCHAR(128),
invocation_key VARCHAR(191) NOT NULL,
logical_call_id VARCHAR(191) NOT NULL,
attempt_no INTEGER NOT NULL,
provider_tool_call_id VARCHAR(191),
tool_name VARCHAR(191) NOT NULL,
goal_id BIGINT,
goal_attempt_id VARCHAR(128),
team_run_id BIGINT,
team_task_id BIGINT,
cron_run_id BIGINT,
approval_id VARCHAR(128),
owner_fence VARCHAR(191) NOT NULL,
state VARCHAR(20) NOT NULL,
effect_outcome VARCHAR(20) NOT NULL,
started_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP,
failure_reason VARCHAR(2048),
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted INTEGER NOT NULL DEFAULT 0,
CONSTRAINT uk_execution_invocation UNIQUE(workspace_id, invocation_key),
CONSTRAINT uk_execution_logical_attempt UNIQUE(workspace_id, logical_call_id, attempt_no)
);
CREATE INDEX idx_execution_conversation ON mate_execution_attempt(workspace_id, conversation_id, started_at, id);
CREATE INDEX idx_execution_state ON mate_execution_attempt(state, update_time);
CREATE INDEX idx_execution_goal ON mate_execution_attempt(workspace_id, goal_id);
CREATE TABLE mate_execution_evidence (
id BIGINT PRIMARY KEY,
workspace_id BIGINT NOT NULL,
attempt_id BIGINT NOT NULL,
source_key VARCHAR(191) NOT NULL,
kind VARCHAR(40) NOT NULL,
result VARCHAR(20) NOT NULL,
source_level VARCHAR(40) NOT NULL,
scope_id BIGINT,
generation BIGINT,
input_fingerprint VARCHAR(128),
recipe_id VARCHAR(191),
recipe_revision BIGINT,
check_scope VARCHAR(2048),
artifact_ref VARCHAR(512),
artifact_digest VARCHAR(128),
summary VARCHAR(2048),
payload_ref VARCHAR(512),
observed_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted INTEGER NOT NULL DEFAULT 0,
CONSTRAINT uk_execution_evidence_source UNIQUE(attempt_id, source_key),
CONSTRAINT fk_execution_evidence_attempt FOREIGN KEY(attempt_id) REFERENCES mate_execution_attempt(id)
);
CREATE INDEX idx_evidence_workspace_observed ON mate_execution_evidence(workspace_id, observed_at, id);
CREATE TABLE mate_evidence_scope (
id BIGINT PRIMARY KEY,
workspace_id BIGINT NOT NULL,
resource_key VARCHAR(191) NOT NULL,
host_id VARCHAR(191) NOT NULL,
root_id VARCHAR(191) NOT NULL,
generation BIGINT NOT NULL DEFAULT 0,
active_mutations INTEGER NOT NULL DEFAULT 0,
tainted BOOLEAN NOT NULL DEFAULT FALSE,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted INTEGER NOT NULL DEFAULT 0,
CONSTRAINT uk_evidence_scope_resource UNIQUE(workspace_id, resource_key)
);
CREATE TABLE mate_goal_criterion_evidence (
id BIGINT PRIMARY KEY,
workspace_id BIGINT NOT NULL,
goal_id BIGINT NOT NULL,
criterion_id VARCHAR(191) NOT NULL,
criterion_revision BIGINT NOT NULL,
evidence_id BIGINT NOT NULL,
bound_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted INTEGER NOT NULL DEFAULT 0,
CONSTRAINT uk_goal_criterion_evidence UNIQUE(goal_id, criterion_id, criterion_revision, evidence_id),
CONSTRAINT fk_goal_criterion_evidence FOREIGN KEY(evidence_id) REFERENCES mate_execution_evidence(id)
);
CREATE INDEX idx_criterion_evidence_goal ON mate_goal_criterion_evidence(workspace_id, goal_id, criterion_id);

View File

@ -0,0 +1,89 @@
-- Bounded execution facts, independent of runtime recovery state.
CREATE TABLE mate_execution_attempt (
id BIGINT PRIMARY KEY,
workspace_id BIGINT NOT NULL,
conversation_id VARCHAR(128) NOT NULL,
runtime_kind VARCHAR(40) NOT NULL,
runtime_session_id VARCHAR(128),
invocation_key VARCHAR(191) NOT NULL,
logical_call_id VARCHAR(191) NOT NULL,
attempt_no INTEGER NOT NULL,
provider_tool_call_id VARCHAR(191),
tool_name VARCHAR(191) NOT NULL,
goal_id BIGINT,
goal_attempt_id VARCHAR(128),
team_run_id BIGINT,
team_task_id BIGINT,
cron_run_id BIGINT,
approval_id VARCHAR(128),
owner_fence VARCHAR(191) NOT NULL,
state VARCHAR(20) NOT NULL,
effect_outcome VARCHAR(20) NOT NULL,
started_at DATETIME(6) NOT NULL,
finished_at DATETIME(6),
failure_reason VARCHAR(2048),
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
deleted INTEGER NOT NULL DEFAULT 0,
CONSTRAINT uk_execution_invocation UNIQUE(workspace_id, invocation_key),
CONSTRAINT uk_execution_logical_attempt UNIQUE(workspace_id, logical_call_id, attempt_no)
);
CREATE INDEX idx_execution_conversation ON mate_execution_attempt(workspace_id, conversation_id, started_at, id);
CREATE INDEX idx_execution_state ON mate_execution_attempt(state, update_time);
CREATE INDEX idx_execution_goal ON mate_execution_attempt(workspace_id, goal_id);
CREATE TABLE mate_execution_evidence (
id BIGINT PRIMARY KEY,
workspace_id BIGINT NOT NULL,
attempt_id BIGINT NOT NULL,
source_key VARCHAR(191) NOT NULL,
kind VARCHAR(40) NOT NULL,
result VARCHAR(20) NOT NULL,
source_level VARCHAR(40) NOT NULL,
scope_id BIGINT,
generation BIGINT,
input_fingerprint VARCHAR(128),
recipe_id VARCHAR(191),
recipe_revision BIGINT,
check_scope VARCHAR(2048),
artifact_ref VARCHAR(512),
artifact_digest VARCHAR(128),
summary VARCHAR(2048),
payload_ref VARCHAR(512),
observed_at DATETIME(6) NOT NULL,
expires_at DATETIME(6),
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
deleted INTEGER NOT NULL DEFAULT 0,
CONSTRAINT uk_execution_evidence_source UNIQUE(attempt_id, source_key),
CONSTRAINT fk_execution_evidence_attempt FOREIGN KEY(attempt_id) REFERENCES mate_execution_attempt(id)
);
CREATE INDEX idx_evidence_workspace_observed ON mate_execution_evidence(workspace_id, observed_at, id);
CREATE TABLE mate_evidence_scope (
id BIGINT PRIMARY KEY,
workspace_id BIGINT NOT NULL,
resource_key VARCHAR(191) NOT NULL,
host_id VARCHAR(191) NOT NULL,
root_id VARCHAR(191) NOT NULL,
generation BIGINT NOT NULL DEFAULT 0,
active_mutations INTEGER NOT NULL DEFAULT 0,
tainted BOOLEAN NOT NULL DEFAULT FALSE,
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
deleted INTEGER NOT NULL DEFAULT 0,
CONSTRAINT uk_evidence_scope_resource UNIQUE(workspace_id, resource_key)
);
CREATE TABLE mate_goal_criterion_evidence (
id BIGINT PRIMARY KEY,
workspace_id BIGINT NOT NULL,
goal_id BIGINT NOT NULL,
criterion_id VARCHAR(191) NOT NULL,
criterion_revision BIGINT NOT NULL,
evidence_id BIGINT NOT NULL,
bound_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
deleted INTEGER NOT NULL DEFAULT 0,
CONSTRAINT uk_goal_criterion_evidence UNIQUE(goal_id, criterion_id, criterion_revision, evidence_id),
CONSTRAINT fk_goal_criterion_evidence FOREIGN KEY(evidence_id) REFERENCES mate_execution_evidence(id)
);
CREATE INDEX idx_criterion_evidence_goal ON mate_goal_criterion_evidence(workspace_id, goal_id, criterion_id);

View File

@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.agent.AgentService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ExecutionAttribution;
import vip.mate.cron.CronChatOriginFactory;
import vip.mate.cron.CronConversationResolver;
import vip.mate.cron.model.CronJobEntity;
@ -75,7 +76,7 @@ class CronJobOriginPropagationTest {
.thenReturn(new CronJobLifecycleService.StartResult(run, MESSAGE_ID));
when(originFactory.from(job, CONVERSATION_ID, MESSAGE_ID)).thenReturn(origin);
when(heartbeat.begin(55L)).thenReturn(lease);
when(agentService.chatWithUsage(eq(AGENT_ID), anyString(), eq(CONVERSATION_ID), eq(origin)))
when(agentService.chatWithUsage(eq(AGENT_ID), anyString(), eq(CONVERSATION_ID), eq(origin.withExecutionAttribution(new ExecutionAttribution(null, null, 55L, null, "cron:55")))))
.thenReturn(AgentService.ChatResult.contentOnly("done"));
CronJobRunner runner = new CronJobRunner(lifecycle, heartbeat, agentService, originFactory, resolver,
mock(WikiProcessingService.class), new ObjectMapper());
@ -85,7 +86,7 @@ class CronJobOriginPropagationTest {
verify(originFactory).from(job, CONVERSATION_ID, MESSAGE_ID);
verify(heartbeat).begin(55L);
verify(lease).close();
verify(agentService).chatWithUsage(eq(AGENT_ID), anyString(), eq(CONVERSATION_ID), eq(origin));
verify(agentService).chatWithUsage(eq(AGENT_ID), anyString(), eq(CONVERSATION_ID), eq(origin.withExecutionAttribution(new ExecutionAttribution(null, null, 55L, null, "cron:55"))));
verify(agentService, never()).chatWithUsage(eq(AGENT_ID), anyString(), eq(CONVERSATION_ID));
}
@ -106,7 +107,7 @@ class CronJobOriginPropagationTest {
.thenReturn(new CronJobLifecycleService.StartResult(run, MESSAGE_ID));
when(originFactory.from(job, CONVERSATION_ID, MESSAGE_ID)).thenReturn(origin);
when(heartbeat.begin(55L)).thenReturn(lease);
when(agentService.chatWithUsage(eq(AGENT_ID), anyString(), eq(CONVERSATION_ID), eq(origin)))
when(agentService.chatWithUsage(eq(AGENT_ID), anyString(), eq(CONVERSATION_ID), eq(origin.withExecutionAttribution(new ExecutionAttribution(null, null, 55L, null, "cron:55")))))
.thenThrow(new IllegalStateException("provider timeout"));
CronJobRunner runner = new CronJobRunner(lifecycle, heartbeat, agentService, originFactory, resolver,
mock(WikiProcessingService.class), new ObjectMapper());
@ -136,7 +137,7 @@ class CronJobOriginPropagationTest {
.thenReturn(new CronJobLifecycleService.StartResult(run, MESSAGE_ID));
when(originFactory.from(job, CONVERSATION_ID, MESSAGE_ID)).thenReturn(origin);
when(heartbeat.begin(55L)).thenReturn(lease);
when(agentService.chatWithUsage(eq(AGENT_ID), anyString(), eq(CONVERSATION_ID), eq(origin)))
when(agentService.chatWithUsage(eq(AGENT_ID), anyString(), eq(CONVERSATION_ID), eq(origin.withExecutionAttribution(new ExecutionAttribution(null, null, 55L, null, "cron:55")))))
.thenReturn(failed);
CronJobRunner runner = new CronJobRunner(lifecycle, heartbeat, agentService, originFactory, resolver,
mock(WikiProcessingService.class), new ObjectMapper());

View File

@ -0,0 +1,206 @@
package vip.mate.execution.evidence;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.transaction.support.TransactionTemplate;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ExecutionAttribution;
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
import vip.mate.execution.evidence.model.*;
import vip.mate.execution.evidence.service.*;
import vip.mate.team.service.TeamWorkerConversationGovernanceService;
import vip.mate.tool.guard.ToolGuard;
import vip.mate.tool.guard.ToolGuardResult;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class ExecutionEvidenceIntegrationTest {
private JdbcTemplate jdbc;
private DataSourceTransactionManager transactions;
private ExecutionEvidenceStore store;
private ExecutionIdentityResolver identities;
private ExecutionEvidenceRecorder recorder;
private final ChatOrigin origin = ChatOrigin.web("conv", "owner", 1L, null);
private final AtomicInteger executions = new AtomicInteger();
@BeforeEach void setup() {
var source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
new ResourceDatabasePopulator(new ClassPathResource("db/migration/h2/V191__execution_evidence_ledger.sql")).execute(source);
jdbc = new JdbcTemplate(source);
jdbc.execute("CREATE TABLE mate_conversation(conversation_id VARCHAR(128) PRIMARY KEY,workspace_id BIGINT,deleted INT DEFAULT 0)");
jdbc.execute("CREATE TABLE mate_agent_goal(id BIGINT PRIMARY KEY,conversation_id VARCHAR(128),workspace_id BIGINT,status VARCHAR(20),deleted INT DEFAULT 0)");
jdbc.execute("CREATE TABLE mate_goal_attempt(attempt_id VARCHAR(128) PRIMARY KEY,goal_id BIGINT,conversation_id VARCHAR(128),lease_token VARCHAR(128),state VARCHAR(20),lease_until TIMESTAMP)");
jdbc.execute("CREATE TABLE mate_cron_job_run(id BIGINT PRIMARY KEY,conversation_id VARCHAR(128),status VARCHAR(20))");
jdbc.execute("CREATE TABLE mate_tool_approval(pending_id VARCHAR(128) PRIMARY KEY,conversation_id VARCHAR(128))");
jdbc.update("INSERT INTO mate_conversation VALUES('conv',1,0)");
jdbc.update("INSERT INTO mate_tool_approval VALUES('approval-one','conv')");
var teams = mock(TeamWorkerConversationGovernanceService.class);
when(teams.resolve("conv", null, null)).thenReturn(Optional.empty());
when(teams.resolve("child", null, null)).thenReturn(Optional.empty());
identities = new ExecutionIdentityResolver(jdbc, teams);
transactions = new DataSourceTransactionManager(source);
var properties = new ExecutionEvidenceProperties();
store = new ExecutionEvidenceStore(jdbc, transactions, properties);
store.setOwnershipValidator(identities);
recorder = new ExecutionEvidenceRecorder(store, identities, properties, new SimpleMeterRegistry());
}
@Test void repeatedProviderIdsInDifferentRoundsProduceDifferentDurableAttempts() {
var executor = executor(false);
executor.execute(List.of(call()), "conv", "1", false, "owner", null, origin);
executor.execute(List.of(call()), "conv", "1", false, "owner", null, origin);
assertEquals(2, executions.get());
assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM mate_execution_attempt", Integer.class));
var reopened = new ExecutionEvidenceStore(jdbc, transactions, new ExecutionEvidenceProperties());
assertEquals(4, reopened.list(1L, "conv", null, null, 20).size());
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_execution_evidence WHERE result='PASS'", Integer.class));
}
@Test void approvalReplayIsCapturedOnceAndDoesNotPoisonLaterOrdinaryTools() {
ChatOrigin replay = origin.withApprovalId("approval-one");
var executor = executor(false);
executor.execute(List.of(call()), "conv", "1", true, "owner", null, replay);
executor.execute(List.of(call()), "conv", "1", true, "owner", null, replay);
assertEquals(1, executions.get());
executor.execute(List.of(call()), "conv", "1", false, "owner", null, replay);
assertEquals(2, executions.get());
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_execution_attempt WHERE approval_id='approval-one'", Integer.class));
}
@Test void preapprovedDirectResultKeepsOnlyMetadataAndPreservesDirectOutput() {
var outputs = new ArrayList<DirectToolOutput>();
var events = new ArrayList<GraphEventPublisher.GraphEvent>();
var response = executor(true).executePreApproved(call(), "{}", events, "conv", null,
outputs, origin.withApprovalId("approval-one"));
assertFalse(response.responseData().contains("private-output"));
assertEquals("private-output", outputs.getFirst().fullResult());
var evidence = store.list(1L, "conv", null, null, 20);
assertEquals(1, evidence.size());
assertEquals(EvidenceKind.TOOL_RETURNED, evidence.getFirst().observation().kind());
assertFalse(evidence.toString().contains("private"));
}
@Test void attributionSurvivesOriginWithersAndJsonButMustMatchPersistedBusinessRows() throws Exception {
jdbc.update("INSERT INTO mate_agent_goal VALUES(100,'conv',1,'active',0)");
jdbc.update("INSERT INTO mate_goal_attempt VALUES('goal-attempt',100,'conv','lease','running',?)", LocalDateTime.now().plusMinutes(2));
var attributed = origin.withExecutionAttribution(new ExecutionAttribution(100L, "goal-attempt", null, null, "lease"));
var mapper = new ObjectMapper();
attributed = mapper.readValue(mapper.writeValueAsString(attributed), ChatOrigin.class)
.withAgent(20L).withSender("Owner", "web", null).withWorkspace(1L, null)
.withConversationId("conv").withBaseUrl("http://localhost").withOriginMessageId(19L);
var identity = identities.resolve(attributed, "invocation", "provider", "tool");
assertEquals(100L, identity.goalId());
assertEquals("goal-attempt", identity.goalAttemptId());
assertNull(identities.resolve(attributed.withWorkspace(2L, null), "invocation", "provider", "tool"));
assertNull(identities.resolve(attributed.withApprovalId("not-real"), "invocation", "provider", "tool"));
}
@Test void revokedOwnerCannotPublishAfterAConcurrentRevocationCommits() throws Exception {
jdbc.update("INSERT INTO mate_agent_goal VALUES(100,'conv',1,'active',0)");
jdbc.update("INSERT INTO mate_goal_attempt VALUES('goal-attempt',100,'conv','lease','running',?)", LocalDateTime.now().plusMinutes(2));
var linked = origin.withExecutionAttribution(new ExecutionAttribution(100L, "goal-attempt", null, null, "lease"));
var attempt = store.begin(identities.resolve(linked, "invocation", "provider", "tool"));
CountDownLatch locked = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
try (var pool = Executors.newFixedThreadPool(2)) {
var revoke = pool.submit(() -> new TransactionTemplate(transactions).execute(status -> {
jdbc.update("UPDATE mate_goal_attempt SET state='cancelled' WHERE attempt_id='goal-attempt'");
locked.countDown();
await(release);
return null;
}));
assertTrue(locked.await(5, TimeUnit.SECONDS));
var finish = pool.submit(() -> assertThrows(IllegalStateException.class,
() -> store.finish(attempt.id(), "lease", AttemptState.SUCCEEDED, EffectOutcome.UNCERTAIN,
List.of(new EvidenceObservation("callback", EvidenceKind.TOOL_RETURNED, EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, "returned")))));
release.countDown(); revoke.get(5, TimeUnit.SECONDS); finish.get(5, TimeUnit.SECONDS);
}
assertEquals(AttemptState.STARTED, store.findAttempt(attempt.id()).orElseThrow().state());
assertTrue(store.list(1L, "conv", null, null, 20).isEmpty());
}
@Test void identicalReceiptRetryAfterOwnerSettlementReturnsOriginalEvidence() {
jdbc.update("INSERT INTO mate_cron_job_run VALUES(100,'conv','running')");
var linked = origin.withExecutionAttribution(new ExecutionAttribution(null, null, 100L, null, "cron:100"));
var attempt = store.begin(identities.resolve(linked, "invocation", "provider", "tool"));
var observations = List.of(new EvidenceObservation("callback", EvidenceKind.TOOL_RETURNED,
EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, "returned"));
var first = store.finish(attempt.id(), "cron:100", AttemptState.SUCCEEDED, EffectOutcome.UNCERTAIN, observations);
jdbc.update("UPDATE mate_cron_job_run SET status='completed' WHERE id=100");
assertEquals(first, store.finish(attempt.id(), "cron:100", AttemptState.SUCCEEDED, EffectOutcome.UNCERTAIN, observations));
}
@Test void childConversationDropsParentGoalAndCronAttributionButCanRecordOwnTools() {
jdbc.update("INSERT INTO mate_conversation VALUES('child',1,0)");
for (ExecutionAttribution source : List.of(new ExecutionAttribution(100L, "parent-attempt", null, null, "lease"),
new ExecutionAttribution(null, null, 100L, null, "cron:100"))) {
var child = origin.withExecutionAttribution(source).withConversationId("child");
assertNull(child.executionAttribution());
var identity = identities.resolve(child, UUID.randomUUID().toString(), "provider", "tool");
assertNotNull(identity);
assertNull(identity.goalId());
assertNull(identity.cronRunId());
assertTrue(store.reserve(identity).created());
}
}
@Test void deletionBetweenResolutionAndReservationCannotRecreateErasedEvidence() {
var identity = identities.resolve(origin, "invocation", "provider", "tool");
jdbc.update("UPDATE mate_conversation SET deleted=1 WHERE conversation_id='conv'");
store.purgeConversation("conv");
assertThrows(IllegalStateException.class, () -> store.reserve(identity));
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_execution_attempt", Integer.class));
}
private ToolExecutionExecutor executor(boolean direct) {
ToolCallback callback = new ToolCallback() {
public ToolDefinition getToolDefinition() { return ToolDefinition.builder().name("evidence_tool").description("test").inputSchema("{}").build(); }
public ToolMetadata getToolMetadata() { return ToolMetadata.builder().returnDirect(direct).build(); }
public String call(String input) { throw new AssertionError("Explicit context required"); }
public String call(String input, ToolContext context) {
executions.incrementAndGet();
var sink = ExecutionObservationSink.from(context);
assertNotNull(sink);
sink.command(1, false, false, false);
return "private-output";
}
};
ToolGuard guard = (name, args) -> ToolGuardResult.allow();
var executor = new ToolExecutionExecutor(AgentToolSet.fromCallbacks(List.of(), List.of(callback)), guard, null, null);
executor.setExecutionEvidenceRecorder(recorder);
return executor;
}
private AssistantMessage.ToolCall call() { return new AssistantMessage.ToolCall("provider-id", "function", "evidence_tool", "{}"); }
private void await(CountDownLatch latch) {
try { assertTrue(latch.await(5, TimeUnit.SECONDS)); }
catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new AssertionError(error); }
}
}

View File

@ -0,0 +1,36 @@
package vip.mate.execution.evidence;
import org.junit.jupiter.api.Test;
import vip.mate.execution.evidence.service.ExecutionEvidenceLifecycle;
import vip.mate.execution.evidence.service.ExecutionEvidenceStore;
import vip.mate.workspace.conversation.event.ConversationDeletedEvent;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ExecutionEvidenceLifecycleTest {
@Test void drainsSeveralBatchesAndStopsWhenCaughtUp() {
var store = mock(ExecutionEvidenceStore.class);
when(store.purgeExpiredMetadata(any(), eq(100))).thenReturn(100, 100, 1);
new ExecutionEvidenceLifecycle(store, new ExecutionEvidenceProperties()).cleanup();
verify(store, times(3)).purgeExpiredMetadata(any(), eq(100));
}
@Test void cleanupRemainsBoundedAndContinuesOnTheNextTick() {
var store = mock(ExecutionEvidenceStore.class);
var properties = new ExecutionEvidenceProperties(); properties.setCleanupMaxBatches(2);
when(store.purgeExpiredMetadata(any(), eq(100))).thenReturn(100, 100, 1);
var lifecycle = new ExecutionEvidenceLifecycle(store, properties);
lifecycle.cleanup();
verify(store, times(2)).purgeExpiredMetadata(any(), eq(100));
lifecycle.cleanup();
verify(store, times(3)).purgeExpiredMetadata(any(), eq(100));
}
@Test void conversationDeletionErasesCopiedContent() {
var store = mock(ExecutionEvidenceStore.class);
new ExecutionEvidenceLifecycle(store, new ExecutionEvidenceProperties())
.onConversationDeleted(new ConversationDeletedEvent("conv"));
verify(store).purgeConversation("conv");
}
}

View File

@ -0,0 +1,111 @@
package vip.mate.execution.evidence;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import vip.mate.config.JacksonConfig;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import vip.mate.auth.service.AuthService;
import vip.mate.exception.MateClawException;
import vip.mate.execution.evidence.model.*;
import vip.mate.execution.evidence.service.*;
import vip.mate.team.service.TeamWorkerConversationGovernanceService;
import vip.mate.tool.document.GeneratedFileCache;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.core.service.WorkspaceService;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ExecutionEvidenceQueryTest {
private final ExecutionEvidenceStore store = mock(ExecutionEvidenceStore.class);
private final ConversationService conversations = mock(ConversationService.class);
private final TeamWorkerConversationGovernanceService teams = mock(TeamWorkerConversationGovernanceService.class);
private final GeneratedFileCache files = mock(GeneratedFileCache.class);
private ExecutionEvidenceQueryService queries;
private final long id = 1999999999999999999L;
private final Instant now = Instant.parse("2026-09-07T00:00:00Z");
@BeforeEach void setup() {
queries = new ExecutionEvidenceQueryService(store, conversations, teams, files,
mock(AuthService.class), mock(WorkspaceService.class), new ExecutionEvidenceProperties(), new SimpleMeterRegistry());
var conversation = new ConversationEntity();
conversation.setConversationId("conv"); conversation.setWorkspaceId(1L); conversation.setDeleted(0);
when(conversations.findByConversationId("conv")).thenReturn(conversation);
when(conversations.isConversationOwner("conv", "owner")).thenReturn(true);
when(store.findAttempt(1L)).thenReturn(Optional.of(new ExecutionAttempt(1L,
new ExecutionIdentity(1L, "conv", "native", null, "call", "call", 1, "provider", "tool",
null, null, null, null, null, null, "fence"),
AttemptState.SUCCEEDED, EffectOutcome.UNCERTAIN, now, now)));
}
@Test void deniesUnscopedAnonymousAndWrongWorkspaceBeforeReadingEvidence() {
assertEquals(404, assertThrows(MateClawException.class, () -> list("stranger", 1L, "conv", null, 20)).getCode());
assertEquals(404, assertThrows(MateClawException.class, () -> list("owner", 2L, "conv", null, 20)).getCode());
assertThrows(MateClawException.class, () -> list(null, 1L, "conv", null, 20));
assertThrows(MateClawException.class, () -> list("owner", 1L, "", null, 20));
verifyNoInteractions(store);
}
@Test void detailDoesNotLeakCrossWorkspaceOrForeignConversation() {
when(store.findById(id)).thenReturn(Optional.of(evidence(id)));
assertEquals(404, assertThrows(MateClawException.class, () -> queries.detail("stranger", 1L, id)).getCode());
assertEquals(404, assertThrows(MateClawException.class, () -> queries.detail("owner", 2L, id)).getCode());
assertEquals(404, assertThrows(MateClawException.class, () -> queries.detail("owner", 1L, 42L)).getCode());
}
@Test void limitsAndCursorPreserveFullPrecisionAndNeverClaimVerification() {
when(store.list(eq(1L), eq("conv"), isNull(), isNull(), eq(2), isNull(), isNull()))
.thenReturn(List.of(evidence(id), evidence(id - 1)));
var page = list("owner", 1L, "conv", null, 1);
assertEquals(id, page.items().getFirst().id());
assertEquals("UNKNOWN", page.items().getFirst().validity());
assertNotNull(page.nextCursor());
when(store.list(eq(1L), eq("conv"), eq(now), eq(id), eq(2), isNull(), isNull()))
.thenReturn(List.of(evidence(id - 1)));
var next = list("owner", 1L, "conv", page.nextCursor(), 1);
assertEquals(id - 1, next.items().getFirst().id());
assertNull(next.nextCursor());
assertThrows(MateClawException.class, () -> list("owner", 1L, "conv", "invalid-cursor", 1));
}
@Test void canonicalTeamTranscriptReaderCanQueryWorkerEvidence() {
when(teams.canReadTranscript("conv", null, null, "reviewer")).thenReturn(true);
when(store.list(eq(1L), eq("conv"), isNull(), isNull(), eq(21), isNull(), isNull())).thenReturn(List.of(evidence(id)));
assertEquals(1, list("reviewer", 1L, "conv", null, null).items().size());
}
@Test void jsonUsesStringIdentifiersAndMissingArtifactsAreUnavailable() throws Exception {
var builder = Jackson2ObjectMapperBuilder.json();
new JacksonConfig().longToStringCustomizer().customize(builder);
var mapper = builder.build();
when(store.findById(id)).thenReturn(Optional.of(evidence(id)));
var json = mapper.readTree(mapper.writeValueAsString(queries.detail("owner", 1L, id)));
assertTrue(json.get("id").isTextual());
assertEquals(Long.toString(id), json.get("id").asText());
var artifact = new EvidenceObservation("artifact:missing", EvidenceKind.ARTIFACT_SNAPSHOT,
EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null,
null, "missing", "digest", "file metadata", null, now, now.minusSeconds(1));
when(store.findById(id)).thenReturn(Optional.of(new ExecutionEvidence(id, 1L, 1L, "conv", artifact)));
var unavailable = queries.detail("owner", 1L, id);
assertEquals("UNAVAILABLE", unavailable.validity());
assertNull(unavailable.artifactRef());
assertNull(unavailable.artifactDigest());
}
private ExecutionEvidenceQueryService.Page list(String user, Long workspace, String conversation, String cursor, Integer limit) {
return queries.list(user, workspace, conversation, cursor, limit, null, null);
}
private ExecutionEvidence evidence(long evidenceId) {
return new ExecutionEvidence(evidenceId, 1L, 1L, "conv", new EvidenceObservation("callback", EvidenceKind.TOOL_RETURNED,
EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null, null,
null, null, "Tool callback returned", null, now, null));
}
}

View File

@ -0,0 +1,106 @@
package vip.mate.execution.evidence;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.execution.evidence.model.*;
import vip.mate.execution.evidence.service.*;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ExecutionEvidenceRecorderTest {
private final ExecutionEvidenceStore store = mock(ExecutionEvidenceStore.class);
private final ExecutionIdentityResolver identities = mock(ExecutionIdentityResolver.class);
private final ExecutionEvidenceProperties properties = new ExecutionEvidenceProperties();
private final ToolCallback callback = mock(ToolCallback.class);
private final ExecutionIdentity identity = new ExecutionIdentity(1L, "conv", "native", null,
"invocation", "invocation", 1, "provider-id", "tool", null, null, null, null, null, null, "fence");
private ExecutionEvidenceRecorder recorder;
@BeforeEach void setup() {
recorder = new ExecutionEvidenceRecorder(store, identities, properties, new SimpleMeterRegistry());
when(callback.getToolDefinition()).thenReturn(ToolDefinition.builder().name("tool").description("test").inputSchema("{}").build());
when(callback.getToolMetadata()).thenReturn(ToolMetadata.builder().returnDirect(false).build());
when(identities.resolve(any(), anyString(), anyString(), anyString())).thenReturn(identity);
when(identities.isCurrent(identity)).thenReturn(true);
when(store.reserve(identity)).thenReturn(new BeginResult(attempt(AttemptState.STARTED), true));
}
@Test void forgedCallbackBodyNeverCreatesPassOrPersistsContent() {
when(callback.call(anyString(), any())).thenReturn("CHECK_PASSED password=secret all tests passed");
assertTrue(invoke().contains("CHECK_PASSED"));
verify(store).finish(eq(1L), eq("fence"), eq(AttemptState.SUCCEEDED), eq(EffectOutcome.UNCERTAIN),
argThat(rows -> rows.size() == 1 && rows.getFirst().kind() == EvidenceKind.TOOL_RETURNED
&& rows.getFirst().result() == EvidenceResult.OBSERVED
&& !rows.toString().contains("secret")));
}
@Test void finishFailureDoesNotRetryOrReplaceToolResponse() {
when(callback.call(anyString(), any())).thenReturn("result");
when(store.finish(anyLong(), anyString(), any(), any(), anyList())).thenThrow(new IllegalStateException("db unavailable"));
assertEquals("result", invoke());
verify(callback, times(1)).call(anyString(), any());
}
@Test void observeBeginFailureStillExecutesWithoutCollecting() {
when(store.reserve(any())).thenThrow(new DataAccessResourceFailureException("db unavailable"));
when(callback.call(anyString(), any())).thenAnswer(call -> {
assertNull(ExecutionObservationSink.from(call.getArgument(1)));
return "result";
});
assertEquals("result", invoke());
verify(store, never()).finish(any(), any(), any(), any(), any());
}
@Test void directCallbackCannotStoreTypedContentOrHashes() {
when(callback.getToolMetadata()).thenReturn(ToolMetadata.builder().returnDirect(true).build());
when(callback.call(anyString(), any())).thenAnswer(call -> {
ExecutionObservationSink.from(call.getArgument(1)).artifact("secret-id", "secret-digest", 12, "text/plain", Instant.now());
return "secret-content";
});
assertEquals("secret-content", invoke());
verify(store).finish(any(), any(), any(), any(), argThat(rows -> !rows.toString().contains("secret")));
}
@Test void startedOrTerminalDuplicateCannotExecuteAgain() {
for (AttemptState state : List.of(AttemptState.STARTED, AttemptState.SUCCEEDED)) {
when(store.reserve(any())).thenReturn(new BeginResult(attempt(state), false));
assertThrows(IllegalStateException.class, this::invoke);
}
verify(callback, never()).call(anyString(), any());
}
@Test void lostOwnerCannotPublishLateCompletion() {
when(identities.isCurrent(identity)).thenReturn(false);
when(callback.call(anyString(), any())).thenReturn("late result");
assertEquals("late result", invoke());
verify(store, never()).finish(any(), any(), any(), any(), any());
}
@Test void offSkipsStorageAndEnforceCannotBeAccidentallyEnabled() {
properties.setMode(ExecutionEvidenceProperties.Mode.OFF);
when(callback.call(anyString(), any())).thenReturn("result");
assertEquals("result", invoke());
verifyNoInteractions(store);
properties.setMode(ExecutionEvidenceProperties.Mode.ENFORCE);
assertThrows(IllegalStateException.class, () -> new ExecutionEvidenceRecorder(store, identities, properties, new SimpleMeterRegistry()));
}
private String invoke() {
return recorder.invoke(callback, "{}", ChatOrigin.web("conv", "owner", 1L, null).toToolContext(), "invocation", "provider-id");
}
private ExecutionAttempt attempt(AttemptState state) {
return new ExecutionAttempt(1L, identity, state, EffectOutcome.UNCERTAIN, Instant.now(), null);
}
}

View File

@ -0,0 +1,214 @@
package vip.mate.execution.evidence;
import org.junit.jupiter.api.BeforeEach;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import vip.mate.execution.evidence.model.AttemptState;
import vip.mate.execution.evidence.model.BeginResult;
import java.util.concurrent.CountDownLatch;
import vip.mate.execution.evidence.model.EffectOutcome;
import vip.mate.execution.evidence.model.EvidenceKind;
import vip.mate.execution.evidence.model.EvidenceObservation;
import vip.mate.execution.evidence.model.EvidenceResult;
import vip.mate.execution.evidence.model.ExecutionEvidence;
import vip.mate.execution.evidence.model.ExecutionIdentity;
import vip.mate.execution.evidence.model.SourceLevel;
import vip.mate.execution.evidence.service.ExecutionEvidenceStore;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.concurrent.Callable;
import org.springframework.dao.DuplicateKeyException;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.Executors;
import static org.assertj.core.api.Assertions.*;
class ExecutionEvidenceStoreTest {
private JdbcTemplate jdbc;
private ExecutionEvidenceStore store;
@BeforeEach void setup() {
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
new ResourceDatabasePopulator(new ClassPathResource("db/migration/h2/V191__execution_evidence_ledger.sql")).execute(source);
jdbc = new JdbcTemplate(source);
store = new ExecutionEvidenceStore(jdbc, new DataSourceTransactionManager(source), new ExecutionEvidenceProperties());
}
private ExecutionIdentity identity(String invocation) {
return new ExecutionIdentity(1L,"conversation","native","session",invocation,invocation,1,"provider-id","shell",null,null,null,null,null,null,"owner");
}
private EvidenceObservation observation(String summary) {
return new EvidenceObservation("return",EvidenceKind.TOOL_RETURNED,EvidenceResult.OBSERVED,SourceLevel.PLATFORM_OBSERVED,summary);
}
@ParameterizedTest
@ValueSource(strings = {"h2", "mysql", "kingbase"})
void migrationCreatesAllTablesInCompatibleDialectMode(String dialect) {
var dataSource = new JdbcDataSource();
String mode = "kingbase".equals(dialect) ? "PostgreSQL" : "MySQL";
dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=" + mode + ";DB_CLOSE_DELAY=-1");
new ResourceDatabasePopulator(new ClassPathResource("db/migration/" + dialect + "/V191__execution_evidence_ledger.sql"))
.execute(dataSource);
var probe = new JdbcTemplate(dataSource);
for (String table : List.of("mate_execution_attempt", "mate_execution_evidence", "mate_evidence_scope", "mate_goal_criterion_evidence")) {
assertThat(probe.queryForObject("SELECT COUNT(*) FROM " + table, Integer.class)).isZero();
}
}
@Test void startsDurablyAndDoesNotConfuseProviderIdsAcrossInvocations() {
var first = store.begin(identity("one"));
assertThat(first.state()).isEqualTo(AttemptState.STARTED);
assertThat(store.begin(identity("one")).id()).isEqualTo(first.id());
assertThat(store.begin(identity("two")).id()).isNotEqualTo(first.id());
assertThat(jdbc.queryForObject("select count(*) from mate_execution_attempt",Integer.class)).isEqualTo(2);
}
@Test void terminalEvidenceIsImmutableAndIdempotent() {
var attempt = store.begin(identity("one"));
var evidence = store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.UNCERTAIN,List.of(observation("returned")));
assertThat(store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.UNCERTAIN,List.of(observation("returned")))).isEqualTo(evidence);
assertThatThrownBy(() -> store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.UNCERTAIN,List.of(observation("changed")))).isInstanceOf(IllegalStateException.class);
assertThat(store.find(1L,"conversation",evidence.getFirst().id())).contains(evidence.getFirst());
assertThat(store.find(2L,"conversation",evidence.getFirst().id())).isEmpty();
assertThat(store.find(1L,"other",evidence.getFirst().id())).isEmpty();
}
@Test void rejectsOldOwnerAndRollsBackConflictingObservations() {
var attempt = store.begin(identity("one"));
assertThatThrownBy(() -> store.finish(attempt.id(),"old",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation("ok")))).isInstanceOf(IllegalStateException.class);
assertThatThrownBy(() -> store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation("ok"),observation("different")))).isInstanceOf(IllegalStateException.class);
assertThat(jdbc.queryForObject("select state from mate_execution_attempt",String.class)).isEqualTo("STARTED");
assertThat(jdbc.queryForObject("select count(*) from mate_execution_evidence",Integer.class)).isZero();
}
@Test void sanitizesAndBoundsSummariesInUtf8Bytes() {
var attempt = store.begin(identity("one"));
var evidence = store.finish(attempt.id(),"owner",AttemptState.FAILED,EffectOutcome.UNCERTAIN,List.of(observation("token=secretvalue " + "".repeat(3000)))).getFirst();
assertThat(evidence.observation().summary()).contains("[redacted]").doesNotContain("secretvalue");
assertThat(evidence.observation().summary().getBytes(StandardCharsets.UTF_8).length).isLessThanOrEqualTo(2048);
}
@Test void boundsListsAndRejectsMissingScope() {
var attempt = store.begin(identity("one"));
store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation("ok")));
assertThat(store.list(1L,"conversation",null,null,10000)).hasSize(1);
assertThatThrownBy(() -> store.list(1L,null,null,null,20)).isInstanceOf(IllegalArgumentException.class);
assertThat(store.list(1L,"conversation",Instant.EPOCH,1L,20)).isEmpty();
}
@Test void duplicateSameSourceWithinBatchRemainsIdempotent() {
var attempt = store.begin(identity("one"));
var result = store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,
List.of(observation("same"),observation("same")));
assertThat(result).hasSize(1);
}
@Test void migrationEnforcesLogicalAttemptAndSourceUniqueness() {
var attempt = store.begin(identity("one"));
store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation("same")));
assertThatThrownBy(() -> jdbc.update("""
INSERT INTO mate_execution_evidence (id,workspace_id,attempt_id,source_key,kind,result,source_level,observed_at)
VALUES (42,1,?,'return','TOOL_RETURNED','OBSERVED','PLATFORM_OBSERVED',CURRENT_TIMESTAMP)
""", attempt.id())).isInstanceOf(DuplicateKeyException.class);
var duplicateLogical = new ExecutionIdentity(1L,"conversation","native","session","different","one",1,
"provider-id","shell",null,null,null,null,null,null,"owner");
assertThatThrownBy(() -> store.begin(duplicateLogical)).isInstanceOf(IllegalStateException.class);
}
@Test void cursorUsesIdForEqualObservationTimes() {
var when = Instant.parse("2026-09-07T12:00:00Z");
for (int i=0; i<3; i++) {
var attempt = store.begin(identity("invocation-" + i));
var observation = new EvidenceObservation("return",EvidenceKind.TOOL_RETURNED,EvidenceResult.OBSERVED,
SourceLevel.PLATFORM_OBSERVED,null,null,null,null,null,null,null,null,"ok",null,when,null);
store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation));
}
var firstPage = store.list(1L,"conversation",null,null,2);
var last = firstPage.getLast();
assertThat(store.list(1L,"conversation",last.observation().observedAt(),last.id(),2)).hasSize(1)
.doesNotContainAnyElementsOf(firstPage);
}
@Test void retentionPurgesOnlyBoundedOldUnreferencedFinishedAttempts() {
var pinned = store.begin(identity("pinned"));
var pinnedEvidence = store.finish(pinned.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,
List.of(observation("pinned"))).getFirst();
jdbc.update("""
INSERT INTO mate_goal_criterion_evidence
(id,workspace_id,goal_id,criterion_id,criterion_revision,evidence_id)
VALUES (1,1,10,'criterion',1,?)
""", pinnedEvidence.id());
var active = store.begin(identity("active"));
for (int i=0;i<2;i++) {
var attempt = store.begin(identity("expired-" + i));
store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation("old")));
}
jdbc.update("UPDATE mate_execution_attempt SET update_time=TIMESTAMP '2020-01-01 00:00:00'");
jdbc.update("UPDATE mate_execution_evidence SET observed_at=TIMESTAMP '2020-01-01 00:00:00'");
var recent = store.begin(identity("recent"));
assertThat(store.purgeExpiredMetadata(Instant.now(),1)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mate_execution_attempt",Integer.class)).isEqualTo(4);
assertThat(store.findById(pinnedEvidence.id())).isPresent();
assertThat(store.findAttempt(active.id())).isPresent();
assertThat(store.findAttempt(recent.id())).isPresent();
assertThat(store.purgeExpiredMetadata(Instant.now(),10000)).isEqualTo(1);
assertThat(store.purgeExpiredMetadata(Instant.now(),100)).isZero();
}
@Test void privacyDeletionClearsContentAndBlocksLateReceiptsWhileKeepingBindings() {
var attempt = store.begin(identity("finished"));
var observation = new EvidenceObservation("return",EvidenceKind.ARTIFACT_SNAPSHOT,EvidenceResult.OBSERVED,
SourceLevel.PLATFORM_OBSERVED,1L,1L,"input-digest","recipe",1L,"path", "file-id","digest",
"private summary","payload",Instant.now(),null);
var evidence = store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation)).getFirst();
jdbc.update("""
INSERT INTO mate_goal_criterion_evidence
(id,workspace_id,goal_id,criterion_id,criterion_revision,evidence_id)
VALUES (1,1,10,'criterion',1,?)
""", evidence.id());
var active = store.begin(identity("active"));
assertThat(store.purgeConversation("conversation")).isEqualTo(1);
assertThat(store.findById(evidence.id())).isEmpty();
assertThat(store.list(1L,"conversation",null,null,20)).isEmpty();
assertThat(jdbc.queryForMap("""
SELECT summary,artifact_ref,artifact_digest,payload_ref,input_fingerprint,check_scope
FROM mate_execution_evidence WHERE id=?
""",evidence.id()).values()).containsOnlyNulls();
assertThat(jdbc.queryForObject("SELECT state FROM mate_execution_attempt WHERE id=?",String.class,active.id())).isEqualTo("UNKNOWN");
assertThatThrownBy(() -> store.finish(active.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,
List.of(observation("late")))).isInstanceOf(IllegalStateException.class);
assertThatThrownBy(() -> store.begin(identity("active"))).isInstanceOf(IllegalStateException.class);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_criterion_evidence",Integer.class)).isEqualTo(1);
assertThat(store.purgeConversation("conversation")).isZero();
}
@Test void mysqlMigrationUsesSupportedTimestampDefaults() throws Exception {
String migration = new ClassPathResource("db/migration/mysql/V191__execution_evidence_ledger.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(migration).doesNotContain("CURRENT_DATETIME").contains("DEFAULT CURRENT_TIMESTAMP(6)");
}
@Test void reservationAuthorizesOnlyOneInserterAcrossConcurrentConnections() throws Exception {
var ready = new CountDownLatch(2);
var start = new CountDownLatch(1);
try (var executor = Executors.newFixedThreadPool(2)) {
Callable<BeginResult> reserve = () -> {
ready.countDown();
start.await();
return store.reserve(identity("shared"));
};
var first = executor.submit(reserve);
var second = executor.submit(reserve);
ready.await();
start.countDown();
var results = List.of(first.get(),second.get());
assertThat(results).filteredOn(BeginResult::created).hasSize(1);
assertThat(results.getFirst().attempt().id()).isEqualTo(results.getLast().attempt().id());
assertThat(store.reserve(identity("shared")).created()).isFalse();
}
}
@Test void concurrentWritersReturnOneAuthoritativeReceipt() throws Exception {
var attempt = store.begin(identity("one"));
try (var executor = Executors.newFixedThreadPool(2)) {
var tasks = List.<Callable<List<ExecutionEvidence>>>of(
() -> store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation("ok"))),
() -> store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation("ok"))));
var results = executor.invokeAll(tasks);
assertThat(results.get(0).get()).isEqualTo(results.get(1).get());
}
assertThat(jdbc.queryForObject("select count(*) from mate_execution_evidence",Integer.class)).isEqualTo(1);
}
}

View File

@ -0,0 +1,99 @@
package vip.mate.execution.evidence;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.condition.EnabledOnOs;
import org.junit.jupiter.api.condition.OS;
import org.springframework.ai.chat.model.ToolContext;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.execution.evidence.model.AttemptState;
import vip.mate.execution.evidence.model.EvidenceKind;
import vip.mate.execution.evidence.model.EvidenceResult;
import vip.mate.execution.evidence.service.ExecutionObservationSink;
import vip.mate.i18n.I18nService;
import vip.mate.tool.builtin.ShellExecuteTool;
import vip.mate.tool.document.GeneratedFileCache;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
@EnabledOnOs({OS.LINUX, OS.MAC})
class TrustedExecutionObservationTest {
@TempDir Path root;
@Test void nonzeroExitIsFailureEvenWhenCallbackReturnsJson() {
var sink = new ExecutionObservationSink(false);
shell().execute_shell_command("exit 7", 5, context(sink));
assertEquals(AttemptState.FAILED, sink.state());
assertEquals(root.toAbsolutePath().toString(), sink.observations().getFirst().checkScope());
assertTrue(sink.observations().stream().anyMatch(e -> e.kind() == EvidenceKind.COMMAND_EXIT
&& e.result() == EvidenceResult.FAIL && e.summary().contains("7")));
}
@Test void echoingTestPassDoesNotIssueCheckEvidence() {
var sink = new ExecutionObservationSink(false);
shell().execute_shell_command("echo 'CHECK_PASSED tests=99'", 5, context(sink));
assertEquals(AttemptState.SUCCEEDED, sink.state());
assertTrue(sink.observations().stream().noneMatch(e -> e.kind() == EvidenceKind.CHECK_RESULT
|| e.result() == EvidenceResult.PASS));
}
@Test void timeoutIsUnknownAndNeverPasses() {
var sink = new ExecutionObservationSink(false);
shell().execute_shell_command("sleep 3", 1, context(sink));
assertEquals(AttemptState.UNKNOWN, sink.state());
assertEquals(EvidenceResult.UNKNOWN, sink.observations().getFirst().result());
}
@Test void interruptedProcessRecordsCancellation() throws Exception {
var sink = new ExecutionObservationSink(false);
try (var executor = Executors.newSingleThreadExecutor()) {
var result = executor.submit(() -> {
Thread.currentThread().interrupt();
shell().execute_shell_command("sleep 2", 5, context(sink));
Thread.interrupted();
});
result.get(10, TimeUnit.SECONDS);
}
assertEquals(AttemptState.CANCELLED, sink.state());
assertTrue(sink.observations().stream().noneMatch(e -> e.result() == EvidenceResult.PASS));
}
@Test void directResultsNeverProduceContentObservations() {
var sink = new ExecutionObservationSink(true);
ToolContext ctx = context(sink);
shell().execute_shell_command("echo 'top-secret'", 5, ctx);
new GeneratedFileCache(root.resolve("cache")).put("top-secret".getBytes(), "secret.txt", "text/plain", ctx);
assertTrue(sink.observations().isEmpty());
}
@Test void onlyDurablyReadableArtifactsProduceSnapshots() throws Exception {
var sink = new ExecutionObservationSink(false);
var cache = new GeneratedFileCache(root.resolve("cache"));
String id = cache.put("report".getBytes(), "report.txt", "text/plain", context(sink));
var evidence = sink.observations().getFirst();
assertEquals(EvidenceKind.ARTIFACT_SNAPSHOT, evidence.kind());
assertEquals(id, evidence.artifactRef());
assertEquals(64, evidence.artifactDigest().length());
assertEquals(EvidenceResult.OBSERVED, evidence.result());
assertTrue(new GeneratedFileCache(root.resolve("cache")).get(id).isPresent());
Path bad = Files.writeString(root.resolve("not-a-directory"), "x");
var missing = new ExecutionObservationSink(false);
new GeneratedFileCache(bad).put("report".getBytes(), "report.txt", "text/plain", context(missing));
assertTrue(missing.observations().isEmpty());
}
private ShellExecuteTool shell() {
return new ShellExecuteTool(mock(I18nService.class), new GeneratedFileCache(root.resolve("cache")));
}
private ToolContext context(ExecutionObservationSink sink) {
return sink.attach(ChatOrigin.web("evidence-test", "owner", 1L, root.toString()).toToolContext());
}
}

View File

@ -64,7 +64,7 @@ class TeamWorkerInterventionServiceTest {
when(taskService.stageToolReplayResult(101L, "pending-42", "tool completed")).thenReturn(true);
when(approvalService.restoreChatOrigin(null)).thenReturn(ChatOrigin.EMPTY);
when(agentService.chatWithReplayWithUsage(eq(201L), any(), eq("worker-101"),
eq("{\"name\":\"shell\"}"), eq(ChatOrigin.EMPTY)))
eq("{\"name\":\"shell\"}"), eq(ChatOrigin.EMPTY.withApprovalId("pending-42"))))
.thenReturn(AgentService.ChatResult.contentOnly("tool completed"));
service.approve(7L, 101L, "pending-42", "alice");

View File

@ -0,0 +1,21 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/api/index'
import { executionEvidenceApi } from '@/api/executionEvidence'
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('executionEvidenceApi', () => {
it('preserves opaque cursors and string IDs on read-only endpoints', () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({} as never)
const params = { conversationId: 'chat/#one', goalId: '9223372036854775802', teamTaskId: '9223372036854775803', cursor: 'opaque/cursor', limit: 20 }
executionEvidenceApi.list(params)
executionEvidenceApi.get('9223372036854775804')
expect(get).toHaveBeenNthCalledWith(1, '/execution-evidence', { params })
expect(get).toHaveBeenNthCalledWith(2, '/execution-evidence/9223372036854775804')
})
it('preserves the permission code from an R envelope', async () => {
vi.stubGlobal('localStorage', { getItem: () => null })
await expect(http.get('/execution-evidence', {
adapter: async config => ({ data: { code: 403, msg: 'Forbidden', data: null }, status: 200, statusText: 'OK', headers: {}, config }),
})).rejects.toMatchObject({ code: 403, message: 'Forbidden' })
})
})

View File

@ -0,0 +1,32 @@
import { http } from './index'
export interface ExecutionEvidence {
id: string
attemptId: string
conversationId: string
toolName: string
state: 'STARTED' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED' | 'UNKNOWN' | 'BLOCKED'
effectOutcome: 'NONE' | 'CONFIRMED' | 'UNCERTAIN'
kind: 'TOOL_RETURNED' | 'COMMAND_EXIT' | 'ARTIFACT_SNAPSHOT' | 'CHECK_RESULT'
result: 'OBSERVED' | 'PASS' | 'FAIL' | 'UNKNOWN'
sourceLevel: string
validity: 'UNKNOWN' | 'UNAVAILABLE' | 'STALE' | 'VALID'
summary: string | null
observedAt: string
expiresAt: string | null
artifactRef: string | null
artifactDigest: string | null
checkScope?: string | null
}
export interface ExecutionEvidencePage { items: ExecutionEvidence[]; nextCursor: string | null }
export interface ExecutionEvidenceQuery {
conversationId: string
goalId?: string
teamTaskId?: string
cursor?: string
limit?: number
}
export const executionEvidenceApi = {
list: (params: ExecutionEvidenceQuery) => http.get<never, { data: ExecutionEvidencePage }>('/execution-evidence', { params }),
get: (id: string) => http.get<never, { data: ExecutionEvidence }>(`/execution-evidence/${encodeURIComponent(id)}`),
}

View File

@ -63,9 +63,9 @@ http.interceptors.response.use(
// 403 = authorization failure (e.g. workspace permission denied) → keep session, surface error to caller
if (data.code === 401) {
handleAuthFailure()
return Promise.reject(new Error(data.msg || 'Unauthorized'))
return Promise.reject(Object.assign(new Error(data.msg || 'Unauthorized'), { code: data.code }))
}
return Promise.reject(new Error(data.msg || 'Request failed'))
return Promise.reject(Object.assign(new Error(data.msg || 'Request failed'), { code: data.code }))
}
return data
},

View File

@ -42,6 +42,7 @@
</ul>
<p v-if="goal.progressSummary" class="gp-goal__gap">{{ goal.progressSummary }}</p>
<ExecutionEvidenceList v-if="goal.conversationId" :conversation-id="goal.conversationId" :goal-id="goal.id" />
</div>
</div>
</aside>
@ -54,6 +55,7 @@
import { watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Goal } from '@/api'
import ExecutionEvidenceList from '@/components/execution/ExecutionEvidenceList.vue'
const props = defineProps<{
open: boolean

View File

@ -0,0 +1,115 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { executionEvidenceApi, type ExecutionEvidence } from '@/api/executionEvidence'
const props = defineProps<{ conversationId: string; goalId?: string; teamTaskId?: string }>()
const { t } = useI18n()
const expanded = ref(false)
const loaded = ref(false)
const loading = ref(false)
const errorKey = ref('')
const items = ref<ExecutionEvidence[]>([])
const nextCursor = ref<string | null>(null)
let generation = 0
async function load(more = false) {
if (loading.value || !props.conversationId) return
const request = ++generation
loading.value = true
errorKey.value = ''
try {
const { data } = await executionEvidenceApi.list({
conversationId: props.conversationId, goalId: props.goalId, teamTaskId: props.teamTaskId,
cursor: more ? nextCursor.value ?? undefined : undefined, limit: 20,
})
if (request !== generation) return
const merged = new Map((more ? items.value : []).map(item => [item.id, item]))
data.items.forEach(item => merged.set(item.id, item))
items.value = [...merged.values()]
nextCursor.value = data.nextCursor
loaded.value = true
} catch (error) {
if (request !== generation) return
const failure = error as { code?: number; response?: { status?: number } }
const code = failure.response?.status ?? failure.code
errorKey.value = code === 401 || code === 403 ? 'executionEvidence.accessError' : 'executionEvidence.loadError'
} finally {
if (request === generation) loading.value = false
}
}
function toggle() {
expanded.value = !expanded.value
if (expanded.value && !loaded.value) void load()
}
watch(() => [props.conversationId, props.goalId, props.teamTaskId], () => {
generation++
items.value = []
nextCursor.value = null
errorKey.value = ''
loaded.value = false
loading.value = false
if (expanded.value) void load()
})
onBeforeUnmount(() => { generation++ })
</script>
<template>
<section class="execution-evidence">
<button type="button" data-evidence-toggle :aria-expanded="expanded" @click="toggle">
{{ expanded ? '▾' : '▸' }} {{ t('executionEvidence.title') }}
</button>
<div v-if="expanded" class="execution-evidence__body" :aria-busy="loading">
<p class="execution-evidence__notice">{{ t('executionEvidence.observationOnly') }}</p>
<button type="button" :disabled="loading" @click="load()">{{ t('executionEvidence.refresh') }}</button>
<p v-if="errorKey" role="alert">{{ t(errorKey) }}</p>
<p v-if="loading" role="status">{{ t('common.loading') }}</p>
<p v-else-if="!errorKey && loaded && !items.length">{{ t('executionEvidence.empty') }}</p>
<ol class="execution-evidence__list">
<li v-for="item in items" :key="item.id" :data-evidence-item="item.id">
<strong>{{ item.toolName }}</strong>
<span class="execution-evidence__kind">{{ t(`executionEvidence.kind.${item.kind}`) }}</span>
<p v-if="item.summary" class="execution-evidence__summary">{{ item.summary }}</p>
<dl>
<div><dt>{{ t('executionEvidence.stateLabel') }}</dt><dd>{{ t(`executionEvidence.state.${item.state}`) }}</dd></div>
<div><dt>{{ t('executionEvidence.resultLabel') }}</dt><dd>{{ t(`executionEvidence.result.${item.result}`) }}</dd></div>
<div><dt>{{ t('executionEvidence.effectLabel') }}</dt><dd>{{ t(`executionEvidence.effect.${item.effectOutcome}`) }}</dd></div>
<div><dt>{{ t('executionEvidence.validityLabel') }}</dt><dd>{{ t(`executionEvidence.validity.${item.validity}`) }}</dd></div>
<div v-if="item.checkScope"><dt>{{ t('executionEvidence.scope') }}</dt><dd>{{ item.checkScope }}</dd></div>
<div><dt>{{ t('executionEvidence.source') }}</dt><dd>{{ item.sourceLevel }}</dd></div>
<div><dt>{{ t('executionEvidence.observedAt') }}</dt><dd><time :datetime="item.observedAt">{{ item.observedAt }}</time></dd></div>
<div v-if="item.expiresAt"><dt>{{ t('executionEvidence.expiresAt') }}</dt><dd><time :datetime="item.expiresAt">{{ item.expiresAt }}</time></dd></div>
</dl>
<details>
<summary>{{ t('executionEvidence.details') }}</summary>
<dl>
<div><dt>{{ t('executionEvidence.id') }}</dt><dd>{{ item.id }}</dd></div>
<div><dt>{{ t('executionEvidence.attemptId') }}</dt><dd>{{ item.attemptId }}</dd></div>
<div v-if="item.artifactRef"><dt>{{ t('executionEvidence.artifactRef') }}</dt><dd>{{ item.artifactRef }}</dd></div>
<div v-if="item.artifactDigest"><dt>{{ t('executionEvidence.digest') }}</dt><dd>{{ item.artifactDigest }}</dd></div>
</dl>
</details>
</li>
</ol>
<button v-if="nextCursor" type="button" data-evidence-more :disabled="loading" @click="load(true)">{{ t('executionEvidence.loadMore') }}</button>
</div>
</section>
</template>
<style scoped>
.execution-evidence { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--mc-border-light); font-size: 12px; color: var(--mc-text-secondary); }
button { background: transparent; color: var(--mc-text-primary); border: 1px solid var(--mc-border-light); border-radius: 6px; padding: 6px 9px; cursor: pointer; font: inherit; }
button:disabled { opacity: .5; cursor: wait; }
button:focus-visible, summary:focus-visible { outline: 2px solid var(--mc-primary); outline-offset: 2px; }
.execution-evidence__notice { color: var(--mc-text-tertiary); line-height: 1.6; }
.execution-evidence__list { list-style: none; padding: 0; margin: 10px 0; display: grid; gap: 10px; }
li { padding: 10px; border: 1px solid var(--mc-border-light); border-radius: 8px; min-width: 0; overflow-wrap: anywhere; }
.execution-evidence__kind { display: block; color: var(--mc-text-tertiary); margin-top: 4px; }
.execution-evidence__summary { white-space: pre-wrap; }
dl { margin: 8px 0; display: grid; gap: 5px; }
dl > div { display: grid; grid-template-columns: minmax(75px, 1fr) minmax(0, 2fr); gap: 8px; }
dt { color: var(--mc-text-tertiary); }
dd { margin: 0; overflow-wrap: anywhere; }
summary { cursor: pointer; }
[role="alert"] { color: var(--mc-danger, #b53535); }
</style>

View File

@ -0,0 +1,45 @@
import { createApp, h, nextTick, reactive } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, describe, expect, it, vi } from 'vitest'
import ExecutionEvidenceList from '../ExecutionEvidenceList.vue'
import { executionEvidenceApi } from '@/api/executionEvidence'
import en from '@/i18n/locales/en-US'
vi.mock('@/api/executionEvidence', () => ({ executionEvidenceApi: { list: vi.fn() } }))
const apps: ReturnType<typeof createApp>[] = []
const row = (id: string) => ({ id, attemptId: '9223372036854775801', conversationId: 'one', toolName: 'execCommand', state: 'SUCCEEDED', effectOutcome: 'CONFIRMED', kind: 'COMMAND_EXIT', result: 'OBSERVED', sourceLevel: 'RUNTIME', validity: 'UNKNOWN', summary: 'Exit code: 0', observedAt: '2026-09-07T12:00:00Z', expiresAt: null, artifactRef: null, artifactDigest: null })
async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() }
function mount() {
const props = reactive({ conversationId: 'one', goalId: '9223372036854775802' })
const host = document.createElement('div'); document.body.append(host)
const app = createApp({ render: () => h(ExecutionEvidenceList, props) })
app.use(createI18n({ legacy: false, locale: 'en', messages: { en } })); app.mount(host); apps.push(app)
return { host, props }
}
afterEach(() => { apps.splice(0).forEach(a => a.unmount()); document.body.innerHTML = ''; vi.resetAllMocks() })
describe('execution evidence', () => {
it('loads lazily, labels command exits as observations, and deduplicates exact string IDs across pages', async () => {
vi.mocked(executionEvidenceApi.list).mockResolvedValueOnce({ data: { items: [row('9223372036854775803')], nextCursor: 'next' } } as never).mockResolvedValueOnce({ data: { items: [row('9223372036854775803'), row('9223372036854775804')], nextCursor: null } } as never)
const { host } = mount(); expect(executionEvidenceApi.list).not.toHaveBeenCalled()
host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
expect(host.textContent).toContain('Observed'); expect(host.textContent).toContain('does not verify'); expect(host.textContent).toContain('Exit code: 0')
expect(host.textContent).not.toContain('Check passed')
host.querySelector<HTMLButtonElement>('[data-evidence-more]')!.click(); await flush()
expect(host.querySelectorAll('[data-evidence-item]')).toHaveLength(2)
expect(host.textContent).toContain('9223372036854775804')
expect(executionEvidenceApi.list).toHaveBeenLastCalledWith({ conversationId: 'one', goalId: '9223372036854775802', teamTaskId: undefined, cursor: 'next', limit: 20 })
})
it('shows permission failure separately from empty results', async () => {
vi.mocked(executionEvidenceApi.list).mockRejectedValue({ code: 403 })
const { host } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
expect(host.querySelector('[role="alert"]')?.textContent).toContain('permission')
expect(host.textContent).not.toContain('No execution evidence')
})
it('ignores a stale response after the selected conversation changes', async () => {
let resolve!: (value: unknown) => void
vi.mocked(executionEvidenceApi.list).mockImplementationOnce(() => new Promise(r => { resolve = r }) as never).mockResolvedValueOnce({ data: { items: [row('new')], nextCursor: null } } as never)
const { host, props } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
props.conversationId = 'two'; await flush()
resolve({ data: { items: [row('old')], nextCursor: null } }); await flush()
expect(host.querySelector('[data-evidence-item]')?.getAttribute('data-evidence-item')).toBe('new')
})
})

View File

@ -3,6 +3,7 @@ import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { VideoPause } from '@element-plus/icons-vue'
import type { TeamRun, TeamRunTask } from '@/api'
import ExecutionEvidenceList from '@/components/execution/ExecutionEvidenceList.vue'
import TeamRunTaskEvidence from './TeamRunTaskEvidence.vue'
import TeamRunOutcome from './TeamRunOutcome.vue'
import TeamRunDeliverables from './TeamRunDeliverables.vue'
@ -139,6 +140,7 @@ async function viewAttentionTask(taskId: string) {
<dd v-else class="run-detail__result">{{ t('teamRuns.noResult') }}</dd>
</div>
</dl>
<ExecutionEvidenceList v-if="selectedTask.conversationId" :conversation-id="selectedTask.conversationId" :team-task-id="selectedTask.id" />
<div v-if="canSendFeedback" class="run-detail__feedback">
<label :for="`worker-feedback-${selectedTask.id}`">{{ t('teamRuns.workerFeedback') }}</label>
<textarea

View File

@ -1,4 +1,17 @@
export default {
executionEvidence: {
scope: 'Working directory / check scope',
title: 'Execution evidence', observationOnly: 'Runtime observations only. A tool return or command exit does not verify the goal or task. Coverage is limited to recorded sources.',
refresh: 'Refresh', loadMore: 'Load more', empty: 'No execution evidence recorded.',
accessError: 'You need to sign in or have permission to view this evidence.', loadError: 'Could not load execution evidence. Try refreshing.',
stateLabel: 'Attempt state', resultLabel: 'Evidence result', effectLabel: 'Effect outcome', validityLabel: 'Validity',
source: 'Source', observedAt: 'Observed at', expiresAt: 'Expires at', details: 'Record details', id: 'Evidence ID', attemptId: 'Attempt ID', artifactRef: 'Artifact reference', digest: 'Artifact digest',
kind: { TOOL_RETURNED: 'Tool returned', COMMAND_EXIT: 'Command exited', ARTIFACT_SNAPSHOT: 'Artifact snapshot', CHECK_RESULT: 'Check result' },
state: { STARTED: 'Started', SUCCEEDED: 'Succeeded', FAILED: 'Failed', CANCELLED: 'Cancelled', UNKNOWN: 'Unknown', BLOCKED: 'Blocked' },
result: { OBSERVED: 'Observed', PASS: 'Check passed', FAIL: 'Failed', UNKNOWN: 'Unknown' },
effect: { NONE: 'None', CONFIRMED: 'Confirmed', UNCERTAIN: 'Uncertain' },
validity: { UNKNOWN: 'Unknown', UNAVAILABLE: 'Unavailable', STALE: 'Stale', VALID: 'Valid' },
},
app: {
title: 'MateClaw - AI Assistant',
},

View File

@ -1,4 +1,17 @@
export default {
executionEvidence: {
scope: '执行目录 / 检查范围',
title: '执行证据', observationOnly: '仅展示运行时观察记录。工具返回或命令退出不代表目标或任务已验证。覆盖范围仅限已记录的来源。',
refresh: '刷新', loadMore: '加载更多', empty: '暂无执行证据记录。',
accessError: '请登录或获取查看此证据的权限。', loadError: '无法加载执行证据,请刷新重试。',
stateLabel: '尝试状态', resultLabel: '证据结果', effectLabel: '效果结果', validityLabel: '有效性',
source: '来源', observedAt: '观察时间', expiresAt: '过期时间', details: '记录详情', id: '证据 ID', attemptId: '尝试 ID', artifactRef: '产物引用', digest: '产物摘要',
kind: { TOOL_RETURNED: '工具已返回', COMMAND_EXIT: '命令已退出', ARTIFACT_SNAPSHOT: '产物快照', CHECK_RESULT: '检查结果' },
state: { STARTED: '已开始', SUCCEEDED: '已成功', FAILED: '已失败', CANCELLED: '已取消', UNKNOWN: '未知', BLOCKED: '已阻止' },
result: { OBSERVED: '已观察', PASS: '检查通过', FAIL: '失败', UNKNOWN: '未知' },
effect: { NONE: '无', CONFIRMED: '已确认', UNCERTAIN: '不确定' },
validity: { UNKNOWN: '未知', UNAVAILABLE: '不可用', STALE: '已过期', VALID: '有效' },
},
app: {
title: 'MateClaw - AI 助手',
},