mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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
This commit is contained in:
parent
f57b31f379
commit
4011050ceb
@ -367,6 +367,20 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== ArchUnit (RFC-063r §2.3 / §5.2 architecture invariants) =====
|
||||
test-scope only — guards:
|
||||
- every ToolCallback implementation overrides call(String, ToolContext)
|
||||
so decorators (LocaleAwareToolCallback) cannot silently drop ChatOrigin
|
||||
- CronJobRunner (introduced in PR-3) must not carry @Transactional
|
||||
(would silently fail under self-invocation; see RFC §5.2)
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>com.tngtech.archunit</groupId>
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<version>1.3.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@ -7,6 +7,8 @@ import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import reactor.core.publisher.Flux;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.context.ChatOriginHolder;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
@ -98,31 +100,67 @@ public class AgentService {
|
||||
// ==================== 运行时入口 ====================
|
||||
|
||||
public String chat(Long agentId, String message, String conversationId) {
|
||||
return chat(agentId, message, conversationId, ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.5: preferred entry — accepts the originating
|
||||
* {@link ChatOrigin} so channel binding and workspace context propagate
|
||||
* down to {@code @Tool} methods via Spring AI {@link org.springframework.ai.chat.model.ToolContext}.
|
||||
*/
|
||||
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return withLifecycleSync(agentId, message, conversationId,
|
||||
(msg, convId) -> agent.chat(msg, convId));
|
||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
try {
|
||||
return withLifecycleSync(agentId, message, conversationId,
|
||||
(msg, convId) -> agent.chat(msg, convId));
|
||||
} finally {
|
||||
ChatOriginHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public Flux<String> chatStream(Long agentId, String message, String conversationId) {
|
||||
return chatStream(agentId, message, conversationId, ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return withLifecycleFlux(agentId, message, conversationId,
|
||||
(msg, convId) -> agent.chatStream(msg, convId),
|
||||
chunk -> chunk);
|
||||
// Capture the origin into a request-scoped holder; cleared on Flux
|
||||
// termination so the next reactive subscriber doesn't inherit stale state.
|
||||
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||
return Flux.defer(() -> {
|
||||
ChatOriginHolder.set(captured);
|
||||
return withLifecycleFlux(agentId, message, conversationId,
|
||||
(msg, convId) -> agent.chatStream(msg, convId),
|
||||
chunk -> chunk);
|
||||
}).doFinally(signal -> ChatOriginHolder.clear());
|
||||
}
|
||||
|
||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId) {
|
||||
return chatStructuredStream(agentId, message, conversationId, "", null);
|
||||
return chatStructuredStream(agentId, message, conversationId, "", null, ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||
String requesterId) {
|
||||
return chatStructuredStream(agentId, message, conversationId, requesterId, null);
|
||||
return chatStructuredStream(agentId, message, conversationId, requesterId, null, ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||
String requesterId, ChatOrigin origin) {
|
||||
return chatStructuredStream(agentId, message, conversationId, requesterId, null, origin);
|
||||
}
|
||||
|
||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||
String requesterId, String thinkingLevel) {
|
||||
return chatStructuredStream(agentId, message, conversationId, requesterId, thinkingLevel,
|
||||
ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||
String requesterId, String thinkingLevel,
|
||||
ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
|
||||
@ -139,27 +177,45 @@ public class AgentService {
|
||||
}
|
||||
}
|
||||
|
||||
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||
if (agent instanceof StructuredStreamCapable capable) {
|
||||
return withLifecycleFlux(agentId, message, conversationId,
|
||||
(msg, convId) -> capable.chatStructuredStream(msg, convId,
|
||||
requesterId != null ? requesterId : "")
|
||||
.doFinally(signal -> ThinkingLevelHolder.clear()),
|
||||
StreamDelta::content);
|
||||
return Flux.defer(() -> {
|
||||
ChatOriginHolder.set(captured);
|
||||
return withLifecycleFlux(agentId, message, conversationId,
|
||||
(msg, convId) -> capable.chatStructuredStream(msg, convId,
|
||||
requesterId != null ? requesterId : "")
|
||||
.doFinally(signal -> ThinkingLevelHolder.clear()),
|
||||
StreamDelta::content);
|
||||
})
|
||||
.doFinally(signal -> ChatOriginHolder.clear());
|
||||
}
|
||||
|
||||
// 降级:不支持结构化流的 Agent,包装为纯内容流
|
||||
ThinkingLevelHolder.clear();
|
||||
return withLifecycleFlux(agentId, message, conversationId,
|
||||
(msg, convId) -> agent.chatStream(msg, convId)
|
||||
.map(chunk -> new StreamDelta(chunk, null)),
|
||||
StreamDelta::content);
|
||||
return Flux.defer(() -> {
|
||||
ChatOriginHolder.set(captured);
|
||||
return withLifecycleFlux(agentId, message, conversationId,
|
||||
(msg, convId) -> agent.chatStream(msg, convId)
|
||||
.map(chunk -> new StreamDelta(chunk, null)),
|
||||
StreamDelta::content);
|
||||
})
|
||||
.doFinally(signal -> ChatOriginHolder.clear());
|
||||
}
|
||||
|
||||
public String execute(Long agentId, String goal, String conversationId) {
|
||||
return execute(agentId, goal, conversationId, ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, goal);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return withLifecycleSync(agentId, goal, conversationId,
|
||||
(msg, convId) -> agent.execute(msg, convId));
|
||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
try {
|
||||
return withLifecycleSync(agentId, goal, conversationId,
|
||||
(msg, convId) -> agent.execute(msg, convId));
|
||||
} finally {
|
||||
ChatOriginHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -173,10 +229,20 @@ public class AgentService {
|
||||
*/
|
||||
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload) {
|
||||
return chatWithReplay(agentId, userMessage, conversationId, toolCallPayload, ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload, ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return withLifecycleSync(agentId, userMessage, conversationId,
|
||||
(msg, convId) -> agent.chatWithReplay(msg, convId, toolCallPayload));
|
||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
try {
|
||||
return withLifecycleSync(agentId, userMessage, conversationId,
|
||||
(msg, convId) -> agent.chatWithReplay(msg, convId, toolCallPayload));
|
||||
} finally {
|
||||
ChatOriginHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -184,17 +250,29 @@ public class AgentService {
|
||||
*/
|
||||
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload) {
|
||||
return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, "");
|
||||
return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, "", ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload, String requesterId) {
|
||||
return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, requesterId,
|
||||
ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload, String requesterId,
|
||||
ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return withLifecycleFlux(agentId, userMessage, conversationId,
|
||||
(msg, convId) -> agent.chatWithReplayStream(msg, convId, toolCallPayload,
|
||||
requesterId != null ? requesterId : ""),
|
||||
StreamDelta::content);
|
||||
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||
return Flux.defer(() -> {
|
||||
ChatOriginHolder.set(captured);
|
||||
return withLifecycleFlux(agentId, userMessage, conversationId,
|
||||
(msg, convId) -> agent.chatWithReplayStream(msg, convId, toolCallPayload,
|
||||
requesterId != null ? requesterId : ""),
|
||||
StreamDelta::content);
|
||||
})
|
||||
.doFinally(signal -> ChatOriginHolder.clear());
|
||||
}
|
||||
|
||||
public AgentState getAgentState(Long agentId) {
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
package vip.mate.agent.context;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
/**
|
||||
* Channel-bound target identity used when an agent's response must be delivered
|
||||
* back to a specific external channel (cron, IM relay, etc.).
|
||||
*
|
||||
* <p>Kept as a sub-VO of {@link ChatOrigin} so future channel-related fields do
|
||||
* not pollute the top-level origin record.
|
||||
*
|
||||
* <p>Field evolution rule: only-add, do-not-rename, deprecate-for-90-days before
|
||||
* physical removal — see {@link ChatOrigin}'s class doc.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ChannelTarget(
|
||||
@Nullable String targetId,
|
||||
@Nullable String threadId,
|
||||
@Nullable String accountId
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
package vip.mate.agent.context;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Immutable value object that travels alongside an agent invocation describing
|
||||
* <em>where the request came from</em> — channel, conversation, requester,
|
||||
* workspace, and optional delivery target.
|
||||
*
|
||||
* <p>Replaces ad-hoc ThreadLocal threading (RFC-063 v1) with explicit Spring AI
|
||||
* {@link ToolContext} carriage (RFC-063r §2.1). The wither-style API enables
|
||||
* the agent runtime to enrich the origin (agentId, workspace) without mutation.
|
||||
*
|
||||
* <h2>Field evolution rule</h2>
|
||||
* <ul>
|
||||
* <li>Only add — never delete; deprecate at least 90 days (covers approval TTL)
|
||||
* before physical removal.</li>
|
||||
* <li>Never rename — add a new field plus deprecate-old-field, double-write
|
||||
* during the migration window.</li>
|
||||
* <li>{@link JsonIgnoreProperties#ignoreUnknown()} guards forward/backward
|
||||
* compatibility when older approval rows are deserialized after upgrades.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ChatOrigin(
|
||||
@Nullable Long agentId,
|
||||
@Nullable String conversationId,
|
||||
@Nullable String requesterId,
|
||||
@Nullable Long workspaceId,
|
||||
@Nullable String workspaceBasePath,
|
||||
@Nullable Long channelId,
|
||||
@Nullable ChannelTarget channelTarget
|
||||
) {
|
||||
|
||||
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
|
||||
public static final String CTX_KEY = "mateclaw.chatOrigin";
|
||||
|
||||
/** Sentinel used by AgentService default overloads where no origin is supplied. */
|
||||
public static final ChatOrigin EMPTY =
|
||||
new ChatOrigin(null, null, "", null, null, null, null);
|
||||
|
||||
// ---------------- Factories per entry point ----------------
|
||||
|
||||
public static ChatOrigin web(@Nullable String conversationId,
|
||||
@Nullable String requesterId,
|
||||
@Nullable Long workspaceId,
|
||||
@Nullable String workspaceBasePath) {
|
||||
return new ChatOrigin(null, conversationId,
|
||||
requesterId != null ? requesterId : "",
|
||||
workspaceId, workspaceBasePath, null, null);
|
||||
}
|
||||
|
||||
public static ChatOrigin cron(@Nullable String conversationId,
|
||||
@Nullable Long workspaceId,
|
||||
@Nullable String workspaceBasePath,
|
||||
@Nullable Long channelId,
|
||||
@Nullable ChannelTarget target) {
|
||||
return new ChatOrigin(null, conversationId, "system",
|
||||
workspaceId, workspaceBasePath, channelId, target);
|
||||
}
|
||||
|
||||
// ---------------- Wither-style updates ----------------
|
||||
|
||||
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
||||
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget);
|
||||
}
|
||||
|
||||
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||
@Nullable String newWorkspaceBasePath) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget);
|
||||
}
|
||||
|
||||
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
||||
return new ChatOrigin(agentId, newConversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget);
|
||||
}
|
||||
|
||||
// ---------------- Spring AI ToolContext interop ----------------
|
||||
|
||||
/** Wrap this origin into a Spring AI {@link ToolContext} the runtime can pass to tools. */
|
||||
public ToolContext toToolContext() {
|
||||
return new ToolContext(Map.of(CTX_KEY, this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a {@link ChatOrigin} stored under {@link #CTX_KEY} in the given
|
||||
* {@link ToolContext}. Returns {@link #EMPTY} when {@code ctx} is null, has
|
||||
* no entry, or the value is not a ChatOrigin (defensive — keeps single-tool
|
||||
* callers safe even if wiring is partial).
|
||||
*/
|
||||
public static ChatOrigin from(@Nullable ToolContext ctx) {
|
||||
if (ctx == null) return EMPTY;
|
||||
Object v = ctx.getContext().get(CTX_KEY);
|
||||
return v instanceof ChatOrigin co ? co : EMPTY;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package vip.mate.agent.context;
|
||||
|
||||
/**
|
||||
* Request-scoped {@link ChatOrigin} bridge between {@code AgentService}'s
|
||||
* public entry points and the StateGraph's {@code buildInitialState}.
|
||||
*
|
||||
* <p>RFC-063r §2.5 carries the origin end-to-end via Spring AI {@code ToolContext}
|
||||
* once it lands in graph state. This holder is the small bridge that gets the
|
||||
* origin from the AgentService method invocation into the graph's initial
|
||||
* state map — the holder lifecycle is bounded by the AgentService method
|
||||
* call (set on entry, cleared in {@code finally}). Once written into the
|
||||
* graph state under {@link vip.mate.agent.graph.state.MateClawStateKeys#CHAT_ORIGIN},
|
||||
* the rest of the runtime reads via the typed accessor — no further ThreadLocal
|
||||
* access. Mirrors {@link vip.mate.agent.ThinkingLevelHolder}.
|
||||
*/
|
||||
public final class ChatOriginHolder {
|
||||
|
||||
private static final ThreadLocal<ChatOrigin> HOLDER = new ThreadLocal<>();
|
||||
|
||||
private ChatOriginHolder() {
|
||||
}
|
||||
|
||||
/** Set the origin for the current AgentService invocation. */
|
||||
public static void set(ChatOrigin origin) {
|
||||
HOLDER.set(origin);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the origin set for the current invocation, or {@link ChatOrigin#EMPTY}
|
||||
* when no entry path has supplied one (legacy callers).
|
||||
*/
|
||||
public static ChatOrigin get() {
|
||||
ChatOrigin v = HOLDER.get();
|
||||
return v != null ? v : ChatOrigin.EMPTY;
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
HOLDER.remove();
|
||||
}
|
||||
}
|
||||
@ -410,6 +410,19 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
||||
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
||||
inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
||||
|
||||
// RFC-063r §2.5: enrich the originating ChatOrigin with this agent's id
|
||||
// and workspace, then write it into graph state so ActionNode +
|
||||
// StepExecutionNode can forward it to ToolExecutionExecutor → ToolContext.
|
||||
vip.mate.agent.context.ChatOrigin origin = vip.mate.agent.context.ChatOriginHolder.get();
|
||||
Long parsedAgentIdForOrigin = null;
|
||||
try { parsedAgentIdForOrigin = agentId != null ? Long.valueOf(agentId) : null; } catch (Exception ignored) {}
|
||||
if (parsedAgentIdForOrigin != null) {
|
||||
origin = origin.withAgent(parsedAgentIdForOrigin);
|
||||
}
|
||||
origin = origin.withConversationId(conversationId)
|
||||
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
||||
inputs.put(CHAT_ORIGIN, origin);
|
||||
return inputs;
|
||||
}
|
||||
|
||||
|
||||
@ -4,10 +4,12 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||
import vip.mate.approval.ApprovalWorkflowService;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
@ -202,6 +204,8 @@ public class ToolExecutionExecutor {
|
||||
private volatile String currentRequesterId;
|
||||
/** 当前工作区活动目录(为空不限制),传递给 ToolExecutionContext */
|
||||
private volatile String currentWorkspaceBasePath;
|
||||
/** RFC-063r §2.5: 当前执行的 ChatOrigin,构建 ToolContext 时透传给工具 */
|
||||
private volatile ChatOrigin currentChatOrigin = ChatOrigin.EMPTY;
|
||||
|
||||
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
|
||||
String conversationId, String agentId,
|
||||
@ -213,8 +217,30 @@ public class ToolExecutionExecutor {
|
||||
String conversationId, String agentId,
|
||||
boolean isReplay, String requesterId,
|
||||
String workspaceBasePath) {
|
||||
return execute(toolCalls, conversationId, agentId, isReplay, requesterId,
|
||||
workspaceBasePath, ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.5: preferred overload — accepts a {@link ChatOrigin} that the
|
||||
* top-level agent has enriched with agentId/workspace/channel context.
|
||||
* Builds a Spring AI {@link ToolContext} per tool invocation so
|
||||
* {@code @Tool} methods can read the origin via
|
||||
* {@code ChatOrigin.from(toolContext)}.
|
||||
*
|
||||
* <p>During the PR-1 transition the legacy {@link ToolExecutionContext}
|
||||
* ThreadLocal is also populated, so existing tools that read from it keep
|
||||
* working unchanged. After all 8 callsites migrate, the ThreadLocal can be
|
||||
* removed.
|
||||
*/
|
||||
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
|
||||
String conversationId, String agentId,
|
||||
boolean isReplay, String requesterId,
|
||||
String workspaceBasePath,
|
||||
ChatOrigin origin) {
|
||||
this.currentRequesterId = requesterId;
|
||||
this.currentWorkspaceBasePath = workspaceBasePath;
|
||||
this.currentChatOrigin = origin != null ? origin : ChatOrigin.EMPTY;
|
||||
List<ToolResponseMessage.ToolResponse> allResponses = new ArrayList<>();
|
||||
List<GraphEventPublisher.GraphEvent> events = Collections.synchronizedList(new ArrayList<>());
|
||||
// RFC-052: accumulate full-text outputs from returnDirect tools so the
|
||||
@ -309,7 +335,7 @@ public class ToolExecutionExecutor {
|
||||
// 4. 分类: concurrencySafe
|
||||
boolean safe = isConcurrencySafe(toolName);
|
||||
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
|
||||
conversationId, currentRequesterId, currentWorkspaceBasePath));
|
||||
conversationId, currentRequesterId, currentWorkspaceBasePath, currentChatOrigin));
|
||||
// 占位,Phase 2 填充
|
||||
allResponses.add(null);
|
||||
}
|
||||
@ -382,7 +408,13 @@ public class ToolExecutionExecutor {
|
||||
|
||||
try {
|
||||
log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName);
|
||||
String result = callback.call(callArguments);
|
||||
// RFC-063r §2.5: forward ToolContext so the pre-approved tool can
|
||||
// still observe the originating ChatOrigin (channel/workspace).
|
||||
ChatOrigin replayOrigin = currentChatOrigin != null ? currentChatOrigin : ChatOrigin.EMPTY;
|
||||
replayOrigin = replayOrigin
|
||||
.withConversationId(conversationId)
|
||||
.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
|
||||
String result = callback.call(callArguments, replayOrigin.toToolContext());
|
||||
int rawLen = result != null ? result.length() : 0;
|
||||
|
||||
// RFC-052: pre-approved tool may itself be returnDirect — in that
|
||||
@ -565,11 +597,19 @@ public class ToolExecutionExecutor {
|
||||
toolName, pc.arguments != null && pc.arguments.length() > 200
|
||||
? pc.arguments.substring(0, 200) + "..." : pc.arguments);
|
||||
|
||||
// 注入工具执行上下文(供 VideoGenerateTool 等获取 conversationId / username / workspaceBasePath)
|
||||
// RFC-063r §2.5 / PR-1 transition window: populate BOTH the explicit
|
||||
// Spring AI ToolContext (preferred — read via ChatOrigin.from(ctx))
|
||||
// AND the legacy ToolExecutionContext ThreadLocal so tools that have
|
||||
// not yet migrated to ToolContext keep working unchanged.
|
||||
ToolExecutionContext.set(pc.conversationId, pc.requesterId, pc.workspaceBasePath);
|
||||
String result;
|
||||
try {
|
||||
result = pc.callback.call(pc.arguments);
|
||||
ChatOrigin runtimeOrigin = pc.origin != null ? pc.origin : ChatOrigin.EMPTY;
|
||||
runtimeOrigin = runtimeOrigin
|
||||
.withConversationId(pc.conversationId)
|
||||
.withWorkspace(runtimeOrigin.workspaceId(), pc.workspaceBasePath);
|
||||
ToolContext toolContext = runtimeOrigin.toToolContext();
|
||||
result = pc.callback.call(pc.arguments, toolContext);
|
||||
} finally {
|
||||
ToolExecutionContext.clear();
|
||||
}
|
||||
@ -781,7 +821,8 @@ public class ToolExecutionExecutor {
|
||||
int resultIndex,
|
||||
String conversationId,
|
||||
String requesterId,
|
||||
String workspaceBasePath
|
||||
String workspaceBasePath,
|
||||
ChatOrigin origin
|
||||
) {}
|
||||
|
||||
private record ApprovalBarrier(String pendingId, String toolName) {}
|
||||
|
||||
@ -65,9 +65,13 @@ public class ActionNode implements NodeAction {
|
||||
// 获取工作区活动目录
|
||||
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
||||
|
||||
// RFC-063r §2.5: read the originating ChatOrigin from graph state and
|
||||
// forward it into the executor — tools see it via Spring AI ToolContext.
|
||||
vip.mate.agent.context.ChatOrigin origin = accessor.chatOrigin();
|
||||
|
||||
// 委托 ToolExecutionExecutor 执行(两阶段:顺序 Guard + 分段并发执行)
|
||||
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
|
||||
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath);
|
||||
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin);
|
||||
|
||||
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
|
||||
.responses(result.responses())
|
||||
|
||||
@ -285,6 +285,19 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
||||
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
||||
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
||||
|
||||
// RFC-063r §2.5: same as ReAct path — enrich and store the ChatOrigin
|
||||
// so StepExecutionNode (and any sub-graphs spawned via DelegateAgentTool)
|
||||
// can read it back from state.
|
||||
vip.mate.agent.context.ChatOrigin origin = vip.mate.agent.context.ChatOriginHolder.get();
|
||||
Long parsedAgentIdForOrigin = null;
|
||||
try { parsedAgentIdForOrigin = agentId != null ? Long.valueOf(agentId) : null; } catch (Exception ignored) {}
|
||||
if (parsedAgentIdForOrigin != null) {
|
||||
origin = origin.withAgent(parsedAgentIdForOrigin);
|
||||
}
|
||||
origin = origin.withConversationId(conversationId)
|
||||
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
||||
inputs.put(MateClawStateKeys.CHAT_ORIGIN, origin);
|
||||
return inputs;
|
||||
}
|
||||
|
||||
|
||||
@ -86,6 +86,12 @@ public class StepExecutionNode implements NodeAction {
|
||||
String conversationId = state.value(MateClawStateKeys.CONVERSATION_ID, "");
|
||||
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
|
||||
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
||||
// RFC-063r §2.5: read parent ChatOrigin from graph state so tools in
|
||||
// this step (and any DelegateAgentTool sub-graphs) inherit channel /
|
||||
// workspace / requester context.
|
||||
vip.mate.agent.context.ChatOrigin chatOrigin =
|
||||
state.<vip.mate.agent.context.ChatOrigin>value(MateClawStateKeys.CHAT_ORIGIN)
|
||||
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
|
||||
|
||||
if (stepIndex >= steps.size()) {
|
||||
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
|
||||
@ -196,7 +202,7 @@ public class StepExecutionNode implements NodeAction {
|
||||
} else {
|
||||
// 非预批准工具走正常执行器
|
||||
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
||||
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath);
|
||||
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
|
||||
toolResponses.addAll(execResult.responses());
|
||||
events.addAll(execResult.events());
|
||||
if (execResult.hasDirectOutputs()) {
|
||||
@ -212,7 +218,7 @@ public class StepExecutionNode implements NodeAction {
|
||||
} else {
|
||||
// 正常路径:委托 ToolExecutionExecutor(支持并发执行 + 审批 barrier)
|
||||
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
||||
allToolCalls, conversationId, agentId, false, "", workspaceBasePath);
|
||||
allToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
|
||||
toolResponses.addAll(execResult.responses());
|
||||
events.addAll(execResult.events());
|
||||
if (execResult.hasDirectOutputs()) {
|
||||
|
||||
@ -3,6 +3,7 @@ package vip.mate.agent.graph.state;
|
||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||
|
||||
import java.util.*;
|
||||
@ -212,6 +213,17 @@ public final class MateClawStateAccessor {
|
||||
return state.value(FORCED_TOOL_CALL, "");
|
||||
}
|
||||
|
||||
// ===== RFC-063r: ChatOrigin =====
|
||||
|
||||
/**
|
||||
* RFC-063r §2.5: the {@link ChatOrigin} written into graph state by the
|
||||
* top-level agent. Returns {@link ChatOrigin#EMPTY} when the entry path
|
||||
* did not supply one (e.g., legacy callers using the bridge overloads).
|
||||
*/
|
||||
public ChatOrigin chatOrigin() {
|
||||
return state.<ChatOrigin>value(CHAT_ORIGIN).orElse(ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
// ===== Token Usage =====
|
||||
|
||||
public int promptTokens() {
|
||||
@ -398,6 +410,11 @@ public final class MateClawStateAccessor {
|
||||
return put(FORCED_TOOL_CALL, json);
|
||||
}
|
||||
|
||||
// ---- RFC-063r: ChatOrigin ----
|
||||
public OutputBuilder chatOrigin(ChatOrigin origin) {
|
||||
return put(CHAT_ORIGIN, origin);
|
||||
}
|
||||
|
||||
// ---- Token Usage ----
|
||||
|
||||
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
|
||||
|
||||
@ -155,4 +155,16 @@ public final class MateClawStateKeys {
|
||||
* tool batch, used by FinalAnswerNode to assemble the final answer.
|
||||
*/
|
||||
public static final String DIRECT_TOOL_OUTPUTS = "direct_tool_outputs";
|
||||
|
||||
// ===== RFC-063r: ChatOrigin propagation through the StateGraph =====
|
||||
|
||||
/**
|
||||
* RFC-063r §2.5: top-level agent writes the {@code ChatOrigin} value object
|
||||
* into graph state once at {@code buildInitialState}; nodes (especially
|
||||
* {@code StepExecutionNode} in the Plan-Execute sub-graph) read it
|
||||
* read-only when invoking {@link vip.mate.agent.graph.executor.ToolExecutionExecutor}
|
||||
* so child graphs and delegated agents inherit the originating channel /
|
||||
* workspace context.
|
||||
*/
|
||||
public static final String CHAT_ORIGIN = "chat_origin";
|
||||
}
|
||||
|
||||
@ -15,6 +15,8 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.context.ChatOriginHolder;
|
||||
import vip.mate.approval.model.ToolApprovalEntity;
|
||||
import vip.mate.approval.repository.ToolApprovalMapper;
|
||||
import vip.mate.tool.guard.model.GuardEvaluation;
|
||||
@ -137,6 +139,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
snapshot.setFindingsJson(entity.getFindingsJson());
|
||||
snapshot.setMaxSeverity(entity.getMaxSeverity());
|
||||
snapshot.setSummary(entity.getSummary());
|
||||
snapshot.setChatOrigin(entity.getChatOrigin());
|
||||
|
||||
approvalService.registerRecovered(snapshot);
|
||||
recovered++;
|
||||
@ -200,6 +203,14 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
conversationId, userId, toolName, toolArguments, reason,
|
||||
toolCallPayload, siblingToolCalls, agentId);
|
||||
|
||||
// RFC-063r §2.12: capture the originating ChatOrigin from the holder.
|
||||
// The holder was set by AgentService.{chat,chatStream,...} for the
|
||||
// duration of the agent invocation that produced this approval — so
|
||||
// it is non-null for IM / web triggered tool calls. Snapshot is
|
||||
// serialized once here and persisted on the DB row so cross-restart
|
||||
// replays keep the channel binding.
|
||||
String chatOriginJson = serializeChatOrigin(ChatOriginHolder.get());
|
||||
|
||||
// 2. 增强内存记录
|
||||
approvalService.getPending(pendingId).ifPresent(pending -> {
|
||||
if (evaluation != null) {
|
||||
@ -207,11 +218,12 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
pending.setMaxSeverity(evaluation.maxSeverity() != null ? evaluation.maxSeverity().name() : null);
|
||||
pending.setSummary(evaluation.summary());
|
||||
}
|
||||
pending.setChatOrigin(chatOriginJson);
|
||||
});
|
||||
|
||||
// 3. DB 层
|
||||
persistToDb(pendingId, conversationId, userId, toolName, toolArguments,
|
||||
toolCallPayload, siblingToolCalls, agentId, evaluation);
|
||||
toolCallPayload, siblingToolCalls, agentId, evaluation, chatOriginJson);
|
||||
|
||||
return pendingId;
|
||||
}
|
||||
@ -517,7 +529,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
private void persistToDb(String pendingId, String conversationId, String userId,
|
||||
String toolName, String toolArguments,
|
||||
String toolCallPayload, String siblingToolCalls, String agentId,
|
||||
GuardEvaluation evaluation) {
|
||||
GuardEvaluation evaluation, String chatOriginJson) {
|
||||
try {
|
||||
ToolApprovalEntity entity = new ToolApprovalEntity();
|
||||
entity.setPendingId(pendingId);
|
||||
@ -531,6 +543,9 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
entity.setStatus("PENDING");
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
entity.setExpireAt(LocalDateTime.now().plusMinutes(30));
|
||||
// RFC-063r §2.12: persist Memento snapshot. Null when the entry
|
||||
// path didn't supply an origin — replay falls back to ChatOrigin.EMPTY.
|
||||
entity.setChatOrigin(chatOriginJson);
|
||||
|
||||
if (evaluation != null) {
|
||||
entity.setFindingsJson(serializeFindings(evaluation.findings()));
|
||||
@ -548,6 +563,44 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.12: serialize a {@link ChatOrigin} for persistence on
|
||||
* {@code mate_tool_approval.chat_origin}. Returns null for
|
||||
* {@code ChatOrigin.EMPTY} so legacy approvals that never captured an
|
||||
* origin do not store a meaningless empty record.
|
||||
*/
|
||||
private String serializeChatOrigin(ChatOrigin origin) {
|
||||
if (origin == null || origin == ChatOrigin.EMPTY) return null;
|
||||
if (origin.agentId() == null && origin.channelId() == null
|
||||
&& origin.conversationId() == null && origin.workspaceId() == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(origin);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("[ApprovalWorkflow] Failed to serialize ChatOrigin: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.12: deserialize a persisted Memento back into a
|
||||
* {@link ChatOrigin}. Returns {@link ChatOrigin#EMPTY} when the column
|
||||
* is null or the payload is corrupt — the caller treats that as
|
||||
* "no channel binding" and replay proceeds with a web-style flow.
|
||||
*/
|
||||
public ChatOrigin restoreChatOrigin(String json) {
|
||||
if (json == null || json.isBlank()) return ChatOrigin.EMPTY;
|
||||
try {
|
||||
ChatOrigin restored = objectMapper.readValue(json, ChatOrigin.class);
|
||||
return restored != null ? restored : ChatOrigin.EMPTY;
|
||||
} catch (Exception e) {
|
||||
log.warn("[ApprovalWorkflow] Failed to restore ChatOrigin: {} (payload-len={})",
|
||||
e.getMessage(), json.length());
|
||||
return ChatOrigin.EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateDbStatus(String pendingId, String status, String resolvedBy) {
|
||||
try {
|
||||
LambdaUpdateWrapper<ToolApprovalEntity> wrapper = new LambdaUpdateWrapper<ToolApprovalEntity>()
|
||||
|
||||
@ -59,6 +59,16 @@ public class PendingApproval {
|
||||
/** 风险摘要 */
|
||||
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;
|
||||
@ -110,6 +120,7 @@ public class PendingApproval {
|
||||
public String getFindingsJson() { return findingsJson; }
|
||||
public String getMaxSeverity() { return maxSeverity; }
|
||||
public String getSummary() { return summary; }
|
||||
public String getChatOrigin() { return chatOrigin; }
|
||||
|
||||
// === Setters ===
|
||||
|
||||
@ -125,4 +136,5 @@ public class PendingApproval {
|
||||
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; }
|
||||
}
|
||||
|
||||
@ -36,6 +36,16 @@ public class ToolApprovalEntity {
|
||||
private LocalDateTime resolvedAt;
|
||||
private LocalDateTime expireAt;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.12: serialized {@link vip.mate.agent.context.ChatOrigin}
|
||||
* snapshot captured when this approval was created. The Memento lets
|
||||
* ChannelMessageRouter.replayApprovedToolCall (and the web ApprovalController
|
||||
* replay path) restore the originating channel/workspace context after
|
||||
* a process restart, so a tool approved hours later still binds back to
|
||||
* the original channel.
|
||||
*/
|
||||
private String chatOrigin;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@ -113,6 +113,19 @@ public interface ChannelAdapter {
|
||||
throw new UnsupportedOperationException(getChannelType() + " does not support proactive send");
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.10: extended overload that accepts a
|
||||
* {@link DeliveryOptions} Parameter Object carrying optional hints
|
||||
* (thread id, multi-bot account id, future ext fields).
|
||||
*
|
||||
* <p>Default implementation delegates to {@link #proactiveSend(String, String)},
|
||||
* dropping hints — concrete adapters (Slack, Telegram) override this
|
||||
* variant to read {@code threadId} and route into the threading API.
|
||||
*/
|
||||
default void proactiveSend(String targetId, String content, DeliveryOptions options) {
|
||||
proactiveSend(targetId, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前渠道是否支持主动推送
|
||||
*
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
package vip.mate.channel;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.context.ChannelTarget;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.channel.model.ChannelEntity;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.2: factory that translates an inbound channel message into a
|
||||
* {@link ChatOrigin}. Lives in {@code vip.mate.channel} (not in
|
||||
* {@code vip.mate.agent.context}) so that the dependency direction stays
|
||||
* {@code channel → agent} and never the reverse.
|
||||
*/
|
||||
@Component
|
||||
public class ChannelChatOriginFactory {
|
||||
|
||||
/**
|
||||
* Build a {@link ChatOrigin} for a channel-originated message.
|
||||
*
|
||||
* @param channel channel entity (non-null) — provides id + workspaceId
|
||||
* @param message inbound message (non-null) — provides senderId + reply target
|
||||
* @param conversationId resolved conversation id (channel-scoped)
|
||||
* @param workspaceBasePath workspace activity directory; null = unrestricted
|
||||
*/
|
||||
public ChatOrigin from(ChannelEntity channel,
|
||||
ChannelMessage message,
|
||||
String conversationId,
|
||||
String workspaceBasePath) {
|
||||
ChannelTarget target = new ChannelTarget(
|
||||
resolveTargetId(message),
|
||||
/* threadId */ null, // adapters fill via ChannelMessage extension fields when available
|
||||
/* accountId */ null);
|
||||
return new ChatOrigin(
|
||||
/* agentId */ null,
|
||||
/* conversationId */ conversationId,
|
||||
/* requesterId */ message.getSenderId(),
|
||||
/* workspaceId */ channel.getWorkspaceId(),
|
||||
/* workspaceBasePath */ workspaceBasePath,
|
||||
/* channelId */ channel.getId(),
|
||||
/* channelTarget */ target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the IM target id used for proactive sends — prefer chatId
|
||||
* (group/room) over senderId so that cron deliveries land in the same
|
||||
* conversation the user originally messaged from.
|
||||
*/
|
||||
private String resolveTargetId(ChannelMessage message) {
|
||||
if (message.getReplyToken() != null && !message.getReplyToken().isBlank()) {
|
||||
return message.getReplyToken();
|
||||
}
|
||||
return message.getChatId() != null ? message.getChatId() : message.getSenderId();
|
||||
}
|
||||
}
|
||||
@ -330,13 +330,23 @@ public class ChannelManager {
|
||||
* @throws IllegalStateException 渠道未启动或不支持主动推送
|
||||
*/
|
||||
public void sendToChannel(Long channelId, String targetId, String content) {
|
||||
sendToChannel(channelId, targetId, content, DeliveryOptions.DEFAULTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.10: preferred overload — accepts a {@link DeliveryOptions}
|
||||
* Parameter Object so cron delivery (and future callers) can pass
|
||||
* Slack {@code thread_ts}, Telegram {@code message_thread_id}, multi-bot
|
||||
* {@code accountId}, etc. without growing a 5-arg signature.
|
||||
*/
|
||||
public void sendToChannel(Long channelId, String targetId, String content, DeliveryOptions options) {
|
||||
ChannelAdapter adapter = getAdapter(channelId)
|
||||
.orElseThrow(() -> new IllegalStateException("Channel not active: " + channelId));
|
||||
if (!adapter.supportsProactiveSend()) {
|
||||
throw new UnsupportedOperationException(
|
||||
"Channel " + adapter.getDisplayName() + " (" + adapter.getChannelType() + ") does not support proactive send");
|
||||
}
|
||||
adapter.proactiveSend(targetId, content);
|
||||
adapter.proactiveSend(targetId, content, options != null ? options : DeliveryOptions.DEFAULTS);
|
||||
log.info("Proactive message sent via channel {} to {}: {}chars",
|
||||
adapter.getDisplayName(), targetId, content.length());
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Flux;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.approval.ApprovalWorkflowService;
|
||||
import vip.mate.approval.ResolveOutcome;
|
||||
import vip.mate.approval.PendingApproval;
|
||||
@ -54,6 +55,7 @@ public class ChannelMessageRouter {
|
||||
private final TtsService ttsService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ChatStreamTracker streamTracker;
|
||||
private final ChannelChatOriginFactory chatOriginFactory;
|
||||
|
||||
/** 队列条目:封装消息及其路由上下文 */
|
||||
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
|
||||
@ -98,7 +100,8 @@ public class ChannelMessageRouter {
|
||||
ConversationCompletionPublisher completionPublisher,
|
||||
TtsService ttsService,
|
||||
ObjectMapper objectMapper,
|
||||
ChatStreamTracker streamTracker) {
|
||||
ChatStreamTracker streamTracker,
|
||||
ChannelChatOriginFactory chatOriginFactory) {
|
||||
this.agentService = agentService;
|
||||
this.conversationService = conversationService;
|
||||
this.channelService = channelService;
|
||||
@ -109,6 +112,7 @@ public class ChannelMessageRouter {
|
||||
this.ttsService = ttsService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.streamTracker = streamTracker;
|
||||
this.chatOriginFactory = chatOriginFactory;
|
||||
}
|
||||
|
||||
// ==================== 防抖辅助类 ====================
|
||||
@ -440,11 +444,17 @@ public class ChannelMessageRouter {
|
||||
Long savedAssistantId = null;
|
||||
try {
|
||||
// 流式路径:渠道实现了 StreamingChannelAdapter 则委托渠道渲染流式事件
|
||||
// RFC-063r §2.5: build the ChatOrigin once per channel-message
|
||||
// so cron jobs created during this conversation inherit the
|
||||
// channel binding (Issue #25 root path).
|
||||
ChatOrigin chatOrigin = chatOriginFactory.from(
|
||||
channelEntity, message, conversationId, /* workspaceBasePath */ null);
|
||||
|
||||
if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
|
||||
savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity);
|
||||
savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin);
|
||||
} else {
|
||||
// 同步路径:直接获取完整回复
|
||||
String reply = agentService.chat(agentId, promptText, conversationId);
|
||||
String reply = agentService.chat(agentId, promptText, conversationId, chatOrigin);
|
||||
|
||||
// 检查 chat 过程中是否产生了审批 pending
|
||||
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
|
||||
@ -521,14 +531,14 @@ public class ChannelMessageRouter {
|
||||
*/
|
||||
private Long processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter,
|
||||
String conversationId, Long agentId, String promptText,
|
||||
ChannelEntity channelEntity) {
|
||||
ChannelEntity channelEntity, ChatOrigin chatOrigin) {
|
||||
String channelType = streamingAdapter.getChannelType();
|
||||
log.info("[{}] Streaming processing started: conversationId={}", channelType, conversationId);
|
||||
|
||||
try {
|
||||
// Step 1: 产生事件流
|
||||
// Step 1: 产生事件流(RFC-063r §2.5: forward ChatOrigin so tools see channelId)
|
||||
Flux<AgentService.StreamDelta> stream = agentService.chatStructuredStream(
|
||||
agentId, promptText, conversationId, message.getSenderId());
|
||||
agentId, promptText, conversationId, message.getSenderId(), chatOrigin);
|
||||
|
||||
// Step 2: 委托渠道渲染(渠道内部消费 Flux 并处理 UI 更新)
|
||||
String finalContent = streamingAdapter.processStream(stream, message, conversationId);
|
||||
@ -591,8 +601,17 @@ public class ChannelMessageRouter {
|
||||
String replayPrompt = "继续执行已批准的工具调用。";
|
||||
|
||||
try {
|
||||
// RFC-063r §2.12: prefer the persisted Memento (covers
|
||||
// cross-restart approval where the channel session changed) and
|
||||
// only fall back to rebuilding from the current inbound message
|
||||
// when no snapshot was captured (legacy rows from before this PR).
|
||||
ChatOrigin replayOrigin = approvalService.restoreChatOrigin(consumed.getChatOrigin());
|
||||
if (replayOrigin == ChatOrigin.EMPTY) {
|
||||
replayOrigin = chatOriginFactory.from(
|
||||
channelEntity, triggerMessage, conversationId, /* workspaceBasePath */ null);
|
||||
}
|
||||
String reply = agentService.chatWithReplay(
|
||||
agentId, replayPrompt, conversationId, consumed.getToolCallPayload());
|
||||
agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
|
||||
|
||||
// 保存 replay 结果(这是正常结果,入库)
|
||||
conversationService.saveMessage(conversationId, "assistant", reply);
|
||||
@ -644,7 +663,11 @@ public class ChannelMessageRouter {
|
||||
conversationService.saveMessage(conversationId, "user", message.getContent(), parts);
|
||||
|
||||
String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode());
|
||||
return agentService.chatStream(agentId, promptText, conversationId);
|
||||
// RFC-063r §2.5: forward ChatOrigin so tools created during this
|
||||
// streaming conversation inherit channel binding.
|
||||
ChatOrigin origin = chatOriginFactory.from(
|
||||
channelEntity, message, conversationId, /* workspaceBasePath */ null);
|
||||
return agentService.chatStream(agentId, promptText, conversationId, origin);
|
||||
}
|
||||
|
||||
// ==================== 优雅关闭 ====================
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
package vip.mate.channel;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.10: Parameter Object that bundles optional delivery hints
|
||||
* (Slack {@code thread_ts}, Telegram {@code message_thread_id}, multi-bot
|
||||
* {@code accountId}, etc.) so {@link ChannelManager#sendToChannel} doesn't
|
||||
* grow a 5-arg overload.
|
||||
*
|
||||
* <p>{@link #DEFAULTS} is the canonical "no hints" instance — adapters that
|
||||
* don't override the 4-arg {@code proactiveSend} keep their pre-RFC behavior.
|
||||
*/
|
||||
public record DeliveryOptions(
|
||||
@Nullable String threadId,
|
||||
@Nullable String accountId,
|
||||
Map<String, Object> ext
|
||||
) {
|
||||
|
||||
public static final DeliveryOptions DEFAULTS = new DeliveryOptions(null, null, Map.of());
|
||||
|
||||
public DeliveryOptions {
|
||||
// Defensive: never expose a null map — the receiver should be able to
|
||||
// call .get(...) without a null check.
|
||||
if (ext == null) ext = Map.of();
|
||||
}
|
||||
}
|
||||
@ -238,6 +238,38 @@ public class SlackChannelAdapter extends AbstractChannelAdapter {
|
||||
sendMessage(targetId, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.10: thread-aware proactive send. Reads
|
||||
* {@link vip.mate.channel.DeliveryOptions#threadId()} (the Slack
|
||||
* {@code thread_ts}) and posts into that thread when supplied; falls
|
||||
* back to the legacy in-channel post when null.
|
||||
*/
|
||||
@Override
|
||||
public void proactiveSend(String targetId, String content,
|
||||
vip.mate.channel.DeliveryOptions options) {
|
||||
if (options == null || options.threadId() == null || options.threadId().isBlank()) {
|
||||
sendMessage(targetId, content);
|
||||
return;
|
||||
}
|
||||
String botToken = getConfigString("bot_token");
|
||||
if (botToken == null || content == null || content.isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String slackContent = convertToSlackMarkdown(content);
|
||||
final String threadTs = options.threadId();
|
||||
ChatPostMessageResponse response = slack.methods(botToken).chatPostMessage(req ->
|
||||
req.channel(targetId).text(slackContent).threadTs(threadTs));
|
||||
if (!response.isOk()) {
|
||||
log.warn("[slack] Failed to send threaded proactive message (thread_ts={}): {}",
|
||||
threadTs, response.getError());
|
||||
}
|
||||
} catch (IOException | SlackApiException e) {
|
||||
log.error("[slack] Error sending threaded proactive message to {}: {}",
|
||||
targetId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook 回调处理(备用模式,Socket Mode 优先)
|
||||
*/
|
||||
|
||||
@ -715,6 +715,80 @@ public class TelegramChannelAdapter extends AbstractChannelAdapter {
|
||||
sendMessage(targetId, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.10: forum-thread-aware proactive send. When
|
||||
* {@link vip.mate.channel.DeliveryOptions#threadId()} is set (Telegram
|
||||
* forum {@code message_thread_id}), include it in the {@code sendMessage}
|
||||
* call so the cron result lands in the correct thread of a forum group.
|
||||
* Falls back to the legacy chat-level send when null.
|
||||
*/
|
||||
@Override
|
||||
public void proactiveSend(String targetId, String content,
|
||||
vip.mate.channel.DeliveryOptions options) {
|
||||
if (options == null || options.threadId() == null || options.threadId().isBlank()) {
|
||||
sendMessage(targetId, content);
|
||||
return;
|
||||
}
|
||||
if (httpClient == null || botToken == null) {
|
||||
log.warn("[telegram] Channel not started, cannot send proactive message");
|
||||
return;
|
||||
}
|
||||
Integer threadId;
|
||||
try {
|
||||
threadId = Integer.valueOf(options.threadId());
|
||||
} catch (NumberFormatException nfe) {
|
||||
log.warn("[telegram] Invalid message_thread_id '{}'; sending to main chat", options.threadId());
|
||||
sendMessage(targetId, content);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Try Markdown first, fall back to plain on parse error — same
|
||||
// contract as sendMessage but with message_thread_id added.
|
||||
if (!sendThreadedText(targetId, threadId, content, "Markdown")) {
|
||||
sendThreadedText(targetId, threadId, content, null);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[telegram] proactiveSend(threadId={}) failed: {}", threadId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private boolean sendThreadedText(String targetId, Integer threadId, String content, String parseMode) {
|
||||
try {
|
||||
Map<String, Object> body = new java.util.LinkedHashMap<>();
|
||||
body.put("chat_id", targetId);
|
||||
body.put("message_thread_id", threadId);
|
||||
body.put("text", content);
|
||||
if (parseMode != null) {
|
||||
body.put("parse_mode", parseMode);
|
||||
}
|
||||
String jsonBody = objectMapper.writeValueAsString(body);
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(apiBaseUrl + "/sendMessage"))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
|
||||
.build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() == 200) {
|
||||
return true;
|
||||
}
|
||||
if (response.statusCode() == 400 && parseMode != null) {
|
||||
try {
|
||||
Map<String, Object> errResult = objectMapper.readValue(response.body(), Map.class);
|
||||
String desc = String.valueOf(errResult.getOrDefault("description", ""));
|
||||
if (desc.contains("can't parse")) {
|
||||
return false;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
log.warn("[telegram] Threaded send failed: status={}, body={}", response.statusCode(), response.body());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("[telegram] Threaded send error: {}", e.getMessage(), e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getChannelType() {
|
||||
return CHANNEL_TYPE;
|
||||
|
||||
@ -283,8 +283,18 @@ public class ChatController {
|
||||
String replayPrompt = "继续执行已批准的工具调用。";
|
||||
|
||||
streamTracker.incrementFlux(conversationId);
|
||||
// RFC-063r §2.12: prefer the persisted Memento snapshot
|
||||
// (covers cross-restart approval where the original channel
|
||||
// is gone) and fall back to a fresh web-origin
|
||||
// ChatOrigin when none was captured.
|
||||
vip.mate.agent.context.ChatOrigin replayOrigin =
|
||||
approvalService.restoreChatOrigin(finalConsumed.getChatOrigin());
|
||||
if (replayOrigin == vip.mate.agent.context.ChatOrigin.EMPTY) {
|
||||
replayOrigin = vip.mate.agent.context.ChatOrigin.web(
|
||||
conversationId, username, workspaceId, null);
|
||||
}
|
||||
Disposable disposable = agentService.chatWithReplayStream(
|
||||
replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username)
|
||||
replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username, replayOrigin)
|
||||
.doOnNext(delta -> {
|
||||
if (approvalEmitterDone.get()) return;
|
||||
try {
|
||||
@ -460,7 +470,12 @@ public class ChatController {
|
||||
));
|
||||
|
||||
streamTracker.incrementFlux(conversationId);
|
||||
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel())
|
||||
// RFC-063r §2.5: web entry — null channelId / no ChannelTarget;
|
||||
// tools that need a workspace path read it from the agent (origin
|
||||
// is enriched with workspaceBasePath in StateGraph buildInitialState).
|
||||
vip.mate.agent.context.ChatOrigin webOrigin =
|
||||
vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null);
|
||||
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
|
||||
.doOnNext(delta -> {
|
||||
if (emitterDone.get()) return;
|
||||
try {
|
||||
@ -1061,7 +1076,12 @@ public class ChatController {
|
||||
broadcastEvent(conversationId, "message_start", Map.of("role", "assistant"));
|
||||
|
||||
streamTracker.incrementFlux(conversationId);
|
||||
Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, requesterId)
|
||||
// RFC-063r §2.5: queued messages land in the same conversation; carry
|
||||
// a web-origin ChatOrigin so any cron job created during the queued
|
||||
// turn keeps a consistent (null-channel) binding.
|
||||
vip.mate.agent.context.ChatOrigin queuedOrigin =
|
||||
vip.mate.agent.context.ChatOrigin.web(conversationId, requesterId, null, null);
|
||||
Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, requesterId, null, queuedOrigin)
|
||||
.doOnNext(delta -> {
|
||||
if (emitterDone.get()) return;
|
||||
try {
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
package vip.mate.cron;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.context.ChannelTarget;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.cron.model.CronJobEntity;
|
||||
import vip.mate.cron.model.DeliveryConfig;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.2: factory that builds a {@link ChatOrigin} for a cron-triggered
|
||||
* agent invocation. Lives in {@code vip.mate.cron} so the dependency arrow
|
||||
* points {@code cron → agent} only — symmetric with
|
||||
* {@code ChannelChatOriginFactory} in {@code vip.mate.channel}.
|
||||
*
|
||||
* <p>workspaceId is reverse-resolved from {@code agent.workspaceId} (with the
|
||||
* legacy {@code 1L} fallback to keep behavior identical to
|
||||
* {@code CronJobService.executeJob}). workspaceBasePath is intentionally not
|
||||
* persisted — the value is derived at run time from the agent's workspace
|
||||
* configuration, matching the previous {@code ToolExecutionContext.workspaceBasePath()}
|
||||
* semantics.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CronChatOriginFactory {
|
||||
|
||||
private final AgentMapper agentMapper;
|
||||
|
||||
public ChatOrigin from(CronJobEntity job, String conversationId) {
|
||||
AgentEntity agent = job.getAgentId() != null ? agentMapper.selectById(job.getAgentId()) : null;
|
||||
Long workspaceId = agent != null && agent.getWorkspaceId() != null ? agent.getWorkspaceId() : 1L;
|
||||
|
||||
DeliveryConfig dc = job.getDeliveryConfig();
|
||||
ChannelTarget target = dc != null ? dc.toChannelTarget() : null;
|
||||
|
||||
return ChatOrigin.cron(conversationId, workspaceId, /* workspaceBasePath */ null,
|
||||
job.getChannelId(), target);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
package vip.mate.cron.delivery;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import vip.mate.channel.ChannelMessageRenderer;
|
||||
import vip.mate.cron.model.CronJobEntity;
|
||||
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||
import vip.mate.dashboard.repository.CronJobRunMapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.6.1: Template-Method base for {@link CronResultDelivery}.
|
||||
*
|
||||
* <p>Owns the cross-strategy invariants:
|
||||
* <ul>
|
||||
* <li><b>Idempotency</b> — atomic SQL CAS on
|
||||
* {@code mate_cron_job_run.delivery_status} via {@link #claimRun} so
|
||||
* only one listener instance proceeds per run row even across cluster
|
||||
* deployments (replaces RFC-063 v1's process-local Caffeine cache).</li>
|
||||
* <li><b>State-machine bookkeeping</b> — {@link #markDelivered} on success,
|
||||
* {@link #markNotDelivered} on failure (truncated message via Hutool
|
||||
* {@code StrUtil.maxLength} per RFC §2.6.1).</li>
|
||||
* <li><b>Render hook</b> — {@link #renderForChannel} delegates to the
|
||||
* project's existing {@link ChannelMessageRenderer} so per-channel
|
||||
* markdown / Block Kit rendering stays consistent with the inline reply
|
||||
* path.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Concrete subclasses implement only
|
||||
* {@link #doDeliver(CronJobEntity, AssistantMessage, CronJobRunEntity)}.
|
||||
*/
|
||||
@Slf4j
|
||||
public abstract class AbstractCronResultDelivery implements CronResultDelivery {
|
||||
|
||||
/**
|
||||
* Default platform message-length cap used when the bound channel type
|
||||
* is unknown — matches Telegram's 4096 ceiling, the most permissive
|
||||
* among the small-cap platforms in {@link ChannelMessageRenderer#PLATFORM_LIMITS}.
|
||||
* RFC-063r §2.11 will refine this in PR-4 by passing per-channel limits
|
||||
* through {@code DeliveryOptions}.
|
||||
*/
|
||||
private static final int DEFAULT_RENDER_MAX_LEN = 4096;
|
||||
|
||||
private final CronJobRunMapper runMapper;
|
||||
|
||||
protected AbstractCronResultDelivery(CronJobRunMapper runMapper) {
|
||||
this.runMapper = runMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public final DeliveryOutcome deliver(CronJobEntity job, AssistantMessage result, CronJobRunEntity run) {
|
||||
if (!claimRun(run)) {
|
||||
return DeliveryOutcome.skipped("already-claimed-by-other-instance");
|
||||
}
|
||||
try {
|
||||
DeliveryOutcome outcome = doDeliver(job, result, run);
|
||||
markDelivered(run, outcome);
|
||||
return outcome;
|
||||
} catch (Exception e) {
|
||||
markNotDelivered(run, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Strategy's actual delivery work — invoked after CAS success. */
|
||||
protected abstract DeliveryOutcome doDeliver(
|
||||
CronJobEntity job, AssistantMessage result, CronJobRunEntity run);
|
||||
|
||||
/**
|
||||
* RFC-063r §2.11: filter thinking + tool-call markers and join the
|
||||
* platform-truncated segments into a single string. Concrete strategies
|
||||
* call this before handing the result to the channel-specific send call.
|
||||
*
|
||||
* <p>Null-safe — null/empty {@link AssistantMessage} returns "" so
|
||||
* adapters never NPE.
|
||||
*/
|
||||
protected String renderForChannel(AssistantMessage msg, Long channelId) {
|
||||
if (msg == null) return "";
|
||||
String text = msg.getText() != null ? msg.getText() : "";
|
||||
if (text.isEmpty()) return "";
|
||||
try {
|
||||
List<String> segments = ChannelMessageRenderer.renderForChannel(
|
||||
text, /* filterThinking */ true, /* filterToolMessages */ true,
|
||||
/* messageFormat */ null, DEFAULT_RENDER_MAX_LEN);
|
||||
return segments.isEmpty() ? "" : String.join("\n\n", segments);
|
||||
} catch (Exception e) {
|
||||
log.debug("[CronDelivery] renderForChannel failed (channelId={}); falling back to raw text: {}",
|
||||
channelId, e.getMessage());
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- SQL state-machine helpers ----------
|
||||
|
||||
/**
|
||||
* Atomic SQL CAS: transition delivery_status from {@code NONE} or
|
||||
* {@code PENDING} → {@code PENDING}. Returns true iff this instance won
|
||||
* the race. NONE-eligibility lets fresh runs claim without a separate
|
||||
* "first-time" branch; PENDING-eligibility covers the rare same-instance
|
||||
* retry inside the listener (cluster paths can't normally hit this).
|
||||
*/
|
||||
private boolean claimRun(CronJobRunEntity run) {
|
||||
return runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||
.eq(CronJobRunEntity::getId, run.getId())
|
||||
.in(CronJobRunEntity::getDeliveryStatus, "NONE", "PENDING", null)
|
||||
.set(CronJobRunEntity::getDeliveryStatus, "PENDING")) == 1;
|
||||
}
|
||||
|
||||
private void markDelivered(CronJobRunEntity run, DeliveryOutcome o) {
|
||||
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||
.eq(CronJobRunEntity::getId, run.getId())
|
||||
.set(CronJobRunEntity::getDeliveryStatus, "DELIVERED")
|
||||
.set(CronJobRunEntity::getDeliveryTarget, o.target()));
|
||||
}
|
||||
|
||||
private void markNotDelivered(CronJobRunEntity run, Exception e) {
|
||||
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||
.eq(CronJobRunEntity::getId, run.getId())
|
||||
.set(CronJobRunEntity::getDeliveryStatus, "NOT_DELIVERED")
|
||||
.set(CronJobRunEntity::getDeliveryError, StrUtil.maxLength(e.getMessage(), 500)));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
package vip.mate.cron.delivery;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.7.3: Domain-Event listener that resolves the right
|
||||
* {@link CronResultDelivery} strategy and runs it asynchronously after the
|
||||
* cron run's T2 transaction commits.
|
||||
*
|
||||
* <p>Why {@code AFTER_COMMIT} + {@code @Async}:
|
||||
* <ul>
|
||||
* <li>{@code AFTER_COMMIT} guarantees the listener can re-read the run
|
||||
* row from a fresh DB connection — eliminates the read-your-writes
|
||||
* trap from the same-tx delivery path.</li>
|
||||
* <li>{@code @Async("cronDeliveryExecutor")} unbinds delivery from the
|
||||
* Spring event-dispatcher thread so a slow IM API never stalls other
|
||||
* listeners. The pool uses {@code AbortPolicy} + audit so an overflow
|
||||
* surfaces immediately rather than degrading silently.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CronDeliveryListener {
|
||||
|
||||
/** Spring auto-collects every {@link CronResultDelivery} bean ordered by {@code @Order}. */
|
||||
private final List<CronResultDelivery> deliveries;
|
||||
private final AuditEventService audit;
|
||||
|
||||
@Async("cronDeliveryExecutor")
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||
public void onCompleted(CronJobCompletedEvent ev) {
|
||||
Optional<CronResultDelivery> strategy = deliveries.stream()
|
||||
.filter(d -> d.supports(ev.job()))
|
||||
.findFirst();
|
||||
if (strategy.isEmpty()) {
|
||||
// RFC §2.6 / §2.8.1: web-origin runs (or any job without a
|
||||
// matching strategy) leave delivery_status=NONE and exit silently.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
strategy.get().deliver(ev.job(), ev.result(), ev.run());
|
||||
} catch (Exception e) {
|
||||
// RFC §2.7.3: always-best-effort — failures audit but never flip
|
||||
// the run's main status. Stale-pending cleanup tightens the
|
||||
// delivery_status state machine if the row gets stuck.
|
||||
log.warn("[CronDelivery] Delivery failed for job {}: {}",
|
||||
ev.job() != null ? ev.job().getId() : null, e.getMessage());
|
||||
try {
|
||||
audit.record("DELIVERY_FAILED", "CRON_JOB",
|
||||
ev.job() != null ? String.valueOf(ev.job().getId()) : "unknown",
|
||||
e.getMessage(), null);
|
||||
} catch (Exception auditError) {
|
||||
log.warn("[CronDelivery] Audit recording failed: {}", auditError.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener hook for unit tests bypassing the {@code @TransactionalEventListener}
|
||||
* proxy — direct {@code ApplicationEventPublisher.publishEvent} delivery.
|
||||
* Production paths always go through the AFTER_COMMIT bridge.
|
||||
*/
|
||||
@EventListener
|
||||
public void onCompletedRaw(CronJobCompletedEvent ev) {
|
||||
// No-op: the @TransactionalEventListener above is the production path
|
||||
// (it only fires when a transaction is active and after it commits).
|
||||
// This raw @EventListener exists so unit tests using fallbackExecution
|
||||
// do not double-fire and so the listener bean stays scannable in
|
||||
// contexts without a tx manager. Intentionally empty.
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.7.3: dedicated executor for cron delivery. Defined here so
|
||||
* the listener and its pool live in the same module without an extra
|
||||
* {@code @Configuration} class.
|
||||
*/
|
||||
@org.springframework.context.annotation.Bean(name = "cronDeliveryExecutor")
|
||||
public ThreadPoolTaskExecutor cronDeliveryExecutor() {
|
||||
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
|
||||
ex.setCorePoolSize(2);
|
||||
ex.setMaxPoolSize(4);
|
||||
ex.setQueueCapacity(1000);
|
||||
ex.setThreadNamePrefix("cron-delivery-");
|
||||
ex.setRejectedExecutionHandler((r, executor) -> {
|
||||
// RFC §2.7.3: AbortPolicy + audit (NOT CallerRunsPolicy) — the
|
||||
// caller is the Spring event-dispatcher thread; blocking it would
|
||||
// stall every other listener.
|
||||
log.error("[CronDelivery] queue overflow ({}); dropping task — review pool sizing",
|
||||
executor.getQueue().size());
|
||||
try {
|
||||
audit.record("DELIVERY_QUEUE_OVERFLOW", "CRON_DELIVERY", "system",
|
||||
"active=" + executor.getActiveCount() + ",queue=" + executor.getQueue().size(),
|
||||
null);
|
||||
} catch (Exception ignored) {
|
||||
// Audit must never block the rejection path itself.
|
||||
}
|
||||
});
|
||||
ex.initialize();
|
||||
return ex;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package vip.mate.cron.delivery;
|
||||
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import vip.mate.cron.model.CronJobEntity;
|
||||
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.7.3: domain event fired by
|
||||
* {@code CronJobLifecycleService.finishRunAndPublish} after T2 commits.
|
||||
* Listeners run with {@code @TransactionalEventListener(AFTER_COMMIT)}, so
|
||||
* the run row is guaranteed visible from a fresh DB connection.
|
||||
*/
|
||||
public record CronJobCompletedEvent(
|
||||
CronJobEntity job,
|
||||
AssistantMessage result,
|
||||
CronJobRunEntity run) {
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package vip.mate.cron.delivery;
|
||||
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import vip.mate.cron.model.CronJobEntity;
|
||||
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.6: pluggable strategy for delivering a cron job's assistant
|
||||
* result back to its originating context (channel, future webhook, future
|
||||
* SSE bridge, etc.).
|
||||
*
|
||||
* <p>{@link AbstractCronResultDelivery} provides the SQL-CAS idempotency +
|
||||
* state-machine update; concrete strategies only implement
|
||||
* {@link AbstractCronResultDelivery#doDeliver}.
|
||||
*/
|
||||
public interface CronResultDelivery {
|
||||
|
||||
/**
|
||||
* Whether this strategy applies to the given job. The first matching
|
||||
* strategy (per {@code @Order}) wins; web-origin jobs match nothing,
|
||||
* leaving {@code delivery_status='NONE'}.
|
||||
*/
|
||||
boolean supports(CronJobEntity job);
|
||||
|
||||
/**
|
||||
* Run the delivery, internally claiming the run row via SQL CAS and
|
||||
* updating {@code delivery_status} on success / failure. Implementations
|
||||
* should override
|
||||
* {@link AbstractCronResultDelivery#doDeliver(CronJobEntity, AssistantMessage, CronJobRunEntity)}
|
||||
* rather than this method directly.
|
||||
*/
|
||||
DeliveryOutcome deliver(CronJobEntity job, AssistantMessage result, CronJobRunEntity run);
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
package vip.mate.cron.delivery;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||
import vip.mate.dashboard.repository.CronJobRunMapper;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.8.2: periodic sweep that recovers two stuck states with one
|
||||
* scheduled job.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code delivery_status=PENDING} older than 15 min → mark
|
||||
* {@code NOT_DELIVERED} with {@code stale-pending-timeout} reason.
|
||||
* Covers listener crashes / OOMs / forced kills after a successful
|
||||
* {@code claimRun()} but before {@code markDelivered}.</li>
|
||||
* <li>{@code status='running'} older than 30 min → mark {@code failed}
|
||||
* with {@code stale-running-timeout}. Covers
|
||||
* {@code CronJobLifecycleService.markRunFailed()} itself failing under
|
||||
* DB jitter (the LLM call already terminated by then).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Single sweep, two predicates, one DB roundtrip per state. Spring
|
||||
* {@code @Scheduled} is already enabled at the application bootstrap.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CronRunStaleCleanup {
|
||||
|
||||
private final CronJobRunMapper runMapper;
|
||||
|
||||
private static final Duration DELIVERY_STALE = Duration.ofMinutes(15);
|
||||
private static final Duration RUN_STALE = Duration.ofMinutes(30);
|
||||
|
||||
@Scheduled(fixedDelay = 5 * 60 * 1000L, initialDelay = 60 * 1000L)
|
||||
public void sweep() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
int stalePending = runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||
.eq(CronJobRunEntity::getDeliveryStatus, "PENDING")
|
||||
.lt(CronJobRunEntity::getStartedAt, now.minus(DELIVERY_STALE))
|
||||
.set(CronJobRunEntity::getDeliveryStatus, "NOT_DELIVERED")
|
||||
.set(CronJobRunEntity::getDeliveryError, "stale-pending-timeout"));
|
||||
|
||||
int staleRunning = runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||
.eq(CronJobRunEntity::getStatus, "running")
|
||||
.lt(CronJobRunEntity::getStartedAt, now.minus(RUN_STALE))
|
||||
.set(CronJobRunEntity::getStatus, "failed")
|
||||
.set(CronJobRunEntity::getFinishedAt, now)
|
||||
.set(CronJobRunEntity::getErrorMessage, "stale-running-timeout"));
|
||||
|
||||
if (stalePending > 0 || staleRunning > 0) {
|
||||
log.warn("[CronCleanup] swept stalePending={} staleRunning={}", stalePending, staleRunning);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package vip.mate.cron.delivery;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.6.0: outcome of a single cron-result delivery attempt.
|
||||
*
|
||||
* <p>Two states only — {@code DELIVERED} and {@code SKIPPED}. There is no
|
||||
* {@code FAILED} state; failures throw from
|
||||
* {@link AbstractCronResultDelivery#doDeliver}, get marked
|
||||
* {@code NOT_DELIVERED} on the run row by the template method, and surface
|
||||
* to the listener as exceptions for audit. Single-purpose return semantics.
|
||||
*/
|
||||
public record DeliveryOutcome(Status status,
|
||||
@Nullable String target,
|
||||
@Nullable String reason) {
|
||||
|
||||
public enum Status { DELIVERED, SKIPPED }
|
||||
|
||||
public static DeliveryOutcome delivered(String target) {
|
||||
return new DeliveryOutcome(Status.DELIVERED, target, null);
|
||||
}
|
||||
|
||||
public static DeliveryOutcome skipped(String reason) {
|
||||
return new DeliveryOutcome(Status.SKIPPED, null, reason);
|
||||
}
|
||||
}
|
||||
@ -28,6 +28,22 @@ public class CronJobDTO {
|
||||
private LocalDateTime createTime;
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
/** RFC-063r §2.9: originating channel binding (null = web-origin cron). */
|
||||
private Long channelId;
|
||||
|
||||
/** RFC-063r §2.9: delivery target detail (targetId / threadId / accountId). */
|
||||
private DeliveryConfig deliveryConfig;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.14: read-model field surfaced by CronJobMapper#selectListWithDeliveryStatus
|
||||
* (PR-3). One of NONE / PENDING / DELIVERED / NOT_DELIVERED, taken from
|
||||
* the most-recent run row. Out-only — never accepted on create/update.
|
||||
*/
|
||||
private String lastDeliveryStatus;
|
||||
|
||||
/** RFC-063r §2.14: out-only error detail for the most-recent delivery attempt. */
|
||||
private String lastDeliveryError;
|
||||
|
||||
public static CronJobDTO from(CronJobEntity entity) {
|
||||
CronJobDTO dto = new CronJobDTO();
|
||||
dto.setId(entity.getId());
|
||||
@ -43,6 +59,15 @@ public class CronJobDTO {
|
||||
dto.setLastRunTime(entity.getLastRunTime());
|
||||
dto.setCreateTime(entity.getCreateTime());
|
||||
dto.setUpdateTime(entity.getUpdateTime());
|
||||
dto.setChannelId(entity.getChannelId());
|
||||
dto.setDeliveryConfig(entity.getDeliveryConfig());
|
||||
// RFC-063r §2.14: surface the latest-run delivery snapshot when the
|
||||
// entity was loaded via selectListWithDeliveryStatus / selectByIdWithDeliveryStatus.
|
||||
// Default "NONE" when no run has ever been recorded so the UI can
|
||||
// render a neutral badge instead of a blank cell.
|
||||
dto.setLastDeliveryStatus(entity.getLastDeliveryStatus() != null
|
||||
? entity.getLastDeliveryStatus() : "NONE");
|
||||
dto.setLastDeliveryError(entity.getLastDeliveryError());
|
||||
return dto;
|
||||
}
|
||||
|
||||
@ -63,6 +88,8 @@ public class CronJobDTO {
|
||||
entity.setTriggerMessage(this.triggerMessage);
|
||||
entity.setRequestBody(this.requestBody);
|
||||
entity.setEnabled(this.enabled);
|
||||
entity.setChannelId(this.channelId);
|
||||
entity.setDeliveryConfig(this.deliveryConfig);
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package vip.mate.cron.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@ -11,7 +12,7 @@ import java.time.LocalDateTime;
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Data
|
||||
@TableName("mate_cron_job")
|
||||
@TableName(value = "mate_cron_job", autoResultMap = true)
|
||||
public class CronJobEntity {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
@ -49,6 +50,21 @@ public class CronJobEntity {
|
||||
/** 上次执行时间 */
|
||||
private LocalDateTime lastRunTime;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.9: originating channel binding. Null when this job was
|
||||
* created from the web (no proactive delivery target). The single
|
||||
* indexed column lets ops query "all jobs delivering to channel X".
|
||||
*/
|
||||
private Long channelId;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.9: delivery target detail (targetId / threadId / accountId)
|
||||
* persisted as JSON via MyBatis Plus JacksonTypeHandler so future fields
|
||||
* don't require schema migrations.
|
||||
*/
|
||||
@TableField(typeHandler = JacksonTypeHandler.class)
|
||||
private DeliveryConfig deliveryConfig;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@ -56,4 +72,16 @@ public class CronJobEntity {
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
private Integer deleted;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.14: read-model field — populated by
|
||||
* {@code CronJobMapper.selectListWithDeliveryStatus()} via a subquery
|
||||
* against {@code mate_cron_job_run}. Not part of the writable schema.
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private String lastDeliveryStatus;
|
||||
|
||||
/** RFC-063r §2.14: matching error column for the most-recent run. */
|
||||
@TableField(exist = false)
|
||||
private String lastDeliveryError;
|
||||
}
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
package vip.mate.cron.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import org.springframework.lang.Nullable;
|
||||
import vip.mate.agent.context.ChannelTarget;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.9: per-cron-job delivery configuration. Persisted as a JSON
|
||||
* column on {@code mate_cron_job.delivery_config} via MyBatis Plus
|
||||
* {@code JacksonTypeHandler}. Mirrors {@link ChannelTarget} but lives in the
|
||||
* cron module so {@code CronJobEntity} doesn't need to depend on the channel
|
||||
* value object directly.
|
||||
*
|
||||
* <p>{@link JsonIgnoreProperties#ignoreUnknown()} keeps deserialization
|
||||
* forward-compatible when older rows are read after a column-add upgrade.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record DeliveryConfig(
|
||||
@Nullable String targetId,
|
||||
@Nullable String threadId,
|
||||
@Nullable String accountId
|
||||
) {
|
||||
|
||||
/** Convert from the {@link ChannelTarget} carried on a {@code ChatOrigin}. */
|
||||
public static DeliveryConfig from(@Nullable ChannelTarget t) {
|
||||
if (t == null) return null;
|
||||
return new DeliveryConfig(t.targetId(), t.threadId(), t.accountId());
|
||||
}
|
||||
|
||||
/** Convert back to a {@link ChannelTarget} for ChatOrigin reconstruction. */
|
||||
public ChannelTarget toChannelTarget() {
|
||||
return new ChannelTarget(targetId, threadId, accountId);
|
||||
}
|
||||
}
|
||||
@ -2,8 +2,12 @@ package vip.mate.cron.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import vip.mate.cron.model.CronJobEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 定时任务 Mapper
|
||||
*
|
||||
@ -11,4 +15,47 @@ import vip.mate.cron.model.CronJobEntity;
|
||||
*/
|
||||
@Mapper
|
||||
public interface CronJobMapper extends BaseMapper<CronJobEntity> {
|
||||
|
||||
/**
|
||||
* RFC-063r §2.14: list cron jobs together with their most-recent
|
||||
* delivery status / error (subquery against {@code mate_cron_job_run}).
|
||||
*
|
||||
* <p>Both H2 and MySQL accept {@code LIMIT 1} inside a correlated
|
||||
* subquery, so the same SQL is portable across the two profiles. Index
|
||||
* coverage: {@code mate_cron_job_run(cron_job_id, started_at)} (created
|
||||
* by V1 baseline migration) makes the subquery cheap.
|
||||
*
|
||||
* <p>Filters out logically-deleted rows and orders by create_time DESC
|
||||
* to mirror the existing {@code list()} ordering.
|
||||
*/
|
||||
@Select("""
|
||||
SELECT j.*,
|
||||
(SELECT r.delivery_status FROM mate_cron_job_run r
|
||||
WHERE r.cron_job_id = j.id
|
||||
ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_status,
|
||||
(SELECT r.delivery_error FROM mate_cron_job_run r
|
||||
WHERE r.cron_job_id = j.id
|
||||
ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_error
|
||||
FROM mate_cron_job j
|
||||
WHERE j.deleted = 0
|
||||
ORDER BY j.create_time DESC
|
||||
""")
|
||||
List<CronJobEntity> selectListWithDeliveryStatus();
|
||||
|
||||
/**
|
||||
* RFC-063r §2.14: per-job variant for the detail page. Same subquery
|
||||
* pattern, restricted to a single id.
|
||||
*/
|
||||
@Select("""
|
||||
SELECT j.*,
|
||||
(SELECT r.delivery_status FROM mate_cron_job_run r
|
||||
WHERE r.cron_job_id = j.id
|
||||
ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_status,
|
||||
(SELECT r.delivery_error FROM mate_cron_job_run r
|
||||
WHERE r.cron_job_id = j.id
|
||||
ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_error
|
||||
FROM mate_cron_job j
|
||||
WHERE j.id = #{id} AND j.deleted = 0
|
||||
""")
|
||||
CronJobEntity selectByIdWithDeliveryStatus(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@ -0,0 +1,128 @@
|
||||
package vip.mate.cron.service;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import vip.mate.cron.delivery.CronJobCompletedEvent;
|
||||
import vip.mate.cron.model.CronJobEntity;
|
||||
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||
import vip.mate.dashboard.repository.CronJobRunMapper;
|
||||
import vip.mate.memory.event.ConversationCompletionPublisher;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.7.2: three-segment transactional support for {@link CronJobRunner}.
|
||||
*
|
||||
* <p>Each method runs in its own short {@code REQUIRES_NEW} transaction so
|
||||
* the long LLM call between T1 and T2 never holds a DB connection.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code T1} — {@link #startRun}: insert run row + persist user message.</li>
|
||||
* <li>{@code T-fail} — {@link #markRunFailed}: terminal state when the LLM
|
||||
* call throws.</li>
|
||||
* <li>{@code T2} — {@link #finishRunAndPublish}: persist assistant
|
||||
* message + dispatch the conversation-completed event (memory pipeline)
|
||||
* + the cron-job-completed event (delivery pipeline).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Lives in a separate {@code @Service} bean so cross-bean invocation from
|
||||
* {@link CronJobRunner} routes through the Spring AOP proxy (RFC §5.2 hard
|
||||
* rule). The lifecycle service is deliberately the only place
|
||||
* {@code @Transactional} appears in the cron-execution path.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CronJobLifecycleService {
|
||||
|
||||
private final CronJobRunMapper runMapper;
|
||||
private final ConversationService conversationService;
|
||||
private final ConversationCompletionPublisher completionPublisher;
|
||||
private final ApplicationEventPublisher events;
|
||||
|
||||
/**
|
||||
* T1 — short transaction: persist a run row in {@code running} state,
|
||||
* persist the user message that triggered the run, and commit. Returns
|
||||
* the persisted entity so callers can observe its assigned id without a
|
||||
* second SELECT.
|
||||
*
|
||||
* @param triggerType {@code scheduled} (cron tick) or {@code manual} (runNow)
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public CronJobRunEntity startRun(CronJobEntity job, String userMessage, String triggerType) {
|
||||
CronJobRunEntity run = new CronJobRunEntity();
|
||||
run.setCronJobId(job.getId());
|
||||
run.setConversationId("cron:" + job.getId());
|
||||
run.setStatus("running");
|
||||
run.setTriggerType(triggerType != null ? triggerType : "scheduled");
|
||||
run.setStartedAt(LocalDateTime.now());
|
||||
run.setDeliveryStatus("NONE");
|
||||
runMapper.insert(run);
|
||||
|
||||
// Persist the user message before the LLM call so history reads
|
||||
// see a coherent (user → assistant) ordering even if the agent
|
||||
// throws mid-run.
|
||||
if (userMessage != null && !userMessage.isBlank()) {
|
||||
conversationService.saveMessage(run.getConversationId(), "user", userMessage);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* T-fail — short transaction: flag the run row as failed when the agent
|
||||
* throws. Always-best-effort policy: delivery_status stays NONE; nothing
|
||||
* is published.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void markRunFailed(CronJobRunEntity run, Throwable error) {
|
||||
String message = error != null && error.getMessage() != null ? error.getMessage() : "unknown error";
|
||||
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||
.eq(CronJobRunEntity::getId, run.getId())
|
||||
.set(CronJobRunEntity::getStatus, "failed")
|
||||
.set(CronJobRunEntity::getFinishedAt, LocalDateTime.now())
|
||||
.set(CronJobRunEntity::getErrorMessage, StrUtil.maxLength(message, 1000)));
|
||||
}
|
||||
|
||||
/**
|
||||
* T2 — short transaction: persist the assistant reply, mark the run
|
||||
* succeeded, then publish the two domain events. The
|
||||
* {@code @TransactionalEventListener(AFTER_COMMIT)} listeners only run
|
||||
* once this method's tx commits, so cross-connection reads in the
|
||||
* delivery / memory pipelines always see the final state.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void finishRunAndPublish(CronJobEntity job, CronJobRunEntity run,
|
||||
String userMessage, AssistantMessage result) {
|
||||
String convId = "cron:" + job.getId();
|
||||
String text = result != null && result.getText() != null ? result.getText() : "";
|
||||
|
||||
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||
.eq(CronJobRunEntity::getId, run.getId())
|
||||
.set(CronJobRunEntity::getStatus, "succeeded")
|
||||
.set(CronJobRunEntity::getFinishedAt, LocalDateTime.now()));
|
||||
|
||||
conversationService.saveMessage(convId, "assistant", text);
|
||||
|
||||
// Memory pipeline (existing behavior preserved — was inline in the
|
||||
// old executeJob; now lives behind the same publisher used by the
|
||||
// web / channel paths so cron is no longer special-cased).
|
||||
try {
|
||||
completionPublisher.publish(job.getAgentId(), convId, userMessage, text, "cron");
|
||||
} catch (Exception e) {
|
||||
// Memory failures must not break delivery — log + carry on.
|
||||
log.warn("[CronLifecycle] completionPublisher failed for job {}: {}", job.getId(), e.getMessage());
|
||||
}
|
||||
|
||||
// Delivery pipeline (RFC-063r §2.7.3) — fired here so listeners only
|
||||
// run after T2 commits.
|
||||
events.publishEvent(new CronJobCompletedEvent(job, result != null ? result : new AssistantMessage(""), run));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,152 @@
|
||||
package vip.mate.cron.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.cron.CronChatOriginFactory;
|
||||
import vip.mate.cron.model.CronJobEntity;
|
||||
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.7.1: scheduler-facing orchestrator that decomposes one cron
|
||||
* tick into the three transactional segments owned by
|
||||
* {@link CronJobLifecycleService}, with the long-running LLM call sitting
|
||||
* <em>outside</em> any transaction.
|
||||
*
|
||||
* <p><b>This class must NOT be annotated {@code @Transactional}</b> — see
|
||||
* RFC-063r §5.2:
|
||||
* <ul>
|
||||
* <li>The class is the entry point invoked by the
|
||||
* {@code ThreadPoolTaskScheduler}'s lambda; the LLM HTTP call inside
|
||||
* {@link #runAgent} is seconds-to-minutes long and must not hold a DB
|
||||
* connection.</li>
|
||||
* <li>Self-invocation in the legacy {@code CronJobService} silently
|
||||
* skipped {@code @Transactional}; that pattern is forbidden here.</li>
|
||||
* <li>An {@code ArchUnit} test (see PR-3) pins this rule so a future
|
||||
* regression fails CI.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The three transactional segments live on
|
||||
* {@link CronJobLifecycleService} (a separate bean), so cross-bean calls
|
||||
* route through Spring AOP and {@code REQUIRES_NEW} works as advertised.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CronJobRunner {
|
||||
|
||||
private final CronJobLifecycleService lifecycle;
|
||||
private final AgentService agentService;
|
||||
private final CronChatOriginFactory originFactory;
|
||||
|
||||
/**
|
||||
* Scheduler-facing entry. Runs three logical segments:
|
||||
* <ol>
|
||||
* <li>T1 — {@link CronJobLifecycleService#startRun} commits run row +
|
||||
* user message.</li>
|
||||
* <li>Untransacted — {@link #runAgent} performs the LLM call.</li>
|
||||
* <li>T2 — {@link CronJobLifecycleService#finishRunAndPublish} commits
|
||||
* assistant message and publishes the completion / delivery
|
||||
* events. {@code AFTER_COMMIT} listeners fire from a fresh DB
|
||||
* connection, so {@link CronJobRunEntity}'s persisted state is
|
||||
* always visible by the time delivery resolves a strategy.</li>
|
||||
* </ol>
|
||||
*/
|
||||
public void executeJob(CronJobEntity job) {
|
||||
executeJob(job, /* triggerType */ "scheduled");
|
||||
}
|
||||
|
||||
/** Variant with explicit trigger type — used by {@code runNow} (manual). */
|
||||
public void executeJob(CronJobEntity job, String triggerType) {
|
||||
if (job == null) {
|
||||
log.warn("[CronRunner] executeJob called with null job — ignoring");
|
||||
return;
|
||||
}
|
||||
String userMessage = "agent".equals(job.getTaskType())
|
||||
? job.getRequestBody()
|
||||
: job.getTriggerMessage();
|
||||
|
||||
// T1 — short tx
|
||||
CronJobRunEntity run;
|
||||
try {
|
||||
run = lifecycle.startRun(job, userMessage, triggerType);
|
||||
} catch (Exception e) {
|
||||
log.error("[CronRunner] T1 startRun failed for job {}: {}", job.getId(), e.getMessage(), e);
|
||||
return;
|
||||
}
|
||||
|
||||
// No-tx segment — long LLM call. RFC §5.2 hard rule: must not hold
|
||||
// any DB connection during this call.
|
||||
AssistantMessage result;
|
||||
try {
|
||||
ChatOrigin origin = originFactory.from(job, "cron:" + job.getId());
|
||||
result = runAgent(job, userMessage, origin);
|
||||
} catch (Exception e) {
|
||||
log.error("[CronRunner] runAgent failed for job {}: {}", job.getId(), e.getMessage(), e);
|
||||
try {
|
||||
lifecycle.markRunFailed(run, e);
|
||||
} catch (Exception markErr) {
|
||||
// CronRunStaleCleanup will sweep status='running' rows older than 30 min.
|
||||
log.warn("[CronRunner] markRunFailed itself failed for run {}: {} (stale-cleanup will recover)",
|
||||
run.getId(), markErr.getMessage());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// T2 — short tx
|
||||
try {
|
||||
lifecycle.finishRunAndPublish(job, run, userMessage, result);
|
||||
} catch (Exception e) {
|
||||
log.error("[CronRunner] T2 finishRunAndPublish failed for job {}: {}", job.getId(), e.getMessage(), e);
|
||||
try {
|
||||
lifecycle.markRunFailed(run, e);
|
||||
} catch (Exception markErr) {
|
||||
log.warn("[CronRunner] markRunFailed after T2 failure also failed for run {}: {}",
|
||||
run.getId(), markErr.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the agent with the cron-derived {@link ChatOrigin} and the
|
||||
* RFC-063r §2.13 system-prompt guard prepended when the cron is bound to
|
||||
* a channel — fixes the Issue #25 LLM hallucination ("install
|
||||
* mateclaw cli to send to wechat") by telling the model that delivery is
|
||||
* framework-handled.
|
||||
*/
|
||||
private AssistantMessage runAgent(CronJobEntity job, String userMessage, ChatOrigin origin) {
|
||||
String guarded = wrapWithDeliveryGuard(userMessage, origin);
|
||||
String text = "agent".equals(job.getTaskType())
|
||||
? agentService.execute(job.getAgentId(), guarded, "cron:" + job.getId(), origin)
|
||||
: agentService.chat(job.getAgentId(), guarded, "cron:" + job.getId(), origin);
|
||||
return new AssistantMessage(text != null ? text : "");
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.13: when the cron is bound to a channel, prepend an
|
||||
* explicit system note telling the LLM that delivery is handled by the
|
||||
* framework. Without this, the model invents tools ("call CLI to send
|
||||
* to wechat") and surfaces "command not found" style errors to users
|
||||
* (Issue #25 second symptom).
|
||||
*
|
||||
* <p>Web-origin crons (no channelId) bypass the wrapper so the
|
||||
* pre-RFC behavior is preserved.
|
||||
*/
|
||||
static String wrapWithDeliveryGuard(String userMessage, ChatOrigin origin) {
|
||||
String body = userMessage != null ? userMessage : "";
|
||||
if (origin == null || origin.channelId() == null) {
|
||||
return body;
|
||||
}
|
||||
return """
|
||||
[系统说明]
|
||||
本次执行由定时任务触发,结果将由系统自动投递回原渠道,
|
||||
你只需直接给出最终回复内容,不要尝试调用 CLI / shell /
|
||||
"发送到微信"等工具——这些操作由框架完成。
|
||||
|
||||
[用户原始消息]
|
||||
""" + body;
|
||||
}
|
||||
}
|
||||
@ -12,15 +12,12 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
|
||||
import org.springframework.scheduling.support.CronExpression;
|
||||
import org.springframework.scheduling.support.CronTrigger;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.cron.model.CronJobDTO;
|
||||
import vip.mate.cron.model.CronJobEntity;
|
||||
import vip.mate.cron.repository.CronJobMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.memory.event.ConversationCompletionPublisher;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
@ -45,17 +42,23 @@ public class CronJobService implements ApplicationRunner {
|
||||
|
||||
private final CronJobMapper cronJobMapper;
|
||||
private final AgentMapper agentMapper;
|
||||
private final AgentService agentService;
|
||||
private final ConversationService conversationService;
|
||||
private final ConversationCompletionPublisher completionPublisher;
|
||||
/**
|
||||
* RFC-063r §2.7.1: cron-tick execution moved to {@link CronJobRunner}
|
||||
* (separate bean) so the three-segment transactional model in
|
||||
* {@link CronJobLifecycleService} works via Spring AOP — no more
|
||||
* self-invocation footgun.
|
||||
*
|
||||
* <p>{@code agentService}, {@code conversationService}, and
|
||||
* {@code completionPublisher} now live on {@link CronJobLifecycleService}
|
||||
* and {@link CronJobRunner} so this service shrinks to CRUD + scheduler
|
||||
* registration only.
|
||||
*/
|
||||
private final CronJobRunner cronJobRunner;
|
||||
|
||||
private final ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||
private final ConcurrentHashMap<Long, ScheduledFuture<?>> scheduledTasks = new ConcurrentHashMap<>();
|
||||
private final ReentrantLock schedulerLock = new ReentrantLock();
|
||||
|
||||
/** 定时任务触发时使用的系统用户标识 */
|
||||
private static final String SYSTEM_USER = "system";
|
||||
|
||||
// ==================== 初始化与销毁 ====================
|
||||
|
||||
/**
|
||||
@ -88,9 +91,10 @@ public class CronJobService implements ApplicationRunner {
|
||||
// ==================== CRUD ====================
|
||||
|
||||
public List<CronJobDTO> list() {
|
||||
List<CronJobEntity> entities = cronJobMapper.selectList(
|
||||
new LambdaQueryWrapper<CronJobEntity>()
|
||||
.orderByDesc(CronJobEntity::getCreateTime));
|
||||
// RFC-063r §2.14: use the variant that aggregates the most-recent
|
||||
// delivery_status from mate_cron_job_run so the list page can
|
||||
// render the "最近投递" badge without a per-row N+1 query.
|
||||
List<CronJobEntity> entities = cronJobMapper.selectListWithDeliveryStatus();
|
||||
|
||||
// 批量加载 Agent 名称
|
||||
List<Long> agentIds = entities.stream()
|
||||
@ -107,7 +111,9 @@ public class CronJobService implements ApplicationRunner {
|
||||
}
|
||||
|
||||
public CronJobDTO getById(Long id) {
|
||||
CronJobEntity entity = cronJobMapper.selectById(id);
|
||||
// RFC-063r §2.14: detail page shows lastDeliveryStatus too — same
|
||||
// subquery shape, restricted to one id.
|
||||
CronJobEntity entity = cronJobMapper.selectByIdWithDeliveryStatus(id);
|
||||
if (entity == null) {
|
||||
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
||||
}
|
||||
@ -219,8 +225,19 @@ public class CronJobService implements ApplicationRunner {
|
||||
if (entity == null) {
|
||||
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
||||
}
|
||||
// 异步执行,不阻塞请求线程
|
||||
scheduler.submit(() -> executeJob(entity));
|
||||
// RFC-063r §2.7.1: delegate to CronJobRunner via Spring proxy so
|
||||
// the three-segment REQUIRES_NEW transactions on
|
||||
// CronJobLifecycleService work as advertised. "manual" trigger type
|
||||
// distinguishes this from scheduler-driven runs in mate_cron_job_run.
|
||||
scheduler.submit(() -> {
|
||||
try {
|
||||
cronJobRunner.executeJob(entity, "manual");
|
||||
} finally {
|
||||
// RFC-063r §2.7.1: bookkeep regardless of run outcome so a
|
||||
// single bad run does not wedge all future ticks.
|
||||
updateRunTimes(entity.getId(), entity.getCronExpression(), entity.getTimezone());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 调度器管理 ====================
|
||||
@ -232,7 +249,16 @@ public class CronJobService implements ApplicationRunner {
|
||||
String springCron = toSpringCron(job.getCronExpression());
|
||||
ZoneId zoneId = ZoneId.of(job.getTimezone());
|
||||
CronTrigger trigger = new CronTrigger(springCron, zoneId);
|
||||
ScheduledFuture<?> future = scheduler.schedule(() -> executeJob(job), trigger);
|
||||
// RFC-063r §2.7.1: delegate to CronJobRunner — see runNow above.
|
||||
// Bookkeeping (lastRunTime / nextRunTime) is wrapped in a finally
|
||||
// so a failed run still advances the schedule.
|
||||
ScheduledFuture<?> future = scheduler.schedule(() -> {
|
||||
try {
|
||||
cronJobRunner.executeJob(job, "scheduled");
|
||||
} finally {
|
||||
updateRunTimes(job.getId(), job.getCronExpression(), job.getTimezone());
|
||||
}
|
||||
}, trigger);
|
||||
scheduledTasks.put(job.getId(), future);
|
||||
log.info("[CronJob] Registered job {} ({}), cron={}, tz={}", job.getId(), job.getName(),
|
||||
job.getCronExpression(), job.getTimezone());
|
||||
@ -249,46 +275,18 @@ public class CronJobService implements ApplicationRunner {
|
||||
}
|
||||
|
||||
// ==================== 任务执行 ====================
|
||||
|
||||
private void executeJob(CronJobEntity job) {
|
||||
String conversationId = "cron:" + job.getId();
|
||||
try {
|
||||
log.info("[CronJob] Executing job {} ({}), type={}", job.getId(), job.getName(), job.getTaskType());
|
||||
|
||||
// 确保会话存在(使用 SYSTEM_USER 作为定时触发的所有者标识,workspace 从 agent 获取)
|
||||
AgentEntity cronAgent = agentMapper.selectById(job.getAgentId());
|
||||
Long cronWorkspaceId = cronAgent != null ? cronAgent.getWorkspaceId() : 1L;
|
||||
conversationService.getOrCreateConversation(conversationId, job.getAgentId(), SYSTEM_USER, cronWorkspaceId);
|
||||
|
||||
String userMessage;
|
||||
String result;
|
||||
if ("agent".equals(job.getTaskType())) {
|
||||
userMessage = job.getRequestBody();
|
||||
// 保存 user 消息
|
||||
conversationService.saveMessage(conversationId, "user", userMessage);
|
||||
result = agentService.execute(job.getAgentId(), userMessage, conversationId);
|
||||
} else {
|
||||
userMessage = job.getTriggerMessage();
|
||||
// 保存 user 消息
|
||||
conversationService.saveMessage(conversationId, "user", userMessage);
|
||||
result = agentService.chat(job.getAgentId(), userMessage, conversationId);
|
||||
}
|
||||
|
||||
// 保存 assistant 消息
|
||||
conversationService.saveMessage(conversationId, "assistant", result);
|
||||
|
||||
// 发布对话完成事件
|
||||
completionPublisher.publish(job.getAgentId(), conversationId, userMessage, result, "cron");
|
||||
|
||||
// 合并更新 lastRunTime + nextRunTime,单次 DB 写入
|
||||
updateRunTimes(job.getId(), job.getCronExpression(), job.getTimezone());
|
||||
|
||||
log.info("[CronJob] Job {} executed successfully, result length={}", job.getId(),
|
||||
result != null ? result.length() : 0);
|
||||
} catch (Exception e) {
|
||||
log.error("[CronJob] Job {} execution failed: {}", job.getId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
//
|
||||
// RFC-063r §2.7.1: the executeJob body moved to CronJobRunner so the
|
||||
// three-segment transactional model in CronJobLifecycleService runs
|
||||
// through a Spring AOP proxy. CronJobService now only owns CRUD +
|
||||
// scheduler registration, and the lastRunTime / nextRunTime bookkeeping
|
||||
// hook below — which deliberately runs *after* the runner returns so a
|
||||
// failed run still advances the next-run pointer (otherwise a single
|
||||
// bad run wedges all future ticks).
|
||||
//
|
||||
// Both register() and runNow() now delegate to cronJobRunner.executeJob;
|
||||
// see those methods above. The wrap below ensures next-run rolls forward
|
||||
// regardless of run outcome.
|
||||
|
||||
/**
|
||||
* 合并更新 lastRunTime 和 nextRunTime,单次 DB 写入替代原来的 4 次 selectById + updateById
|
||||
|
||||
@ -19,6 +19,24 @@ public class CronJobRunEntity {
|
||||
private LocalDateTime finishedAt;
|
||||
private String errorMessage;
|
||||
private Integer tokenUsage;
|
||||
|
||||
// ===== RFC-063r §2.9: delivery state machine =====
|
||||
|
||||
/**
|
||||
* Delivery state machine — see RFC-063r §2.8.1:
|
||||
* {@code NONE} (web-origin) | {@code PENDING} (claimed) |
|
||||
* {@code DELIVERED} | {@code NOT_DELIVERED} (failure or stale-cleanup).
|
||||
* Orthogonal to {@link #status} (run main state); always-best-effort
|
||||
* policy means delivery failure never flips the main state to failed.
|
||||
*/
|
||||
private String deliveryStatus;
|
||||
|
||||
/** Resolved delivery target (IM userId / chat_id / Feishu webhook URL). */
|
||||
private String deliveryTarget;
|
||||
|
||||
/** Delivery error reason — Hutool-truncated to 500 chars max. */
|
||||
private String deliveryError;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
|
||||
@ -1,15 +1,23 @@
|
||||
package vip.mate.i18n;
|
||||
|
||||
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.lang.Nullable;
|
||||
|
||||
/**
|
||||
* 国际化工具回调装饰器
|
||||
* <p>
|
||||
* 包装原始 ToolCallback,覆写 {@link #getToolDefinition()} 返回本地化描述。
|
||||
* 其他方法(call、name 等)全部委托给原始回调。
|
||||
* I18n decorator around a {@link ToolCallback} — overrides
|
||||
* {@link #getToolDefinition()} so the LLM sees the localized description.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
* <p>RFC-063r §2.3 (P0): every {@code call(String, ToolContext)} invocation
|
||||
* must be forwarded to the underlying delegate so {@link ToolContext} (carrying
|
||||
* {@code ChatOrigin}) reaches downstream {@code @Tool} methods. The previous
|
||||
* implementation only overrode {@code call(String)}, which silently dropped the
|
||||
* ToolContext via the framework default.
|
||||
*
|
||||
* <p>{@link #getToolMetadata()} is forwarded so {@code returnDirect=true}
|
||||
* tools keep their direct-return semantics under this decorator.
|
||||
*/
|
||||
public class LocaleAwareToolCallback implements ToolCallback {
|
||||
|
||||
@ -31,8 +39,18 @@ public class LocaleAwareToolCallback implements ToolCallback {
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ToolMetadata getToolMetadata() {
|
||||
return delegate.getToolMetadata();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String call(String toolInput) {
|
||||
return delegate.call(toolInput);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String call(String toolInput, @Nullable ToolContext toolContext) {
|
||||
return delegate.call(toolInput, toolContext);
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,8 +12,10 @@ import com.microsoft.playwright.PlaywrightException;
|
||||
import com.microsoft.playwright.options.LoadState;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.tool.browser.BrowserDiagnosticsService;
|
||||
import vip.mate.tool.browser.BrowserLauncher;
|
||||
@ -61,6 +63,15 @@ public class BrowserUseTool {
|
||||
private final Object playwrightLock = new Object();
|
||||
|
||||
private final ConcurrentHashMap<String, BrowserSession> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* RFC-063r §2.5 transition: ToolContext for the current invocation, set
|
||||
* at the @Tool entry point and read by {@link #broadcastBrowserEvent}.
|
||||
* Tool calls are serialized per ToolExecutionExecutor instance so this
|
||||
* volatile field is safe; the field is read-only inside the action
|
||||
* handlers.
|
||||
*/
|
||||
private volatile ToolContext currentToolContext;
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "browser-idle-watchdog");
|
||||
t.setDaemon(true);
|
||||
@ -97,8 +108,15 @@ public class BrowserUseTool {
|
||||
@ToolParam(description = "JavaScript code to execute (for action=eval)", required = false) String code,
|
||||
@ToolParam(description = "File path to save screenshot (for action=screenshot)", required = false) String path,
|
||||
@ToolParam(description = "Launch visible browser window (for action=start, default false)", required = false) Boolean headed,
|
||||
@ToolParam(description = "Single CDP port to scan (for action=list_cdp_targets)", required = false) Integer cdpPort
|
||||
@ToolParam(description = "Single CDP port to scan (for action=list_cdp_targets)", required = false) Integer cdpPort,
|
||||
// RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator.
|
||||
@Nullable ToolContext ctx
|
||||
) {
|
||||
// The conversationId resolution lives in broadcastBrowserEvent below;
|
||||
// capture the ctx into a field so the helper can read it without
|
||||
// passing it down every action handler. Race-free because tool calls
|
||||
// are serialized per executor.
|
||||
this.currentToolContext = ctx;
|
||||
if (action == null || action.isBlank()) {
|
||||
return error("action is required");
|
||||
}
|
||||
@ -163,7 +181,7 @@ public class BrowserUseTool {
|
||||
*/
|
||||
private void broadcastBrowserEvent(String action, boolean success, String url, String title,
|
||||
String screenshot, long durationMs) {
|
||||
String conversationId = ToolExecutionContext.conversationId();
|
||||
String conversationId = ToolExecutionContext.conversationId(currentToolContext);
|
||||
if (conversationId == null || streamTracker == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -5,9 +5,12 @@ import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.cron.model.CronJobDTO;
|
||||
import vip.mate.cron.service.CronJobService;
|
||||
|
||||
@ -38,12 +41,21 @@ public class CronJobTool {
|
||||
@ToolParam(description = "Task name, e.g. 'Daily AI News Summary'") String name,
|
||||
@ToolParam(description = "5-field cron expression: minute hour day month weekday") String cronExpression,
|
||||
@ToolParam(description = "Message to send when the task triggers, e.g. 'Search for the latest AI news and summarize'") String triggerMessage,
|
||||
@ToolParam(description = "Timezone, default Asia/Shanghai. Examples: UTC, America/New_York", required = false) String timezone) {
|
||||
@ToolParam(description = "Timezone, default Asia/Shanghai. Examples: UTC, America/New_York", required = false) String timezone,
|
||||
// RFC-063r §2.4: ToolContext is *not* exposed to the LLM —
|
||||
// JsonSchemaGenerator skips it (Spring AI 1.1 framework convention)
|
||||
@Nullable ToolContext ctx) {
|
||||
|
||||
try {
|
||||
// Resolve current agent ID from conversation context
|
||||
String conversationId = ToolExecutionContext.conversationId();
|
||||
Long agentId = resolveAgentId(conversationId);
|
||||
// RFC-063r §2.5: prefer the explicit ChatOrigin (channelId / channelTarget
|
||||
// / agentId all live there). Fall back to the legacy ToolExecutionContext
|
||||
// ThreadLocal during the PR-1 transition window so callers that have not
|
||||
// yet migrated keep working.
|
||||
ChatOrigin origin = ChatOrigin.from(ctx);
|
||||
String conversationId = origin.conversationId() != null && !origin.conversationId().isEmpty()
|
||||
? origin.conversationId()
|
||||
: ToolExecutionContext.conversationId();
|
||||
Long agentId = origin.agentId() != null ? origin.agentId() : resolveAgentId(conversationId);
|
||||
|
||||
CronJobDTO dto = new CronJobDTO();
|
||||
dto.setName(name);
|
||||
@ -54,6 +66,12 @@ public class CronJobTool {
|
||||
dto.setTaskType("text");
|
||||
dto.setEnabled(true);
|
||||
|
||||
// RFC-063r §2.4 / PR-2: when the originating context carries a
|
||||
// channelId, the cron job inherits the binding so its results can
|
||||
// be delivered back to the same channel. Fields are wired via
|
||||
// reflection until PR-2 adds them to CronJobDTO + CronJobEntity.
|
||||
propagateChannelBinding(dto, origin);
|
||||
|
||||
CronJobDTO created = cronJobService.create(dto);
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
@ -166,4 +184,17 @@ public class CronJobTool {
|
||||
result.set("error", message);
|
||||
return JSONUtil.toJsonPrettyStr(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.4: propagate the originating channel binding into the cron
|
||||
* job DTO so PR-3's delivery dispatcher can route results back to the
|
||||
* originating channel.
|
||||
*/
|
||||
private void propagateChannelBinding(CronJobDTO dto, ChatOrigin origin) {
|
||||
if (origin == null || origin.channelId() == null) return;
|
||||
dto.setChannelId(origin.channelId());
|
||||
if (origin.channelTarget() != null) {
|
||||
dto.setDeliveryConfig(vip.mate.cron.model.DeliveryConfig.from(origin.channelTarget()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,10 +5,13 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
@ -76,7 +79,12 @@ public class DelegateAgentTool {
|
||||
For multiple parallel tasks, use delegateParallel instead.""")
|
||||
public String delegateToAgent(
|
||||
@ToolParam(description = "Target Agent name (exact match)") String agentName,
|
||||
@ToolParam(description = "Task description with complete context information") String task) {
|
||||
@ToolParam(description = "Task description with complete context information") String task,
|
||||
// RFC-063r §2.5 改动点 5: parent ChatOrigin (channel binding /
|
||||
// workspace) propagates into the delegated child so a sub-agent
|
||||
// creating a cron job still binds back to the originating channel.
|
||||
// Hidden from the LLM by JsonSchemaGenerator.
|
||||
@Nullable ToolContext ctx) {
|
||||
|
||||
if (agentName == null || agentName.isBlank()) {
|
||||
return "[错误] 请指定目标 Agent 名称。" + availableAgentsHint();
|
||||
@ -111,8 +119,11 @@ public class DelegateAgentTool {
|
||||
}
|
||||
Runnable stopRelay = hasParent ? registerRelay(childConversationId, parentConversationId, target.getName()) : null;
|
||||
|
||||
// Execute child agent
|
||||
ChildResult result = runSingleChild(0, target, task, parentConversationId, childConversationId);
|
||||
// Execute child agent — RFC-063r §2.5 改动点 5: inherit the parent
|
||||
// ChatOrigin and only swap the agentId, so channel binding /
|
||||
// workspace / requester all flow into the child.
|
||||
ChatOrigin parentOrigin = ChatOrigin.from(ctx);
|
||||
ChildResult result = runSingleChild(0, target, task, parentConversationId, childConversationId, parentOrigin);
|
||||
|
||||
// Cleanup relay, then broadcast final result
|
||||
if (stopRelay != null) stopRelay.run();
|
||||
@ -133,7 +144,9 @@ public class DelegateAgentTool {
|
||||
Input is a JSON array: [{"agentName":"Agent名称","task":"任务描述"}, ...]""")
|
||||
public String delegateParallel(
|
||||
@ToolParam(description = "JSON array of tasks: [{\"agentName\":\"X\",\"task\":\"Y\"}, ...]")
|
||||
String tasksJson) {
|
||||
String tasksJson,
|
||||
// RFC-063r §2.5 改动点 5: hidden from LLM, used to inherit ChatOrigin into children.
|
||||
@Nullable ToolContext ctx) {
|
||||
|
||||
// 1. Parse task list
|
||||
List<Map<String, String>> tasks;
|
||||
@ -206,9 +219,14 @@ public class DelegateAgentTool {
|
||||
long startTime = System.currentTimeMillis();
|
||||
Map<Integer, CompletableFuture<ChildResult>> futures = new LinkedHashMap<>();
|
||||
|
||||
// RFC-063r §2.5 改动点 5: capture parent origin once on this thread,
|
||||
// then hand it to each child future — the worker virtual threads
|
||||
// can't re-read the ToolContext (no parameter scope), so we close
|
||||
// over the captured origin.
|
||||
ChatOrigin parentOriginParallel = ChatOrigin.from(ctx);
|
||||
for (PreparedChild p : prepared) {
|
||||
CompletableFuture<ChildResult> future = CompletableFuture.supplyAsync(
|
||||
() -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId),
|
||||
() -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId, parentOriginParallel),
|
||||
DELEGATION_EXECUTOR);
|
||||
|
||||
// Broadcast per-child completion as soon as each child finishes
|
||||
@ -385,11 +403,18 @@ public class DelegateAgentTool {
|
||||
* truncated length, making "blank_success" detection unreliable.
|
||||
*/
|
||||
private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task,
|
||||
String parentConversationId, String childConversationId) {
|
||||
String parentConversationId, String childConversationId,
|
||||
ChatOrigin parentOrigin) {
|
||||
DelegationContext.enter(parentConversationId, CHILD_DENIED_TOOLS);
|
||||
try {
|
||||
long startTime = System.currentTimeMillis();
|
||||
String rawResult = agentService.chat(target.getId(), task, childConversationId);
|
||||
// RFC-063r §2.5 改动点 5: inherit parent origin, swap agentId
|
||||
// so child reads correct identity from ToolContext while keeping
|
||||
// channelId / channelTarget / workspace context intact.
|
||||
ChatOrigin childOrigin = (parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY)
|
||||
.withAgent(target.getId())
|
||||
.withConversationId(childConversationId);
|
||||
String rawResult = agentService.chat(target.getId(), task, childConversationId, childOrigin);
|
||||
long durationMs = System.currentTimeMillis() - startTime;
|
||||
// Measure lengths before truncation so ChildResult carries accurate metadata.
|
||||
return ChildResult.ofSuccess(taskIndex, target.getName(), rawResult, durationMs,
|
||||
|
||||
@ -2,8 +2,10 @@ package vip.mate.tool.builtin;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.system.model.SystemSettingsDTO;
|
||||
import vip.mate.system.service.SystemSettingService;
|
||||
@ -39,14 +41,16 @@ public class ImageGenerateTool {
|
||||
@ToolParam(description = "Aspect ratio: 1:1 / 16:9 / 9:16, default 1:1", required = false) String aspectRatio,
|
||||
@ToolParam(description = "Generation count (1-4), default 1", required = false) Integer count,
|
||||
@ToolParam(description = "Model name (optional)", required = false) String model,
|
||||
@ToolParam(description = "Task ID to check status (for status action)", required = false) String taskId
|
||||
@ToolParam(description = "Task ID to check status (for status action)", required = false) String taskId,
|
||||
// RFC-063r §2.5: ToolContext is hidden from the LLM by JsonSchemaGenerator.
|
||||
@Nullable ToolContext ctx
|
||||
) {
|
||||
String normalizedAction = (action == null || action.isBlank()) ? "generate" : action.trim().toLowerCase();
|
||||
|
||||
return switch (normalizedAction) {
|
||||
case "list" -> handleListAction();
|
||||
case "status" -> handleStatusAction(taskId);
|
||||
default -> handleGenerateAction(prompt, size, aspectRatio, count, model);
|
||||
case "status" -> handleStatusAction(taskId, ctx);
|
||||
default -> handleGenerateAction(prompt, size, aspectRatio, count, model, ctx);
|
||||
};
|
||||
}
|
||||
|
||||
@ -84,8 +88,8 @@ public class ImageGenerateTool {
|
||||
|
||||
// ==================== action=status ====================
|
||||
|
||||
private String handleStatusAction(String taskId) {
|
||||
String conversationId = ToolExecutionContext.conversationId();
|
||||
private String handleStatusAction(String taskId, @Nullable ToolContext ctx) {
|
||||
String conversationId = ToolExecutionContext.conversationId(ctx);
|
||||
|
||||
if (taskId != null && !taskId.isBlank()) {
|
||||
AsyncTaskInfo info = imageGenerationService.checkTaskStatus(taskId);
|
||||
@ -117,9 +121,9 @@ public class ImageGenerateTool {
|
||||
// ==================== action=generate ====================
|
||||
|
||||
private String handleGenerateAction(String prompt, String size, String aspectRatio,
|
||||
Integer count, String model) {
|
||||
String conversationId = ToolExecutionContext.conversationId();
|
||||
String username = ToolExecutionContext.username();
|
||||
Integer count, String model, @Nullable ToolContext ctx) {
|
||||
String conversationId = ToolExecutionContext.conversationId(ctx);
|
||||
String username = ToolExecutionContext.username(ctx);
|
||||
|
||||
if (conversationId == null || conversationId.isBlank()) {
|
||||
return "错误:无法获取当前会话信息,请重试";
|
||||
|
||||
@ -3,8 +3,10 @@ package vip.mate.tool.builtin;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
@ -57,7 +59,9 @@ public class ReadFileTool {
|
||||
public String read_file(
|
||||
@ToolParam(description = "Absolute or relative file path") String filePath,
|
||||
@ToolParam(description = "Start line number (1-based, inclusive). Omit to start from line 1", required = false) Integer startLine,
|
||||
@ToolParam(description = "End line number (1-based, inclusive). Omit to read to EOF or truncation limit", required = false) Integer endLine) {
|
||||
@ToolParam(description = "End line number (1-based, inclusive). Omit to read to EOF or truncation limit", required = false) Integer endLine,
|
||||
// RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator.
|
||||
@Nullable ToolContext ctx) {
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("filePath", filePath);
|
||||
@ -65,7 +69,9 @@ public class ReadFileTool {
|
||||
try {
|
||||
Path path;
|
||||
try {
|
||||
path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath);
|
||||
// RFC-063r §2.5: forward ToolContext so workspace boundary
|
||||
// honors ChatOrigin.workspaceBasePath when available.
|
||||
path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath, ctx);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Sandbox rejected the literal path. The LLM may have hallucinated
|
||||
// a Linux-style path (e.g. /app/Dockerfile) for a chat-upload that
|
||||
|
||||
@ -1,11 +1,21 @@
|
||||
package vip.mate.tool.builtin;
|
||||
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.lang.Nullable;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
|
||||
/**
|
||||
* 工具执行上下文 — 通过 ThreadLocal 向 @Tool 方法传递执行环境信息
|
||||
* <p>
|
||||
* 在 ToolExecutionExecutor.executeSingleTool() 中 set,在 finally 中 clear。
|
||||
* 视频生成等需要知道 conversationId 的工具从此处获取。
|
||||
*
|
||||
* <p>RFC-063r §2.5 兼容期:执行器会同时填充本 ThreadLocal 和 Spring AI 的
|
||||
* {@link ToolContext}(携带 {@link ChatOrigin})。优先读 ToolContext 的工具
|
||||
* 调用 {@link #conversationId(ToolContext)} / {@link #username(ToolContext)}
|
||||
* / {@link #workspaceBasePath(ToolContext)} 等三参重载即可——传入 ctx 不为
|
||||
* null 时优先返回 origin 的字段,否则回退到 ThreadLocal。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public final class ToolExecutionContext {
|
||||
@ -47,4 +57,34 @@ public final class ToolExecutionContext {
|
||||
USERNAME.remove();
|
||||
WORKSPACE_BASE_PATH.remove();
|
||||
}
|
||||
|
||||
// ===== RFC-063r §2.5: ToolContext-aware accessors =====
|
||||
//
|
||||
// Preferred over the parameter-less variants: read from the explicit
|
||||
// Spring AI ToolContext (carries ChatOrigin) when available, otherwise
|
||||
// fall back to the legacy ThreadLocal so legacy paths keep working.
|
||||
|
||||
public static String conversationId(@Nullable ToolContext ctx) {
|
||||
if (ctx != null) {
|
||||
String v = ChatOrigin.from(ctx).conversationId();
|
||||
if (v != null && !v.isEmpty()) return v;
|
||||
}
|
||||
return CONVERSATION_ID.get();
|
||||
}
|
||||
|
||||
public static String username(@Nullable ToolContext ctx) {
|
||||
if (ctx != null) {
|
||||
String v = ChatOrigin.from(ctx).requesterId();
|
||||
if (v != null && !v.isEmpty()) return v;
|
||||
}
|
||||
return USERNAME.get();
|
||||
}
|
||||
|
||||
public static String workspaceBasePath(@Nullable ToolContext ctx) {
|
||||
if (ctx != null) {
|
||||
String v = ChatOrigin.from(ctx).workspaceBasePath();
|
||||
if (v != null && !v.isBlank()) return v;
|
||||
}
|
||||
return WORKSPACE_BASE_PATH.get();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,8 +2,10 @@ package vip.mate.tool.builtin;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.system.model.SystemSettingsDTO;
|
||||
import vip.mate.system.service.SystemSettingService;
|
||||
@ -45,15 +47,18 @@ public class VideoGenerateTool {
|
||||
@ToolParam(description = "视频时长(秒),如 5 或 10,默认 5", required = false) Integer duration,
|
||||
@ToolParam(description = "参考图片 URL(图生视频模式)", required = false) String imageUrl,
|
||||
@ToolParam(description = "指定模型名称(可选)", required = false) String model,
|
||||
@ToolParam(description = "查询指定任务 ID 的状态(status 模式时使用)", required = false) String taskId
|
||||
@ToolParam(description = "查询指定任务 ID 的状态(status 模式时使用)", required = false) String taskId,
|
||||
// RFC-063r §2.5: ToolContext is auto-injected by Spring AI MethodToolCallback
|
||||
// and explicitly skipped by JsonSchemaGenerator — never visible to the LLM.
|
||||
@Nullable ToolContext ctx
|
||||
) {
|
||||
// 路由 action
|
||||
String normalizedAction = (action == null || action.isBlank()) ? "generate" : action.trim().toLowerCase();
|
||||
|
||||
return switch (normalizedAction) {
|
||||
case "list" -> handleListAction();
|
||||
case "status" -> handleStatusAction(taskId);
|
||||
default -> handleGenerateAction(prompt, aspectRatio, duration, imageUrl, model);
|
||||
case "status" -> handleStatusAction(taskId, ctx);
|
||||
default -> handleGenerateAction(prompt, aspectRatio, duration, imageUrl, model, ctx);
|
||||
};
|
||||
}
|
||||
|
||||
@ -92,8 +97,8 @@ public class VideoGenerateTool {
|
||||
|
||||
// ==================== action=status ====================
|
||||
|
||||
private String handleStatusAction(String taskId) {
|
||||
String conversationId = ToolExecutionContext.conversationId();
|
||||
private String handleStatusAction(String taskId, @Nullable ToolContext ctx) {
|
||||
String conversationId = ToolExecutionContext.conversationId(ctx);
|
||||
|
||||
// 指定 taskId 查询
|
||||
if (taskId != null && !taskId.isBlank()) {
|
||||
@ -124,9 +129,9 @@ public class VideoGenerateTool {
|
||||
// ==================== action=generate ====================
|
||||
|
||||
private String handleGenerateAction(String prompt, String aspectRatio, Integer duration,
|
||||
String imageUrl, String model) {
|
||||
String conversationId = ToolExecutionContext.conversationId();
|
||||
String username = ToolExecutionContext.username();
|
||||
String imageUrl, String model, @Nullable ToolContext ctx) {
|
||||
String conversationId = ToolExecutionContext.conversationId(ctx);
|
||||
String username = ToolExecutionContext.username(ctx);
|
||||
|
||||
if (conversationId == null || conversationId.isBlank()) {
|
||||
return "错误:无法获取当前会话信息,请重试";
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package vip.mate.tool.guard;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.lang.Nullable;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||
|
||||
import java.io.IOException;
|
||||
@ -33,9 +36,19 @@ public final class WorkspacePathGuard {
|
||||
* @throws IllegalArgumentException 路径不在允许范围内
|
||||
*/
|
||||
public static Path validatePath(String rawPath) {
|
||||
return validatePath(rawPath, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.5: ToolContext-aware overload. Reads the workspace base path
|
||||
* from the explicit {@link ChatOrigin} when present; falls back to the
|
||||
* legacy {@link ToolExecutionContext} ThreadLocal during the PR-1
|
||||
* transition window.
|
||||
*/
|
||||
public static Path validatePath(String rawPath, @Nullable ToolContext ctx) {
|
||||
Path normalized = Paths.get(rawPath).toAbsolutePath().normalize();
|
||||
|
||||
String basePath = ToolExecutionContext.workspaceBasePath();
|
||||
String basePath = resolveBasePath(ctx);
|
||||
if (basePath == null || basePath.isBlank()) {
|
||||
return normalized; // 未配置活动目录,不限制
|
||||
}
|
||||
@ -72,10 +85,35 @@ public final class WorkspacePathGuard {
|
||||
* @return 活动目录 Path,未配置时返回 null
|
||||
*/
|
||||
public static Path getWorkingDirectory() {
|
||||
String basePath = ToolExecutionContext.workspaceBasePath();
|
||||
return getWorkingDirectory(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §2.5: ToolContext-aware variant — prefer the explicit
|
||||
* {@link ChatOrigin} workspaceBasePath when available.
|
||||
*/
|
||||
public static Path getWorkingDirectory(@Nullable ToolContext ctx) {
|
||||
String basePath = resolveBasePath(ctx);
|
||||
if (basePath == null || basePath.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return Paths.get(basePath).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active workspace base path. Order of preference:
|
||||
* <ol>
|
||||
* <li>ChatOrigin from ToolContext (RFC-063r §2.5)</li>
|
||||
* <li>Legacy {@link ToolExecutionContext} ThreadLocal (PR-1 transition)</li>
|
||||
* </ol>
|
||||
*/
|
||||
private static String resolveBasePath(@Nullable ToolContext ctx) {
|
||||
if (ctx != null) {
|
||||
ChatOrigin origin = ChatOrigin.from(ctx);
|
||||
if (origin.workspaceBasePath() != null && !origin.workspaceBasePath().isBlank()) {
|
||||
return origin.workspaceBasePath();
|
||||
}
|
||||
}
|
||||
return ToolExecutionContext.workspaceBasePath();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,8 +2,10 @@ package vip.mate.tool.music;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||
|
||||
@ -23,9 +25,11 @@ public class MusicGenerateTool {
|
||||
public String music_generate(
|
||||
@ToolParam(description = "音乐风格/场景描述,如:'轻快的钢琴爵士乐'、'史诗电影配乐'、'欢快的流行歌曲'") String prompt,
|
||||
@ToolParam(description = "歌词文本(可选,不填则由 AI 生成或生成纯音乐)") String lyrics,
|
||||
@ToolParam(description = "是否生成纯音乐(无人声),默认 false") Boolean instrumental) {
|
||||
@ToolParam(description = "是否生成纯音乐(无人声),默认 false") Boolean instrumental,
|
||||
// RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator.
|
||||
@Nullable ToolContext ctx) {
|
||||
|
||||
String conversationId = ToolExecutionContext.conversationId();
|
||||
String conversationId = ToolExecutionContext.conversationId(ctx);
|
||||
if (conversationId == null) {
|
||||
return "无法获取会话 ID";
|
||||
}
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
-- RFC-063r §2.9: cron run delivery state machine.
|
||||
--
|
||||
-- delivery_status drives the SQL-CAS that prevents duplicate proactive sends
|
||||
-- (replaces RFC-063 v1's Caffeine 30-min TTL idempotency). 4-state machine:
|
||||
-- NONE — no delivery strategy (web-origin cron)
|
||||
-- PENDING — claimed by AbstractCronResultDelivery#claimRun
|
||||
-- DELIVERED — markDelivered after successful proactiveSend
|
||||
-- NOT_DELIVERED — markNotDelivered (or stale-cleanup timeout)
|
||||
--
|
||||
-- delivery_target VARCHAR(512) covers both IM userId (≤64) and Feishu
|
||||
-- sessionWebhook URLs (~200-256). Index (delivery_status, started_at) is
|
||||
-- the exact predicate of CronRunStaleCleanup.sweep().
|
||||
|
||||
ALTER TABLE mate_cron_job_run ADD COLUMN IF NOT EXISTS delivery_status VARCHAR(16) DEFAULT 'NONE' NOT NULL;
|
||||
ALTER TABLE mate_cron_job_run ADD COLUMN IF NOT EXISTS delivery_target VARCHAR(512);
|
||||
ALTER TABLE mate_cron_job_run ADD COLUMN IF NOT EXISTS delivery_error VARCHAR(500);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cron_run_pending_started ON mate_cron_job_run(delivery_status, started_at);
|
||||
@ -0,0 +1,12 @@
|
||||
-- RFC-063r §2.9: bind a cron job to its originating channel + delivery target.
|
||||
--
|
||||
-- channel_id is the only field that needs an index (operations team queries
|
||||
-- "all jobs delivering to channel X"). delivery_config is a JSON column —
|
||||
-- targetId / threadId / accountId are persisted as a structured value via
|
||||
-- MyBatis Plus JacksonTypeHandler so future delivery-target fields don't
|
||||
-- require schema changes.
|
||||
|
||||
ALTER TABLE mate_cron_job ADD COLUMN IF NOT EXISTS channel_id BIGINT;
|
||||
ALTER TABLE mate_cron_job ADD COLUMN IF NOT EXISTS delivery_config TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cron_channel ON mate_cron_job(channel_id);
|
||||
@ -0,0 +1,6 @@
|
||||
-- RFC-063r §2.12: persist the originating ChatOrigin as a JSON snapshot on the
|
||||
-- approval row. Used by ChannelMessageRouter.replayApprovedToolCall + web
|
||||
-- ApprovalController so cross-process / cross-restart approval replays still
|
||||
-- preserve the channel binding (Memento pattern).
|
||||
|
||||
ALTER TABLE mate_tool_approval ADD COLUMN IF NOT EXISTS chat_origin TEXT;
|
||||
@ -0,0 +1,22 @@
|
||||
-- RFC-063r §2.9: cron run delivery state machine (MySQL dialect).
|
||||
-- See V57 H2 file for state-machine + design reasoning.
|
||||
--
|
||||
-- MySQL has no `ADD COLUMN IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS` —
|
||||
-- guard each statement via INFORMATION_SCHEMA + dynamic SQL (project pattern
|
||||
-- previously used in V4/V19/V44).
|
||||
|
||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job_run' AND COLUMN_NAME = 'delivery_status');
|
||||
SET @s := IF(@c = 0, 'ALTER TABLE mate_cron_job_run ADD COLUMN delivery_status VARCHAR(16) NOT NULL DEFAULT ''NONE''', 'SELECT 1');
|
||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job_run' AND COLUMN_NAME = 'delivery_target');
|
||||
SET @s := IF(@c = 0, 'ALTER TABLE mate_cron_job_run ADD COLUMN delivery_target VARCHAR(512)', 'SELECT 1');
|
||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job_run' AND COLUMN_NAME = 'delivery_error');
|
||||
SET @s := IF(@c = 0, 'ALTER TABLE mate_cron_job_run ADD COLUMN delivery_error VARCHAR(500)', 'SELECT 1');
|
||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job_run' AND INDEX_NAME = 'idx_cron_run_pending_started');
|
||||
SET @s := IF(@c = 0, 'CREATE INDEX idx_cron_run_pending_started ON mate_cron_job_run(delivery_status, started_at)', 'SELECT 1');
|
||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
@ -0,0 +1,13 @@
|
||||
-- RFC-063r §2.9: bind a cron job to its originating channel (MySQL dialect).
|
||||
|
||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job' AND COLUMN_NAME = 'channel_id');
|
||||
SET @s := IF(@c = 0, 'ALTER TABLE mate_cron_job ADD COLUMN channel_id BIGINT', 'SELECT 1');
|
||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job' AND COLUMN_NAME = 'delivery_config');
|
||||
SET @s := IF(@c = 0, 'ALTER TABLE mate_cron_job ADD COLUMN delivery_config TEXT', 'SELECT 1');
|
||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job' AND INDEX_NAME = 'idx_cron_channel');
|
||||
SET @s := IF(@c = 0, 'CREATE INDEX idx_cron_channel ON mate_cron_job(channel_id)', 'SELECT 1');
|
||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
@ -0,0 +1,5 @@
|
||||
-- RFC-063r §2.12: persist ChatOrigin Memento snapshot on approval (MySQL dialect).
|
||||
|
||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_tool_approval' AND COLUMN_NAME = 'chat_origin');
|
||||
SET @s := IF(@c = 0, 'ALTER TABLE mate_tool_approval ADD COLUMN chat_origin TEXT', 'SELECT 1');
|
||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
@ -1432,6 +1432,15 @@ export default {
|
||||
lastRun: 'Last Run',
|
||||
enabled: 'Enabled',
|
||||
actions: 'Actions',
|
||||
// RFC-063r §2.14: list-page "Last Delivery" column
|
||||
lastDelivery: 'Last Delivery',
|
||||
},
|
||||
// RFC-063r §2.14: delivery_status state machine labels
|
||||
lastDelivery: {
|
||||
none: 'No delivery',
|
||||
pending: 'In flight',
|
||||
delivered: 'Delivered',
|
||||
not_delivered: 'Failed',
|
||||
},
|
||||
taskTypes: { text: 'Text Message', agent: 'Agent Goal' },
|
||||
cronTypes: { hourly: 'Hourly', daily: 'Daily', weekly: 'Weekly', custom: 'Custom' },
|
||||
|
||||
@ -1442,6 +1442,15 @@ export default {
|
||||
lastRun: '上次执行',
|
||||
enabled: '启用',
|
||||
actions: '操作',
|
||||
// RFC-063r §2.14: 列表页"最近投递"列
|
||||
lastDelivery: '最近投递',
|
||||
},
|
||||
// RFC-063r §2.14: delivery_status 状态机展示
|
||||
lastDelivery: {
|
||||
none: '无投递',
|
||||
pending: '投递中',
|
||||
delivered: '已送达',
|
||||
not_delivered: '投递失败',
|
||||
},
|
||||
taskTypes: { text: '文字消息', agent: 'Agent 目标' },
|
||||
cronTypes: { hourly: '每小时', daily: '每天', weekly: '每周', custom: '自定义' },
|
||||
|
||||
@ -786,4 +786,12 @@ export interface CronJob {
|
||||
lastRunTime?: string
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
// RFC-063r §2.9 / §2.14: channel binding + most-recent delivery snapshot.
|
||||
// channelId / deliveryConfig: round-trippable on create/update.
|
||||
// lastDeliveryStatus / lastDeliveryError: read-only, populated by
|
||||
// selectListWithDeliveryStatus / selectByIdWithDeliveryStatus on the backend.
|
||||
channelId?: number | null
|
||||
deliveryConfig?: { targetId?: string | null; threadId?: string | null; accountId?: string | null } | null
|
||||
lastDeliveryStatus?: 'NONE' | 'PENDING' | 'DELIVERED' | 'NOT_DELIVERED'
|
||||
lastDeliveryError?: string | null
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
<th>{{ t('cronJobs.columns.name') }}</th>
|
||||
<th>{{ t('cronJobs.columns.cron') }}</th>
|
||||
<th>{{ t('tokenUsage.date') }}</th>
|
||||
<th>{{ t('cronJobs.columns.lastDelivery') }}</th>
|
||||
<th>{{ t('cronJobs.columns.enabled') }}</th>
|
||||
<th>{{ t('cronJobs.columns.actions') }}</th>
|
||||
</tr>
|
||||
@ -53,6 +54,14 @@
|
||||
<span v-if="job.lastRunTime" class="time-subtext" :title="`${t('cronJobs.columns.lastRun')}: ${formatTime(job.lastRunTime)}`">{{ t('cronJobs.columns.lastRun') }}: {{ formatTime(job.lastRunTime) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<!-- RFC-063r §2.14: most-recent delivery status badge.
|
||||
hover surfaces the error detail when not delivered. -->
|
||||
<span class="delivery-badge" :class="'delivery-' + (job.lastDeliveryStatus || 'NONE').toLowerCase()"
|
||||
:title="job.lastDeliveryError || t('cronJobs.lastDelivery.' + (job.lastDeliveryStatus || 'NONE').toLowerCase())">
|
||||
{{ t('cronJobs.lastDelivery.' + (job.lastDeliveryStatus || 'NONE').toLowerCase()) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" :checked="job.enabled" @change="handleToggle(job)" />
|
||||
@ -87,7 +96,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="store.jobs.length === 0">
|
||||
<td colspan="5" class="empty-row">
|
||||
<td colspan="6" class="empty-row">
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">⏱</span>
|
||||
<p>{{ t('cronJobs.noJobs') }}</p>
|
||||
@ -653,6 +662,21 @@ function formatTime(datetime: string | undefined): string {
|
||||
}
|
||||
.time-empty { color: var(--mc-text-tertiary); }
|
||||
|
||||
/* RFC-063r §2.14: delivery-status badge — neutral / blue / green / red. */
|
||||
.delivery-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.delivery-none { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
|
||||
.delivery-pending { background: rgba(59, 130, 246, 0.12); color: rgb(59, 130, 246); }
|
||||
.delivery-delivered { background: rgba(34, 197, 94, 0.12); color: rgb(34, 197, 94); }
|
||||
.delivery-not_delivered { background: rgba(239, 68, 68, 0.12); color: rgb(239, 68, 68); }
|
||||
|
||||
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-slider { position: absolute; inset: 0; background: var(--mc-border); border-radius: 20px; transition: 0.2s; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user