mateclaw/mateclaw-server/src/main/java/vip/mate/approval/PendingApproval.java
matevip 4011050ceb feat(cron): channel delivery via ChatOrigin + Spring AI ToolContext
Replaces the prior ThreadLocal context plumbing with explicit Spring AI
ToolContext threading carried by an immutable ChatOrigin value object,
so a cron created from inside WeChat (or any IM channel) delivers its
results back to the originating channel.

Architecture
- ChatOrigin / ChannelTarget value objects + per-entry-point factories
  (ChannelChatOriginFactory in vip.mate.channel, CronChatOriginFactory
  in vip.mate.cron — symmetric, no cyclic deps).
- LocaleAwareToolCallback now forwards call(String, ToolContext) and
  getToolMetadata so the decorator chain cannot silently drop the origin.
- AgentService 6-method overhaul + ChatOriginHolder bridge into
  StateGraph buildInitialState which writes CHAT_ORIGIN; ActionNode +
  StepExecutionNode forward it to ToolExecutionExecutor.
- ToolExecutionExecutor builds ToolContext per call; 8/8 tools migrated
  (CronJobTool, WorkspacePathGuard, Video/Image/Browser/ReadFile/Music,
  DelegateAgentTool with parent-origin inheritance).
- CronJobRunner + CronJobLifecycleService 3-segment REQUIRES_NEW model
  (T1 startRun / no-tx runAgent / T2 finishRunAndPublish); ArchUnit
  pins CronJobRunner as @Transactional-free.
- CronResultDelivery Strategy + AbstractCronResultDelivery Template
  with SQL CAS idempotency on mate_cron_job_run.delivery_status —
  replaces the prior process-local Caffeine TTL, cluster-safe.
- CronJobCompletedEvent + @Async @TransactionalEventListener(AFTER_COMMIT);
  cronDeliveryExecutor (core=2, max=4, queue=1000, AbortPolicy + audit).
- CronRunStaleCleanup @Scheduled(5min) sweeps PENDING-15min and
  status='running'-30min in one query each.
- CronJobRunner.wrapWithDeliveryGuard prepends a system note for
  channel-bound crons to suppress hallucinated 'install CLI to send
  WeChat' suggestions.
- ApprovalWorkflowService Memento: persist ChatOrigin snapshot on
  create, restore on replay so cross-restart approvals keep channel
  binding; ChannelMessageRouter + ChatController web-replay both prefer
  the Memento and fall back to fresh-build.
- ChannelManager.sendToChannel 4-arg DeliveryOptions overload;
  ChannelAdapter#proactiveSend default 4-arg pass-through; Slack
  overrides for thread_ts and Telegram overrides for message_thread_id.
- CronJobs UI: read-only 'last delivery' badge driven by
  CronJobMapper.selectListWithDeliveryStatus subquery.

Schema migrations V57/V58/V59 (V56 was already taken by an unrelated
provider migration — Flyway processes versions in order regardless of
gaps):
- V57: mate_cron_job_run delivery_status / target / error + composite
       index (delivery_status, started_at) covering the cleanup sweep.
- V58: mate_cron_job channel_id (indexed) + delivery_config TEXT (JSON
       via MyBatis Plus JacksonTypeHandler).
- V59: mate_tool_approval chat_origin TEXT (Memento).
All idempotent in both H2 (IF NOT EXISTS) and MySQL (INFORMATION_SCHEMA
guard + PREPARE).

ArchUnit guards (test scope, archunit-junit5 1.3.0):
- every concrete vip.mate.* ToolCallback must override
  call(String, ToolContext) — pins the decorator-forward fix.
- CronJobRunner must NOT carry @Transactional on the class or any
  method — pins the 3-segment lifecycle rule.

Tests: 32 new unit tests + 21 regression tests in touched areas, all
53 green:
- ChatOriginTest (6) — value-object invariants + JSON round-trip.
- LocaleAwareToolCallbackToolContextTest (2) — decorator forward.
- DeliveryConfigTest (4) — Jackson round-trip + forward-compat.
- ToolCallbackToolContextForwardArchTest (2) — both ArchUnit guards.
- CronJobRunnerDeliveryGuardTest (3) — channel-cron prefix injection.
- AbstractCronResultDeliveryTest (4) — claim CAS + concurrent CAS.
- ChannelCronResultDeliveryTest (6) — supports / doDeliver / errors.
- ApprovalReplayContinuityTest (5) — Memento round-trip + corrupt
  payload fallback + unknown-field tolerance.

Refs: #25, #16
2026-04-28 21:43:58 +08:00

141 lines
5.5 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.approval;
import java.time.Instant;
/**
* 待审批记录(消息驱动版)
* <p>
* 不再持有 CompletableFuture状态流转由 status 字段驱动。
* 包含工具调用重放所需的全部信息。
*/
public class PendingApproval {
private final String pendingId;
private final String conversationId;
private final String userId;
private final String toolName;
private final String toolArguments;
private final String reason;
private final Instant createdAt;
// === 状态 ===
// pending → approved → consumed / denied / timeout / superseded
private volatile String status;
// === 重放相关字段 ===
/** 发起审批的渠道类型 */
private String channelType;
/** 发送者名称(审计日志) */
private String requesterName;
/** 回复目标标识(飞书 chatId、钉钉 conversationId 等) */
private String replyTarget;
/** 完整的 tool call 载荷JSON用于 replay 重放 */
private String toolCallPayload;
/** 同一轮中其他被阻塞的 tool callsJSON 数组) */
private String siblingToolCalls;
/** Agent ID重放时需要知道用哪个 Agent */
private String agentId;
/** 审批解决时间 */
private Instant resolvedAt;
/** 审批解决者 userId */
private String resolvedBy;
// === 增强字段Phase 2: 结构化风险信息)===
/** Guard findings JSON结构化风险发现列表 */
private String findingsJson;
/** 最高风险等级 */
private String maxSeverity;
/** 风险摘要 */
private String summary;
/**
* RFC-063r §2.12: serialized {@code ChatOrigin} snapshot captured when
* this approval was created. Lets cross-process / cross-restart replays
* (the user approves hours later from a different node) restore the
* original channel binding so the replayed tool call still delivers
* back to the correct channel. Persisted into
* {@code mate_tool_approval.chat_origin}.
*/
private String chatOrigin;
public PendingApproval(String pendingId, String conversationId, String userId,
String toolName, String toolArguments, String reason) {
this.pendingId = pendingId;
this.conversationId = conversationId;
this.userId = userId;
this.toolName = toolName;
this.toolArguments = toolArguments;
this.reason = reason;
this.createdAt = Instant.now();
this.status = "pending";
}
/**
* INTERNAL — recovery constructor for {@code ApprovalWorkflowService.recoverFromDb}.
* Preserves the persisted {@code createdAt} and {@code status} so TTL/GC keep working
* across JVM restarts. Do not use from business paths.
*/
PendingApproval(String pendingId, String conversationId, String userId,
String toolName, String toolArguments, String reason,
Instant createdAt, String status) {
this.pendingId = pendingId;
this.conversationId = conversationId;
this.userId = userId;
this.toolName = toolName;
this.toolArguments = toolArguments;
this.reason = reason;
this.createdAt = createdAt;
this.status = status;
}
// === Getters ===
public String getPendingId() { return pendingId; }
public String getConversationId() { return conversationId; }
public String getUserId() { return userId; }
public String getToolName() { return toolName; }
public String getToolArguments() { return toolArguments; }
public String getReason() { return reason; }
public Instant getCreatedAt() { return createdAt; }
public String getStatus() { return status; }
public String getChannelType() { return channelType; }
public String getRequesterName() { return requesterName; }
public String getReplyTarget() { return replyTarget; }
public String getToolCallPayload() { return toolCallPayload; }
public String getSiblingToolCalls() { return siblingToolCalls; }
public String getAgentId() { return agentId; }
public Instant getResolvedAt() { return resolvedAt; }
public String getResolvedBy() { return resolvedBy; }
public String getFindingsJson() { return findingsJson; }
public String getMaxSeverity() { return maxSeverity; }
public String getSummary() { return summary; }
public String getChatOrigin() { return chatOrigin; }
// === Setters ===
public void setStatus(String status) { this.status = status; }
public void setChannelType(String channelType) { this.channelType = channelType; }
public void setRequesterName(String requesterName) { this.requesterName = requesterName; }
public void setReplyTarget(String replyTarget) { this.replyTarget = replyTarget; }
public void setToolCallPayload(String toolCallPayload) { this.toolCallPayload = toolCallPayload; }
public void setSiblingToolCalls(String siblingToolCalls) { this.siblingToolCalls = siblingToolCalls; }
public void setAgentId(String agentId) { this.agentId = agentId; }
public void setResolvedAt(Instant resolvedAt) { this.resolvedAt = resolvedAt; }
public void setResolvedBy(String resolvedBy) { this.resolvedBy = resolvedBy; }
public void setFindingsJson(String findingsJson) { this.findingsJson = findingsJson; }
public void setMaxSeverity(String maxSeverity) { this.maxSeverity = maxSeverity; }
public void setSummary(String summary) { this.summary = summary; }
public void setChatOrigin(String chatOrigin) { this.chatOrigin = chatOrigin; }
}