mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
feat(execution): add persistent execution evidence in observe mode
This commit is contained in:
parent
26150b68e8
commit
7406fc99bf
@ -18,6 +18,7 @@ import org.springframework.stereotype.Component;
|
|||||||
import vip.mate.agent.graph.StateGraphReActAgent;
|
import vip.mate.agent.graph.StateGraphReActAgent;
|
||||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||||
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
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.ObservationDispatcher;
|
||||||
import vip.mate.agent.graph.edge.ReasoningDispatcher;
|
import vip.mate.agent.graph.edge.ReasoningDispatcher;
|
||||||
import vip.mate.agent.graph.lifecycle.ReActLifecycleListener;
|
import vip.mate.agent.graph.lifecycle.ReActLifecycleListener;
|
||||||
@ -103,6 +104,13 @@ public class AgentGraphBuilder {
|
|||||||
"${mateclaw.skill.disclosure.load-skill-tool.enabled:true}")
|
"${mateclaw.skill.disclosure.load-skill-tool.enabled:true}")
|
||||||
private boolean loadSkillToolEnabled;
|
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. */
|
/** Escape hatch: when false, the final answer is sent verbatim without Markdown normalization. */
|
||||||
@org.springframework.beans.factory.annotation.Value(
|
@org.springframework.beans.factory.annotation.Value(
|
||||||
"${mate.agent.markdown-normalize-enabled:true}")
|
"${mate.agent.markdown-normalize-enabled:true}")
|
||||||
@ -675,6 +683,7 @@ public class AgentGraphBuilder {
|
|||||||
executor.setSkillRuntimeService(skillRuntimeService);
|
executor.setSkillRuntimeService(skillRuntimeService);
|
||||||
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
|
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
|
||||||
executor.setProgressContext(progressContext);
|
executor.setProgressContext(progressContext);
|
||||||
|
executor.setExecutionEvidenceRecorder(executionEvidenceRecorder);
|
||||||
// Optional: route child-agent denied-tool audit events through
|
// Optional: route child-agent denied-tool audit events through
|
||||||
// the audit pipeline. Null when audit is not wired (legacy / test).
|
// the audit pipeline. Null when audit is not wired (legacy / test).
|
||||||
if (auditEventService != null) {
|
if (auditEventService != null) {
|
||||||
@ -998,6 +1007,7 @@ public class AgentGraphBuilder {
|
|||||||
executor.setSkillRuntimeService(skillRuntimeService);
|
executor.setSkillRuntimeService(skillRuntimeService);
|
||||||
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
|
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
|
||||||
executor.setProgressContext(progressContext);
|
executor.setProgressContext(progressContext);
|
||||||
|
executor.setExecutionEvidenceRecorder(executionEvidenceRecorder);
|
||||||
// Optional: route child-agent denied-tool audit events through
|
// Optional: route child-agent denied-tool audit events through
|
||||||
// the audit pipeline. Null when audit is not wired (legacy / test).
|
// the audit pipeline. Null when audit is not wired (legacy / test).
|
||||||
if (auditEventService != null) {
|
if (auditEventService != null) {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import org.springframework.ai.chat.model.ToolContext;
|
|||||||
import org.springframework.lang.Nullable;
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Immutable value object that travels alongside an agent invocation describing
|
* 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).
|
* from "this is an external/anonymous identifier" (RFC: identity typing).
|
||||||
*/
|
*/
|
||||||
@Nullable Long requesterUserId,
|
@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,
|
public ChatOrigin(@Nullable Long agentId, @Nullable String conversationId,
|
||||||
@Nullable String requesterId, @Nullable Long workspaceId,
|
@Nullable String requesterId, @Nullable Long workspaceId,
|
||||||
@Nullable String workspaceBasePath, @Nullable Long channelId,
|
@Nullable String workspaceBasePath, @Nullable Long channelId,
|
||||||
@ -149,27 +163,27 @@ public record ChatOrigin(
|
|||||||
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
||||||
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
|
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, executionAttribution);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||||
@Nullable String newWorkspaceBasePath) {
|
@Nullable String newWorkspaceBasePath) {
|
||||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
|
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
|
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, executionAttribution);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
||||||
return new ChatOrigin(agentId, newConversationId, requesterId,
|
return new ChatOrigin(agentId, newConversationId, requesterId,
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
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()}). */
|
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */
|
||||||
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
|
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
|
||||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
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) {
|
@Nullable String newChatId) {
|
||||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId);
|
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId, executionAttribution);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ChatOrigin withOriginMessageId(@Nullable Long newOriginMessageId) {
|
public ChatOrigin withOriginMessageId(@Nullable Long newOriginMessageId) {
|
||||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
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 ----------------
|
// ---------------- Spring AI ToolContext interop ----------------
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
|
||||||
|
/** Server-issued execution linkage. This record is never a tool argument or an HTTP request body. */
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record ExecutionAttribution(Long goalId, String goalAttemptId, Long cronRunId,
|
||||||
|
String approvalId, String ownerFence) {
|
||||||
|
public ExecutionAttribution withApproval(String pendingId) {
|
||||||
|
return new ExecutionAttribution(goalId, goalAttemptId, cronRunId, pendingId, ownerFence);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ import vip.mate.tool.mcp.runtime.ProgressAwareMcpToolCallback;
|
|||||||
import vip.mate.agent.AgentToolSet;
|
import vip.mate.agent.AgentToolSet;
|
||||||
import vip.mate.agent.GraphEventPublisher;
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
import vip.mate.agent.context.ChatOrigin;
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.execution.evidence.service.ExecutionEvidenceRecorder;
|
||||||
import vip.mate.agent.context.StructuredTruncator;
|
import vip.mate.agent.context.StructuredTruncator;
|
||||||
import vip.mate.agent.graph.state.DirectToolOutput;
|
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||||
@ -483,6 +484,9 @@ public class ToolExecutionExecutor {
|
|||||||
ChatOrigin origin,
|
ChatOrigin origin,
|
||||||
Set<String> loadedSkills) {
|
Set<String> loadedSkills) {
|
||||||
ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY;
|
ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY;
|
||||||
|
if (!isReplay && safeOrigin.executionAttribution() != null) {
|
||||||
|
safeOrigin = safeOrigin.withApprovalId(null);
|
||||||
|
}
|
||||||
if (isBlank(safeOrigin.conversationId()) && !isBlank(conversationId)) {
|
if (isBlank(safeOrigin.conversationId()) && !isBlank(conversationId)) {
|
||||||
safeOrigin = safeOrigin.withConversationId(conversationId);
|
safeOrigin = safeOrigin.withConversationId(conversationId);
|
||||||
}
|
}
|
||||||
@ -701,7 +705,7 @@ public class ToolExecutionExecutor {
|
|||||||
// 4. 分类: concurrencySafe
|
// 4. 分类: concurrencySafe
|
||||||
boolean safe = isConcurrencySafe(toolName);
|
boolean safe = isConcurrencySafe(toolName);
|
||||||
preparedCalls.add(new PreparedToolCall(toolCall, responseName, callback, arguments, safe, allResponses.size(),
|
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 填充
|
// 占位,Phase 2 填充
|
||||||
allResponses.add(null);
|
allResponses.add(null);
|
||||||
}
|
}
|
||||||
@ -791,6 +795,15 @@ public class ToolExecutionExecutor {
|
|||||||
List<GraphEventPublisher.GraphEvent> events,
|
List<GraphEventPublisher.GraphEvent> events,
|
||||||
String conversationId, String workspaceBasePath,
|
String conversationId, String workspaceBasePath,
|
||||||
List<DirectToolOutput> directOutputs) {
|
List<DirectToolOutput> directOutputs) {
|
||||||
|
return executePreApproved(toolCall, storedArguments, events, conversationId, workspaceBasePath,
|
||||||
|
directOutputs, ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ToolResponseMessage.ToolResponse executePreApproved(
|
||||||
|
AssistantMessage.ToolCall toolCall, String storedArguments,
|
||||||
|
List<GraphEventPublisher.GraphEvent> events,
|
||||||
|
String conversationId, String workspaceBasePath,
|
||||||
|
List<DirectToolOutput> directOutputs, ChatOrigin origin) {
|
||||||
String toolName = resolveToolName(toolCall.name());
|
String toolName = resolveToolName(toolCall.name());
|
||||||
String callArguments = storedArguments != null ? storedArguments : toolCall.arguments();
|
String callArguments = storedArguments != null ? storedArguments : toolCall.arguments();
|
||||||
|
|
||||||
@ -826,10 +839,11 @@ public class ToolExecutionExecutor {
|
|||||||
// Origin is method-local (see thread-safety note on execute());
|
// Origin is method-local (see thread-safety note on execute());
|
||||||
// the legacy ThreadLocal that used to carry it across executePreApproved
|
// the legacy ThreadLocal that used to carry it across executePreApproved
|
||||||
// calls was a cross-conversation footgun and has been removed.
|
// calls was a cross-conversation footgun and has been removed.
|
||||||
ChatOrigin replayOrigin = ChatOrigin.EMPTY
|
ChatOrigin replayOrigin = (origin == null ? ChatOrigin.EMPTY : origin)
|
||||||
.withConversationId(conversationId)
|
.withConversationId(conversationId);
|
||||||
.withWorkspace(null, workspaceBasePath);
|
replayOrigin = replayOrigin.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
|
||||||
String result = callback.call(callArguments, toolContextWithScopedCatalog(replayOrigin));
|
String result = invokeObserved(callback, callArguments, toolContextWithScopedCatalog(replayOrigin),
|
||||||
|
UUID.randomUUID().toString(), toolCall.id());
|
||||||
throwIfStopRequested(conversationId);
|
throwIfStopRequested(conversationId);
|
||||||
int rawLen = result != null ? result.length() : 0;
|
int rawLen = result != null ? result.length() : 0;
|
||||||
|
|
||||||
@ -1077,7 +1091,7 @@ public class ToolExecutionExecutor {
|
|||||||
toolContext = new ToolContext(ctxMap);
|
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);
|
throwIfStopRequested(pc.conversationId);
|
||||||
} finally {
|
} finally {
|
||||||
if (progressToken != null) {
|
if (progressToken != null) {
|
||||||
@ -1772,6 +1786,18 @@ public class ToolExecutionExecutor {
|
|||||||
return new ToolContext(context);
|
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(
|
private record PreparedToolCall(
|
||||||
@ -1785,6 +1811,7 @@ public class ToolExecutionExecutor {
|
|||||||
String requesterId,
|
String requesterId,
|
||||||
String workspaceBasePath,
|
String workspaceBasePath,
|
||||||
ChatOrigin origin,
|
ChatOrigin origin,
|
||||||
|
String invocationKey,
|
||||||
/**
|
/**
|
||||||
* Shared reference (one per execute() invocation) where each
|
* Shared reference (one per execute() invocation) where each
|
||||||
* concurrent {@code executeSingleTool} merges a {@link SourceEvidenceLedger}
|
* concurrent {@code executeSingleTool} merges a {@link SourceEvidenceLedger}
|
||||||
|
|||||||
@ -362,7 +362,7 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
// (instead of leaking into the next LLM round).
|
// (instead of leaking into the next LLM round).
|
||||||
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
|
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
|
||||||
toolCall, storedArguments, events, conversationId, workspaceBasePath,
|
toolCall, storedArguments, events, conversationId, workspaceBasePath,
|
||||||
stepDirectOutputs);
|
stepDirectOutputs, chatOrigin);
|
||||||
toolResponses.add(response);
|
toolResponses.add(response);
|
||||||
preApprovedPayload = ""; // 只消费一次
|
preApprovedPayload = ""; // 只消费一次
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1469,6 +1469,7 @@ public class ChannelMessageRouter {
|
|||||||
replayOrigin = chatOriginFactory.from(
|
replayOrigin = chatOriginFactory.from(
|
||||||
channelEntity, triggerMessage, conversationId, /* workspaceBasePath */ null);
|
channelEntity, triggerMessage, conversationId, /* workspaceBasePath */ null);
|
||||||
}
|
}
|
||||||
|
replayOrigin = replayOrigin.withApprovalId(consumed.getPendingId());
|
||||||
AgentService.ChatResult replayResult = agentService.chatWithReplayWithUsage(
|
AgentService.ChatResult replayResult = agentService.chatWithReplayWithUsage(
|
||||||
agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
|
agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
|
||||||
String reply = replayResult.content();
|
String reply = replayResult.content();
|
||||||
|
|||||||
@ -346,7 +346,7 @@ public class ChatController {
|
|||||||
}
|
}
|
||||||
// Carry the request-thread base URL so any file a replayed
|
// Carry the request-thread base URL so any file a replayed
|
||||||
// tool generates gets an absolute download link.
|
// tool generates gets an absolute download link.
|
||||||
replayOrigin = replayOrigin.withBaseUrl(requestBaseUrl);
|
replayOrigin = replayOrigin.withBaseUrl(requestBaseUrl).withApprovalId(finalConsumed.getPendingId());
|
||||||
Disposable disposable = agentService.chatWithReplayStream(
|
Disposable disposable = agentService.chatWithReplayStream(
|
||||||
replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username, replayOrigin)
|
replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username, replayOrigin)
|
||||||
.doOnNext(delta -> {
|
.doOnNext(delta -> {
|
||||||
|
|||||||
@ -1362,6 +1362,8 @@ public class WebChatController {
|
|||||||
conversationId, actor, wsId, null).withSender(null, "api", null);
|
conversationId, actor, wsId, null).withSender(null, "api", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replayOrigin = replayOrigin.withApprovalId(snapshot.getPendingId());
|
||||||
|
|
||||||
// Neutral replay prompt (aligned with IM + web channels — naming a
|
// Neutral replay prompt (aligned with IM + web channels — naming a
|
||||||
// tool here can mislead the LLM on fallthrough).
|
// tool here can mislead the LLM on fallthrough).
|
||||||
String replayPrompt = "继续执行已批准的工具调用。";
|
String replayPrompt = "继续执行已批准的工具调用。";
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import org.springframework.ai.chat.messages.AssistantMessage;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
import vip.mate.agent.context.ChatOrigin;
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.agent.context.ExecutionAttribution;
|
||||||
import vip.mate.agent.graph.state.FinishReason;
|
import vip.mate.agent.graph.state.FinishReason;
|
||||||
import vip.mate.cron.CronChatOriginFactory;
|
import vip.mate.cron.CronChatOriginFactory;
|
||||||
import vip.mate.cron.model.CronJobEntity;
|
import vip.mate.cron.model.CronJobEntity;
|
||||||
@ -146,6 +147,8 @@ public class CronJobRunner {
|
|||||||
try {
|
try {
|
||||||
ChatOrigin origin = originFactory.from(
|
ChatOrigin origin = originFactory.from(
|
||||||
job, conversationId, started.originMessageId());
|
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())) {
|
try (CronRunHeartbeatService.Lease ignored = heartbeat.begin(run.getId())) {
|
||||||
chatResult = runAgent(job, userMessage, origin, conversationId);
|
chatResult = runAgent(job, userMessage, origin, conversationId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
package vip.mate.execution.evidence;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "mateclaw.execution-evidence")
|
||||||
|
public class ExecutionEvidenceProperties {
|
||||||
|
public enum Mode { OFF, OBSERVE, ENFORCE }
|
||||||
|
private Mode mode = Mode.OBSERVE;
|
||||||
|
private int retentionDays = 90;
|
||||||
|
private int cleanupMaxBatches = 10;
|
||||||
|
public int getCleanupMaxBatches() { return cleanupMaxBatches; }
|
||||||
|
public void setCleanupMaxBatches(int value) { cleanupMaxBatches = Math.clamp(value, 1, 100); }
|
||||||
|
private int maxObservations = 32;
|
||||||
|
public int getMaxObservations() { return maxObservations; }
|
||||||
|
public void setMaxObservations(int value) { maxObservations = Math.clamp(value, 1, 99); }
|
||||||
|
private int maxSummaryBytes = 2048;
|
||||||
|
private int defaultListLimit = 20;
|
||||||
|
private int maxListLimit = 100;
|
||||||
|
public Mode getMode() { return mode; }
|
||||||
|
public void setMode(Mode mode) { this.mode = mode; }
|
||||||
|
public int getRetentionDays() { return retentionDays; }
|
||||||
|
public void setRetentionDays(int value) { retentionDays = Math.max(1, value); }
|
||||||
|
public int getMaxSummaryBytes() { return maxSummaryBytes; }
|
||||||
|
public void setMaxSummaryBytes(int value) { maxSummaryBytes = Math.clamp(value, 1, 2048); }
|
||||||
|
public int getDefaultListLimit() { return defaultListLimit; }
|
||||||
|
public void setDefaultListLimit(int value) { defaultListLimit = Math.clamp(value, 1, 100); }
|
||||||
|
public int getMaxListLimit() { return maxListLimit; }
|
||||||
|
public void setMaxListLimit(int value) { maxListLimit = Math.clamp(value, 1, 100); }
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package vip.mate.execution.evidence.controller;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
import vip.mate.execution.evidence.service.ExecutionEvidenceQueryService;
|
||||||
|
|
||||||
|
/** Source-authorized, read-only observations. There is deliberately no evidence write endpoint. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/execution-evidence")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExecutionEvidenceController {
|
||||||
|
private final ExecutionEvidenceQueryService queries;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public R<ExecutionEvidenceQueryService.Page> list(Authentication auth,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||||
|
@RequestParam String conversationId,
|
||||||
|
@RequestParam(required = false) String cursor,
|
||||||
|
@RequestParam(required = false) Integer limit,
|
||||||
|
@RequestParam(required = false) Long goalId,
|
||||||
|
@RequestParam(required = false) Long teamTaskId) {
|
||||||
|
return R.ok(queries.list(auth == null ? null : auth.getName(), workspaceId, conversationId,
|
||||||
|
cursor, limit, goalId, teamTaskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public R<ExecutionEvidenceQueryService.View> detail(Authentication auth,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||||
|
@PathVariable Long id) {
|
||||||
|
return R.ok(queries.detail(auth == null ? null : auth.getName(), workspaceId, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
package vip.mate.execution.evidence.model;
|
||||||
|
|
||||||
|
public enum AttemptState { STARTED, SUCCEEDED, FAILED, CANCELLED, UNKNOWN, BLOCKED }
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
package vip.mate.execution.evidence.model;
|
||||||
|
|
||||||
|
/** Only the successful inserter may initiate a new execution. */
|
||||||
|
public record BeginResult(ExecutionAttempt attempt, boolean created) { }
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
package vip.mate.execution.evidence.model;
|
||||||
|
|
||||||
|
public enum EffectOutcome { NONE, CONFIRMED, UNCERTAIN }
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
package vip.mate.execution.evidence.model;
|
||||||
|
|
||||||
|
public enum EvidenceKind { TOOL_RETURNED, COMMAND_EXIT, CHECK_RESULT, ARTIFACT_SNAPSHOT }
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package vip.mate.execution.evidence.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** Allowlisted execution metadata; raw invocation parameters are never accepted. */
|
||||||
|
public record EvidenceObservation(String sourceKey, EvidenceKind kind, EvidenceResult result,
|
||||||
|
SourceLevel sourceLevel, Long scopeId, Long generation, String inputFingerprint,
|
||||||
|
String recipeId, Long recipeRevision, String checkScope, String artifactRef,
|
||||||
|
String artifactDigest, String summary, String payloadRef, Instant observedAt, Instant expiresAt) {
|
||||||
|
public EvidenceObservation(String sourceKey, EvidenceKind kind, EvidenceResult result,
|
||||||
|
SourceLevel sourceLevel, String summary) {
|
||||||
|
this(sourceKey, kind, result, sourceLevel, null, null, null, null, null, null,
|
||||||
|
null, null, summary, null, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
package vip.mate.execution.evidence.model;
|
||||||
|
|
||||||
|
public enum EvidenceResult { OBSERVED, PASS, FAIL, UNKNOWN }
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package vip.mate.execution.evidence.model;
|
||||||
|
|
||||||
|
/** Persisted resource identity reserved for managed validation scopes. */
|
||||||
|
public record EvidenceScope(Long id, Long workspaceId, String resourceKey, String hostId,
|
||||||
|
String rootId, long generation, int activeMutations, boolean tainted) { }
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package vip.mate.execution.evidence.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record ExecutionAttempt(Long id, ExecutionIdentity identity, AttemptState state, EffectOutcome effectOutcome,
|
||||||
|
Instant startedAt, Instant finishedAt) { }
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
package vip.mate.execution.evidence.model;
|
||||||
|
|
||||||
|
public record ExecutionEvidence(Long id, Long workspaceId, Long attemptId, String conversationId, EvidenceObservation observation) { }
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package vip.mate.execution.evidence.model;
|
||||||
|
|
||||||
|
public record ExecutionIdentity(Long workspaceId, String conversationId, String runtimeKind, String runtimeSessionId,
|
||||||
|
String invocationKey, String logicalCallId, int attemptNo, String providerToolCallId,
|
||||||
|
String toolName, Long goalId, String goalAttemptId, Long teamRunId, Long teamTaskId,
|
||||||
|
Long cronRunId, String approvalId, String ownerFence) { }
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package vip.mate.execution.evidence.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** Versioned binding metadata; creating a binding requires separate source authorization. */
|
||||||
|
public record GoalCriterionEvidence(Long id, Long workspaceId, Long goalId, String criterionId,
|
||||||
|
long criterionRevision, Long evidenceId, Instant boundAt) { }
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
package vip.mate.execution.evidence.model;
|
||||||
|
|
||||||
|
public enum SourceLevel { PLATFORM_OBSERVED, ADAPTER_ATTESTED, EXTERNAL_REPORTED, LEGACY_TEXT }
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package vip.mate.execution.evidence.service;
|
||||||
|
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.workspace.conversation.event.ConversationDeletedEvent;
|
||||||
|
import vip.mate.execution.evidence.ExecutionEvidenceProperties;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** Metadata retention is independent of source-file retention and respects conversation deletion. */
|
||||||
|
@Component
|
||||||
|
public class ExecutionEvidenceLifecycle {
|
||||||
|
private final ExecutionEvidenceStore store;
|
||||||
|
|
||||||
|
private final ExecutionEvidenceProperties properties;
|
||||||
|
|
||||||
|
public ExecutionEvidenceLifecycle(ExecutionEvidenceStore store, ExecutionEvidenceProperties properties) {
|
||||||
|
this.store = store;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener
|
||||||
|
public void onConversationDeleted(ConversationDeletedEvent event) {
|
||||||
|
store.purgeConversation(event.conversationId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${mateclaw.execution-evidence.cleanup-interval-ms:60000}")
|
||||||
|
public void cleanup() {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
for (int batch = 0; batch < properties.getCleanupMaxBatches(); batch++) {
|
||||||
|
if (store.purgeExpiredMetadata(now, 100) < 100) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
package vip.mate.execution.evidence.service;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.execution.evidence.ExecutionEvidenceProperties;
|
||||||
|
import vip.mate.execution.evidence.model.AttemptState;
|
||||||
|
import vip.mate.execution.evidence.model.EffectOutcome;
|
||||||
|
import vip.mate.execution.evidence.model.EvidenceKind;
|
||||||
|
import vip.mate.execution.evidence.model.EvidenceResult;
|
||||||
|
import vip.mate.execution.evidence.model.SourceLevel;
|
||||||
|
import vip.mate.auth.service.AuthService;
|
||||||
|
import vip.mate.team.service.TeamWorkerConversationGovernanceService;
|
||||||
|
import vip.mate.tool.document.GeneratedFileCache;
|
||||||
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
import vip.mate.workspace.core.service.WorkspaceService;
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
import vip.mate.execution.evidence.model.ExecutionEvidence;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ExecutionEvidenceQueryService {
|
||||||
|
public record View(Long id, Long attemptId, String conversationId, String toolName, AttemptState state,
|
||||||
|
EffectOutcome effectOutcome, EvidenceKind kind, EvidenceResult result, SourceLevel sourceLevel,
|
||||||
|
String validity, String summary, Instant observedAt, Instant expiresAt,
|
||||||
|
String artifactRef, String artifactDigest, String checkScope) { }
|
||||||
|
public record Page(List<View> items, String nextCursor) { }
|
||||||
|
private record Cursor(Instant observedAt, Long id) { }
|
||||||
|
private final ExecutionEvidenceStore store;
|
||||||
|
private final ConversationService conversations;
|
||||||
|
private final TeamWorkerConversationGovernanceService teams;
|
||||||
|
private final GeneratedFileCache files;
|
||||||
|
private final AuthService auth;
|
||||||
|
private final WorkspaceService workspaces;
|
||||||
|
private final ExecutionEvidenceProperties properties;
|
||||||
|
private final MeterRegistry metrics;
|
||||||
|
|
||||||
|
public ExecutionEvidenceQueryService(ExecutionEvidenceStore store, ConversationService conversations,
|
||||||
|
TeamWorkerConversationGovernanceService teams, GeneratedFileCache files,
|
||||||
|
AuthService auth, WorkspaceService workspaces, ExecutionEvidenceProperties properties, MeterRegistry metrics) {
|
||||||
|
this.store = store;
|
||||||
|
this.conversations = conversations;
|
||||||
|
this.teams = teams;
|
||||||
|
this.files = files;
|
||||||
|
this.auth = auth;
|
||||||
|
this.workspaces = workspaces;
|
||||||
|
this.properties = properties;
|
||||||
|
this.metrics = metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page list(String username, Long workspaceId, String conversationId, String cursor, Integer limit,
|
||||||
|
Long goalId, Long teamTaskId) {
|
||||||
|
long started = System.nanoTime();
|
||||||
|
try {
|
||||||
|
Long canonicalWorkspace = authorize(username, workspaceId, conversationId);
|
||||||
|
int bounded = Math.clamp(limit == null ? properties.getDefaultListLimit() : limit, 1, properties.getMaxListLimit());
|
||||||
|
Cursor before = decode(cursor);
|
||||||
|
List<ExecutionEvidence> rows = store.list(canonicalWorkspace, conversationId, before.observedAt(),
|
||||||
|
before.id(), bounded + 1, goalId, teamTaskId);
|
||||||
|
boolean hasMore = rows.size() > bounded;
|
||||||
|
List<ExecutionEvidence> page = rows.stream().limit(bounded).toList();
|
||||||
|
return new Page(page.stream().map(row -> view(username, row)).toList(),
|
||||||
|
hasMore ? encode(page.getLast()) : null);
|
||||||
|
} finally {
|
||||||
|
metrics.timer("mateclaw.execution.evidence.query.latency").record(
|
||||||
|
System.nanoTime() - started, TimeUnit.NANOSECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public View detail(String username, Long workspaceId, Long id) {
|
||||||
|
if (username == null || username.isBlank() || id == null) throw hidden();
|
||||||
|
ExecutionEvidence row = store.findById(id).orElseThrow(this::hidden);
|
||||||
|
Long canonicalWorkspace = authorize(username, workspaceId, row.conversationId());
|
||||||
|
if (!canonicalWorkspace.equals(row.workspaceId())) throw hidden();
|
||||||
|
return view(username, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long authorize(String username, Long workspaceId, String conversationId) {
|
||||||
|
if (username == null || username.isBlank() || conversationId == null || conversationId.isBlank()) throw hidden();
|
||||||
|
var conversation = conversations.findByConversationId(conversationId);
|
||||||
|
if (conversation == null || conversation.getWorkspaceId() == null || Integer.valueOf(1).equals(conversation.getDeleted())
|
||||||
|
|| workspaceId != null && !workspaceId.equals(conversation.getWorkspaceId())) throw hidden();
|
||||||
|
if (!conversations.isConversationOwner(conversationId, username)
|
||||||
|
&& !teams.canReadTranscript(conversationId, null, null, username)) throw hidden();
|
||||||
|
return conversation.getWorkspaceId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private View view(String username, ExecutionEvidence row) {
|
||||||
|
var attempt = store.findAttempt(row.attemptId()).orElseThrow(this::hidden);
|
||||||
|
if (!Objects.equals(attempt.identity().workspaceId(), row.workspaceId())
|
||||||
|
|| !Objects.equals(attempt.identity().conversationId(), row.conversationId())) throw hidden();
|
||||||
|
var evidence = row.observation();
|
||||||
|
String validity = "UNKNOWN";
|
||||||
|
String artifact = evidence.artifactRef();
|
||||||
|
String digest = evidence.artifactDigest();
|
||||||
|
String summary = evidence.summary();
|
||||||
|
if (evidence.expiresAt() != null && !evidence.expiresAt().isAfter(Instant.now())) validity = "UNAVAILABLE";
|
||||||
|
if (evidence.kind() == EvidenceKind.ARTIFACT_SNAPSHOT) {
|
||||||
|
var user = auth.findByUsername(username);
|
||||||
|
boolean canReadFile = user != null && ("admin".equalsIgnoreCase(user.getRole())
|
||||||
|
|| workspaces.hasPermissionCached(row.workspaceId(), user.getId(), "viewer"));
|
||||||
|
if (!canReadFile) {
|
||||||
|
artifact = null;
|
||||||
|
digest = null;
|
||||||
|
summary = null;
|
||||||
|
validity = "UNAVAILABLE";
|
||||||
|
} else if (!files.isDurablyAvailable(artifact, row.workspaceId(), row.conversationId())) {
|
||||||
|
validity = "UNAVAILABLE";
|
||||||
|
artifact = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metrics.counter("mateclaw.execution.evidence.validity", "status", validity).increment();
|
||||||
|
// An available observation is not a freshness or correctness certificate.
|
||||||
|
return new View(row.id(), row.attemptId(), row.conversationId(), attempt.identity().toolName(), attempt.state(),
|
||||||
|
attempt.effectOutcome(), evidence.kind(), evidence.result(), evidence.sourceLevel(), validity,
|
||||||
|
summary, evidence.observedAt(), evidence.expiresAt(), artifact, digest, evidence.checkScope());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Cursor decode(String cursor) {
|
||||||
|
if (cursor == null || cursor.isBlank()) return new Cursor(null, null);
|
||||||
|
try {
|
||||||
|
if (cursor.length() > 256) throw new IllegalArgumentException();
|
||||||
|
String[] parts = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8).split("\\|", -1);
|
||||||
|
if (parts.length != 2) throw new IllegalArgumentException();
|
||||||
|
long id = Long.parseLong(parts[1]);
|
||||||
|
if (id <= 0) throw new IllegalArgumentException();
|
||||||
|
return new Cursor(Instant.parse(parts[0]), id);
|
||||||
|
} catch (RuntimeException invalid) {
|
||||||
|
throw new MateClawException(400, "Invalid execution evidence cursor");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String encode(ExecutionEvidence row) {
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(
|
||||||
|
(row.observation().observedAt() + "|" + row.id()).getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private MateClawException hidden() {
|
||||||
|
metrics.counter("mateclaw.execution.evidence.query.denied").increment();
|
||||||
|
return new MateClawException(404, "Execution evidence not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,127 @@
|
|||||||
|
package vip.mate.execution.evidence.service;
|
||||||
|
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.execution.evidence.ExecutionEvidenceProperties;
|
||||||
|
import vip.mate.execution.evidence.model.AttemptState;
|
||||||
|
import vip.mate.execution.evidence.model.EffectOutcome;
|
||||||
|
import vip.mate.execution.evidence.model.EvidenceKind;
|
||||||
|
import vip.mate.execution.evidence.model.EvidenceObservation;
|
||||||
|
import vip.mate.execution.evidence.model.EvidenceResult;
|
||||||
|
import vip.mate.execution.evidence.model.ExecutionAttempt;
|
||||||
|
import vip.mate.execution.evidence.model.SourceLevel;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.concurrent.CancellationException;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/** Observes the actual callback boundary. It never interprets returned text as a check result. */
|
||||||
|
@Service
|
||||||
|
public class ExecutionEvidenceRecorder {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ExecutionEvidenceRecorder.class);
|
||||||
|
private final ExecutionEvidenceStore store;
|
||||||
|
private final ExecutionIdentityResolver identities;
|
||||||
|
private final ExecutionEvidenceProperties properties;
|
||||||
|
private final MeterRegistry metrics;
|
||||||
|
|
||||||
|
public ExecutionEvidenceRecorder(ExecutionEvidenceStore store, ExecutionIdentityResolver identities,
|
||||||
|
ExecutionEvidenceProperties properties, MeterRegistry metrics) {
|
||||||
|
this.store = store;
|
||||||
|
this.identities = identities;
|
||||||
|
this.properties = properties;
|
||||||
|
this.metrics = metrics;
|
||||||
|
metrics.gauge("mateclaw.execution.evidence.attempts.unresolved", store, ExecutionEvidenceStore::countUnresolved);
|
||||||
|
if (properties.getMode() == ExecutionEvidenceProperties.Mode.ENFORCE) {
|
||||||
|
throw new IllegalStateException("Execution evidence enforcement requires managed verification scopes; use observe or off");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String invoke(ToolCallback callback, String arguments, ToolContext context,
|
||||||
|
String invocationKey, String providerCallId) {
|
||||||
|
if (properties.getMode() == ExecutionEvidenceProperties.Mode.OFF) return callback.call(arguments, context);
|
||||||
|
ExecutionAttempt attempt = null;
|
||||||
|
boolean duplicate = false;
|
||||||
|
long began = System.nanoTime();
|
||||||
|
try {
|
||||||
|
var identity = identities.resolve(ChatOrigin.from(context), invocationKey, providerCallId,
|
||||||
|
callback.getToolDefinition().name());
|
||||||
|
if (identity != null) {
|
||||||
|
var reservation = store.reserve(identity);
|
||||||
|
attempt = reservation.attempt();
|
||||||
|
duplicate = !reservation.created();
|
||||||
|
}
|
||||||
|
else metrics.counter("mateclaw.execution.evidence.unattributed").increment();
|
||||||
|
} catch (IllegalStateException conflict) {
|
||||||
|
throw conflict;
|
||||||
|
} catch (RuntimeException failure) {
|
||||||
|
failure("begin");
|
||||||
|
} finally {
|
||||||
|
metrics.timer("mateclaw.execution.evidence.capture.latency", "phase", "begin")
|
||||||
|
.record(System.nanoTime() - began, TimeUnit.NANOSECONDS);
|
||||||
|
}
|
||||||
|
// An existing receipt is not a license to repeat an approved side effect.
|
||||||
|
if (duplicate) {
|
||||||
|
throw new IllegalStateException("Execution already observed; recover the existing approval result");
|
||||||
|
}
|
||||||
|
boolean direct = callback.getToolMetadata() != null && callback.getToolMetadata().returnDirect();
|
||||||
|
var sink = new ExecutionObservationSink(direct, properties.getMaxObservations());
|
||||||
|
try {
|
||||||
|
ToolContext observedContext = context;
|
||||||
|
if (attempt != null) {
|
||||||
|
var values = new HashMap<String, Object>(context.getContext());
|
||||||
|
ChatOrigin canonical = ChatOrigin.from(context).withWorkspace(attempt.identity().workspaceId(),
|
||||||
|
ChatOrigin.from(context).workspaceBasePath());
|
||||||
|
values.put(ChatOrigin.CTX_KEY, canonical);
|
||||||
|
observedContext = sink.attach(new ToolContext(values));
|
||||||
|
}
|
||||||
|
String result = callback.call(arguments, observedContext);
|
||||||
|
finish(attempt, sink, sink.state(), "Tool callback returned");
|
||||||
|
return result;
|
||||||
|
} catch (RuntimeException | Error error) {
|
||||||
|
AttemptState state = error instanceof CancellationException || Thread.currentThread().isInterrupted()
|
||||||
|
? AttemptState.CANCELLED : AttemptState.FAILED;
|
||||||
|
finish(attempt, sink, state, "Tool callback did not complete normally");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finish(ExecutionAttempt attempt, ExecutionObservationSink sink, AttemptState state, String summary) {
|
||||||
|
sink.seal();
|
||||||
|
if (attempt == null) return;
|
||||||
|
long began = System.nanoTime();
|
||||||
|
try {
|
||||||
|
if (!identities.isCurrent(attempt.identity())) {
|
||||||
|
failure("owner_lost");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var observations = new ArrayList<>(sink.observations());
|
||||||
|
observations.add(new EvidenceObservation("callback", EvidenceKind.TOOL_RETURNED,
|
||||||
|
state == AttemptState.SUCCEEDED ? EvidenceResult.OBSERVED
|
||||||
|
: state == AttemptState.UNKNOWN || state == AttemptState.CANCELLED
|
||||||
|
? EvidenceResult.UNKNOWN : EvidenceResult.FAIL,
|
||||||
|
SourceLevel.PLATFORM_OBSERVED, summary));
|
||||||
|
store.finish(attempt.id(), attempt.identity().ownerFence(), state,
|
||||||
|
state == AttemptState.BLOCKED ? EffectOutcome.NONE : EffectOutcome.UNCERTAIN, observations);
|
||||||
|
for (var observation : observations) {
|
||||||
|
metrics.counter("mateclaw.execution.evidence.observations", "kind", observation.kind().name()).increment();
|
||||||
|
}
|
||||||
|
} catch (RuntimeException failure) {
|
||||||
|
// Preserve STARTED as uncertain; the existing recovery authority owns any retry.
|
||||||
|
failure("finish");
|
||||||
|
} finally {
|
||||||
|
metrics.timer("mateclaw.execution.evidence.capture.latency", "phase", "finish")
|
||||||
|
.record(System.nanoTime() - began, TimeUnit.NANOSECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void failure(String phase) {
|
||||||
|
metrics.counter("mateclaw.execution.evidence.capture.failures", "phase", phase).increment();
|
||||||
|
log.warn("Execution evidence capture unavailable (phase={}); consult execution recovery state", phase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,319 @@
|
|||||||
|
package vip.mate.execution.evidence.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.TransactionDefinition;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import vip.mate.common.text.SecretRedactor;
|
||||||
|
import vip.mate.execution.evidence.ExecutionEvidenceProperties;
|
||||||
|
import vip.mate.execution.evidence.model.AttemptState;
|
||||||
|
import vip.mate.execution.evidence.model.BeginResult;
|
||||||
|
import vip.mate.execution.evidence.model.EffectOutcome;
|
||||||
|
import vip.mate.execution.evidence.model.EvidenceKind;
|
||||||
|
import vip.mate.execution.evidence.model.EvidenceObservation;
|
||||||
|
import vip.mate.execution.evidence.model.EvidenceResult;
|
||||||
|
import vip.mate.execution.evidence.model.ExecutionAttempt;
|
||||||
|
import vip.mate.execution.evidence.model.ExecutionEvidence;
|
||||||
|
import vip.mate.execution.evidence.model.ExecutionIdentity;
|
||||||
|
import vip.mate.execution.evidence.model.SourceLevel;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/** Short, independent transactions never span tool execution. No public write API exists. */
|
||||||
|
@Service
|
||||||
|
public class ExecutionEvidenceStore {
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
private final TransactionTemplate transaction;
|
||||||
|
private final ExecutionEvidenceProperties properties;
|
||||||
|
private static final String EVIDENCE_QUERY = "SELECT e.*, a.conversation_id FROM mate_execution_evidence e JOIN mate_execution_attempt a ON a.id=e.attempt_id WHERE e.deleted=0 AND a.deleted=0";
|
||||||
|
|
||||||
|
public ExecutionEvidenceStore(JdbcTemplate jdbc, PlatformTransactionManager manager,
|
||||||
|
ExecutionEvidenceProperties properties) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
this.properties = properties;
|
||||||
|
transaction = new TransactionTemplate(manager);
|
||||||
|
transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExecutionIdentityResolver ownershipValidator;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public void setOwnershipValidator(ExecutionIdentityResolver validator) {
|
||||||
|
this.ownershipValidator = validator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExecutionAttempt begin(ExecutionIdentity identity) {
|
||||||
|
return reserve(identity).attempt();
|
||||||
|
}
|
||||||
|
|
||||||
|
public BeginResult reserve(ExecutionIdentity identity) {
|
||||||
|
validate(identity);
|
||||||
|
try {
|
||||||
|
return transaction.execute(status -> {
|
||||||
|
if (ownershipValidator != null) ownershipValidator.lockCurrentForUpdate(identity, false);
|
||||||
|
var existing = byInvocation(identity);
|
||||||
|
if (existing.isPresent()) return new BeginResult(sameIdentity(existing.get(), identity), false);
|
||||||
|
long id = IdWorker.getId();
|
||||||
|
Instant now = now();
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO mate_execution_attempt
|
||||||
|
(id,workspace_id,conversation_id,runtime_kind,runtime_session_id,invocation_key,
|
||||||
|
logical_call_id,attempt_no,provider_tool_call_id,tool_name,goal_id,goal_attempt_id,
|
||||||
|
team_run_id,team_task_id,cron_run_id,approval_id,owner_fence,state,effect_outcome,started_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'STARTED','UNCERTAIN',?)
|
||||||
|
""", id, identity.workspaceId(), identity.conversationId(), identity.runtimeKind(),
|
||||||
|
identity.runtimeSessionId(), identity.invocationKey(), identity.logicalCallId(),
|
||||||
|
identity.attemptNo(), identity.providerToolCallId(), identity.toolName(), identity.goalId(),
|
||||||
|
identity.goalAttemptId(), identity.teamRunId(), identity.teamTaskId(), identity.cronRunId(),
|
||||||
|
identity.approvalId(), identity.ownerFence(), timestamp(now));
|
||||||
|
return new BeginResult(new ExecutionAttempt(id, identity, AttemptState.STARTED, EffectOutcome.UNCERTAIN, now, null), true);
|
||||||
|
});
|
||||||
|
} catch (DuplicateKeyException conflict) {
|
||||||
|
return new BeginResult(sameIdentity(byInvocation(identity).orElseThrow(() ->
|
||||||
|
new IllegalStateException("Logical execution attempt already exists")), identity), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ExecutionEvidence> finish(Long attemptId, String ownerFence, AttemptState state,
|
||||||
|
EffectOutcome effect, List<EvidenceObservation> observations) {
|
||||||
|
if (state == null || state == AttemptState.STARTED || effect == null || observations == null)
|
||||||
|
throw new IllegalArgumentException("A terminal execution outcome is required");
|
||||||
|
if (observations.size() > 100) throw new IllegalArgumentException("Too many observations");
|
||||||
|
return transaction.execute(status -> {
|
||||||
|
var snapshot = findAttempt(attemptId).orElseThrow(() -> new IllegalStateException("Execution attempt unavailable"));
|
||||||
|
boolean currentOwner = ownershipValidator == null || ownershipValidator.lockCurrentForUpdate(snapshot.identity(), true);
|
||||||
|
var rows = jdbc.query("SELECT * FROM mate_execution_attempt WHERE id=? AND deleted=0 FOR UPDATE",
|
||||||
|
this::attempt, attemptId);
|
||||||
|
if (rows.isEmpty()) throw new IllegalStateException("Execution attempt unavailable");
|
||||||
|
var attempt = rows.getFirst();
|
||||||
|
if (!Objects.equals(attempt.identity().ownerFence(), ownerFence))
|
||||||
|
throw new IllegalStateException("Execution owner fence rejected");
|
||||||
|
var existing = jdbc.query(EVIDENCE_QUERY + " AND e.attempt_id=? ORDER BY e.id", this::evidence, attemptId);
|
||||||
|
var bySource = new LinkedHashMap<String, ExecutionEvidence>();
|
||||||
|
existing.forEach(row -> bySource.put(row.observation().sourceKey(), row));
|
||||||
|
var normalized = new LinkedHashMap<String, EvidenceObservation>();
|
||||||
|
for (var observation : observations) {
|
||||||
|
var prior = bySource.get(observation.sourceKey());
|
||||||
|
var baseline = prior == null ? normalized.get(observation.sourceKey()) : prior.observation();
|
||||||
|
var clean = normalize(observation, baseline);
|
||||||
|
var duplicate = normalized.putIfAbsent(clean.sourceKey(), clean);
|
||||||
|
if (duplicate != null && !duplicate.equals(clean)) throw conflict();
|
||||||
|
if (prior != null && !prior.observation().equals(clean)) throw conflict();
|
||||||
|
}
|
||||||
|
if (attempt.state() != AttemptState.STARTED) {
|
||||||
|
if (attempt.state() != state || attempt.effectOutcome() != effect
|
||||||
|
|| bySource.size() != normalized.size() || !bySource.keySet().equals(normalized.keySet()))
|
||||||
|
throw conflict();
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
if (!currentOwner) throw new IllegalStateException("Execution owner fence rejected");
|
||||||
|
int updated = jdbc.update("""
|
||||||
|
UPDATE mate_execution_attempt SET state=?,effect_outcome=?,finished_at=?,update_time=?
|
||||||
|
WHERE id=? AND owner_fence=? AND state='STARTED' AND deleted=0
|
||||||
|
""", state.name(), effect.name(), timestamp(now()), timestamp(now()), attemptId, ownerFence);
|
||||||
|
if (updated != 1) throw new IllegalStateException("Execution owner fence rejected");
|
||||||
|
for (var observation : normalized.values()) {
|
||||||
|
long id = IdWorker.getId();
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO mate_execution_evidence
|
||||||
|
(id,workspace_id,attempt_id,source_key,kind,result,source_level,scope_id,generation,
|
||||||
|
input_fingerprint,recipe_id,recipe_revision,check_scope,artifact_ref,artifact_digest,
|
||||||
|
summary,payload_ref,observed_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""", id, attempt.identity().workspaceId(), attemptId, observation.sourceKey(),
|
||||||
|
observation.kind().name(), observation.result().name(), observation.sourceLevel().name(),
|
||||||
|
observation.scopeId(), observation.generation(), observation.inputFingerprint(),
|
||||||
|
observation.recipeId(), observation.recipeRevision(), observation.checkScope(),
|
||||||
|
observation.artifactRef(), observation.artifactDigest(), observation.summary(),
|
||||||
|
observation.payloadRef(), timestamp(observation.observedAt()), timestamp(observation.expiresAt()));
|
||||||
|
}
|
||||||
|
return jdbc.query(EVIDENCE_QUERY + " AND e.attempt_id=? ORDER BY e.id", this::evidence, attemptId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete bounded, unreferenced terminal metadata; unresolved executions and bindings remain pinned. */
|
||||||
|
public int purgeExpiredMetadata(Instant now, int batchLimit) {
|
||||||
|
Objects.requireNonNull(now, "Retention time required");
|
||||||
|
if (batchLimit < 1) throw new IllegalArgumentException("Positive cleanup batch required");
|
||||||
|
var cutoff = timestamp(now.minus(properties.getRetentionDays(), ChronoUnit.DAYS));
|
||||||
|
return transaction.execute(status -> {
|
||||||
|
var ids = jdbc.queryForList("""
|
||||||
|
SELECT a.id FROM mate_execution_attempt a
|
||||||
|
WHERE a.state IN ('SUCCEEDED','FAILED','CANCELLED','BLOCKED') AND a.update_time<?
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM mate_execution_evidence e
|
||||||
|
WHERE e.attempt_id=a.id AND e.observed_at>=?)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM mate_execution_evidence e
|
||||||
|
JOIN mate_goal_criterion_evidence b ON b.evidence_id=e.id WHERE e.attempt_id=a.id)
|
||||||
|
ORDER BY a.id LIMIT ? FOR UPDATE
|
||||||
|
""", Long.class, cutoff, cutoff, Math.min(batchLimit,100));
|
||||||
|
for (var id : ids) {
|
||||||
|
// Foreign keys also prevent deletion if a new binding races the candidate selection.
|
||||||
|
jdbc.update("DELETE FROM mate_execution_evidence WHERE attempt_id=?", id);
|
||||||
|
jdbc.update("DELETE FROM mate_execution_attempt WHERE id=?", id);
|
||||||
|
}
|
||||||
|
return ids.size();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erase copied content while retaining non-content tombstones for existing evidence bindings. */
|
||||||
|
public int purgeConversation(String conversationId) {
|
||||||
|
required(conversationId,128);
|
||||||
|
return transaction.execute(status -> {
|
||||||
|
// Lock attempts before receipts, matching finish, so a late writer cannot restore deleted content.
|
||||||
|
jdbc.update("""
|
||||||
|
UPDATE mate_execution_attempt SET
|
||||||
|
effect_outcome=CASE WHEN state='STARTED' THEN 'UNCERTAIN' ELSE effect_outcome END,
|
||||||
|
finished_at=CASE WHEN state='STARTED' THEN ? ELSE finished_at END,
|
||||||
|
state=CASE WHEN state='STARTED' THEN 'UNKNOWN' ELSE state END,
|
||||||
|
failure_reason=NULL,deleted=1,update_time=?
|
||||||
|
WHERE conversation_id=? AND deleted=0
|
||||||
|
""", timestamp(now()), timestamp(now()), conversationId);
|
||||||
|
return jdbc.update("""
|
||||||
|
UPDATE mate_execution_evidence SET summary=NULL,input_fingerprint=NULL,check_scope=NULL,
|
||||||
|
artifact_ref=NULL,artifact_digest=NULL,payload_ref=NULL,recipe_id=NULL,
|
||||||
|
deleted=1,update_time=? WHERE deleted=0 AND attempt_id IN
|
||||||
|
(SELECT id FROM mate_execution_attempt WHERE conversation_id=?)
|
||||||
|
""", timestamp(now()), conversationId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countUnresolved() {
|
||||||
|
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM mate_execution_attempt WHERE deleted=0 AND state IN ('STARTED','UNKNOWN')", Long.class);
|
||||||
|
return count == null ? 0 : count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<ExecutionAttempt> findAttempt(Long id) {
|
||||||
|
return jdbc.query("SELECT * FROM mate_execution_attempt WHERE id=? AND deleted=0", this::attempt, id).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal lookup for source authorization; callers must authorize before exposing the result. */
|
||||||
|
public Optional<ExecutionEvidence> findById(Long id) {
|
||||||
|
return jdbc.query(EVIDENCE_QUERY + " AND e.id=?", this::evidence, id).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<ExecutionEvidence> find(Long workspaceId, String conversationId, Long id) {
|
||||||
|
scope(workspaceId, conversationId);
|
||||||
|
return jdbc.query(EVIDENCE_QUERY + " AND e.workspace_id=? AND a.conversation_id=? AND e.id=?",
|
||||||
|
this::evidence, workspaceId, conversationId, id).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ExecutionEvidence> list(Long workspaceId, String conversationId, Instant beforeObservedAt,
|
||||||
|
Long beforeId, int limit) {
|
||||||
|
return list(workspaceId, conversationId, beforeObservedAt, beforeId, limit, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ExecutionEvidence> list(Long workspaceId, String conversationId, Instant beforeObservedAt,
|
||||||
|
Long beforeId, int limit, Long goalId, Long teamTaskId) {
|
||||||
|
scope(workspaceId, conversationId);
|
||||||
|
if ((beforeObservedAt == null) != (beforeId == null))
|
||||||
|
throw new IllegalArgumentException("Both cursor components are required");
|
||||||
|
int bounded = Math.min(properties.getMaxListLimit() + 1,
|
||||||
|
limit <= 0 ? properties.getDefaultListLimit() : limit);
|
||||||
|
var args = new ArrayList<Object>(List.of(workspaceId, conversationId));
|
||||||
|
String query = EVIDENCE_QUERY + " AND e.workspace_id=? AND a.conversation_id=?";
|
||||||
|
if (goalId != null) { query += " AND a.goal_id=?"; args.add(goalId); }
|
||||||
|
if (teamTaskId != null) { query += " AND a.team_task_id=?"; args.add(teamTaskId); }
|
||||||
|
if (beforeObservedAt != null) {
|
||||||
|
query += " AND (e.observed_at<? OR (e.observed_at=? AND e.id<?))";
|
||||||
|
args.add(timestamp(beforeObservedAt)); args.add(timestamp(beforeObservedAt)); args.add(beforeId);
|
||||||
|
}
|
||||||
|
args.add(bounded);
|
||||||
|
return jdbc.query(query + " ORDER BY e.observed_at DESC,e.id DESC LIMIT ?", this::evidence, args.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<ExecutionAttempt> byInvocation(ExecutionIdentity identity) {
|
||||||
|
return jdbc.query("SELECT * FROM mate_execution_attempt WHERE workspace_id=? AND invocation_key=? AND deleted=0",
|
||||||
|
this::attempt, identity.workspaceId(), identity.invocationKey()).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExecutionAttempt sameIdentity(ExecutionAttempt attempt, ExecutionIdentity identity) {
|
||||||
|
if (!attempt.identity().equals(identity)) throw conflict();
|
||||||
|
return attempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private EvidenceObservation normalize(EvidenceObservation value, EvidenceObservation prior) {
|
||||||
|
Objects.requireNonNull(value.kind(), "Evidence kind required");
|
||||||
|
Objects.requireNonNull(value.result(), "Evidence result required");
|
||||||
|
Objects.requireNonNull(value.sourceLevel(), "Evidence source required");
|
||||||
|
required(value.sourceKey(), 191);
|
||||||
|
Instant observed = value.observedAt() == null ? (prior == null ? now() : prior.observedAt())
|
||||||
|
: value.observedAt().truncatedTo(ChronoUnit.MICROS);
|
||||||
|
return new EvidenceObservation(value.sourceKey(), value.kind(), value.result(), value.sourceLevel(),
|
||||||
|
value.scopeId(), value.generation(), bounded(value.inputFingerprint(),128), bounded(value.recipeId(),191),
|
||||||
|
value.recipeRevision(), bounded(value.checkScope(),2048), bounded(value.artifactRef(),512),
|
||||||
|
bounded(value.artifactDigest(),128), bounded(value.summary(),properties.getMaxSummaryBytes()),
|
||||||
|
bounded(value.payloadRef(),512), observed,
|
||||||
|
value.expiresAt() == null ? null : value.expiresAt().truncatedTo(ChronoUnit.MICROS));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String bounded(String value, int bytes) {
|
||||||
|
String clean = SecretRedactor.redact(value);
|
||||||
|
if (clean == null) return null;
|
||||||
|
int end = 0, used = 0;
|
||||||
|
while (end < clean.length()) {
|
||||||
|
int cp = clean.codePointAt(end);
|
||||||
|
int length = new String(Character.toChars(cp)).getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
if (used + length > bytes) break;
|
||||||
|
used += length; end += Character.charCount(cp);
|
||||||
|
}
|
||||||
|
return clean.substring(0,end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validate(ExecutionIdentity identity) {
|
||||||
|
Objects.requireNonNull(identity, "Execution identity required");
|
||||||
|
scope(identity.workspaceId(), identity.conversationId());
|
||||||
|
required(identity.runtimeKind(),40); required(identity.invocationKey(),191);
|
||||||
|
required(identity.logicalCallId(),191); required(identity.toolName(),191); required(identity.ownerFence(),191);
|
||||||
|
if (identity.attemptNo() < 1) throw new IllegalArgumentException("Attempt number must be positive");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scope(Long workspaceId, String conversationId) {
|
||||||
|
if (workspaceId == null) throw new IllegalArgumentException("Workspace required");
|
||||||
|
required(conversationId,128);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void required(String value, int max) {
|
||||||
|
if (value == null || value.isBlank() || value.length() > max)
|
||||||
|
throw new IllegalArgumentException("Missing or oversized execution identity");
|
||||||
|
}
|
||||||
|
|
||||||
|
private IllegalStateException conflict() { return new IllegalStateException("Immutable execution evidence conflict"); }
|
||||||
|
private static Instant now() { return Instant.now().truncatedTo(ChronoUnit.MICROS); }
|
||||||
|
private static Timestamp timestamp(Instant instant) { return instant == null ? null : Timestamp.from(instant); }
|
||||||
|
private static Instant instant(ResultSet row, String column) throws SQLException {
|
||||||
|
var value = row.getTimestamp(column); return value == null ? null : value.toInstant();
|
||||||
|
}
|
||||||
|
private ExecutionAttempt attempt(ResultSet row, int number) throws SQLException {
|
||||||
|
var identity = new ExecutionIdentity(row.getObject("workspace_id",Long.class),row.getString("conversation_id"),
|
||||||
|
row.getString("runtime_kind"),row.getString("runtime_session_id"),row.getString("invocation_key"),
|
||||||
|
row.getString("logical_call_id"),row.getInt("attempt_no"),row.getString("provider_tool_call_id"),
|
||||||
|
row.getString("tool_name"),row.getObject("goal_id",Long.class),row.getString("goal_attempt_id"),
|
||||||
|
row.getObject("team_run_id",Long.class),row.getObject("team_task_id",Long.class),
|
||||||
|
row.getObject("cron_run_id",Long.class),row.getString("approval_id"),row.getString("owner_fence"));
|
||||||
|
return new ExecutionAttempt(row.getLong("id"), identity,AttemptState.valueOf(row.getString("state")),
|
||||||
|
EffectOutcome.valueOf(row.getString("effect_outcome")),instant(row,"started_at"),instant(row,"finished_at"));
|
||||||
|
}
|
||||||
|
private ExecutionEvidence evidence(ResultSet row, int number) throws SQLException {
|
||||||
|
var observation = new EvidenceObservation(row.getString("source_key"),EvidenceKind.valueOf(row.getString("kind")),
|
||||||
|
EvidenceResult.valueOf(row.getString("result")),SourceLevel.valueOf(row.getString("source_level")),
|
||||||
|
row.getObject("scope_id",Long.class),row.getObject("generation",Long.class),row.getString("input_fingerprint"),
|
||||||
|
row.getString("recipe_id"),row.getObject("recipe_revision",Long.class),row.getString("check_scope"),
|
||||||
|
row.getString("artifact_ref"),row.getString("artifact_digest"),row.getString("summary"),row.getString("payload_ref"),
|
||||||
|
instant(row,"observed_at"),instant(row,"expires_at"));
|
||||||
|
return new ExecutionEvidence(row.getLong("id"),row.getLong("workspace_id"),row.getLong("attempt_id"),
|
||||||
|
row.getString("conversation_id"),observation);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
package vip.mate.execution.evidence.service;
|
||||||
|
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.agent.context.ExecutionAttribution;
|
||||||
|
import vip.mate.execution.evidence.model.ExecutionIdentity;
|
||||||
|
import vip.mate.team.service.TeamWorkerConversationGovernanceService;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** Resolves business linkage from persisted rows, never from tool arguments. */
|
||||||
|
@Service
|
||||||
|
public class ExecutionIdentityResolver {
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
private final TeamWorkerConversationGovernanceService teamGovernance;
|
||||||
|
|
||||||
|
public ExecutionIdentityResolver(JdbcTemplate jdbc, TeamWorkerConversationGovernanceService teamGovernance) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
this.teamGovernance = teamGovernance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExecutionIdentity resolve(ChatOrigin origin, String invocationKey, String providerCallId, String toolName) {
|
||||||
|
if (origin == null || origin.conversationId() == null) return null;
|
||||||
|
var workspaces = jdbc.queryForList("SELECT workspace_id FROM mate_conversation WHERE conversation_id=? AND deleted=0",
|
||||||
|
Long.class, origin.conversationId());
|
||||||
|
if (workspaces.size() != 1 || workspaces.getFirst() == null) return null;
|
||||||
|
Long workspaceId = workspaces.getFirst();
|
||||||
|
if (origin.workspaceId() != null && !workspaceId.equals(origin.workspaceId())) return null;
|
||||||
|
ExecutionAttribution source = origin.executionAttribution();
|
||||||
|
Long goalId = source == null ? null : source.goalId();
|
||||||
|
String goalAttemptId = source == null ? null : source.goalAttemptId();
|
||||||
|
Long cronRunId = source == null ? null : source.cronRunId();
|
||||||
|
String approvalId = source == null ? null : source.approvalId();
|
||||||
|
if (goalId == null && cronRunId == null) {
|
||||||
|
var activeGoals = jdbc.queryForList("SELECT id FROM mate_agent_goal WHERE conversation_id=? AND workspace_id=? AND status='active' AND deleted=0",
|
||||||
|
Long.class, origin.conversationId(), workspaceId);
|
||||||
|
if (activeGoals.size() == 1) goalId = activeGoals.getFirst();
|
||||||
|
}
|
||||||
|
if (goalId != null && !exists("SELECT COUNT(*) FROM mate_agent_goal WHERE id=? AND conversation_id=? AND workspace_id=? AND deleted=0",
|
||||||
|
goalId, origin.conversationId(), workspaceId)) return null;
|
||||||
|
if (goalAttemptId != null && !exists("SELECT COUNT(*) FROM mate_goal_attempt WHERE attempt_id=? AND goal_id=? AND conversation_id=? AND lease_token=?",
|
||||||
|
goalAttemptId, goalId, origin.conversationId(), source.ownerFence())) return null;
|
||||||
|
if (cronRunId != null && !exists("SELECT COUNT(*) FROM mate_cron_job_run WHERE id=? AND conversation_id=?",
|
||||||
|
cronRunId, origin.conversationId())) return null;
|
||||||
|
if (approvalId != null && !exists("SELECT COUNT(*) FROM mate_tool_approval WHERE pending_id=? AND conversation_id=?",
|
||||||
|
approvalId, origin.conversationId())) return null;
|
||||||
|
var team = teamGovernance.resolve(origin.conversationId(), null, null).orElse(null);
|
||||||
|
String fence = source != null && source.ownerFence() != null ? source.ownerFence() : invocationKey;
|
||||||
|
String key = approvalId == null ? invocationKey : "approval:" + approvalId;
|
||||||
|
return new ExecutionIdentity(workspaceId, origin.conversationId(), "native", goalAttemptId,
|
||||||
|
key, key, 1, approvalId == null ? providerCallId : null, toolName, goalId, goalAttemptId,
|
||||||
|
team == null ? null : team.runId(), team == null ? null : team.taskId(), cronRunId,
|
||||||
|
approvalId, goalAttemptId != null ? fence : approvalId == null ? fence : "approval:" + approvalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An expired business owner may leave historical observations, but cannot publish a new terminal result. */
|
||||||
|
public boolean isCurrent(ExecutionIdentity identity) {
|
||||||
|
if (identity.goalAttemptId() != null && !exists("""
|
||||||
|
SELECT COUNT(*) FROM mate_goal_attempt WHERE attempt_id=? AND goal_id=? AND conversation_id=?
|
||||||
|
AND state IN ('claimed','running') AND lease_until>CURRENT_TIMESTAMP
|
||||||
|
""", identity.goalAttemptId(), identity.goalId(), identity.conversationId())) return false;
|
||||||
|
return identity.cronRunId() == null || exists("SELECT COUNT(*) FROM mate_cron_job_run WHERE id=? AND conversation_id=? AND status='running'",
|
||||||
|
identity.cronRunId(), identity.conversationId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lock order: conversation, business owner, then execution attempt. Called inside the store transaction. */
|
||||||
|
public boolean lockCurrentForUpdate(ExecutionIdentity identity, boolean terminal) {
|
||||||
|
var conversations = jdbc.queryForList("SELECT workspace_id,deleted FROM mate_conversation WHERE conversation_id=? FOR UPDATE",
|
||||||
|
identity.conversationId());
|
||||||
|
if (conversations.size() != 1 || !Objects.equals(((Number) conversations.getFirst().get("workspace_id")).longValue(), identity.workspaceId())
|
||||||
|
|| ((Number) conversations.getFirst().get("deleted")).intValue() != 0) {
|
||||||
|
throw new IllegalStateException("Execution conversation unavailable");
|
||||||
|
}
|
||||||
|
if (!terminal) return true;
|
||||||
|
if (identity.goalAttemptId() != null) {
|
||||||
|
var owners = jdbc.query("SELECT goal_id,conversation_id,lease_token,state,lease_until FROM mate_goal_attempt WHERE attempt_id=? FOR UPDATE",
|
||||||
|
(row, index) -> Objects.equals(row.getLong("goal_id"), identity.goalId())
|
||||||
|
&& Objects.equals(row.getString("conversation_id"), identity.conversationId())
|
||||||
|
&& Objects.equals(row.getString("lease_token"), identity.ownerFence())
|
||||||
|
&& ("claimed".equals(row.getString("state")) || "running".equals(row.getString("state")))
|
||||||
|
&& row.getTimestamp("lease_until") != null
|
||||||
|
&& row.getTimestamp("lease_until").toLocalDateTime().isAfter(LocalDateTime.now()),
|
||||||
|
identity.goalAttemptId());
|
||||||
|
if (owners.size() != 1 || !owners.getFirst()) return false;
|
||||||
|
}
|
||||||
|
if (identity.cronRunId() != null) {
|
||||||
|
var owners = jdbc.query("SELECT conversation_id,status FROM mate_cron_job_run WHERE id=? FOR UPDATE",
|
||||||
|
(row, index) -> Objects.equals(row.getString("conversation_id"), identity.conversationId())
|
||||||
|
&& "running".equals(row.getString("status")), identity.cronRunId());
|
||||||
|
if (owners.size() != 1 || !owners.getFirst()) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean exists(String sql, Object... values) {
|
||||||
|
return Objects.equals(1L, jdbc.queryForObject(sql, Long.class, values));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
package vip.mate.execution.evidence.service;
|
||||||
|
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
import vip.mate.execution.evidence.model.AttemptState;
|
||||||
|
import vip.mate.execution.evidence.model.EvidenceObservation;
|
||||||
|
import vip.mate.execution.evidence.model.EvidenceKind;
|
||||||
|
import vip.mate.execution.evidence.model.EvidenceResult;
|
||||||
|
import vip.mate.execution.evidence.model.SourceLevel;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Typed, invocation-local channel for trusted implementations to report observations. */
|
||||||
|
public final class ExecutionObservationSink {
|
||||||
|
private static final String KEY = "mateclaw.executionObservationSink";
|
||||||
|
private final boolean metadataOnly;
|
||||||
|
private final int maxObservations;
|
||||||
|
private final List<EvidenceObservation> observations = new ArrayList<>();
|
||||||
|
private AttemptState state = AttemptState.SUCCEEDED;
|
||||||
|
private boolean sealed;
|
||||||
|
|
||||||
|
public ExecutionObservationSink(boolean metadataOnly) { this(metadataOnly, 32); }
|
||||||
|
|
||||||
|
public ExecutionObservationSink(boolean metadataOnly, int maxObservations) {
|
||||||
|
this.metadataOnly = metadataOnly;
|
||||||
|
this.maxObservations = Math.clamp(maxObservations, 1, 99);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ToolContext attach(ToolContext context) {
|
||||||
|
var values = new HashMap<String, Object>(context.getContext());
|
||||||
|
values.put(KEY, this);
|
||||||
|
return new ToolContext(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ExecutionObservationSink from(ToolContext context) {
|
||||||
|
if (context == null) return null;
|
||||||
|
Object sink = context.getContext().get(KEY);
|
||||||
|
return sink instanceof ExecutionObservationSink typed ? typed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean metadataOnly() { return metadataOnly; }
|
||||||
|
public synchronized AttemptState state() { return state; }
|
||||||
|
public synchronized List<EvidenceObservation> observations() { return List.copyOf(observations); }
|
||||||
|
public synchronized void seal() { sealed = true; }
|
||||||
|
|
||||||
|
/** Called by the process adapter, never by parsing a tool's returned text. */
|
||||||
|
public void command(Integer exitCode, boolean timedOut, boolean cancelled, boolean blocked) {
|
||||||
|
command(exitCode, timedOut, cancelled, blocked, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void command(Integer exitCode, boolean timedOut, boolean cancelled, boolean blocked, String workingDirectory) {
|
||||||
|
if (sealed) return;
|
||||||
|
state = cancelled ? AttemptState.CANCELLED : timedOut || exitCode == null ? AttemptState.UNKNOWN
|
||||||
|
: blocked ? AttemptState.BLOCKED : exitCode == 0 ? AttemptState.SUCCEEDED : AttemptState.FAILED;
|
||||||
|
EvidenceResult result = state == AttemptState.SUCCEEDED ? EvidenceResult.OBSERVED
|
||||||
|
: state == AttemptState.UNKNOWN || state == AttemptState.CANCELLED
|
||||||
|
? EvidenceResult.UNKNOWN : EvidenceResult.FAIL;
|
||||||
|
append(new EvidenceObservation("command", EvidenceKind.COMMAND_EXIT, result,
|
||||||
|
SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null, workingDirectory,
|
||||||
|
null, null, "exit=" + exitCode + "; timedOut=" + timedOut
|
||||||
|
+ "; cancelled=" + cancelled + "; blocked=" + blocked, null, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called only after file bytes and owner metadata have survived durable read-back. */
|
||||||
|
public synchronized void artifact(String id, String digest, long length, String mimeType, Instant expiresAt) {
|
||||||
|
append(new EvidenceObservation("artifact:" + id, EvidenceKind.ARTIFACT_SNAPSHOT,
|
||||||
|
EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null,
|
||||||
|
null, id, digest, "bytes=" + length + "; mime=" + mimeType, null, null, expiresAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void append(EvidenceObservation observation) {
|
||||||
|
if (!sealed && !metadataOnly && observations.size() < maxObservations
|
||||||
|
&& observations.stream().noneMatch(e -> e.sourceKey().equals(observation.sourceKey()))) {
|
||||||
|
observations.add(observation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import org.springframework.stereotype.Component;
|
|||||||
import reactor.core.Disposable;
|
import reactor.core.Disposable;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
import vip.mate.agent.context.ChatOrigin;
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.agent.context.ExecutionAttribution;
|
||||||
import vip.mate.agent.context.GoalContinuationContext;
|
import vip.mate.agent.context.GoalContinuationContext;
|
||||||
import vip.mate.agent.runtime.ConversationTurnGate;
|
import vip.mate.agent.runtime.ConversationTurnGate;
|
||||||
import vip.mate.approval.ApprovalWorkflowService;
|
import vip.mate.approval.ApprovalWorkflowService;
|
||||||
@ -126,7 +127,10 @@ public class GoalSegmentRunner {
|
|||||||
String guidance=recovered ? "The previous execution was interrupted by a runtime restart. "
|
String guidance=recovered ? "The previous execution was interrupted by a runtime restart. "
|
||||||
+ "Inspect the workspace, progress ledger and existing async handles before acting. "
|
+ "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" : "";
|
+ "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;
|
SegmentResult result;
|
||||||
ConversationInputQueueStore.QueuedInput queued=claimNextInput(convId,claimedRun);
|
ConversationInputQueueStore.QueuedInput queued=claimNextInput(convId,claimedRun);
|
||||||
do {
|
do {
|
||||||
|
|||||||
@ -61,7 +61,7 @@ public class TeamWorkerInterventionService {
|
|||||||
}
|
}
|
||||||
heartbeat = dispatchService.startLeaseHeartbeat(taskId);
|
heartbeat = dispatchService.startLeaseHeartbeat(taskId);
|
||||||
conversationService.removeApprovalPlaceholders(intervention.conversationId());
|
conversationService.removeApprovalPlaceholders(intervention.conversationId());
|
||||||
ChatOrigin origin = approvalService.restoreChatOrigin(claimedPending.getChatOrigin());
|
ChatOrigin origin = approvalService.restoreChatOrigin(claimedPending.getChatOrigin()).withApprovalId(claimedPending.getPendingId());
|
||||||
AgentService.ChatResult result;
|
AgentService.ChatResult result;
|
||||||
try {
|
try {
|
||||||
result = turnGate.withPermit(permit, () -> agentService.chatWithReplayWithUsage(
|
result = turnGate.withPermit(permit, () -> agentService.chatWithReplayWithUsage(
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import org.springframework.ai.tool.annotation.ToolParam;
|
|||||||
import org.springframework.lang.Nullable;
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.tool.document.GeneratedFileCache;
|
import vip.mate.tool.document.GeneratedFileCache;
|
||||||
|
import vip.mate.execution.evidence.service.ExecutionObservationSink;
|
||||||
import vip.mate.tool.document.WorkspaceArtifactSurfacer;
|
import vip.mate.tool.document.WorkspaceArtifactSurfacer;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@ -63,6 +64,7 @@ public class ShellExecuteTool {
|
|||||||
// ChatOrigin so the workspace boundary check honors per-agent basePath.
|
// ChatOrigin so the workspace boundary check honors per-agent basePath.
|
||||||
@Nullable ToolContext ctx) {
|
@Nullable ToolContext ctx) {
|
||||||
|
|
||||||
|
ExecutionObservationSink evidence = ExecutionObservationSink.from(ctx);
|
||||||
int timeout = (timeoutSeconds != null && timeoutSeconds > 0) ? timeoutSeconds : DEFAULT_TIMEOUT_SECONDS;
|
int timeout = (timeoutSeconds != null && timeoutSeconds > 0) ? timeoutSeconds : DEFAULT_TIMEOUT_SECONDS;
|
||||||
// 硬上限:不允许超过 300 秒
|
// 硬上限:不允许超过 300 秒
|
||||||
timeout = Math.min(timeout, 300);
|
timeout = Math.min(timeout, 300);
|
||||||
@ -80,6 +82,7 @@ public class ShellExecuteTool {
|
|||||||
vip.mate.tool.guard.WorkspacePathGuard.validateShellCommand(command, ctx);
|
vip.mate.tool.guard.WorkspacePathGuard.validateShellCommand(command, ctx);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
log.warn("[ShellExecute] Sandbox rejected command: {}", e.getMessage());
|
log.warn("[ShellExecute] Sandbox rejected command: {}", e.getMessage());
|
||||||
|
if (evidence != null) evidence.command(-1, false, false, true);
|
||||||
result.set("exitCode", -1);
|
result.set("exitCode", -1);
|
||||||
result.set("stdout", "");
|
result.set("stdout", "");
|
||||||
result.set("stderr", e.getMessage());
|
result.set("stderr", e.getMessage());
|
||||||
@ -91,6 +94,7 @@ public class ShellExecuteTool {
|
|||||||
Path stdoutFile = null;
|
Path stdoutFile = null;
|
||||||
Path stderrFile = null;
|
Path stderrFile = null;
|
||||||
Process process = null;
|
Process process = null;
|
||||||
|
String observedDirectory = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 处理命令中的嵌入换行符(LLM 生成的 JSON 解码后可能包含真实换行)
|
// 处理命令中的嵌入换行符(LLM 生成的 JSON 解码后可能包含真实换行)
|
||||||
@ -98,6 +102,7 @@ public class ShellExecuteTool {
|
|||||||
String sanitizedCommand = collapseEmbeddedNewlines(command);
|
String sanitizedCommand = collapseEmbeddedNewlines(command);
|
||||||
|
|
||||||
ProcessBuilder pb = buildShellProcess(sanitizedCommand, ctx);
|
ProcessBuilder pb = buildShellProcess(sanitizedCommand, ctx);
|
||||||
|
observedDirectory = (pb.directory() == null ? Path.of("") : pb.directory().toPath()).toAbsolutePath().normalize().toString();
|
||||||
// 不继承环境变量中的敏感信息
|
// 不继承环境变量中的敏感信息
|
||||||
pb.environment().keySet().removeIf(key ->
|
pb.environment().keySet().removeIf(key ->
|
||||||
key.contains("KEY") || key.contains("SECRET") || key.contains("TOKEN")
|
key.contains("KEY") || key.contains("SECRET") || key.contains("TOKEN")
|
||||||
@ -121,6 +126,7 @@ public class ShellExecuteTool {
|
|||||||
if (!completed) {
|
if (!completed) {
|
||||||
// 超时:强制终止进程(树)
|
// 超时:强制终止进程(树)
|
||||||
killProcessTree(process);
|
killProcessTree(process);
|
||||||
|
if (evidence != null) evidence.command(null, true, false, false, observedDirectory);
|
||||||
log.warn("[ShellExecute] Command timed out after {}s: {}", timeout, truncateForLog(command));
|
log.warn("[ShellExecute] Command timed out after {}s: {}", timeout, truncateForLog(command));
|
||||||
result.set("exitCode", -1);
|
result.set("exitCode", -1);
|
||||||
result.set("stdout", readFileTruncated(stdoutFile, MAX_OUTPUT_BYTES));
|
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));
|
result.set("message", i18n.msg("tool.shell.error.timeout", timeout));
|
||||||
} else {
|
} else {
|
||||||
int exitCode = process.exitValue();
|
int exitCode = process.exitValue();
|
||||||
|
if (evidence != null) evidence.command(exitCode, false, false, false, observedDirectory);
|
||||||
String stdout = readFileTruncated(stdoutFile, MAX_OUTPUT_BYTES);
|
String stdout = readFileTruncated(stdoutFile, MAX_OUTPUT_BYTES);
|
||||||
String stderr = readFileTruncated(stderrFile, MAX_OUTPUT_BYTES);
|
String stderr = readFileTruncated(stderrFile, MAX_OUTPUT_BYTES);
|
||||||
log.info("[ShellExecute] Command completed: exitCode={}, stdout={}chars, stderr={}chars",
|
log.info("[ShellExecute] Command completed: exitCode={}, stdout={}chars, stderr={}chars",
|
||||||
@ -155,6 +162,7 @@ public class ShellExecuteTool {
|
|||||||
killProcessTree(process);
|
killProcessTree(process);
|
||||||
}
|
}
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
|
if (evidence != null) evidence.command(null, false, true, false, observedDirectory);
|
||||||
log.info("[ShellExecute] Command interrupted by cancellation");
|
log.info("[ShellExecute] Command interrupted by cancellation");
|
||||||
result.set("exitCode", -1);
|
result.set("exitCode", -1);
|
||||||
result.set("stdout", "");
|
result.set("stdout", "");
|
||||||
@ -162,6 +170,7 @@ public class ShellExecuteTool {
|
|||||||
result.set("timedOut", false);
|
result.set("timedOut", false);
|
||||||
result.set("cancelled", true);
|
result.set("cancelled", true);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
if (evidence != null) evidence.command(null, false, false, false, observedDirectory);
|
||||||
log.error("[ShellExecute] Command execution failed: {}", e.getMessage(), e);
|
log.error("[ShellExecute] Command execution failed: {}", e.getMessage(), e);
|
||||||
result.set("exitCode", -1);
|
result.set("exitCode", -1);
|
||||||
result.set("stdout", "");
|
result.set("stdout", "");
|
||||||
|
|||||||
@ -10,6 +10,13 @@ import org.springframework.web.context.request.RequestAttributes;
|
|||||||
import org.springframework.web.context.request.RequestContextHolder;
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||||
import vip.mate.agent.context.ChatOrigin;
|
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.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@ -163,7 +170,25 @@ public class GeneratedFileCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String put(byte[] bytes, String filename, String mimeType, @Nullable ToolContext ctx) {
|
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) {
|
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) {
|
private Entry loadFromDisk(String id) {
|
||||||
Path bin = storageDir.resolve(id).normalize();
|
Path bin = storageDir.resolve(id).normalize();
|
||||||
Path meta = storageDir.resolve(id + META_SUFFIX).normalize();
|
Path meta = storageDir.resolve(id + META_SUFFIX).normalize();
|
||||||
|
|||||||
@ -131,6 +131,16 @@ springdoc:
|
|||||||
|
|
||||||
# MateClaw 自定义配置
|
# MateClaw 自定义配置
|
||||||
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:
|
a2a:
|
||||||
enabled: ${MATECLAW_A2A_ENABLED:false}
|
enabled: ${MATECLAW_A2A_ENABLED:false}
|
||||||
# Public base URL for Agent Cards. Production deployments should set this
|
# Public base URL for Agent Cards. Production deployments should set this
|
||||||
|
|||||||
@ -0,0 +1,89 @@
|
|||||||
|
-- Bounded execution facts, independent of runtime recovery state.
|
||||||
|
CREATE TABLE mate_execution_attempt (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
workspace_id BIGINT NOT NULL,
|
||||||
|
conversation_id VARCHAR(128) NOT NULL,
|
||||||
|
runtime_kind VARCHAR(40) NOT NULL,
|
||||||
|
runtime_session_id VARCHAR(128),
|
||||||
|
invocation_key VARCHAR(191) NOT NULL,
|
||||||
|
logical_call_id VARCHAR(191) NOT NULL,
|
||||||
|
attempt_no INTEGER NOT NULL,
|
||||||
|
provider_tool_call_id VARCHAR(191),
|
||||||
|
tool_name VARCHAR(191) NOT NULL,
|
||||||
|
goal_id BIGINT,
|
||||||
|
goal_attempt_id VARCHAR(128),
|
||||||
|
team_run_id BIGINT,
|
||||||
|
team_task_id BIGINT,
|
||||||
|
cron_run_id BIGINT,
|
||||||
|
approval_id VARCHAR(128),
|
||||||
|
owner_fence VARCHAR(191) NOT NULL,
|
||||||
|
state VARCHAR(20) NOT NULL,
|
||||||
|
effect_outcome VARCHAR(20) NOT NULL,
|
||||||
|
started_at TIMESTAMP NOT NULL,
|
||||||
|
finished_at TIMESTAMP,
|
||||||
|
failure_reason VARCHAR(2048),
|
||||||
|
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT uk_execution_invocation UNIQUE(workspace_id, invocation_key),
|
||||||
|
CONSTRAINT uk_execution_logical_attempt UNIQUE(workspace_id, logical_call_id, attempt_no)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_execution_conversation ON mate_execution_attempt(workspace_id, conversation_id, started_at, id);
|
||||||
|
CREATE INDEX idx_execution_state ON mate_execution_attempt(state, update_time);
|
||||||
|
CREATE INDEX idx_execution_goal ON mate_execution_attempt(workspace_id, goal_id);
|
||||||
|
CREATE TABLE mate_execution_evidence (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
workspace_id BIGINT NOT NULL,
|
||||||
|
attempt_id BIGINT NOT NULL,
|
||||||
|
source_key VARCHAR(191) NOT NULL,
|
||||||
|
kind VARCHAR(40) NOT NULL,
|
||||||
|
result VARCHAR(20) NOT NULL,
|
||||||
|
source_level VARCHAR(40) NOT NULL,
|
||||||
|
scope_id BIGINT,
|
||||||
|
generation BIGINT,
|
||||||
|
input_fingerprint VARCHAR(128),
|
||||||
|
recipe_id VARCHAR(191),
|
||||||
|
recipe_revision BIGINT,
|
||||||
|
check_scope VARCHAR(2048),
|
||||||
|
artifact_ref VARCHAR(512),
|
||||||
|
artifact_digest VARCHAR(128),
|
||||||
|
summary VARCHAR(2048),
|
||||||
|
payload_ref VARCHAR(512),
|
||||||
|
observed_at TIMESTAMP NOT NULL,
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT uk_execution_evidence_source UNIQUE(attempt_id, source_key),
|
||||||
|
CONSTRAINT fk_execution_evidence_attempt FOREIGN KEY(attempt_id) REFERENCES mate_execution_attempt(id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_evidence_workspace_observed ON mate_execution_evidence(workspace_id, observed_at, id);
|
||||||
|
CREATE TABLE mate_evidence_scope (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
workspace_id BIGINT NOT NULL,
|
||||||
|
resource_key VARCHAR(191) NOT NULL,
|
||||||
|
host_id VARCHAR(191) NOT NULL,
|
||||||
|
root_id VARCHAR(191) NOT NULL,
|
||||||
|
generation BIGINT NOT NULL DEFAULT 0,
|
||||||
|
active_mutations INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tainted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT uk_evidence_scope_resource UNIQUE(workspace_id, resource_key)
|
||||||
|
);
|
||||||
|
CREATE TABLE mate_goal_criterion_evidence (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
workspace_id BIGINT NOT NULL,
|
||||||
|
goal_id BIGINT NOT NULL,
|
||||||
|
criterion_id VARCHAR(191) NOT NULL,
|
||||||
|
criterion_revision BIGINT NOT NULL,
|
||||||
|
evidence_id BIGINT NOT NULL,
|
||||||
|
bound_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT uk_goal_criterion_evidence UNIQUE(goal_id, criterion_id, criterion_revision, evidence_id),
|
||||||
|
CONSTRAINT fk_goal_criterion_evidence FOREIGN KEY(evidence_id) REFERENCES mate_execution_evidence(id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_criterion_evidence_goal ON mate_goal_criterion_evidence(workspace_id, goal_id, criterion_id);
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
-- Bounded execution facts, independent of runtime recovery state.
|
||||||
|
CREATE TABLE mate_execution_attempt (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
workspace_id BIGINT NOT NULL,
|
||||||
|
conversation_id VARCHAR(128) NOT NULL,
|
||||||
|
runtime_kind VARCHAR(40) NOT NULL,
|
||||||
|
runtime_session_id VARCHAR(128),
|
||||||
|
invocation_key VARCHAR(191) NOT NULL,
|
||||||
|
logical_call_id VARCHAR(191) NOT NULL,
|
||||||
|
attempt_no INTEGER NOT NULL,
|
||||||
|
provider_tool_call_id VARCHAR(191),
|
||||||
|
tool_name VARCHAR(191) NOT NULL,
|
||||||
|
goal_id BIGINT,
|
||||||
|
goal_attempt_id VARCHAR(128),
|
||||||
|
team_run_id BIGINT,
|
||||||
|
team_task_id BIGINT,
|
||||||
|
cron_run_id BIGINT,
|
||||||
|
approval_id VARCHAR(128),
|
||||||
|
owner_fence VARCHAR(191) NOT NULL,
|
||||||
|
state VARCHAR(20) NOT NULL,
|
||||||
|
effect_outcome VARCHAR(20) NOT NULL,
|
||||||
|
started_at TIMESTAMP NOT NULL,
|
||||||
|
finished_at TIMESTAMP,
|
||||||
|
failure_reason VARCHAR(2048),
|
||||||
|
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT uk_execution_invocation UNIQUE(workspace_id, invocation_key),
|
||||||
|
CONSTRAINT uk_execution_logical_attempt UNIQUE(workspace_id, logical_call_id, attempt_no)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_execution_conversation ON mate_execution_attempt(workspace_id, conversation_id, started_at, id);
|
||||||
|
CREATE INDEX idx_execution_state ON mate_execution_attempt(state, update_time);
|
||||||
|
CREATE INDEX idx_execution_goal ON mate_execution_attempt(workspace_id, goal_id);
|
||||||
|
CREATE TABLE mate_execution_evidence (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
workspace_id BIGINT NOT NULL,
|
||||||
|
attempt_id BIGINT NOT NULL,
|
||||||
|
source_key VARCHAR(191) NOT NULL,
|
||||||
|
kind VARCHAR(40) NOT NULL,
|
||||||
|
result VARCHAR(20) NOT NULL,
|
||||||
|
source_level VARCHAR(40) NOT NULL,
|
||||||
|
scope_id BIGINT,
|
||||||
|
generation BIGINT,
|
||||||
|
input_fingerprint VARCHAR(128),
|
||||||
|
recipe_id VARCHAR(191),
|
||||||
|
recipe_revision BIGINT,
|
||||||
|
check_scope VARCHAR(2048),
|
||||||
|
artifact_ref VARCHAR(512),
|
||||||
|
artifact_digest VARCHAR(128),
|
||||||
|
summary VARCHAR(2048),
|
||||||
|
payload_ref VARCHAR(512),
|
||||||
|
observed_at TIMESTAMP NOT NULL,
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT uk_execution_evidence_source UNIQUE(attempt_id, source_key),
|
||||||
|
CONSTRAINT fk_execution_evidence_attempt FOREIGN KEY(attempt_id) REFERENCES mate_execution_attempt(id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_evidence_workspace_observed ON mate_execution_evidence(workspace_id, observed_at, id);
|
||||||
|
CREATE TABLE mate_evidence_scope (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
workspace_id BIGINT NOT NULL,
|
||||||
|
resource_key VARCHAR(191) NOT NULL,
|
||||||
|
host_id VARCHAR(191) NOT NULL,
|
||||||
|
root_id VARCHAR(191) NOT NULL,
|
||||||
|
generation BIGINT NOT NULL DEFAULT 0,
|
||||||
|
active_mutations INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tainted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT uk_evidence_scope_resource UNIQUE(workspace_id, resource_key)
|
||||||
|
);
|
||||||
|
CREATE TABLE mate_goal_criterion_evidence (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
workspace_id BIGINT NOT NULL,
|
||||||
|
goal_id BIGINT NOT NULL,
|
||||||
|
criterion_id VARCHAR(191) NOT NULL,
|
||||||
|
criterion_revision BIGINT NOT NULL,
|
||||||
|
evidence_id BIGINT NOT NULL,
|
||||||
|
bound_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT uk_goal_criterion_evidence UNIQUE(goal_id, criterion_id, criterion_revision, evidence_id),
|
||||||
|
CONSTRAINT fk_goal_criterion_evidence FOREIGN KEY(evidence_id) REFERENCES mate_execution_evidence(id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_criterion_evidence_goal ON mate_goal_criterion_evidence(workspace_id, goal_id, criterion_id);
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
-- Bounded execution facts, independent of runtime recovery state.
|
||||||
|
CREATE TABLE mate_execution_attempt (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
workspace_id BIGINT NOT NULL,
|
||||||
|
conversation_id VARCHAR(128) NOT NULL,
|
||||||
|
runtime_kind VARCHAR(40) NOT NULL,
|
||||||
|
runtime_session_id VARCHAR(128),
|
||||||
|
invocation_key VARCHAR(191) NOT NULL,
|
||||||
|
logical_call_id VARCHAR(191) NOT NULL,
|
||||||
|
attempt_no INTEGER NOT NULL,
|
||||||
|
provider_tool_call_id VARCHAR(191),
|
||||||
|
tool_name VARCHAR(191) NOT NULL,
|
||||||
|
goal_id BIGINT,
|
||||||
|
goal_attempt_id VARCHAR(128),
|
||||||
|
team_run_id BIGINT,
|
||||||
|
team_task_id BIGINT,
|
||||||
|
cron_run_id BIGINT,
|
||||||
|
approval_id VARCHAR(128),
|
||||||
|
owner_fence VARCHAR(191) NOT NULL,
|
||||||
|
state VARCHAR(20) NOT NULL,
|
||||||
|
effect_outcome VARCHAR(20) NOT NULL,
|
||||||
|
started_at DATETIME(6) NOT NULL,
|
||||||
|
finished_at DATETIME(6),
|
||||||
|
failure_reason VARCHAR(2048),
|
||||||
|
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT uk_execution_invocation UNIQUE(workspace_id, invocation_key),
|
||||||
|
CONSTRAINT uk_execution_logical_attempt UNIQUE(workspace_id, logical_call_id, attempt_no)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_execution_conversation ON mate_execution_attempt(workspace_id, conversation_id, started_at, id);
|
||||||
|
CREATE INDEX idx_execution_state ON mate_execution_attempt(state, update_time);
|
||||||
|
CREATE INDEX idx_execution_goal ON mate_execution_attempt(workspace_id, goal_id);
|
||||||
|
CREATE TABLE mate_execution_evidence (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
workspace_id BIGINT NOT NULL,
|
||||||
|
attempt_id BIGINT NOT NULL,
|
||||||
|
source_key VARCHAR(191) NOT NULL,
|
||||||
|
kind VARCHAR(40) NOT NULL,
|
||||||
|
result VARCHAR(20) NOT NULL,
|
||||||
|
source_level VARCHAR(40) NOT NULL,
|
||||||
|
scope_id BIGINT,
|
||||||
|
generation BIGINT,
|
||||||
|
input_fingerprint VARCHAR(128),
|
||||||
|
recipe_id VARCHAR(191),
|
||||||
|
recipe_revision BIGINT,
|
||||||
|
check_scope VARCHAR(2048),
|
||||||
|
artifact_ref VARCHAR(512),
|
||||||
|
artifact_digest VARCHAR(128),
|
||||||
|
summary VARCHAR(2048),
|
||||||
|
payload_ref VARCHAR(512),
|
||||||
|
observed_at DATETIME(6) NOT NULL,
|
||||||
|
expires_at DATETIME(6),
|
||||||
|
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT uk_execution_evidence_source UNIQUE(attempt_id, source_key),
|
||||||
|
CONSTRAINT fk_execution_evidence_attempt FOREIGN KEY(attempt_id) REFERENCES mate_execution_attempt(id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_evidence_workspace_observed ON mate_execution_evidence(workspace_id, observed_at, id);
|
||||||
|
CREATE TABLE mate_evidence_scope (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
workspace_id BIGINT NOT NULL,
|
||||||
|
resource_key VARCHAR(191) NOT NULL,
|
||||||
|
host_id VARCHAR(191) NOT NULL,
|
||||||
|
root_id VARCHAR(191) NOT NULL,
|
||||||
|
generation BIGINT NOT NULL DEFAULT 0,
|
||||||
|
active_mutations INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tainted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT uk_evidence_scope_resource UNIQUE(workspace_id, resource_key)
|
||||||
|
);
|
||||||
|
CREATE TABLE mate_goal_criterion_evidence (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
workspace_id BIGINT NOT NULL,
|
||||||
|
goal_id BIGINT NOT NULL,
|
||||||
|
criterion_id VARCHAR(191) NOT NULL,
|
||||||
|
criterion_revision BIGINT NOT NULL,
|
||||||
|
evidence_id BIGINT NOT NULL,
|
||||||
|
bound_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT uk_goal_criterion_evidence UNIQUE(goal_id, criterion_id, criterion_revision, evidence_id),
|
||||||
|
CONSTRAINT fk_goal_criterion_evidence FOREIGN KEY(evidence_id) REFERENCES mate_execution_evidence(id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_criterion_evidence_goal ON mate_goal_criterion_evidence(workspace_id, goal_id, criterion_id);
|
||||||
@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.context.ApplicationEventPublisher;
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
import vip.mate.agent.context.ChatOrigin;
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.agent.context.ExecutionAttribution;
|
||||||
import vip.mate.cron.CronChatOriginFactory;
|
import vip.mate.cron.CronChatOriginFactory;
|
||||||
import vip.mate.cron.CronConversationResolver;
|
import vip.mate.cron.CronConversationResolver;
|
||||||
import vip.mate.cron.model.CronJobEntity;
|
import vip.mate.cron.model.CronJobEntity;
|
||||||
@ -75,7 +76,7 @@ class CronJobOriginPropagationTest {
|
|||||||
.thenReturn(new CronJobLifecycleService.StartResult(run, MESSAGE_ID));
|
.thenReturn(new CronJobLifecycleService.StartResult(run, MESSAGE_ID));
|
||||||
when(originFactory.from(job, CONVERSATION_ID, MESSAGE_ID)).thenReturn(origin);
|
when(originFactory.from(job, CONVERSATION_ID, MESSAGE_ID)).thenReturn(origin);
|
||||||
when(heartbeat.begin(55L)).thenReturn(lease);
|
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"));
|
.thenReturn(AgentService.ChatResult.contentOnly("done"));
|
||||||
CronJobRunner runner = new CronJobRunner(lifecycle, heartbeat, agentService, originFactory, resolver,
|
CronJobRunner runner = new CronJobRunner(lifecycle, heartbeat, agentService, originFactory, resolver,
|
||||||
mock(WikiProcessingService.class), new ObjectMapper());
|
mock(WikiProcessingService.class), new ObjectMapper());
|
||||||
@ -85,7 +86,7 @@ class CronJobOriginPropagationTest {
|
|||||||
verify(originFactory).from(job, CONVERSATION_ID, MESSAGE_ID);
|
verify(originFactory).from(job, CONVERSATION_ID, MESSAGE_ID);
|
||||||
verify(heartbeat).begin(55L);
|
verify(heartbeat).begin(55L);
|
||||||
verify(lease).close();
|
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));
|
verify(agentService, never()).chatWithUsage(eq(AGENT_ID), anyString(), eq(CONVERSATION_ID));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,7 +107,7 @@ class CronJobOriginPropagationTest {
|
|||||||
.thenReturn(new CronJobLifecycleService.StartResult(run, MESSAGE_ID));
|
.thenReturn(new CronJobLifecycleService.StartResult(run, MESSAGE_ID));
|
||||||
when(originFactory.from(job, CONVERSATION_ID, MESSAGE_ID)).thenReturn(origin);
|
when(originFactory.from(job, CONVERSATION_ID, MESSAGE_ID)).thenReturn(origin);
|
||||||
when(heartbeat.begin(55L)).thenReturn(lease);
|
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"));
|
.thenThrow(new IllegalStateException("provider timeout"));
|
||||||
CronJobRunner runner = new CronJobRunner(lifecycle, heartbeat, agentService, originFactory, resolver,
|
CronJobRunner runner = new CronJobRunner(lifecycle, heartbeat, agentService, originFactory, resolver,
|
||||||
mock(WikiProcessingService.class), new ObjectMapper());
|
mock(WikiProcessingService.class), new ObjectMapper());
|
||||||
@ -136,7 +137,7 @@ class CronJobOriginPropagationTest {
|
|||||||
.thenReturn(new CronJobLifecycleService.StartResult(run, MESSAGE_ID));
|
.thenReturn(new CronJobLifecycleService.StartResult(run, MESSAGE_ID));
|
||||||
when(originFactory.from(job, CONVERSATION_ID, MESSAGE_ID)).thenReturn(origin);
|
when(originFactory.from(job, CONVERSATION_ID, MESSAGE_ID)).thenReturn(origin);
|
||||||
when(heartbeat.begin(55L)).thenReturn(lease);
|
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);
|
.thenReturn(failed);
|
||||||
CronJobRunner runner = new CronJobRunner(lifecycle, heartbeat, agentService, originFactory, resolver,
|
CronJobRunner runner = new CronJobRunner(lifecycle, heartbeat, agentService, originFactory, resolver,
|
||||||
mock(WikiProcessingService.class), new ObjectMapper());
|
mock(WikiProcessingService.class), new ObjectMapper());
|
||||||
|
|||||||
@ -0,0 +1,206 @@
|
|||||||
|
package vip.mate.execution.evidence;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||||
|
import org.h2.jdbcx.JdbcDataSource;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||||
|
import org.springframework.ai.tool.metadata.ToolMetadata;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||||
|
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import vip.mate.agent.AgentToolSet;
|
||||||
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
|
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.agent.context.ExecutionAttribution;
|
||||||
|
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
||||||
|
import vip.mate.execution.evidence.model.*;
|
||||||
|
import vip.mate.execution.evidence.service.*;
|
||||||
|
import vip.mate.team.service.TeamWorkerConversationGovernanceService;
|
||||||
|
import vip.mate.tool.guard.ToolGuard;
|
||||||
|
import vip.mate.tool.guard.ToolGuardResult;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class ExecutionEvidenceIntegrationTest {
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
private DataSourceTransactionManager transactions;
|
||||||
|
private ExecutionEvidenceStore store;
|
||||||
|
private ExecutionIdentityResolver identities;
|
||||||
|
private ExecutionEvidenceRecorder recorder;
|
||||||
|
private final ChatOrigin origin = ChatOrigin.web("conv", "owner", 1L, null);
|
||||||
|
private final AtomicInteger executions = new AtomicInteger();
|
||||||
|
|
||||||
|
@BeforeEach void setup() {
|
||||||
|
var source = new JdbcDataSource();
|
||||||
|
source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
|
||||||
|
new ResourceDatabasePopulator(new ClassPathResource("db/migration/h2/V191__execution_evidence_ledger.sql")).execute(source);
|
||||||
|
jdbc = new JdbcTemplate(source);
|
||||||
|
jdbc.execute("CREATE TABLE mate_conversation(conversation_id VARCHAR(128) PRIMARY KEY,workspace_id BIGINT,deleted INT DEFAULT 0)");
|
||||||
|
jdbc.execute("CREATE TABLE mate_agent_goal(id BIGINT PRIMARY KEY,conversation_id VARCHAR(128),workspace_id BIGINT,status VARCHAR(20),deleted INT DEFAULT 0)");
|
||||||
|
jdbc.execute("CREATE TABLE mate_goal_attempt(attempt_id VARCHAR(128) PRIMARY KEY,goal_id BIGINT,conversation_id VARCHAR(128),lease_token VARCHAR(128),state VARCHAR(20),lease_until TIMESTAMP)");
|
||||||
|
jdbc.execute("CREATE TABLE mate_cron_job_run(id BIGINT PRIMARY KEY,conversation_id VARCHAR(128),status VARCHAR(20))");
|
||||||
|
jdbc.execute("CREATE TABLE mate_tool_approval(pending_id VARCHAR(128) PRIMARY KEY,conversation_id VARCHAR(128))");
|
||||||
|
jdbc.update("INSERT INTO mate_conversation VALUES('conv',1,0)");
|
||||||
|
jdbc.update("INSERT INTO mate_tool_approval VALUES('approval-one','conv')");
|
||||||
|
var teams = mock(TeamWorkerConversationGovernanceService.class);
|
||||||
|
when(teams.resolve("conv", null, null)).thenReturn(Optional.empty());
|
||||||
|
when(teams.resolve("child", null, null)).thenReturn(Optional.empty());
|
||||||
|
identities = new ExecutionIdentityResolver(jdbc, teams);
|
||||||
|
transactions = new DataSourceTransactionManager(source);
|
||||||
|
var properties = new ExecutionEvidenceProperties();
|
||||||
|
store = new ExecutionEvidenceStore(jdbc, transactions, properties);
|
||||||
|
store.setOwnershipValidator(identities);
|
||||||
|
recorder = new ExecutionEvidenceRecorder(store, identities, properties, new SimpleMeterRegistry());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void repeatedProviderIdsInDifferentRoundsProduceDifferentDurableAttempts() {
|
||||||
|
var executor = executor(false);
|
||||||
|
executor.execute(List.of(call()), "conv", "1", false, "owner", null, origin);
|
||||||
|
executor.execute(List.of(call()), "conv", "1", false, "owner", null, origin);
|
||||||
|
assertEquals(2, executions.get());
|
||||||
|
assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM mate_execution_attempt", Integer.class));
|
||||||
|
var reopened = new ExecutionEvidenceStore(jdbc, transactions, new ExecutionEvidenceProperties());
|
||||||
|
assertEquals(4, reopened.list(1L, "conv", null, null, 20).size());
|
||||||
|
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_execution_evidence WHERE result='PASS'", Integer.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void approvalReplayIsCapturedOnceAndDoesNotPoisonLaterOrdinaryTools() {
|
||||||
|
ChatOrigin replay = origin.withApprovalId("approval-one");
|
||||||
|
var executor = executor(false);
|
||||||
|
executor.execute(List.of(call()), "conv", "1", true, "owner", null, replay);
|
||||||
|
executor.execute(List.of(call()), "conv", "1", true, "owner", null, replay);
|
||||||
|
assertEquals(1, executions.get());
|
||||||
|
executor.execute(List.of(call()), "conv", "1", false, "owner", null, replay);
|
||||||
|
assertEquals(2, executions.get());
|
||||||
|
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_execution_attempt WHERE approval_id='approval-one'", Integer.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void preapprovedDirectResultKeepsOnlyMetadataAndPreservesDirectOutput() {
|
||||||
|
var outputs = new ArrayList<DirectToolOutput>();
|
||||||
|
var events = new ArrayList<GraphEventPublisher.GraphEvent>();
|
||||||
|
var response = executor(true).executePreApproved(call(), "{}", events, "conv", null,
|
||||||
|
outputs, origin.withApprovalId("approval-one"));
|
||||||
|
assertFalse(response.responseData().contains("private-output"));
|
||||||
|
assertEquals("private-output", outputs.getFirst().fullResult());
|
||||||
|
var evidence = store.list(1L, "conv", null, null, 20);
|
||||||
|
assertEquals(1, evidence.size());
|
||||||
|
assertEquals(EvidenceKind.TOOL_RETURNED, evidence.getFirst().observation().kind());
|
||||||
|
assertFalse(evidence.toString().contains("private"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void attributionSurvivesOriginWithersAndJsonButMustMatchPersistedBusinessRows() throws Exception {
|
||||||
|
jdbc.update("INSERT INTO mate_agent_goal VALUES(100,'conv',1,'active',0)");
|
||||||
|
jdbc.update("INSERT INTO mate_goal_attempt VALUES('goal-attempt',100,'conv','lease','running',?)", LocalDateTime.now().plusMinutes(2));
|
||||||
|
var attributed = origin.withExecutionAttribution(new ExecutionAttribution(100L, "goal-attempt", null, null, "lease"));
|
||||||
|
var mapper = new ObjectMapper();
|
||||||
|
attributed = mapper.readValue(mapper.writeValueAsString(attributed), ChatOrigin.class)
|
||||||
|
.withAgent(20L).withSender("Owner", "web", null).withWorkspace(1L, null)
|
||||||
|
.withConversationId("conv").withBaseUrl("http://localhost").withOriginMessageId(19L);
|
||||||
|
var identity = identities.resolve(attributed, "invocation", "provider", "tool");
|
||||||
|
assertEquals(100L, identity.goalId());
|
||||||
|
assertEquals("goal-attempt", identity.goalAttemptId());
|
||||||
|
assertNull(identities.resolve(attributed.withWorkspace(2L, null), "invocation", "provider", "tool"));
|
||||||
|
assertNull(identities.resolve(attributed.withApprovalId("not-real"), "invocation", "provider", "tool"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void revokedOwnerCannotPublishAfterAConcurrentRevocationCommits() throws Exception {
|
||||||
|
jdbc.update("INSERT INTO mate_agent_goal VALUES(100,'conv',1,'active',0)");
|
||||||
|
jdbc.update("INSERT INTO mate_goal_attempt VALUES('goal-attempt',100,'conv','lease','running',?)", LocalDateTime.now().plusMinutes(2));
|
||||||
|
var linked = origin.withExecutionAttribution(new ExecutionAttribution(100L, "goal-attempt", null, null, "lease"));
|
||||||
|
var attempt = store.begin(identities.resolve(linked, "invocation", "provider", "tool"));
|
||||||
|
CountDownLatch locked = new CountDownLatch(1);
|
||||||
|
CountDownLatch release = new CountDownLatch(1);
|
||||||
|
try (var pool = Executors.newFixedThreadPool(2)) {
|
||||||
|
var revoke = pool.submit(() -> new TransactionTemplate(transactions).execute(status -> {
|
||||||
|
jdbc.update("UPDATE mate_goal_attempt SET state='cancelled' WHERE attempt_id='goal-attempt'");
|
||||||
|
locked.countDown();
|
||||||
|
await(release);
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
assertTrue(locked.await(5, TimeUnit.SECONDS));
|
||||||
|
var finish = pool.submit(() -> assertThrows(IllegalStateException.class,
|
||||||
|
() -> store.finish(attempt.id(), "lease", AttemptState.SUCCEEDED, EffectOutcome.UNCERTAIN,
|
||||||
|
List.of(new EvidenceObservation("callback", EvidenceKind.TOOL_RETURNED, EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, "returned")))));
|
||||||
|
release.countDown(); revoke.get(5, TimeUnit.SECONDS); finish.get(5, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
assertEquals(AttemptState.STARTED, store.findAttempt(attempt.id()).orElseThrow().state());
|
||||||
|
assertTrue(store.list(1L, "conv", null, null, 20).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void identicalReceiptRetryAfterOwnerSettlementReturnsOriginalEvidence() {
|
||||||
|
jdbc.update("INSERT INTO mate_cron_job_run VALUES(100,'conv','running')");
|
||||||
|
var linked = origin.withExecutionAttribution(new ExecutionAttribution(null, null, 100L, null, "cron:100"));
|
||||||
|
var attempt = store.begin(identities.resolve(linked, "invocation", "provider", "tool"));
|
||||||
|
var observations = List.of(new EvidenceObservation("callback", EvidenceKind.TOOL_RETURNED,
|
||||||
|
EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, "returned"));
|
||||||
|
var first = store.finish(attempt.id(), "cron:100", AttemptState.SUCCEEDED, EffectOutcome.UNCERTAIN, observations);
|
||||||
|
jdbc.update("UPDATE mate_cron_job_run SET status='completed' WHERE id=100");
|
||||||
|
assertEquals(first, store.finish(attempt.id(), "cron:100", AttemptState.SUCCEEDED, EffectOutcome.UNCERTAIN, observations));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void childConversationDropsParentGoalAndCronAttributionButCanRecordOwnTools() {
|
||||||
|
jdbc.update("INSERT INTO mate_conversation VALUES('child',1,0)");
|
||||||
|
for (ExecutionAttribution source : List.of(new ExecutionAttribution(100L, "parent-attempt", null, null, "lease"),
|
||||||
|
new ExecutionAttribution(null, null, 100L, null, "cron:100"))) {
|
||||||
|
var child = origin.withExecutionAttribution(source).withConversationId("child");
|
||||||
|
assertNull(child.executionAttribution());
|
||||||
|
var identity = identities.resolve(child, UUID.randomUUID().toString(), "provider", "tool");
|
||||||
|
assertNotNull(identity);
|
||||||
|
assertNull(identity.goalId());
|
||||||
|
assertNull(identity.cronRunId());
|
||||||
|
assertTrue(store.reserve(identity).created());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void deletionBetweenResolutionAndReservationCannotRecreateErasedEvidence() {
|
||||||
|
var identity = identities.resolve(origin, "invocation", "provider", "tool");
|
||||||
|
jdbc.update("UPDATE mate_conversation SET deleted=1 WHERE conversation_id='conv'");
|
||||||
|
store.purgeConversation("conv");
|
||||||
|
assertThrows(IllegalStateException.class, () -> store.reserve(identity));
|
||||||
|
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_execution_attempt", Integer.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ToolExecutionExecutor executor(boolean direct) {
|
||||||
|
ToolCallback callback = new ToolCallback() {
|
||||||
|
public ToolDefinition getToolDefinition() { return ToolDefinition.builder().name("evidence_tool").description("test").inputSchema("{}").build(); }
|
||||||
|
public ToolMetadata getToolMetadata() { return ToolMetadata.builder().returnDirect(direct).build(); }
|
||||||
|
public String call(String input) { throw new AssertionError("Explicit context required"); }
|
||||||
|
public String call(String input, ToolContext context) {
|
||||||
|
executions.incrementAndGet();
|
||||||
|
var sink = ExecutionObservationSink.from(context);
|
||||||
|
assertNotNull(sink);
|
||||||
|
sink.command(1, false, false, false);
|
||||||
|
return "private-output";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ToolGuard guard = (name, args) -> ToolGuardResult.allow();
|
||||||
|
var executor = new ToolExecutionExecutor(AgentToolSet.fromCallbacks(List.of(), List.of(callback)), guard, null, null);
|
||||||
|
executor.setExecutionEvidenceRecorder(recorder);
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AssistantMessage.ToolCall call() { return new AssistantMessage.ToolCall("provider-id", "function", "evidence_tool", "{}"); }
|
||||||
|
private void await(CountDownLatch latch) {
|
||||||
|
try { assertTrue(latch.await(5, TimeUnit.SECONDS)); }
|
||||||
|
catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new AssertionError(error); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package vip.mate.execution.evidence;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.execution.evidence.service.ExecutionEvidenceLifecycle;
|
||||||
|
import vip.mate.execution.evidence.service.ExecutionEvidenceStore;
|
||||||
|
import vip.mate.workspace.conversation.event.ConversationDeletedEvent;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class ExecutionEvidenceLifecycleTest {
|
||||||
|
@Test void drainsSeveralBatchesAndStopsWhenCaughtUp() {
|
||||||
|
var store = mock(ExecutionEvidenceStore.class);
|
||||||
|
when(store.purgeExpiredMetadata(any(), eq(100))).thenReturn(100, 100, 1);
|
||||||
|
new ExecutionEvidenceLifecycle(store, new ExecutionEvidenceProperties()).cleanup();
|
||||||
|
verify(store, times(3)).purgeExpiredMetadata(any(), eq(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void cleanupRemainsBoundedAndContinuesOnTheNextTick() {
|
||||||
|
var store = mock(ExecutionEvidenceStore.class);
|
||||||
|
var properties = new ExecutionEvidenceProperties(); properties.setCleanupMaxBatches(2);
|
||||||
|
when(store.purgeExpiredMetadata(any(), eq(100))).thenReturn(100, 100, 1);
|
||||||
|
var lifecycle = new ExecutionEvidenceLifecycle(store, properties);
|
||||||
|
lifecycle.cleanup();
|
||||||
|
verify(store, times(2)).purgeExpiredMetadata(any(), eq(100));
|
||||||
|
lifecycle.cleanup();
|
||||||
|
verify(store, times(3)).purgeExpiredMetadata(any(), eq(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void conversationDeletionErasesCopiedContent() {
|
||||||
|
var store = mock(ExecutionEvidenceStore.class);
|
||||||
|
new ExecutionEvidenceLifecycle(store, new ExecutionEvidenceProperties())
|
||||||
|
.onConversationDeleted(new ConversationDeletedEvent("conv"));
|
||||||
|
verify(store).purgeConversation("conv");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
package vip.mate.execution.evidence;
|
||||||
|
|
||||||
|
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||||
|
import vip.mate.config.JacksonConfig;
|
||||||
|
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.auth.service.AuthService;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
import vip.mate.execution.evidence.model.*;
|
||||||
|
import vip.mate.execution.evidence.service.*;
|
||||||
|
import vip.mate.team.service.TeamWorkerConversationGovernanceService;
|
||||||
|
import vip.mate.tool.document.GeneratedFileCache;
|
||||||
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||||
|
import vip.mate.workspace.core.service.WorkspaceService;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class ExecutionEvidenceQueryTest {
|
||||||
|
private final ExecutionEvidenceStore store = mock(ExecutionEvidenceStore.class);
|
||||||
|
private final ConversationService conversations = mock(ConversationService.class);
|
||||||
|
private final TeamWorkerConversationGovernanceService teams = mock(TeamWorkerConversationGovernanceService.class);
|
||||||
|
private final GeneratedFileCache files = mock(GeneratedFileCache.class);
|
||||||
|
private ExecutionEvidenceQueryService queries;
|
||||||
|
private final long id = 1999999999999999999L;
|
||||||
|
private final Instant now = Instant.parse("2026-09-07T00:00:00Z");
|
||||||
|
|
||||||
|
@BeforeEach void setup() {
|
||||||
|
queries = new ExecutionEvidenceQueryService(store, conversations, teams, files,
|
||||||
|
mock(AuthService.class), mock(WorkspaceService.class), new ExecutionEvidenceProperties(), new SimpleMeterRegistry());
|
||||||
|
var conversation = new ConversationEntity();
|
||||||
|
conversation.setConversationId("conv"); conversation.setWorkspaceId(1L); conversation.setDeleted(0);
|
||||||
|
when(conversations.findByConversationId("conv")).thenReturn(conversation);
|
||||||
|
when(conversations.isConversationOwner("conv", "owner")).thenReturn(true);
|
||||||
|
when(store.findAttempt(1L)).thenReturn(Optional.of(new ExecutionAttempt(1L,
|
||||||
|
new ExecutionIdentity(1L, "conv", "native", null, "call", "call", 1, "provider", "tool",
|
||||||
|
null, null, null, null, null, null, "fence"),
|
||||||
|
AttemptState.SUCCEEDED, EffectOutcome.UNCERTAIN, now, now)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void deniesUnscopedAnonymousAndWrongWorkspaceBeforeReadingEvidence() {
|
||||||
|
assertEquals(404, assertThrows(MateClawException.class, () -> list("stranger", 1L, "conv", null, 20)).getCode());
|
||||||
|
assertEquals(404, assertThrows(MateClawException.class, () -> list("owner", 2L, "conv", null, 20)).getCode());
|
||||||
|
assertThrows(MateClawException.class, () -> list(null, 1L, "conv", null, 20));
|
||||||
|
assertThrows(MateClawException.class, () -> list("owner", 1L, "", null, 20));
|
||||||
|
verifyNoInteractions(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void detailDoesNotLeakCrossWorkspaceOrForeignConversation() {
|
||||||
|
when(store.findById(id)).thenReturn(Optional.of(evidence(id)));
|
||||||
|
assertEquals(404, assertThrows(MateClawException.class, () -> queries.detail("stranger", 1L, id)).getCode());
|
||||||
|
assertEquals(404, assertThrows(MateClawException.class, () -> queries.detail("owner", 2L, id)).getCode());
|
||||||
|
assertEquals(404, assertThrows(MateClawException.class, () -> queries.detail("owner", 1L, 42L)).getCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void limitsAndCursorPreserveFullPrecisionAndNeverClaimVerification() {
|
||||||
|
when(store.list(eq(1L), eq("conv"), isNull(), isNull(), eq(2), isNull(), isNull()))
|
||||||
|
.thenReturn(List.of(evidence(id), evidence(id - 1)));
|
||||||
|
var page = list("owner", 1L, "conv", null, 1);
|
||||||
|
assertEquals(id, page.items().getFirst().id());
|
||||||
|
assertEquals("UNKNOWN", page.items().getFirst().validity());
|
||||||
|
assertNotNull(page.nextCursor());
|
||||||
|
when(store.list(eq(1L), eq("conv"), eq(now), eq(id), eq(2), isNull(), isNull()))
|
||||||
|
.thenReturn(List.of(evidence(id - 1)));
|
||||||
|
var next = list("owner", 1L, "conv", page.nextCursor(), 1);
|
||||||
|
assertEquals(id - 1, next.items().getFirst().id());
|
||||||
|
assertNull(next.nextCursor());
|
||||||
|
assertThrows(MateClawException.class, () -> list("owner", 1L, "conv", "invalid-cursor", 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void canonicalTeamTranscriptReaderCanQueryWorkerEvidence() {
|
||||||
|
when(teams.canReadTranscript("conv", null, null, "reviewer")).thenReturn(true);
|
||||||
|
when(store.list(eq(1L), eq("conv"), isNull(), isNull(), eq(21), isNull(), isNull())).thenReturn(List.of(evidence(id)));
|
||||||
|
assertEquals(1, list("reviewer", 1L, "conv", null, null).items().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void jsonUsesStringIdentifiersAndMissingArtifactsAreUnavailable() throws Exception {
|
||||||
|
var builder = Jackson2ObjectMapperBuilder.json();
|
||||||
|
new JacksonConfig().longToStringCustomizer().customize(builder);
|
||||||
|
var mapper = builder.build();
|
||||||
|
when(store.findById(id)).thenReturn(Optional.of(evidence(id)));
|
||||||
|
var json = mapper.readTree(mapper.writeValueAsString(queries.detail("owner", 1L, id)));
|
||||||
|
assertTrue(json.get("id").isTextual());
|
||||||
|
assertEquals(Long.toString(id), json.get("id").asText());
|
||||||
|
var artifact = new EvidenceObservation("artifact:missing", EvidenceKind.ARTIFACT_SNAPSHOT,
|
||||||
|
EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null,
|
||||||
|
null, "missing", "digest", "file metadata", null, now, now.minusSeconds(1));
|
||||||
|
when(store.findById(id)).thenReturn(Optional.of(new ExecutionEvidence(id, 1L, 1L, "conv", artifact)));
|
||||||
|
var unavailable = queries.detail("owner", 1L, id);
|
||||||
|
assertEquals("UNAVAILABLE", unavailable.validity());
|
||||||
|
assertNull(unavailable.artifactRef());
|
||||||
|
assertNull(unavailable.artifactDigest());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExecutionEvidenceQueryService.Page list(String user, Long workspace, String conversation, String cursor, Integer limit) {
|
||||||
|
return queries.list(user, workspace, conversation, cursor, limit, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExecutionEvidence evidence(long evidenceId) {
|
||||||
|
return new ExecutionEvidence(evidenceId, 1L, 1L, "conv", new EvidenceObservation("callback", EvidenceKind.TOOL_RETURNED,
|
||||||
|
EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null, null,
|
||||||
|
null, null, "Tool callback returned", null, now, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
package vip.mate.execution.evidence;
|
||||||
|
|
||||||
|
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
import org.springframework.dao.DataAccessResourceFailureException;
|
||||||
|
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||||
|
import org.springframework.ai.tool.metadata.ToolMetadata;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.execution.evidence.model.*;
|
||||||
|
import vip.mate.execution.evidence.service.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class ExecutionEvidenceRecorderTest {
|
||||||
|
private final ExecutionEvidenceStore store = mock(ExecutionEvidenceStore.class);
|
||||||
|
private final ExecutionIdentityResolver identities = mock(ExecutionIdentityResolver.class);
|
||||||
|
private final ExecutionEvidenceProperties properties = new ExecutionEvidenceProperties();
|
||||||
|
private final ToolCallback callback = mock(ToolCallback.class);
|
||||||
|
private final ExecutionIdentity identity = new ExecutionIdentity(1L, "conv", "native", null,
|
||||||
|
"invocation", "invocation", 1, "provider-id", "tool", null, null, null, null, null, null, "fence");
|
||||||
|
private ExecutionEvidenceRecorder recorder;
|
||||||
|
|
||||||
|
@BeforeEach void setup() {
|
||||||
|
recorder = new ExecutionEvidenceRecorder(store, identities, properties, new SimpleMeterRegistry());
|
||||||
|
when(callback.getToolDefinition()).thenReturn(ToolDefinition.builder().name("tool").description("test").inputSchema("{}").build());
|
||||||
|
when(callback.getToolMetadata()).thenReturn(ToolMetadata.builder().returnDirect(false).build());
|
||||||
|
when(identities.resolve(any(), anyString(), anyString(), anyString())).thenReturn(identity);
|
||||||
|
when(identities.isCurrent(identity)).thenReturn(true);
|
||||||
|
when(store.reserve(identity)).thenReturn(new BeginResult(attempt(AttemptState.STARTED), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void forgedCallbackBodyNeverCreatesPassOrPersistsContent() {
|
||||||
|
when(callback.call(anyString(), any())).thenReturn("CHECK_PASSED password=secret all tests passed");
|
||||||
|
assertTrue(invoke().contains("CHECK_PASSED"));
|
||||||
|
verify(store).finish(eq(1L), eq("fence"), eq(AttemptState.SUCCEEDED), eq(EffectOutcome.UNCERTAIN),
|
||||||
|
argThat(rows -> rows.size() == 1 && rows.getFirst().kind() == EvidenceKind.TOOL_RETURNED
|
||||||
|
&& rows.getFirst().result() == EvidenceResult.OBSERVED
|
||||||
|
&& !rows.toString().contains("secret")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void finishFailureDoesNotRetryOrReplaceToolResponse() {
|
||||||
|
when(callback.call(anyString(), any())).thenReturn("result");
|
||||||
|
when(store.finish(anyLong(), anyString(), any(), any(), anyList())).thenThrow(new IllegalStateException("db unavailable"));
|
||||||
|
assertEquals("result", invoke());
|
||||||
|
verify(callback, times(1)).call(anyString(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void observeBeginFailureStillExecutesWithoutCollecting() {
|
||||||
|
when(store.reserve(any())).thenThrow(new DataAccessResourceFailureException("db unavailable"));
|
||||||
|
when(callback.call(anyString(), any())).thenAnswer(call -> {
|
||||||
|
assertNull(ExecutionObservationSink.from(call.getArgument(1)));
|
||||||
|
return "result";
|
||||||
|
});
|
||||||
|
assertEquals("result", invoke());
|
||||||
|
verify(store, never()).finish(any(), any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void directCallbackCannotStoreTypedContentOrHashes() {
|
||||||
|
when(callback.getToolMetadata()).thenReturn(ToolMetadata.builder().returnDirect(true).build());
|
||||||
|
when(callback.call(anyString(), any())).thenAnswer(call -> {
|
||||||
|
ExecutionObservationSink.from(call.getArgument(1)).artifact("secret-id", "secret-digest", 12, "text/plain", Instant.now());
|
||||||
|
return "secret-content";
|
||||||
|
});
|
||||||
|
assertEquals("secret-content", invoke());
|
||||||
|
verify(store).finish(any(), any(), any(), any(), argThat(rows -> !rows.toString().contains("secret")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void startedOrTerminalDuplicateCannotExecuteAgain() {
|
||||||
|
for (AttemptState state : List.of(AttemptState.STARTED, AttemptState.SUCCEEDED)) {
|
||||||
|
when(store.reserve(any())).thenReturn(new BeginResult(attempt(state), false));
|
||||||
|
assertThrows(IllegalStateException.class, this::invoke);
|
||||||
|
}
|
||||||
|
verify(callback, never()).call(anyString(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void lostOwnerCannotPublishLateCompletion() {
|
||||||
|
when(identities.isCurrent(identity)).thenReturn(false);
|
||||||
|
when(callback.call(anyString(), any())).thenReturn("late result");
|
||||||
|
assertEquals("late result", invoke());
|
||||||
|
verify(store, never()).finish(any(), any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void offSkipsStorageAndEnforceCannotBeAccidentallyEnabled() {
|
||||||
|
properties.setMode(ExecutionEvidenceProperties.Mode.OFF);
|
||||||
|
when(callback.call(anyString(), any())).thenReturn("result");
|
||||||
|
assertEquals("result", invoke());
|
||||||
|
verifyNoInteractions(store);
|
||||||
|
properties.setMode(ExecutionEvidenceProperties.Mode.ENFORCE);
|
||||||
|
assertThrows(IllegalStateException.class, () -> new ExecutionEvidenceRecorder(store, identities, properties, new SimpleMeterRegistry()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String invoke() {
|
||||||
|
return recorder.invoke(callback, "{}", ChatOrigin.web("conv", "owner", 1L, null).toToolContext(), "invocation", "provider-id");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExecutionAttempt attempt(AttemptState state) {
|
||||||
|
return new ExecutionAttempt(1L, identity, state, EffectOutcome.UNCERTAIN, Instant.now(), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,214 @@
|
|||||||
|
package vip.mate.execution.evidence;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.h2.jdbcx.JdbcDataSource;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||||
|
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||||
|
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
|
||||||
|
import vip.mate.execution.evidence.model.AttemptState;
|
||||||
|
import vip.mate.execution.evidence.model.BeginResult;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import vip.mate.execution.evidence.model.EffectOutcome;
|
||||||
|
import vip.mate.execution.evidence.model.EvidenceKind;
|
||||||
|
import vip.mate.execution.evidence.model.EvidenceObservation;
|
||||||
|
import vip.mate.execution.evidence.model.EvidenceResult;
|
||||||
|
import vip.mate.execution.evidence.model.ExecutionEvidence;
|
||||||
|
import vip.mate.execution.evidence.model.ExecutionIdentity;
|
||||||
|
import vip.mate.execution.evidence.model.SourceLevel;
|
||||||
|
import vip.mate.execution.evidence.service.ExecutionEvidenceStore;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.concurrent.Callable;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
|
||||||
|
class ExecutionEvidenceStoreTest {
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
private ExecutionEvidenceStore store;
|
||||||
|
@BeforeEach void setup() {
|
||||||
|
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||||
|
new ResourceDatabasePopulator(new ClassPathResource("db/migration/h2/V191__execution_evidence_ledger.sql")).execute(source);
|
||||||
|
jdbc = new JdbcTemplate(source);
|
||||||
|
store = new ExecutionEvidenceStore(jdbc, new DataSourceTransactionManager(source), new ExecutionEvidenceProperties());
|
||||||
|
}
|
||||||
|
private ExecutionIdentity identity(String invocation) {
|
||||||
|
return new ExecutionIdentity(1L,"conversation","native","session",invocation,invocation,1,"provider-id","shell",null,null,null,null,null,null,"owner");
|
||||||
|
}
|
||||||
|
private EvidenceObservation observation(String summary) {
|
||||||
|
return new EvidenceObservation("return",EvidenceKind.TOOL_RETURNED,EvidenceResult.OBSERVED,SourceLevel.PLATFORM_OBSERVED,summary);
|
||||||
|
}
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"h2", "mysql", "kingbase"})
|
||||||
|
void migrationCreatesAllTablesInCompatibleDialectMode(String dialect) {
|
||||||
|
var dataSource = new JdbcDataSource();
|
||||||
|
String mode = "kingbase".equals(dialect) ? "PostgreSQL" : "MySQL";
|
||||||
|
dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=" + mode + ";DB_CLOSE_DELAY=-1");
|
||||||
|
new ResourceDatabasePopulator(new ClassPathResource("db/migration/" + dialect + "/V191__execution_evidence_ledger.sql"))
|
||||||
|
.execute(dataSource);
|
||||||
|
var probe = new JdbcTemplate(dataSource);
|
||||||
|
for (String table : List.of("mate_execution_attempt", "mate_execution_evidence", "mate_evidence_scope", "mate_goal_criterion_evidence")) {
|
||||||
|
assertThat(probe.queryForObject("SELECT COUNT(*) FROM " + table, Integer.class)).isZero();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void startsDurablyAndDoesNotConfuseProviderIdsAcrossInvocations() {
|
||||||
|
var first = store.begin(identity("one"));
|
||||||
|
assertThat(first.state()).isEqualTo(AttemptState.STARTED);
|
||||||
|
assertThat(store.begin(identity("one")).id()).isEqualTo(first.id());
|
||||||
|
assertThat(store.begin(identity("two")).id()).isNotEqualTo(first.id());
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from mate_execution_attempt",Integer.class)).isEqualTo(2);
|
||||||
|
}
|
||||||
|
@Test void terminalEvidenceIsImmutableAndIdempotent() {
|
||||||
|
var attempt = store.begin(identity("one"));
|
||||||
|
var evidence = store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.UNCERTAIN,List.of(observation("returned")));
|
||||||
|
assertThat(store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.UNCERTAIN,List.of(observation("returned")))).isEqualTo(evidence);
|
||||||
|
assertThatThrownBy(() -> store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.UNCERTAIN,List.of(observation("changed")))).isInstanceOf(IllegalStateException.class);
|
||||||
|
assertThat(store.find(1L,"conversation",evidence.getFirst().id())).contains(evidence.getFirst());
|
||||||
|
assertThat(store.find(2L,"conversation",evidence.getFirst().id())).isEmpty();
|
||||||
|
assertThat(store.find(1L,"other",evidence.getFirst().id())).isEmpty();
|
||||||
|
}
|
||||||
|
@Test void rejectsOldOwnerAndRollsBackConflictingObservations() {
|
||||||
|
var attempt = store.begin(identity("one"));
|
||||||
|
assertThatThrownBy(() -> store.finish(attempt.id(),"old",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation("ok")))).isInstanceOf(IllegalStateException.class);
|
||||||
|
assertThatThrownBy(() -> store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation("ok"),observation("different")))).isInstanceOf(IllegalStateException.class);
|
||||||
|
assertThat(jdbc.queryForObject("select state from mate_execution_attempt",String.class)).isEqualTo("STARTED");
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from mate_execution_evidence",Integer.class)).isZero();
|
||||||
|
}
|
||||||
|
@Test void sanitizesAndBoundsSummariesInUtf8Bytes() {
|
||||||
|
var attempt = store.begin(identity("one"));
|
||||||
|
var evidence = store.finish(attempt.id(),"owner",AttemptState.FAILED,EffectOutcome.UNCERTAIN,List.of(observation("token=secretvalue " + "界".repeat(3000)))).getFirst();
|
||||||
|
assertThat(evidence.observation().summary()).contains("[redacted]").doesNotContain("secretvalue");
|
||||||
|
assertThat(evidence.observation().summary().getBytes(StandardCharsets.UTF_8).length).isLessThanOrEqualTo(2048);
|
||||||
|
}
|
||||||
|
@Test void boundsListsAndRejectsMissingScope() {
|
||||||
|
var attempt = store.begin(identity("one"));
|
||||||
|
store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation("ok")));
|
||||||
|
assertThat(store.list(1L,"conversation",null,null,10000)).hasSize(1);
|
||||||
|
assertThatThrownBy(() -> store.list(1L,null,null,null,20)).isInstanceOf(IllegalArgumentException.class);
|
||||||
|
assertThat(store.list(1L,"conversation",Instant.EPOCH,1L,20)).isEmpty();
|
||||||
|
}
|
||||||
|
@Test void duplicateSameSourceWithinBatchRemainsIdempotent() {
|
||||||
|
var attempt = store.begin(identity("one"));
|
||||||
|
var result = store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,
|
||||||
|
List.of(observation("same"),observation("same")));
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
}
|
||||||
|
@Test void migrationEnforcesLogicalAttemptAndSourceUniqueness() {
|
||||||
|
var attempt = store.begin(identity("one"));
|
||||||
|
store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation("same")));
|
||||||
|
assertThatThrownBy(() -> jdbc.update("""
|
||||||
|
INSERT INTO mate_execution_evidence (id,workspace_id,attempt_id,source_key,kind,result,source_level,observed_at)
|
||||||
|
VALUES (42,1,?,'return','TOOL_RETURNED','OBSERVED','PLATFORM_OBSERVED',CURRENT_TIMESTAMP)
|
||||||
|
""", attempt.id())).isInstanceOf(DuplicateKeyException.class);
|
||||||
|
var duplicateLogical = new ExecutionIdentity(1L,"conversation","native","session","different","one",1,
|
||||||
|
"provider-id","shell",null,null,null,null,null,null,"owner");
|
||||||
|
assertThatThrownBy(() -> store.begin(duplicateLogical)).isInstanceOf(IllegalStateException.class);
|
||||||
|
}
|
||||||
|
@Test void cursorUsesIdForEqualObservationTimes() {
|
||||||
|
var when = Instant.parse("2026-09-07T12:00:00Z");
|
||||||
|
for (int i=0; i<3; i++) {
|
||||||
|
var attempt = store.begin(identity("invocation-" + i));
|
||||||
|
var observation = new EvidenceObservation("return",EvidenceKind.TOOL_RETURNED,EvidenceResult.OBSERVED,
|
||||||
|
SourceLevel.PLATFORM_OBSERVED,null,null,null,null,null,null,null,null,"ok",null,when,null);
|
||||||
|
store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation));
|
||||||
|
}
|
||||||
|
var firstPage = store.list(1L,"conversation",null,null,2);
|
||||||
|
var last = firstPage.getLast();
|
||||||
|
assertThat(store.list(1L,"conversation",last.observation().observedAt(),last.id(),2)).hasSize(1)
|
||||||
|
.doesNotContainAnyElementsOf(firstPage);
|
||||||
|
}
|
||||||
|
@Test void retentionPurgesOnlyBoundedOldUnreferencedFinishedAttempts() {
|
||||||
|
var pinned = store.begin(identity("pinned"));
|
||||||
|
var pinnedEvidence = store.finish(pinned.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,
|
||||||
|
List.of(observation("pinned"))).getFirst();
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO mate_goal_criterion_evidence
|
||||||
|
(id,workspace_id,goal_id,criterion_id,criterion_revision,evidence_id)
|
||||||
|
VALUES (1,1,10,'criterion',1,?)
|
||||||
|
""", pinnedEvidence.id());
|
||||||
|
var active = store.begin(identity("active"));
|
||||||
|
for (int i=0;i<2;i++) {
|
||||||
|
var attempt = store.begin(identity("expired-" + i));
|
||||||
|
store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation("old")));
|
||||||
|
}
|
||||||
|
jdbc.update("UPDATE mate_execution_attempt SET update_time=TIMESTAMP '2020-01-01 00:00:00'");
|
||||||
|
jdbc.update("UPDATE mate_execution_evidence SET observed_at=TIMESTAMP '2020-01-01 00:00:00'");
|
||||||
|
var recent = store.begin(identity("recent"));
|
||||||
|
assertThat(store.purgeExpiredMetadata(Instant.now(),1)).isEqualTo(1);
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mate_execution_attempt",Integer.class)).isEqualTo(4);
|
||||||
|
assertThat(store.findById(pinnedEvidence.id())).isPresent();
|
||||||
|
assertThat(store.findAttempt(active.id())).isPresent();
|
||||||
|
assertThat(store.findAttempt(recent.id())).isPresent();
|
||||||
|
assertThat(store.purgeExpiredMetadata(Instant.now(),10000)).isEqualTo(1);
|
||||||
|
assertThat(store.purgeExpiredMetadata(Instant.now(),100)).isZero();
|
||||||
|
}
|
||||||
|
@Test void privacyDeletionClearsContentAndBlocksLateReceiptsWhileKeepingBindings() {
|
||||||
|
var attempt = store.begin(identity("finished"));
|
||||||
|
var observation = new EvidenceObservation("return",EvidenceKind.ARTIFACT_SNAPSHOT,EvidenceResult.OBSERVED,
|
||||||
|
SourceLevel.PLATFORM_OBSERVED,1L,1L,"input-digest","recipe",1L,"path", "file-id","digest",
|
||||||
|
"private summary","payload",Instant.now(),null);
|
||||||
|
var evidence = store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation)).getFirst();
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO mate_goal_criterion_evidence
|
||||||
|
(id,workspace_id,goal_id,criterion_id,criterion_revision,evidence_id)
|
||||||
|
VALUES (1,1,10,'criterion',1,?)
|
||||||
|
""", evidence.id());
|
||||||
|
var active = store.begin(identity("active"));
|
||||||
|
assertThat(store.purgeConversation("conversation")).isEqualTo(1);
|
||||||
|
assertThat(store.findById(evidence.id())).isEmpty();
|
||||||
|
assertThat(store.list(1L,"conversation",null,null,20)).isEmpty();
|
||||||
|
assertThat(jdbc.queryForMap("""
|
||||||
|
SELECT summary,artifact_ref,artifact_digest,payload_ref,input_fingerprint,check_scope
|
||||||
|
FROM mate_execution_evidence WHERE id=?
|
||||||
|
""",evidence.id()).values()).containsOnlyNulls();
|
||||||
|
assertThat(jdbc.queryForObject("SELECT state FROM mate_execution_attempt WHERE id=?",String.class,active.id())).isEqualTo("UNKNOWN");
|
||||||
|
assertThatThrownBy(() -> store.finish(active.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,
|
||||||
|
List.of(observation("late")))).isInstanceOf(IllegalStateException.class);
|
||||||
|
assertThatThrownBy(() -> store.begin(identity("active"))).isInstanceOf(IllegalStateException.class);
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_criterion_evidence",Integer.class)).isEqualTo(1);
|
||||||
|
assertThat(store.purgeConversation("conversation")).isZero();
|
||||||
|
}
|
||||||
|
@Test void mysqlMigrationUsesSupportedTimestampDefaults() throws Exception {
|
||||||
|
String migration = new ClassPathResource("db/migration/mysql/V191__execution_evidence_ledger.sql")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertThat(migration).doesNotContain("CURRENT_DATETIME").contains("DEFAULT CURRENT_TIMESTAMP(6)");
|
||||||
|
}
|
||||||
|
@Test void reservationAuthorizesOnlyOneInserterAcrossConcurrentConnections() throws Exception {
|
||||||
|
var ready = new CountDownLatch(2);
|
||||||
|
var start = new CountDownLatch(1);
|
||||||
|
try (var executor = Executors.newFixedThreadPool(2)) {
|
||||||
|
Callable<BeginResult> reserve = () -> {
|
||||||
|
ready.countDown();
|
||||||
|
start.await();
|
||||||
|
return store.reserve(identity("shared"));
|
||||||
|
};
|
||||||
|
var first = executor.submit(reserve);
|
||||||
|
var second = executor.submit(reserve);
|
||||||
|
ready.await();
|
||||||
|
start.countDown();
|
||||||
|
var results = List.of(first.get(),second.get());
|
||||||
|
assertThat(results).filteredOn(BeginResult::created).hasSize(1);
|
||||||
|
assertThat(results.getFirst().attempt().id()).isEqualTo(results.getLast().attempt().id());
|
||||||
|
assertThat(store.reserve(identity("shared")).created()).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Test void concurrentWritersReturnOneAuthoritativeReceipt() throws Exception {
|
||||||
|
var attempt = store.begin(identity("one"));
|
||||||
|
try (var executor = Executors.newFixedThreadPool(2)) {
|
||||||
|
var tasks = List.<Callable<List<ExecutionEvidence>>>of(
|
||||||
|
() -> store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation("ok"))),
|
||||||
|
() -> store.finish(attempt.id(),"owner",AttemptState.SUCCEEDED,EffectOutcome.NONE,List.of(observation("ok"))));
|
||||||
|
var results = executor.invokeAll(tasks);
|
||||||
|
assertThat(results.get(0).get()).isEqualTo(results.get(1).get());
|
||||||
|
}
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from mate_execution_evidence",Integer.class)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
package vip.mate.execution.evidence;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.junit.jupiter.api.condition.EnabledOnOs;
|
||||||
|
import org.junit.jupiter.api.condition.OS;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.execution.evidence.model.AttemptState;
|
||||||
|
import vip.mate.execution.evidence.model.EvidenceKind;
|
||||||
|
import vip.mate.execution.evidence.model.EvidenceResult;
|
||||||
|
import vip.mate.execution.evidence.service.ExecutionObservationSink;
|
||||||
|
import vip.mate.i18n.I18nService;
|
||||||
|
import vip.mate.tool.builtin.ShellExecuteTool;
|
||||||
|
import vip.mate.tool.document.GeneratedFileCache;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
@EnabledOnOs({OS.LINUX, OS.MAC})
|
||||||
|
class TrustedExecutionObservationTest {
|
||||||
|
@TempDir Path root;
|
||||||
|
|
||||||
|
@Test void nonzeroExitIsFailureEvenWhenCallbackReturnsJson() {
|
||||||
|
var sink = new ExecutionObservationSink(false);
|
||||||
|
shell().execute_shell_command("exit 7", 5, context(sink));
|
||||||
|
assertEquals(AttemptState.FAILED, sink.state());
|
||||||
|
assertEquals(root.toAbsolutePath().toString(), sink.observations().getFirst().checkScope());
|
||||||
|
assertTrue(sink.observations().stream().anyMatch(e -> e.kind() == EvidenceKind.COMMAND_EXIT
|
||||||
|
&& e.result() == EvidenceResult.FAIL && e.summary().contains("7")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void echoingTestPassDoesNotIssueCheckEvidence() {
|
||||||
|
var sink = new ExecutionObservationSink(false);
|
||||||
|
shell().execute_shell_command("echo 'CHECK_PASSED tests=99'", 5, context(sink));
|
||||||
|
assertEquals(AttemptState.SUCCEEDED, sink.state());
|
||||||
|
assertTrue(sink.observations().stream().noneMatch(e -> e.kind() == EvidenceKind.CHECK_RESULT
|
||||||
|
|| e.result() == EvidenceResult.PASS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void timeoutIsUnknownAndNeverPasses() {
|
||||||
|
var sink = new ExecutionObservationSink(false);
|
||||||
|
shell().execute_shell_command("sleep 3", 1, context(sink));
|
||||||
|
assertEquals(AttemptState.UNKNOWN, sink.state());
|
||||||
|
assertEquals(EvidenceResult.UNKNOWN, sink.observations().getFirst().result());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void interruptedProcessRecordsCancellation() throws Exception {
|
||||||
|
var sink = new ExecutionObservationSink(false);
|
||||||
|
try (var executor = Executors.newSingleThreadExecutor()) {
|
||||||
|
var result = executor.submit(() -> {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
shell().execute_shell_command("sleep 2", 5, context(sink));
|
||||||
|
Thread.interrupted();
|
||||||
|
});
|
||||||
|
result.get(10, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
assertEquals(AttemptState.CANCELLED, sink.state());
|
||||||
|
assertTrue(sink.observations().stream().noneMatch(e -> e.result() == EvidenceResult.PASS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void directResultsNeverProduceContentObservations() {
|
||||||
|
var sink = new ExecutionObservationSink(true);
|
||||||
|
ToolContext ctx = context(sink);
|
||||||
|
shell().execute_shell_command("echo 'top-secret'", 5, ctx);
|
||||||
|
new GeneratedFileCache(root.resolve("cache")).put("top-secret".getBytes(), "secret.txt", "text/plain", ctx);
|
||||||
|
assertTrue(sink.observations().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void onlyDurablyReadableArtifactsProduceSnapshots() throws Exception {
|
||||||
|
var sink = new ExecutionObservationSink(false);
|
||||||
|
var cache = new GeneratedFileCache(root.resolve("cache"));
|
||||||
|
String id = cache.put("report".getBytes(), "report.txt", "text/plain", context(sink));
|
||||||
|
var evidence = sink.observations().getFirst();
|
||||||
|
assertEquals(EvidenceKind.ARTIFACT_SNAPSHOT, evidence.kind());
|
||||||
|
assertEquals(id, evidence.artifactRef());
|
||||||
|
assertEquals(64, evidence.artifactDigest().length());
|
||||||
|
assertEquals(EvidenceResult.OBSERVED, evidence.result());
|
||||||
|
assertTrue(new GeneratedFileCache(root.resolve("cache")).get(id).isPresent());
|
||||||
|
|
||||||
|
Path bad = Files.writeString(root.resolve("not-a-directory"), "x");
|
||||||
|
var missing = new ExecutionObservationSink(false);
|
||||||
|
new GeneratedFileCache(bad).put("report".getBytes(), "report.txt", "text/plain", context(missing));
|
||||||
|
assertTrue(missing.observations().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShellExecuteTool shell() {
|
||||||
|
return new ShellExecuteTool(mock(I18nService.class), new GeneratedFileCache(root.resolve("cache")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ToolContext context(ExecutionObservationSink sink) {
|
||||||
|
return sink.attach(ChatOrigin.web("evidence-test", "owner", 1L, root.toString()).toToolContext());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -64,7 +64,7 @@ class TeamWorkerInterventionServiceTest {
|
|||||||
when(taskService.stageToolReplayResult(101L, "pending-42", "tool completed")).thenReturn(true);
|
when(taskService.stageToolReplayResult(101L, "pending-42", "tool completed")).thenReturn(true);
|
||||||
when(approvalService.restoreChatOrigin(null)).thenReturn(ChatOrigin.EMPTY);
|
when(approvalService.restoreChatOrigin(null)).thenReturn(ChatOrigin.EMPTY);
|
||||||
when(agentService.chatWithReplayWithUsage(eq(201L), any(), eq("worker-101"),
|
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"));
|
.thenReturn(AgentService.ChatResult.contentOnly("tool completed"));
|
||||||
|
|
||||||
service.approve(7L, 101L, "pending-42", "alice");
|
service.approve(7L, 101L, "pending-42", "alice");
|
||||||
|
|||||||
21
mateclaw-ui/src/api/__tests__/executionEvidence.test.ts
Normal file
21
mateclaw-ui/src/api/__tests__/executionEvidence.test.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { http } from '@/api/index'
|
||||||
|
import { executionEvidenceApi } from '@/api/executionEvidence'
|
||||||
|
|
||||||
|
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||||
|
describe('executionEvidenceApi', () => {
|
||||||
|
it('preserves opaque cursors and string IDs on read-only endpoints', () => {
|
||||||
|
const get = vi.spyOn(http, 'get').mockResolvedValue({} as never)
|
||||||
|
const params = { conversationId: 'chat/#one', goalId: '9223372036854775802', teamTaskId: '9223372036854775803', cursor: 'opaque/cursor', limit: 20 }
|
||||||
|
executionEvidenceApi.list(params)
|
||||||
|
executionEvidenceApi.get('9223372036854775804')
|
||||||
|
expect(get).toHaveBeenNthCalledWith(1, '/execution-evidence', { params })
|
||||||
|
expect(get).toHaveBeenNthCalledWith(2, '/execution-evidence/9223372036854775804')
|
||||||
|
})
|
||||||
|
it('preserves the permission code from an R envelope', async () => {
|
||||||
|
vi.stubGlobal('localStorage', { getItem: () => null })
|
||||||
|
await expect(http.get('/execution-evidence', {
|
||||||
|
adapter: async config => ({ data: { code: 403, msg: 'Forbidden', data: null }, status: 200, statusText: 'OK', headers: {}, config }),
|
||||||
|
})).rejects.toMatchObject({ code: 403, message: 'Forbidden' })
|
||||||
|
})
|
||||||
|
})
|
||||||
32
mateclaw-ui/src/api/executionEvidence.ts
Normal file
32
mateclaw-ui/src/api/executionEvidence.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { http } from './index'
|
||||||
|
|
||||||
|
export interface ExecutionEvidence {
|
||||||
|
id: string
|
||||||
|
attemptId: string
|
||||||
|
conversationId: string
|
||||||
|
toolName: string
|
||||||
|
state: 'STARTED' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED' | 'UNKNOWN' | 'BLOCKED'
|
||||||
|
effectOutcome: 'NONE' | 'CONFIRMED' | 'UNCERTAIN'
|
||||||
|
kind: 'TOOL_RETURNED' | 'COMMAND_EXIT' | 'ARTIFACT_SNAPSHOT' | 'CHECK_RESULT'
|
||||||
|
result: 'OBSERVED' | 'PASS' | 'FAIL' | 'UNKNOWN'
|
||||||
|
sourceLevel: string
|
||||||
|
validity: 'UNKNOWN' | 'UNAVAILABLE' | 'STALE' | 'VALID'
|
||||||
|
summary: string | null
|
||||||
|
observedAt: string
|
||||||
|
expiresAt: string | null
|
||||||
|
artifactRef: string | null
|
||||||
|
artifactDigest: string | null
|
||||||
|
checkScope?: string | null
|
||||||
|
}
|
||||||
|
export interface ExecutionEvidencePage { items: ExecutionEvidence[]; nextCursor: string | null }
|
||||||
|
export interface ExecutionEvidenceQuery {
|
||||||
|
conversationId: string
|
||||||
|
goalId?: string
|
||||||
|
teamTaskId?: string
|
||||||
|
cursor?: string
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
export const executionEvidenceApi = {
|
||||||
|
list: (params: ExecutionEvidenceQuery) => http.get<never, { data: ExecutionEvidencePage }>('/execution-evidence', { params }),
|
||||||
|
get: (id: string) => http.get<never, { data: ExecutionEvidence }>(`/execution-evidence/${encodeURIComponent(id)}`),
|
||||||
|
}
|
||||||
@ -63,9 +63,9 @@ http.interceptors.response.use(
|
|||||||
// 403 = authorization failure (e.g. workspace permission denied) → keep session, surface error to caller
|
// 403 = authorization failure (e.g. workspace permission denied) → keep session, surface error to caller
|
||||||
if (data.code === 401) {
|
if (data.code === 401) {
|
||||||
handleAuthFailure()
|
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
|
return data
|
||||||
},
|
},
|
||||||
|
|||||||
@ -42,6 +42,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p v-if="goal.progressSummary" class="gp-goal__gap">{{ goal.progressSummary }}</p>
|
<p v-if="goal.progressSummary" class="gp-goal__gap">{{ goal.progressSummary }}</p>
|
||||||
|
<ExecutionEvidenceList v-if="goal.conversationId" :conversation-id="goal.conversationId" :goal-id="goal.id" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -54,6 +55,7 @@
|
|||||||
import { watch, onMounted, onBeforeUnmount } from 'vue'
|
import { watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { Goal } from '@/api'
|
import type { Goal } from '@/api'
|
||||||
|
import ExecutionEvidenceList from '@/components/execution/ExecutionEvidenceList.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
|
|||||||
115
mateclaw-ui/src/components/execution/ExecutionEvidenceList.vue
Normal file
115
mateclaw-ui/src/components/execution/ExecutionEvidenceList.vue
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { executionEvidenceApi, type ExecutionEvidence } from '@/api/executionEvidence'
|
||||||
|
|
||||||
|
const props = defineProps<{ conversationId: string; goalId?: string; teamTaskId?: string }>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const expanded = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorKey = ref('')
|
||||||
|
const items = ref<ExecutionEvidence[]>([])
|
||||||
|
const nextCursor = ref<string | null>(null)
|
||||||
|
let generation = 0
|
||||||
|
|
||||||
|
async function load(more = false) {
|
||||||
|
if (loading.value || !props.conversationId) return
|
||||||
|
const request = ++generation
|
||||||
|
loading.value = true
|
||||||
|
errorKey.value = ''
|
||||||
|
try {
|
||||||
|
const { data } = await executionEvidenceApi.list({
|
||||||
|
conversationId: props.conversationId, goalId: props.goalId, teamTaskId: props.teamTaskId,
|
||||||
|
cursor: more ? nextCursor.value ?? undefined : undefined, limit: 20,
|
||||||
|
})
|
||||||
|
if (request !== generation) return
|
||||||
|
const merged = new Map((more ? items.value : []).map(item => [item.id, item]))
|
||||||
|
data.items.forEach(item => merged.set(item.id, item))
|
||||||
|
items.value = [...merged.values()]
|
||||||
|
nextCursor.value = data.nextCursor
|
||||||
|
loaded.value = true
|
||||||
|
} catch (error) {
|
||||||
|
if (request !== generation) return
|
||||||
|
const failure = error as { code?: number; response?: { status?: number } }
|
||||||
|
const code = failure.response?.status ?? failure.code
|
||||||
|
errorKey.value = code === 401 || code === 403 ? 'executionEvidence.accessError' : 'executionEvidence.loadError'
|
||||||
|
} finally {
|
||||||
|
if (request === generation) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function toggle() {
|
||||||
|
expanded.value = !expanded.value
|
||||||
|
if (expanded.value && !loaded.value) void load()
|
||||||
|
}
|
||||||
|
watch(() => [props.conversationId, props.goalId, props.teamTaskId], () => {
|
||||||
|
generation++
|
||||||
|
items.value = []
|
||||||
|
nextCursor.value = null
|
||||||
|
errorKey.value = ''
|
||||||
|
loaded.value = false
|
||||||
|
loading.value = false
|
||||||
|
if (expanded.value) void load()
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => { generation++ })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="execution-evidence">
|
||||||
|
<button type="button" data-evidence-toggle :aria-expanded="expanded" @click="toggle">
|
||||||
|
{{ expanded ? '▾' : '▸' }} {{ t('executionEvidence.title') }}
|
||||||
|
</button>
|
||||||
|
<div v-if="expanded" class="execution-evidence__body" :aria-busy="loading">
|
||||||
|
<p class="execution-evidence__notice">{{ t('executionEvidence.observationOnly') }}</p>
|
||||||
|
<button type="button" :disabled="loading" @click="load()">{{ t('executionEvidence.refresh') }}</button>
|
||||||
|
<p v-if="errorKey" role="alert">{{ t(errorKey) }}</p>
|
||||||
|
<p v-if="loading" role="status">{{ t('common.loading') }}</p>
|
||||||
|
<p v-else-if="!errorKey && loaded && !items.length">{{ t('executionEvidence.empty') }}</p>
|
||||||
|
<ol class="execution-evidence__list">
|
||||||
|
<li v-for="item in items" :key="item.id" :data-evidence-item="item.id">
|
||||||
|
<strong>{{ item.toolName }}</strong>
|
||||||
|
<span class="execution-evidence__kind">{{ t(`executionEvidence.kind.${item.kind}`) }}</span>
|
||||||
|
<p v-if="item.summary" class="execution-evidence__summary">{{ item.summary }}</p>
|
||||||
|
<dl>
|
||||||
|
<div><dt>{{ t('executionEvidence.stateLabel') }}</dt><dd>{{ t(`executionEvidence.state.${item.state}`) }}</dd></div>
|
||||||
|
<div><dt>{{ t('executionEvidence.resultLabel') }}</dt><dd>{{ t(`executionEvidence.result.${item.result}`) }}</dd></div>
|
||||||
|
<div><dt>{{ t('executionEvidence.effectLabel') }}</dt><dd>{{ t(`executionEvidence.effect.${item.effectOutcome}`) }}</dd></div>
|
||||||
|
<div><dt>{{ t('executionEvidence.validityLabel') }}</dt><dd>{{ t(`executionEvidence.validity.${item.validity}`) }}</dd></div>
|
||||||
|
<div v-if="item.checkScope"><dt>{{ t('executionEvidence.scope') }}</dt><dd>{{ item.checkScope }}</dd></div>
|
||||||
|
<div><dt>{{ t('executionEvidence.source') }}</dt><dd>{{ item.sourceLevel }}</dd></div>
|
||||||
|
<div><dt>{{ t('executionEvidence.observedAt') }}</dt><dd><time :datetime="item.observedAt">{{ item.observedAt }}</time></dd></div>
|
||||||
|
<div v-if="item.expiresAt"><dt>{{ t('executionEvidence.expiresAt') }}</dt><dd><time :datetime="item.expiresAt">{{ item.expiresAt }}</time></dd></div>
|
||||||
|
</dl>
|
||||||
|
<details>
|
||||||
|
<summary>{{ t('executionEvidence.details') }}</summary>
|
||||||
|
<dl>
|
||||||
|
<div><dt>{{ t('executionEvidence.id') }}</dt><dd>{{ item.id }}</dd></div>
|
||||||
|
<div><dt>{{ t('executionEvidence.attemptId') }}</dt><dd>{{ item.attemptId }}</dd></div>
|
||||||
|
<div v-if="item.artifactRef"><dt>{{ t('executionEvidence.artifactRef') }}</dt><dd>{{ item.artifactRef }}</dd></div>
|
||||||
|
<div v-if="item.artifactDigest"><dt>{{ t('executionEvidence.digest') }}</dt><dd>{{ item.artifactDigest }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<button v-if="nextCursor" type="button" data-evidence-more :disabled="loading" @click="load(true)">{{ t('executionEvidence.loadMore') }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.execution-evidence { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--mc-border-light); font-size: 12px; color: var(--mc-text-secondary); }
|
||||||
|
button { background: transparent; color: var(--mc-text-primary); border: 1px solid var(--mc-border-light); border-radius: 6px; padding: 6px 9px; cursor: pointer; font: inherit; }
|
||||||
|
button:disabled { opacity: .5; cursor: wait; }
|
||||||
|
button:focus-visible, summary:focus-visible { outline: 2px solid var(--mc-primary); outline-offset: 2px; }
|
||||||
|
.execution-evidence__notice { color: var(--mc-text-tertiary); line-height: 1.6; }
|
||||||
|
.execution-evidence__list { list-style: none; padding: 0; margin: 10px 0; display: grid; gap: 10px; }
|
||||||
|
li { padding: 10px; border: 1px solid var(--mc-border-light); border-radius: 8px; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.execution-evidence__kind { display: block; color: var(--mc-text-tertiary); margin-top: 4px; }
|
||||||
|
.execution-evidence__summary { white-space: pre-wrap; }
|
||||||
|
dl { margin: 8px 0; display: grid; gap: 5px; }
|
||||||
|
dl > div { display: grid; grid-template-columns: minmax(75px, 1fr) minmax(0, 2fr); gap: 8px; }
|
||||||
|
dt { color: var(--mc-text-tertiary); }
|
||||||
|
dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
summary { cursor: pointer; }
|
||||||
|
[role="alert"] { color: var(--mc-danger, #b53535); }
|
||||||
|
</style>
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import { createApp, h, nextTick, reactive } from 'vue'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import ExecutionEvidenceList from '../ExecutionEvidenceList.vue'
|
||||||
|
import { executionEvidenceApi } from '@/api/executionEvidence'
|
||||||
|
import en from '@/i18n/locales/en-US'
|
||||||
|
vi.mock('@/api/executionEvidence', () => ({ executionEvidenceApi: { list: vi.fn() } }))
|
||||||
|
const apps: ReturnType<typeof createApp>[] = []
|
||||||
|
const row = (id: string) => ({ id, attemptId: '9223372036854775801', conversationId: 'one', toolName: 'execCommand', state: 'SUCCEEDED', effectOutcome: 'CONFIRMED', kind: 'COMMAND_EXIT', result: 'OBSERVED', sourceLevel: 'RUNTIME', validity: 'UNKNOWN', summary: 'Exit code: 0', observedAt: '2026-09-07T12:00:00Z', expiresAt: null, artifactRef: null, artifactDigest: null })
|
||||||
|
async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() }
|
||||||
|
function mount() {
|
||||||
|
const props = reactive({ conversationId: 'one', goalId: '9223372036854775802' })
|
||||||
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
|
const app = createApp({ render: () => h(ExecutionEvidenceList, props) })
|
||||||
|
app.use(createI18n({ legacy: false, locale: 'en', messages: { en } })); app.mount(host); apps.push(app)
|
||||||
|
return { host, props }
|
||||||
|
}
|
||||||
|
afterEach(() => { apps.splice(0).forEach(a => a.unmount()); document.body.innerHTML = ''; vi.resetAllMocks() })
|
||||||
|
describe('execution evidence', () => {
|
||||||
|
it('loads lazily, labels command exits as observations, and deduplicates exact string IDs across pages', async () => {
|
||||||
|
vi.mocked(executionEvidenceApi.list).mockResolvedValueOnce({ data: { items: [row('9223372036854775803')], nextCursor: 'next' } } as never).mockResolvedValueOnce({ data: { items: [row('9223372036854775803'), row('9223372036854775804')], nextCursor: null } } as never)
|
||||||
|
const { host } = mount(); expect(executionEvidenceApi.list).not.toHaveBeenCalled()
|
||||||
|
host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
|
||||||
|
expect(host.textContent).toContain('Observed'); expect(host.textContent).toContain('does not verify'); expect(host.textContent).toContain('Exit code: 0')
|
||||||
|
expect(host.textContent).not.toContain('Check passed')
|
||||||
|
host.querySelector<HTMLButtonElement>('[data-evidence-more]')!.click(); await flush()
|
||||||
|
expect(host.querySelectorAll('[data-evidence-item]')).toHaveLength(2)
|
||||||
|
expect(host.textContent).toContain('9223372036854775804')
|
||||||
|
expect(executionEvidenceApi.list).toHaveBeenLastCalledWith({ conversationId: 'one', goalId: '9223372036854775802', teamTaskId: undefined, cursor: 'next', limit: 20 })
|
||||||
|
})
|
||||||
|
it('shows permission failure separately from empty results', async () => {
|
||||||
|
vi.mocked(executionEvidenceApi.list).mockRejectedValue({ code: 403 })
|
||||||
|
const { host } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
|
||||||
|
expect(host.querySelector('[role="alert"]')?.textContent).toContain('permission')
|
||||||
|
expect(host.textContent).not.toContain('No execution evidence')
|
||||||
|
})
|
||||||
|
it('ignores a stale response after the selected conversation changes', async () => {
|
||||||
|
let resolve!: (value: unknown) => void
|
||||||
|
vi.mocked(executionEvidenceApi.list).mockImplementationOnce(() => new Promise(r => { resolve = r }) as never).mockResolvedValueOnce({ data: { items: [row('new')], nextCursor: null } } as never)
|
||||||
|
const { host, props } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
|
||||||
|
props.conversationId = 'two'; await flush()
|
||||||
|
resolve({ data: { items: [row('old')], nextCursor: null } }); await flush()
|
||||||
|
expect(host.querySelector('[data-evidence-item]')?.getAttribute('data-evidence-item')).toBe('new')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -3,6 +3,7 @@ import { computed, nextTick, ref, watch } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { VideoPause } from '@element-plus/icons-vue'
|
import { VideoPause } from '@element-plus/icons-vue'
|
||||||
import type { TeamRun, TeamRunTask } from '@/api'
|
import type { TeamRun, TeamRunTask } from '@/api'
|
||||||
|
import ExecutionEvidenceList from '@/components/execution/ExecutionEvidenceList.vue'
|
||||||
import TeamRunTaskEvidence from './TeamRunTaskEvidence.vue'
|
import TeamRunTaskEvidence from './TeamRunTaskEvidence.vue'
|
||||||
import TeamRunOutcome from './TeamRunOutcome.vue'
|
import TeamRunOutcome from './TeamRunOutcome.vue'
|
||||||
import TeamRunDeliverables from './TeamRunDeliverables.vue'
|
import TeamRunDeliverables from './TeamRunDeliverables.vue'
|
||||||
@ -139,6 +140,7 @@ async function viewAttentionTask(taskId: string) {
|
|||||||
<dd v-else class="run-detail__result">{{ t('teamRuns.noResult') }}</dd>
|
<dd v-else class="run-detail__result">{{ t('teamRuns.noResult') }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
<ExecutionEvidenceList v-if="selectedTask.conversationId" :conversation-id="selectedTask.conversationId" :team-task-id="selectedTask.id" />
|
||||||
<div v-if="canSendFeedback" class="run-detail__feedback">
|
<div v-if="canSendFeedback" class="run-detail__feedback">
|
||||||
<label :for="`worker-feedback-${selectedTask.id}`">{{ t('teamRuns.workerFeedback') }}</label>
|
<label :for="`worker-feedback-${selectedTask.id}`">{{ t('teamRuns.workerFeedback') }}</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@ -1,4 +1,17 @@
|
|||||||
export default {
|
export default {
|
||||||
|
executionEvidence: {
|
||||||
|
scope: 'Working directory / check scope',
|
||||||
|
title: 'Execution evidence', observationOnly: 'Runtime observations only. A tool return or command exit does not verify the goal or task. Coverage is limited to recorded sources.',
|
||||||
|
refresh: 'Refresh', loadMore: 'Load more', empty: 'No execution evidence recorded.',
|
||||||
|
accessError: 'You need to sign in or have permission to view this evidence.', loadError: 'Could not load execution evidence. Try refreshing.',
|
||||||
|
stateLabel: 'Attempt state', resultLabel: 'Evidence result', effectLabel: 'Effect outcome', validityLabel: 'Validity',
|
||||||
|
source: 'Source', observedAt: 'Observed at', expiresAt: 'Expires at', details: 'Record details', id: 'Evidence ID', attemptId: 'Attempt ID', artifactRef: 'Artifact reference', digest: 'Artifact digest',
|
||||||
|
kind: { TOOL_RETURNED: 'Tool returned', COMMAND_EXIT: 'Command exited', ARTIFACT_SNAPSHOT: 'Artifact snapshot', CHECK_RESULT: 'Check result' },
|
||||||
|
state: { STARTED: 'Started', SUCCEEDED: 'Succeeded', FAILED: 'Failed', CANCELLED: 'Cancelled', UNKNOWN: 'Unknown', BLOCKED: 'Blocked' },
|
||||||
|
result: { OBSERVED: 'Observed', PASS: 'Check passed', FAIL: 'Failed', UNKNOWN: 'Unknown' },
|
||||||
|
effect: { NONE: 'None', CONFIRMED: 'Confirmed', UNCERTAIN: 'Uncertain' },
|
||||||
|
validity: { UNKNOWN: 'Unknown', UNAVAILABLE: 'Unavailable', STALE: 'Stale', VALID: 'Valid' },
|
||||||
|
},
|
||||||
app: {
|
app: {
|
||||||
title: 'MateClaw - AI Assistant',
|
title: 'MateClaw - AI Assistant',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,4 +1,17 @@
|
|||||||
export default {
|
export default {
|
||||||
|
executionEvidence: {
|
||||||
|
scope: '执行目录 / 检查范围',
|
||||||
|
title: '执行证据', observationOnly: '仅展示运行时观察记录。工具返回或命令退出不代表目标或任务已验证。覆盖范围仅限已记录的来源。',
|
||||||
|
refresh: '刷新', loadMore: '加载更多', empty: '暂无执行证据记录。',
|
||||||
|
accessError: '请登录或获取查看此证据的权限。', loadError: '无法加载执行证据,请刷新重试。',
|
||||||
|
stateLabel: '尝试状态', resultLabel: '证据结果', effectLabel: '效果结果', validityLabel: '有效性',
|
||||||
|
source: '来源', observedAt: '观察时间', expiresAt: '过期时间', details: '记录详情', id: '证据 ID', attemptId: '尝试 ID', artifactRef: '产物引用', digest: '产物摘要',
|
||||||
|
kind: { TOOL_RETURNED: '工具已返回', COMMAND_EXIT: '命令已退出', ARTIFACT_SNAPSHOT: '产物快照', CHECK_RESULT: '检查结果' },
|
||||||
|
state: { STARTED: '已开始', SUCCEEDED: '已成功', FAILED: '已失败', CANCELLED: '已取消', UNKNOWN: '未知', BLOCKED: '已阻止' },
|
||||||
|
result: { OBSERVED: '已观察', PASS: '检查通过', FAIL: '失败', UNKNOWN: '未知' },
|
||||||
|
effect: { NONE: '无', CONFIRMED: '已确认', UNCERTAIN: '不确定' },
|
||||||
|
validity: { UNKNOWN: '未知', UNAVAILABLE: '不可用', STALE: '已过期', VALID: '有效' },
|
||||||
|
},
|
||||||
app: {
|
app: {
|
||||||
title: 'MateClaw - AI 助手',
|
title: 'MateClaw - AI 助手',
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user