mateclaw/mateclaw-server/src/main/java/vip/mate/cron/delivery/ChannelCronResultDelivery.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

56 lines
2.3 KiB
Java

package vip.mate.cron.delivery;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import vip.mate.channel.ChannelManager;
import vip.mate.channel.DeliveryOptions;
import vip.mate.cron.model.CronJobEntity;
import vip.mate.cron.model.DeliveryConfig;
import vip.mate.dashboard.model.CronJobRunEntity;
import vip.mate.dashboard.repository.CronJobRunMapper;
import java.util.Map;
/**
* RFC-063r §2.6: deliver a cron job's assistant result back to its
* originating IM channel via {@link ChannelManager#sendToChannel}.
*
* <p>{@link #supports} returns true only when both {@code channelId} and a
* non-null {@code deliveryConfig.targetId()} are present — web-origin jobs
* (no channelId) and partial bindings fall through and the run stays in
* {@code delivery_status='NONE'}, matching the always-best-effort policy in
* RFC §2.7.3.
*/
@Component
@Order(10)
public class ChannelCronResultDelivery extends AbstractCronResultDelivery {
private final ChannelManager channelManager;
public ChannelCronResultDelivery(CronJobRunMapper runMapper,
ChannelManager channelManager) {
super(runMapper);
this.channelManager = channelManager;
}
@Override
public boolean supports(CronJobEntity job) {
if (job == null || job.getChannelId() == null) return false;
DeliveryConfig dc = job.getDeliveryConfig();
return dc != null && dc.targetId() != null && !dc.targetId().isBlank();
}
@Override
protected DeliveryOutcome doDeliver(CronJobEntity job, AssistantMessage result, CronJobRunEntity run) {
DeliveryConfig dc = job.getDeliveryConfig();
String rendered = renderForChannel(result, job.getChannelId());
// RFC-063r §2.10: forward thread / account hints via DeliveryOptions.
// Adapters that don't override the 4-arg proactiveSend default ignore
// the hints — preserves pre-RFC behavior for non-threading platforms.
DeliveryOptions options = new DeliveryOptions(dc.threadId(), dc.accountId(), Map.of());
channelManager.sendToChannel(job.getChannelId(), dc.targetId(), rendered, options);
return DeliveryOutcome.delivered(dc.targetId());
}
}