From 7406fc99bf9eea353b1cb69ca295acb510a8d085 Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 7 Sep 2026 06:38:58 -0400 Subject: [PATCH] feat(execution): add persistent execution evidence in observe mode --- .../vip/mate/agent/AgentGraphBuilder.java | 10 + .../vip/mate/agent/context/ChatOrigin.java | 41 ++- .../agent/context/ExecutionAttribution.java | 12 + .../graph/executor/ToolExecutionExecutor.java | 39 ++- .../graph/plan/node/StepExecutionNode.java | 2 +- .../mate/channel/ChannelMessageRouter.java | 1 + .../vip/mate/channel/web/ChatController.java | 2 +- .../channel/webchat/WebChatController.java | 2 + .../vip/mate/cron/service/CronJobRunner.java | 3 + .../evidence/ExecutionEvidenceProperties.java | 31 ++ .../ExecutionEvidenceController.java | 39 +++ .../evidence/model/AttemptState.java | 3 + .../execution/evidence/model/BeginResult.java | 4 + .../evidence/model/EffectOutcome.java | 3 + .../evidence/model/EvidenceKind.java | 3 + .../evidence/model/EvidenceObservation.java | 15 + .../evidence/model/EvidenceResult.java | 3 + .../evidence/model/EvidenceScope.java | 5 + .../evidence/model/ExecutionAttempt.java | 6 + .../evidence/model/ExecutionEvidence.java | 3 + .../evidence/model/ExecutionIdentity.java | 6 + .../evidence/model/GoalCriterionEvidence.java | 7 + .../execution/evidence/model/SourceLevel.java | 3 + .../service/ExecutionEvidenceLifecycle.java | 35 ++ .../ExecutionEvidenceQueryService.java | 147 ++++++++ .../service/ExecutionEvidenceRecorder.java | 127 +++++++ .../service/ExecutionEvidenceStore.java | 319 ++++++++++++++++++ .../service/ExecutionIdentityResolver.java | 100 ++++++ .../service/ExecutionObservationSink.java | 79 +++++ .../mate/goal/service/GoalSegmentRunner.java | 6 +- .../TeamWorkerInterventionService.java | 2 +- .../mate/tool/builtin/ShellExecuteTool.java | 9 + .../tool/document/GeneratedFileCache.java | 44 ++- .../src/main/resources/application.yml | 10 + .../h2/V191__execution_evidence_ledger.sql | 89 +++++ .../V191__execution_evidence_ledger.sql | 89 +++++ .../mysql/V191__execution_evidence_ledger.sql | 89 +++++ .../service/CronJobOriginPropagationTest.java | 9 +- .../ExecutionEvidenceIntegrationTest.java | 206 +++++++++++ .../ExecutionEvidenceLifecycleTest.java | 36 ++ .../evidence/ExecutionEvidenceQueryTest.java | 111 ++++++ .../ExecutionEvidenceRecorderTest.java | 106 ++++++ .../evidence/ExecutionEvidenceStoreTest.java | 214 ++++++++++++ .../TrustedExecutionObservationTest.java | 99 ++++++ .../TeamWorkerInterventionServiceTest.java | 2 +- .../api/__tests__/executionEvidence.test.ts | 21 ++ mateclaw-ui/src/api/executionEvidence.ts | 32 ++ mateclaw-ui/src/api/index.ts | 4 +- .../src/components/agents/GoalsPanel.vue | 2 + .../execution/ExecutionEvidenceList.vue | 115 +++++++ .../__tests__/ExecutionEvidenceList.test.ts | 45 +++ .../src/components/team-run/TeamRunDetail.vue | 2 + mateclaw-ui/src/i18n/locales/en-US.ts | 13 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 13 + 54 files changed, 2393 insertions(+), 25 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/context/ExecutionAttribution.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/ExecutionEvidenceProperties.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/controller/ExecutionEvidenceController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/model/AttemptState.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/model/BeginResult.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EffectOutcome.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EvidenceKind.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EvidenceObservation.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EvidenceResult.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EvidenceScope.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/model/ExecutionAttempt.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/model/ExecutionEvidence.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/model/ExecutionIdentity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/model/GoalCriterionEvidence.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/model/SourceLevel.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceLifecycle.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceQueryService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceRecorder.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceStore.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionIdentityResolver.java create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionObservationSink.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V191__execution_evidence_ledger.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V191__execution_evidence_ledger.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V191__execution_evidence_ledger.sql create mode 100644 mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceIntegrationTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceLifecycleTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceQueryTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceRecorderTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceStoreTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/execution/evidence/TrustedExecutionObservationTest.java create mode 100644 mateclaw-ui/src/api/__tests__/executionEvidence.test.ts create mode 100644 mateclaw-ui/src/api/executionEvidence.ts create mode 100644 mateclaw-ui/src/components/execution/ExecutionEvidenceList.vue create mode 100644 mateclaw-ui/src/components/execution/__tests__/ExecutionEvidenceList.test.ts diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index c4fe1b07..464c77fe 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -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) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java index 0e4e2035..30211dd3 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java @@ -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 ---------------- diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ExecutionAttribution.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ExecutionAttribution.java new file mode 100644 index 00000000..04978c8c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ExecutionAttribution.java @@ -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); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java index f68279fc..3f92eb46 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java @@ -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 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 events, String conversationId, String workspaceBasePath, List directOutputs) { + return executePreApproved(toolCall, storedArguments, events, conversationId, workspaceBasePath, + directOutputs, ChatOrigin.EMPTY); + } + + public ToolResponseMessage.ToolResponse executePreApproved( + AssistantMessage.ToolCall toolCall, String storedArguments, + List events, + String conversationId, String workspaceBasePath, + List 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} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java index 2fc267b4..16f453f3 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java @@ -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 { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index 184f7905..017df0de 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -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(); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index cd9999b8..0c075cfa 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -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 -> { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java index 45ae4076..08794503 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java @@ -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 = "继续执行已批准的工具调用。"; diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java index 5ce5e76e..4b9fcb3a 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java @@ -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); } diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/ExecutionEvidenceProperties.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/ExecutionEvidenceProperties.java new file mode 100644 index 00000000..e089b309 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/ExecutionEvidenceProperties.java @@ -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); } +} diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/controller/ExecutionEvidenceController.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/controller/ExecutionEvidenceController.java new file mode 100644 index 00000000..f0e53053 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/controller/ExecutionEvidenceController.java @@ -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 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 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)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/AttemptState.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/AttemptState.java new file mode 100644 index 00000000..c0aba699 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/AttemptState.java @@ -0,0 +1,3 @@ +package vip.mate.execution.evidence.model; + +public enum AttemptState { STARTED, SUCCEEDED, FAILED, CANCELLED, UNKNOWN, BLOCKED } diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/BeginResult.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/BeginResult.java new file mode 100644 index 00000000..f473ba2b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/BeginResult.java @@ -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) { } diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EffectOutcome.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EffectOutcome.java new file mode 100644 index 00000000..28fadfe4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EffectOutcome.java @@ -0,0 +1,3 @@ +package vip.mate.execution.evidence.model; + +public enum EffectOutcome { NONE, CONFIRMED, UNCERTAIN } diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EvidenceKind.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EvidenceKind.java new file mode 100644 index 00000000..1bfdef42 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EvidenceKind.java @@ -0,0 +1,3 @@ +package vip.mate.execution.evidence.model; + +public enum EvidenceKind { TOOL_RETURNED, COMMAND_EXIT, CHECK_RESULT, ARTIFACT_SNAPSHOT } diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EvidenceObservation.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EvidenceObservation.java new file mode 100644 index 00000000..bba651cb --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EvidenceObservation.java @@ -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); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EvidenceResult.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EvidenceResult.java new file mode 100644 index 00000000..c5e84157 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EvidenceResult.java @@ -0,0 +1,3 @@ +package vip.mate.execution.evidence.model; + +public enum EvidenceResult { OBSERVED, PASS, FAIL, UNKNOWN } diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EvidenceScope.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EvidenceScope.java new file mode 100644 index 00000000..c67fc925 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/EvidenceScope.java @@ -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) { } diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/ExecutionAttempt.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/ExecutionAttempt.java new file mode 100644 index 00000000..514fc27f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/ExecutionAttempt.java @@ -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) { } diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/ExecutionEvidence.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/ExecutionEvidence.java new file mode 100644 index 00000000..eebda5e7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/ExecutionEvidence.java @@ -0,0 +1,3 @@ +package vip.mate.execution.evidence.model; + +public record ExecutionEvidence(Long id, Long workspaceId, Long attemptId, String conversationId, EvidenceObservation observation) { } diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/ExecutionIdentity.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/ExecutionIdentity.java new file mode 100644 index 00000000..60b4f28f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/ExecutionIdentity.java @@ -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) { } diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/GoalCriterionEvidence.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/GoalCriterionEvidence.java new file mode 100644 index 00000000..aaad15d2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/GoalCriterionEvidence.java @@ -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) { } diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/SourceLevel.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/SourceLevel.java new file mode 100644 index 00000000..590714fe --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/model/SourceLevel.java @@ -0,0 +1,3 @@ +package vip.mate.execution.evidence.model; + +public enum SourceLevel { PLATFORM_OBSERVED, ADAPTER_ATTESTED, EXTERNAL_REPORTED, LEGACY_TEXT } diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceLifecycle.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceLifecycle.java new file mode 100644 index 00000000..a10d32f3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceLifecycle.java @@ -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; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceQueryService.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceQueryService.java new file mode 100644 index 00000000..c1dffade --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceQueryService.java @@ -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 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 rows = store.list(canonicalWorkspace, conversationId, before.observedAt(), + before.id(), bounded + 1, goalId, teamTaskId); + boolean hasMore = rows.size() > bounded; + List 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"); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceRecorder.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceRecorder.java new file mode 100644 index 00000000..fa44fec4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceRecorder.java @@ -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(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); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceStore.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceStore.java new file mode 100644 index 00000000..c7d23667 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceStore.java @@ -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 finish(Long attemptId, String ownerFence, AttemptState state, + EffectOutcome effect, List 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(); + existing.forEach(row -> bySource.put(row.observation().sourceKey(), row)); + var normalized = new LinkedHashMap(); + 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 + 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 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 findById(Long id) { + return jdbc.query(EVIDENCE_QUERY + " AND e.id=?", this::evidence, id).stream().findFirst(); + } + + public Optional 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 list(Long workspaceId, String conversationId, Instant beforeObservedAt, + Long beforeId, int limit) { + return list(workspaceId, conversationId, beforeObservedAt, beforeId, limit, null, null); + } + + public List 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(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 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); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionIdentityResolver.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionIdentityResolver.java new file mode 100644 index 00000000..c1aef422 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionIdentityResolver.java @@ -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)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionObservationSink.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionObservationSink.java new file mode 100644 index 00000000..54ecd5da --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionObservationSink.java @@ -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 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(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 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); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalSegmentRunner.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalSegmentRunner.java index a87f0561..3a51e0c5 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalSegmentRunner.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalSegmentRunner.java @@ -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 { diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerInterventionService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerInterventionService.java index 47021d39..d0ad70e5 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerInterventionService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerInterventionService.java @@ -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( diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java index 943d16a1..d20bbd63 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java @@ -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", ""); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java index 7f5ea3b5..34041f11 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java @@ -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(); diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 4a3a6690..51fa3f7b 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -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 diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V191__execution_evidence_ledger.sql b/mateclaw-server/src/main/resources/db/migration/h2/V191__execution_evidence_ledger.sql new file mode 100644 index 00000000..c24f68c5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V191__execution_evidence_ledger.sql @@ -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); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V191__execution_evidence_ledger.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V191__execution_evidence_ledger.sql new file mode 100644 index 00000000..c24f68c5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V191__execution_evidence_ledger.sql @@ -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); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V191__execution_evidence_ledger.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V191__execution_evidence_ledger.sql new file mode 100644 index 00000000..9b020d99 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V191__execution_evidence_ledger.sql @@ -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); diff --git a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobOriginPropagationTest.java b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobOriginPropagationTest.java index 985f14a5..4a29b30a 100644 --- a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobOriginPropagationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobOriginPropagationTest.java @@ -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()); diff --git a/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceIntegrationTest.java new file mode 100644 index 00000000..2f29576a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceIntegrationTest.java @@ -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(); + var events = new ArrayList(); + 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); } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceLifecycleTest.java b/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceLifecycleTest.java new file mode 100644 index 00000000..94b1329c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceLifecycleTest.java @@ -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"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceQueryTest.java b/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceQueryTest.java new file mode 100644 index 00000000..9f823ef3 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceQueryTest.java @@ -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)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceRecorderTest.java b/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceRecorderTest.java new file mode 100644 index 00000000..a09466de --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceRecorderTest.java @@ -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); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceStoreTest.java b/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceStoreTest.java new file mode 100644 index 00000000..96182d9e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceStoreTest.java @@ -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 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.>>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); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/execution/evidence/TrustedExecutionObservationTest.java b/mateclaw-server/src/test/java/vip/mate/execution/evidence/TrustedExecutionObservationTest.java new file mode 100644 index 00000000..8765f5eb --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/execution/evidence/TrustedExecutionObservationTest.java @@ -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()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamWorkerInterventionServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamWorkerInterventionServiceTest.java index f5e8934f..1815e1c1 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamWorkerInterventionServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamWorkerInterventionServiceTest.java @@ -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"); diff --git a/mateclaw-ui/src/api/__tests__/executionEvidence.test.ts b/mateclaw-ui/src/api/__tests__/executionEvidence.test.ts new file mode 100644 index 00000000..54578ae0 --- /dev/null +++ b/mateclaw-ui/src/api/__tests__/executionEvidence.test.ts @@ -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' }) + }) +}) diff --git a/mateclaw-ui/src/api/executionEvidence.ts b/mateclaw-ui/src/api/executionEvidence.ts new file mode 100644 index 00000000..60b4408b --- /dev/null +++ b/mateclaw-ui/src/api/executionEvidence.ts @@ -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('/execution-evidence', { params }), + get: (id: string) => http.get(`/execution-evidence/${encodeURIComponent(id)}`), +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 3efd9708..770a3dec 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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 }, diff --git a/mateclaw-ui/src/components/agents/GoalsPanel.vue b/mateclaw-ui/src/components/agents/GoalsPanel.vue index 3ca5ec48..21ea8b7d 100644 --- a/mateclaw-ui/src/components/agents/GoalsPanel.vue +++ b/mateclaw-ui/src/components/agents/GoalsPanel.vue @@ -42,6 +42,7 @@

{{ goal.progressSummary }}

+ @@ -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 diff --git a/mateclaw-ui/src/components/execution/ExecutionEvidenceList.vue b/mateclaw-ui/src/components/execution/ExecutionEvidenceList.vue new file mode 100644 index 00000000..60fdded8 --- /dev/null +++ b/mateclaw-ui/src/components/execution/ExecutionEvidenceList.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/mateclaw-ui/src/components/execution/__tests__/ExecutionEvidenceList.test.ts b/mateclaw-ui/src/components/execution/__tests__/ExecutionEvidenceList.test.ts new file mode 100644 index 00000000..3fae0803 --- /dev/null +++ b/mateclaw-ui/src/components/execution/__tests__/ExecutionEvidenceList.test.ts @@ -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[] = [] +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('[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('[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('[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('[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') + }) +}) diff --git a/mateclaw-ui/src/components/team-run/TeamRunDetail.vue b/mateclaw-ui/src/components/team-run/TeamRunDetail.vue index f02d2605..f12c8bcb 100644 --- a/mateclaw-ui/src/components/team-run/TeamRunDetail.vue +++ b/mateclaw-ui/src/components/team-run/TeamRunDetail.vue @@ -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) {
{{ t('teamRuns.noResult') }}
+