mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(execution): add persistent execution evidence in observe mode
This commit is contained in:
parent
26150b68e8
commit
7406fc99bf
@ -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) {
|
||||
|
||||
@ -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 ----------------
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 -> {
|
||||
|
||||
@ -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 = "继续执行已批准的工具调用。";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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); }
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
package vip.mate.execution.evidence.model;
|
||||
|
||||
public enum AttemptState { STARTED, SUCCEEDED, FAILED, CANCELLED, UNKNOWN, BLOCKED }
|
||||
@ -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) { }
|
||||
@ -0,0 +1,3 @@
|
||||
package vip.mate.execution.evidence.model;
|
||||
|
||||
public enum EffectOutcome { NONE, CONFIRMED, UNCERTAIN }
|
||||
@ -0,0 +1,3 @@
|
||||
package vip.mate.execution.evidence.model;
|
||||
|
||||
public enum EvidenceKind { TOOL_RETURNED, COMMAND_EXIT, CHECK_RESULT, ARTIFACT_SNAPSHOT }
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
package vip.mate.execution.evidence.model;
|
||||
|
||||
public enum EvidenceResult { OBSERVED, PASS, FAIL, UNKNOWN }
|
||||
@ -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) { }
|
||||
@ -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) { }
|
||||
@ -0,0 +1,3 @@
|
||||
package vip.mate.execution.evidence.model;
|
||||
|
||||
public record ExecutionEvidence(Long id, Long workspaceId, Long attemptId, String conversationId, EvidenceObservation observation) { }
|
||||
@ -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) { }
|
||||
@ -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) { }
|
||||
@ -0,0 +1,3 @@
|
||||
package vip.mate.execution.evidence.model;
|
||||
|
||||
public enum SourceLevel { PLATFORM_OBSERVED, ADAPTER_ATTESTED, EXTERNAL_REPORTED, LEGACY_TEXT }
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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", "");
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
@ -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);
|
||||
@ -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);
|
||||
@ -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());
|
||||
|
||||
@ -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); }
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
21
mateclaw-ui/src/api/__tests__/executionEvidence.test.ts
Normal file
21
mateclaw-ui/src/api/__tests__/executionEvidence.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
32
mateclaw-ui/src/api/executionEvidence.ts
Normal file
32
mateclaw-ui/src/api/executionEvidence.ts
Normal 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)}`),
|
||||
}
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
115
mateclaw-ui/src/components/execution/ExecutionEvidenceList.vue
Normal file
115
mateclaw-ui/src/components/execution/ExecutionEvidenceList.vue
Normal 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>
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
},
|
||||
|
||||
@ -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 助手',
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user