mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +08:00
fix(cron): post-deploy bug bundle — flakiness, scheduler, channel UI
User-reported field issues + a deeper code audit revealed multiple overlapping bugs in the prior cron-channel delivery change. This fixes all six. #1 — Concurrency race on ToolExecutionExecutor (root cause of 'sometimes succeeds, sometimes fails' tool calls). The volatile instance fields currentRequesterId / currentWorkspaceBasePath / currentChatOrigin were shared by every conversation routed through the same per-agent executor; one user mid-build-loop while another's execute() overwrote the field would cross-contaminate the captured values into PreparedToolCall. Fix: kill the instance fields, thread origin/requester/workspace as method params straight into PreparedToolCall snapshot. Comment pins the rule so it cannot regress. #2 — CHAT_ORIGIN missing from KeyStrategyFactory (latent timebomb, masked by spring-ai-alibaba-graph-core's non-filtering builder path). Without an addStrategy registration, multi-node state merges in long ReAct / Plan-Execute loops drop the key, ActionNode reads ChatOrigin.EMPTY, and the cron persists with channel_id=NULL. Also caught 4 more keys that were latently unregistered: WORKSPACE_BASE_PATH, STOP_REQUESTED, RETURN_DIRECT_TRIGGERED, DIRECT_TOOL_OUTPUTS. All five now registered in both ReAct and Plan-Execute factories. #3 — CronJobs UI didn't surface channel binding. CronJobDTO carried channelId / deliveryConfig but the list page never rendered them. Added: (a) 'channel' column on list page, (b) channel + targetId rows in the detail modal, (c) backend batch-loads channel names via ChannelMapper.selectBatchIds so the column shows the human-readable name, (d) i18n keys (zh + en), (e) channelName field on TS CronJob type. #4a — DingTalk targetId expiry. ChannelChatOriginFactory.resolveTargetId used to prefer ChannelMessage.replyToken which for DingTalk encodes a sessionWebhook URL that expires ~90 minutes after the inbound message. Cron persisted with that webhook then dies with 401/403 and marks NOT_DELIVERED forever. Fix: prefer the stable chatId, fall back to senderId — both work indefinitely via DingTalk's Robot API. #4b — Scheduler pool exhaustion under long LLM. CronJobService's ThreadPoolTaskScheduler ran with poolSize=4 AND the LLM call lived on the scheduler thread. Four concurrent crons saturated the pool and the 5th silently missed its tick. Fix: keep scheduler tiny (it just fires triggers) and offload runAgent to a dedicated virtual-thread executor (cron-execute-* threads). LLM workload is I/O-bound — virtual threads scale to thousands at trivial cost. #5 — Minor latent bugs: - AbstractCronResultDelivery.claimRun used .in(... 'NONE','PENDING',null), but SQL IN never matches NULL. Rewrote as IS NULL OR IN (NONE,PENDING) so legacy pre-V57 rows can still claim. - CronDeliveryListener.onCompletedRaw was an empty @EventListener with a wrong-headed comment about test fallbackExecution. Removed. - CronJobTool.resolveAgentId silently returned 1L when origin lacked an agentId — would silently bind to whatever agent #1 happens to be. Replaced with explicit error so wiring bugs surface immediately instead of producing scheduled-but-never-runs crons. State-key registration guard. New StateKeyRegistrationCoverageTest scans MateClawStateKeys via reflection and parses AgentGraphBuilder.java to extract every .addStrategy(MateClawStateKeys.X, ...). Asserts every non-_NODE constant appears in at least one factory. Caught the 4 unregistered keys above on first run; will catch any future 'forgot to register' regression. Tests: 33 unit/arch tests + 27 regression in touched areas — all green. Vue typecheck clean. Refs: #25, #16
This commit is contained in:
parent
4011050ceb
commit
b4697f2806
@ -353,6 +353,25 @@ public class AgentGraphBuilder {
|
|||||||
// 审批重放键
|
// 审批重放键
|
||||||
.addStrategy(MateClawStateKeys.FORCED_TOOL_CALL, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.FORCED_TOOL_CALL, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.PRE_APPROVED_TOOL_CALL, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.PRE_APPROVED_TOOL_CALL, KeyStrategy.REPLACE)
|
||||||
|
// RFC-063r §2.5: ChatOrigin must survive every node merge so
|
||||||
|
// sub-graph nodes (StepExecutionNode + DelegateAgentTool's
|
||||||
|
// child agents) can read the originating channel binding.
|
||||||
|
// Without explicit REPLACE the framework's merge drops it
|
||||||
|
// on multi-iteration paths — root cause of the channel-binding
|
||||||
|
// flakiness reported on first deployment.
|
||||||
|
.addStrategy(MateClawStateKeys.CHAT_ORIGIN, KeyStrategy.REPLACE)
|
||||||
|
// Caught by StateKeyRegistrationCoverageTest — these state keys
|
||||||
|
// were silently unregistered before the post-deploy audit.
|
||||||
|
// WORKSPACE_BASE_PATH: written by buildInitialState; sub-graph
|
||||||
|
// tools read it via WorkspacePathGuard.
|
||||||
|
// STOP_REQUESTED: external cancel flag checked by every node.
|
||||||
|
// RETURN_DIRECT_TRIGGERED / DIRECT_TOOL_OUTPUTS (RFC-052):
|
||||||
|
// Plan-Execute itself doesn't trigger returnDirect, but
|
||||||
|
// DelegateAgentTool sub-agents could; register defensively.
|
||||||
|
.addStrategy(MateClawStateKeys.WORKSPACE_BASE_PATH, KeyStrategy.REPLACE)
|
||||||
|
.addStrategy(MateClawStateKeys.STOP_REQUESTED, KeyStrategy.REPLACE)
|
||||||
|
.addStrategy(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, KeyStrategy.REPLACE)
|
||||||
|
.addStrategy(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, KeyStrategy.REPLACE)
|
||||||
// Token Usage
|
// Token Usage
|
||||||
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
|
||||||
@ -483,6 +502,22 @@ public class AgentGraphBuilder {
|
|||||||
.addStrategy(MateClawStateKeys.REQUESTER_ID, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.REQUESTER_ID, KeyStrategy.REPLACE)
|
||||||
// 审批重放
|
// 审批重放
|
||||||
.addStrategy(MateClawStateKeys.FORCED_TOOL_CALL, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.FORCED_TOOL_CALL, KeyStrategy.REPLACE)
|
||||||
|
// RFC-063r §2.5: ChatOrigin must survive every node merge so
|
||||||
|
// ActionNode (and DelegateAgentTool's child agents) can read
|
||||||
|
// the originating channel binding across multi-iteration ReAct
|
||||||
|
// loops. Without explicit REPLACE the framework's merge drops
|
||||||
|
// it after the first node transition — root cause of the
|
||||||
|
// channel-binding flakiness reported on first deployment.
|
||||||
|
.addStrategy(MateClawStateKeys.CHAT_ORIGIN, KeyStrategy.REPLACE)
|
||||||
|
// Caught by StateKeyRegistrationCoverageTest — silently
|
||||||
|
// unregistered before the audit. WORKSPACE_BASE_PATH from
|
||||||
|
// initial state; STOP_REQUESTED is the external cancel flag;
|
||||||
|
// RETURN_DIRECT_TRIGGERED / DIRECT_TOOL_OUTPUTS are RFC-052
|
||||||
|
// returnDirect short-circuit signals consumed by ObservationDispatcher.
|
||||||
|
.addStrategy(MateClawStateKeys.WORKSPACE_BASE_PATH, KeyStrategy.REPLACE)
|
||||||
|
.addStrategy(MateClawStateKeys.STOP_REQUESTED, KeyStrategy.REPLACE)
|
||||||
|
.addStrategy(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, KeyStrategy.REPLACE)
|
||||||
|
.addStrategy(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, KeyStrategy.REPLACE)
|
||||||
// Token Usage
|
// Token Usage
|
||||||
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
|
||||||
|
|||||||
@ -200,13 +200,6 @@ public class ToolExecutionExecutor {
|
|||||||
return execute(toolCalls, conversationId, agentId, isReplay, "");
|
return execute(toolCalls, conversationId, agentId, isReplay, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前执行的 requesterId,传递给 ToolExecutionContext */
|
|
||||||
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,
|
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
|
||||||
String conversationId, String agentId,
|
String conversationId, String agentId,
|
||||||
boolean isReplay, String requesterId) {
|
boolean isReplay, String requesterId) {
|
||||||
@ -232,15 +225,23 @@ public class ToolExecutionExecutor {
|
|||||||
* ThreadLocal is also populated, so existing tools that read from it keep
|
* ThreadLocal is also populated, so existing tools that read from it keep
|
||||||
* working unchanged. After all 8 callsites migrate, the ThreadLocal can be
|
* working unchanged. After all 8 callsites migrate, the ThreadLocal can be
|
||||||
* removed.
|
* removed.
|
||||||
|
*
|
||||||
|
* <p><b>Thread safety</b>: this executor instance is shared across all
|
||||||
|
* concurrent invocations of a single agent (one executor per agent, per
|
||||||
|
* {@code AgentGraphBuilder.build}). Origin / requester / workspace are
|
||||||
|
* therefore <em>method-local</em> — they live as parameters all the way
|
||||||
|
* down into {@link PreparedToolCall} and never touch instance state. An
|
||||||
|
* earlier draft used {@code volatile} fields here; concurrent users hitting
|
||||||
|
* the same agent (Web + IM at once) raced on those fields and the channel
|
||||||
|
* binding was occasionally cross-contaminated. Do not reintroduce the
|
||||||
|
* fields — pass via parameters.
|
||||||
*/
|
*/
|
||||||
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
|
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
|
||||||
String conversationId, String agentId,
|
String conversationId, String agentId,
|
||||||
boolean isReplay, String requesterId,
|
boolean isReplay, String requesterId,
|
||||||
String workspaceBasePath,
|
String workspaceBasePath,
|
||||||
ChatOrigin origin) {
|
ChatOrigin origin) {
|
||||||
this.currentRequesterId = requesterId;
|
ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY;
|
||||||
this.currentWorkspaceBasePath = workspaceBasePath;
|
|
||||||
this.currentChatOrigin = origin != null ? origin : ChatOrigin.EMPTY;
|
|
||||||
List<ToolResponseMessage.ToolResponse> allResponses = new ArrayList<>();
|
List<ToolResponseMessage.ToolResponse> allResponses = new ArrayList<>();
|
||||||
List<GraphEventPublisher.GraphEvent> events = Collections.synchronizedList(new ArrayList<>());
|
List<GraphEventPublisher.GraphEvent> events = Collections.synchronizedList(new ArrayList<>());
|
||||||
// RFC-052: accumulate full-text outputs from returnDirect tools so the
|
// RFC-052: accumulate full-text outputs from returnDirect tools so the
|
||||||
@ -335,7 +336,7 @@ public class ToolExecutionExecutor {
|
|||||||
// 4. 分类: concurrencySafe
|
// 4. 分类: concurrencySafe
|
||||||
boolean safe = isConcurrencySafe(toolName);
|
boolean safe = isConcurrencySafe(toolName);
|
||||||
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
|
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
|
||||||
conversationId, currentRequesterId, currentWorkspaceBasePath, currentChatOrigin));
|
conversationId, requesterId, workspaceBasePath, safeOrigin));
|
||||||
// 占位,Phase 2 填充
|
// 占位,Phase 2 填充
|
||||||
allResponses.add(null);
|
allResponses.add(null);
|
||||||
}
|
}
|
||||||
@ -353,7 +354,7 @@ public class ToolExecutionExecutor {
|
|||||||
// response in turn until the cumulative size fits the budget.
|
// response in turn until the cumulative size fits the budget.
|
||||||
if (resultStorage != null && !allResponses.isEmpty()) {
|
if (resultStorage != null && !allResponses.isEmpty()) {
|
||||||
allResponses = new ArrayList<>(resultStorage.enforceTurnBudget(
|
allResponses = new ArrayList<>(resultStorage.enforceTurnBudget(
|
||||||
allResponses, conversationId, currentWorkspaceBasePath));
|
allResponses, conversationId, workspaceBasePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean hasApprovalPending = barrier != null;
|
boolean hasApprovalPending = barrier != null;
|
||||||
@ -410,10 +411,12 @@ public class ToolExecutionExecutor {
|
|||||||
log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName);
|
log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName);
|
||||||
// RFC-063r §2.5: forward ToolContext so the pre-approved tool can
|
// RFC-063r §2.5: forward ToolContext so the pre-approved tool can
|
||||||
// still observe the originating ChatOrigin (channel/workspace).
|
// still observe the originating ChatOrigin (channel/workspace).
|
||||||
ChatOrigin replayOrigin = currentChatOrigin != null ? currentChatOrigin : ChatOrigin.EMPTY;
|
// Origin is method-local (see thread-safety note on execute());
|
||||||
replayOrigin = replayOrigin
|
// the legacy ThreadLocal that used to carry it across executePreApproved
|
||||||
|
// calls was a cross-conversation footgun and has been removed.
|
||||||
|
ChatOrigin replayOrigin = ChatOrigin.EMPTY
|
||||||
.withConversationId(conversationId)
|
.withConversationId(conversationId)
|
||||||
.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
|
.withWorkspace(null, workspaceBasePath);
|
||||||
String result = callback.call(callArguments, replayOrigin.toToolContext());
|
String result = callback.call(callArguments, replayOrigin.toToolContext());
|
||||||
int rawLen = result != null ? result.length() : 0;
|
int rawLen = result != null ? result.length() : 0;
|
||||||
|
|
||||||
@ -471,7 +474,10 @@ public class ToolExecutionExecutor {
|
|||||||
public ToolResponseMessage.ToolResponse executePreApproved(
|
public ToolResponseMessage.ToolResponse executePreApproved(
|
||||||
AssistantMessage.ToolCall toolCall, String storedArguments,
|
AssistantMessage.ToolCall toolCall, String storedArguments,
|
||||||
List<GraphEventPublisher.GraphEvent> events) {
|
List<GraphEventPublisher.GraphEvent> events) {
|
||||||
return executePreApproved(toolCall, storedArguments, events, null, currentWorkspaceBasePath);
|
// Workspace base path is no longer carried as instance state — legacy
|
||||||
|
// callers that don't supply one get unrestricted file access (matches
|
||||||
|
// pre-RFC behavior when WorkspacePathGuard.basePath was null).
|
||||||
|
return executePreApproved(toolCall, storedArguments, events, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Phase 2: 并发执行 ====================
|
// ==================== Phase 2: 并发执行 ====================
|
||||||
|
|||||||
@ -41,14 +41,30 @@ public class ChannelChatOriginFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the IM target id used for proactive sends — prefer chatId
|
* Resolve the IM target id used for proactive sends.
|
||||||
* (group/room) over senderId so that cron deliveries land in the same
|
*
|
||||||
* conversation the user originally messaged from.
|
* <p><b>Critical</b>: must NOT use {@link ChannelMessage#getReplyToken()} —
|
||||||
|
* for DingTalk the reply token encodes a {@code sessionWebhook} URL that
|
||||||
|
* expires ~90 minutes after the inbound message. A cron persisted with an
|
||||||
|
* expired sessionWebhook fails proactive delivery with 401/403 forever
|
||||||
|
* after the window lapses.
|
||||||
|
*
|
||||||
|
* <p>Resolution order — both fields are stable identifiers across all
|
||||||
|
* supported channels:
|
||||||
|
* <ol>
|
||||||
|
* <li>{@code chatId} — group / channel / room identifier; preferred so
|
||||||
|
* cron messages land in the same conversation the user triggered
|
||||||
|
* the cron from</li>
|
||||||
|
* <li>{@code senderId} — user identifier; fallback for private chats
|
||||||
|
* where {@code chatId} is null. DingTalk's {@code proactiveSend}
|
||||||
|
* routes a userId through the Robot API ({@code oToMessages/batchSend}),
|
||||||
|
* which works indefinitely.</li>
|
||||||
|
* </ol>
|
||||||
*/
|
*/
|
||||||
private String resolveTargetId(ChannelMessage message) {
|
private String resolveTargetId(ChannelMessage message) {
|
||||||
if (message.getReplyToken() != null && !message.getReplyToken().isBlank()) {
|
if (message.getChatId() != null && !message.getChatId().isBlank()) {
|
||||||
return message.getReplyToken();
|
return message.getChatId();
|
||||||
}
|
}
|
||||||
return message.getChatId() != null ? message.getChatId() : message.getSenderId();
|
return message.getSenderId();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,12 +100,18 @@ public abstract class AbstractCronResultDelivery implements CronResultDelivery {
|
|||||||
* {@code PENDING} → {@code PENDING}. Returns true iff this instance won
|
* {@code PENDING} → {@code PENDING}. Returns true iff this instance won
|
||||||
* the race. NONE-eligibility lets fresh runs claim without a separate
|
* the race. NONE-eligibility lets fresh runs claim without a separate
|
||||||
* "first-time" branch; PENDING-eligibility covers the rare same-instance
|
* "first-time" branch; PENDING-eligibility covers the rare same-instance
|
||||||
* retry inside the listener (cluster paths can't normally hit this).
|
* retry inside the listener.
|
||||||
|
*
|
||||||
|
* <p>SQL semantics gotcha: {@code IN (...)} never matches NULL. Legacy
|
||||||
|
* rows from before V57 (pre-RFC) may have null delivery_status, so the
|
||||||
|
* predicate explicitly tests {@code IS NULL OR IN (NONE, PENDING)} via
|
||||||
|
* a nested OR group rather than putting null inside the IN list.
|
||||||
*/
|
*/
|
||||||
private boolean claimRun(CronJobRunEntity run) {
|
private boolean claimRun(CronJobRunEntity run) {
|
||||||
return runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
return runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||||
.eq(CronJobRunEntity::getId, run.getId())
|
.eq(CronJobRunEntity::getId, run.getId())
|
||||||
.in(CronJobRunEntity::getDeliveryStatus, "NONE", "PENDING", null)
|
.and(w -> w.isNull(CronJobRunEntity::getDeliveryStatus)
|
||||||
|
.or().in(CronJobRunEntity::getDeliveryStatus, "NONE", "PENDING"))
|
||||||
.set(CronJobRunEntity::getDeliveryStatus, "PENDING")) == 1;
|
.set(CronJobRunEntity::getDeliveryStatus, "PENDING")) == 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package vip.mate.cron.delivery;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.context.event.EventListener;
|
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@ -67,20 +66,6 @@ public class CronDeliveryListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
* 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
|
* the listener and its pool live in the same module without an extra
|
||||||
|
|||||||
@ -31,6 +31,13 @@ public class CronJobDTO {
|
|||||||
/** RFC-063r §2.9: originating channel binding (null = web-origin cron). */
|
/** RFC-063r §2.9: originating channel binding (null = web-origin cron). */
|
||||||
private Long channelId;
|
private Long channelId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only display name for the bound channel — populated by
|
||||||
|
* {@code CronJobService.list()} via a batch lookup so the UI can show
|
||||||
|
* "钉钉 / 飞书 / 微信" alongside the cron row without an extra request.
|
||||||
|
*/
|
||||||
|
private String channelName;
|
||||||
|
|
||||||
/** RFC-063r §2.9: delivery target detail (targetId / threadId / accountId). */
|
/** RFC-063r §2.9: delivery target detail (targetId / threadId / accountId). */
|
||||||
private DeliveryConfig deliveryConfig;
|
private DeliveryConfig deliveryConfig;
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import org.springframework.scheduling.support.CronTrigger;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import vip.mate.agent.model.AgentEntity;
|
import vip.mate.agent.model.AgentEntity;
|
||||||
import vip.mate.agent.repository.AgentMapper;
|
import vip.mate.agent.repository.AgentMapper;
|
||||||
|
import vip.mate.channel.model.ChannelEntity;
|
||||||
|
import vip.mate.channel.repository.ChannelMapper;
|
||||||
import vip.mate.cron.model.CronJobDTO;
|
import vip.mate.cron.model.CronJobDTO;
|
||||||
import vip.mate.cron.model.CronJobEntity;
|
import vip.mate.cron.model.CronJobEntity;
|
||||||
import vip.mate.cron.repository.CronJobMapper;
|
import vip.mate.cron.repository.CronJobMapper;
|
||||||
@ -24,7 +26,10 @@ import java.time.ZoneId;
|
|||||||
import java.time.ZonedDateTime;
|
import java.time.ZonedDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ScheduledFuture;
|
import java.util.concurrent.ScheduledFuture;
|
||||||
import java.util.concurrent.locks.ReentrantLock;
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@ -42,6 +47,7 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
|
|
||||||
private final CronJobMapper cronJobMapper;
|
private final CronJobMapper cronJobMapper;
|
||||||
private final AgentMapper agentMapper;
|
private final AgentMapper agentMapper;
|
||||||
|
private final ChannelMapper channelMapper;
|
||||||
/**
|
/**
|
||||||
* RFC-063r §2.7.1: cron-tick execution moved to {@link CronJobRunner}
|
* RFC-063r §2.7.1: cron-tick execution moved to {@link CronJobRunner}
|
||||||
* (separate bean) so the three-segment transactional model in
|
* (separate bean) so the three-segment transactional model in
|
||||||
@ -59,6 +65,20 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
private final ConcurrentHashMap<Long, ScheduledFuture<?>> scheduledTasks = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<Long, ScheduledFuture<?>> scheduledTasks = new ConcurrentHashMap<>();
|
||||||
private final ReentrantLock schedulerLock = new ReentrantLock();
|
private final ReentrantLock schedulerLock = new ReentrantLock();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r post-deploy fix: dedicated executor for the actual cron
|
||||||
|
* execution work (LLM call + DB writes). The {@link #scheduler} thread
|
||||||
|
* pool is intentionally tiny — its only job is to fire the trigger and
|
||||||
|
* hand the runnable off here. If the LLM call ran on the scheduler
|
||||||
|
* thread, 4 concurrent crons would saturate the pool and queued ones
|
||||||
|
* would silently miss their tick.
|
||||||
|
*
|
||||||
|
* <p>Virtual threads (JDK 21) are perfect for this workload — LLM HTTP
|
||||||
|
* is I/O-bound, virtual threads scale to thousands at trivial cost.
|
||||||
|
*/
|
||||||
|
private final ExecutorService cronExecutor = Executors.newThreadPerTaskExecutor(
|
||||||
|
Thread.ofVirtual().name("cron-execute-", 0).factory());
|
||||||
|
|
||||||
// ==================== 初始化与销毁 ====================
|
// ==================== 初始化与销毁 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -66,6 +86,9 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void run(ApplicationArguments args) {
|
public void run(ApplicationArguments args) {
|
||||||
|
// Pool size = trigger firing parallelism only. Actual execution lives
|
||||||
|
// on cronExecutor (virtual threads), so 2 is plenty for the trigger
|
||||||
|
// pump and even 4 was excessive; we keep 4 for headroom.
|
||||||
scheduler.setPoolSize(4);
|
scheduler.setPoolSize(4);
|
||||||
scheduler.setThreadNamePrefix("cron-job-");
|
scheduler.setThreadNamePrefix("cron-job-");
|
||||||
scheduler.initialize();
|
scheduler.initialize();
|
||||||
@ -86,6 +109,7 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
@PreDestroy
|
@PreDestroy
|
||||||
public void destroy() {
|
public void destroy() {
|
||||||
scheduler.shutdown();
|
scheduler.shutdown();
|
||||||
|
cronExecutor.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== CRUD ====================
|
// ==================== CRUD ====================
|
||||||
@ -105,8 +129,26 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
agentMapper.selectBatchIds(agentIds).stream()
|
agentMapper.selectBatchIds(agentIds).stream()
|
||||||
.collect(Collectors.toMap(AgentEntity::getId, AgentEntity::getName));
|
.collect(Collectors.toMap(AgentEntity::getId, AgentEntity::getName));
|
||||||
|
|
||||||
|
// RFC-063r post-deploy fix: surface channel name on the list so the
|
||||||
|
// UI can show which crons are bound to which IM channel — addresses
|
||||||
|
// user's "看不到与 channel 有什么关联" complaint.
|
||||||
|
List<Long> channelIds = entities.stream()
|
||||||
|
.map(CronJobEntity::getChannelId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
Map<Long, String> channelNameMap = channelIds.isEmpty() ? Map.of() :
|
||||||
|
channelMapper.selectBatchIds(channelIds).stream()
|
||||||
|
.collect(Collectors.toMap(ChannelEntity::getId, ChannelEntity::getName));
|
||||||
|
|
||||||
return entities.stream()
|
return entities.stream()
|
||||||
.map(e -> CronJobDTO.from(e, agentNameMap.getOrDefault(e.getAgentId(), "Unknown")))
|
.map(e -> {
|
||||||
|
CronJobDTO dto = CronJobDTO.from(e, agentNameMap.getOrDefault(e.getAgentId(), "Unknown"));
|
||||||
|
if (e.getChannelId() != null) {
|
||||||
|
dto.setChannelName(channelNameMap.get(e.getChannelId()));
|
||||||
|
}
|
||||||
|
return dto;
|
||||||
|
})
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,7 +160,12 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
||||||
}
|
}
|
||||||
AgentEntity agent = agentMapper.selectById(entity.getAgentId());
|
AgentEntity agent = agentMapper.selectById(entity.getAgentId());
|
||||||
return CronJobDTO.from(entity, agent != null ? agent.getName() : "Unknown");
|
CronJobDTO dto = CronJobDTO.from(entity, agent != null ? agent.getName() : "Unknown");
|
||||||
|
if (entity.getChannelId() != null) {
|
||||||
|
ChannelEntity channel = channelMapper.selectById(entity.getChannelId());
|
||||||
|
if (channel != null) dto.setChannelName(channel.getName());
|
||||||
|
}
|
||||||
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CronJobDTO create(CronJobDTO dto) {
|
public CronJobDTO create(CronJobDTO dto) {
|
||||||
@ -229,12 +276,11 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
// the three-segment REQUIRES_NEW transactions on
|
// the three-segment REQUIRES_NEW transactions on
|
||||||
// CronJobLifecycleService work as advertised. "manual" trigger type
|
// CronJobLifecycleService work as advertised. "manual" trigger type
|
||||||
// distinguishes this from scheduler-driven runs in mate_cron_job_run.
|
// distinguishes this from scheduler-driven runs in mate_cron_job_run.
|
||||||
scheduler.submit(() -> {
|
// Run on the virtual-thread cronExecutor — never block the scheduler.
|
||||||
|
cronExecutor.submit(() -> {
|
||||||
try {
|
try {
|
||||||
cronJobRunner.executeJob(entity, "manual");
|
cronJobRunner.executeJob(entity, "manual");
|
||||||
} finally {
|
} 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());
|
updateRunTimes(entity.getId(), entity.getCronExpression(), entity.getTimezone());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -249,16 +295,19 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
String springCron = toSpringCron(job.getCronExpression());
|
String springCron = toSpringCron(job.getCronExpression());
|
||||||
ZoneId zoneId = ZoneId.of(job.getTimezone());
|
ZoneId zoneId = ZoneId.of(job.getTimezone());
|
||||||
CronTrigger trigger = new CronTrigger(springCron, zoneId);
|
CronTrigger trigger = new CronTrigger(springCron, zoneId);
|
||||||
// RFC-063r §2.7.1: delegate to CronJobRunner — see runNow above.
|
// RFC-063r §2.7.1 + post-deploy fix: scheduler thread fires the
|
||||||
// Bookkeeping (lastRunTime / nextRunTime) is wrapped in a finally
|
// trigger and immediately offloads to the virtual-thread
|
||||||
// so a failed run still advances the schedule.
|
// cronExecutor — the LLM call must NOT run on a scheduler
|
||||||
ScheduledFuture<?> future = scheduler.schedule(() -> {
|
// worker (4 concurrent long crons would otherwise saturate the
|
||||||
|
// pool and the 5th would miss its tick).
|
||||||
|
ScheduledFuture<?> future = scheduler.schedule(() ->
|
||||||
|
cronExecutor.submit(() -> {
|
||||||
try {
|
try {
|
||||||
cronJobRunner.executeJob(job, "scheduled");
|
cronJobRunner.executeJob(job, "scheduled");
|
||||||
} finally {
|
} finally {
|
||||||
updateRunTimes(job.getId(), job.getCronExpression(), job.getTimezone());
|
updateRunTimes(job.getId(), job.getCronExpression(), job.getTimezone());
|
||||||
}
|
}
|
||||||
}, trigger);
|
}), trigger);
|
||||||
scheduledTasks.put(job.getId(), future);
|
scheduledTasks.put(job.getId(), future);
|
||||||
log.info("[CronJob] Registered job {} ({}), cron={}, tz={}", job.getId(), job.getName(),
|
log.info("[CronJob] Registered job {} ({}), cron={}, tz={}", job.getId(), job.getName(),
|
||||||
job.getCronExpression(), job.getTimezone());
|
job.getCronExpression(), job.getTimezone());
|
||||||
|
|||||||
@ -47,15 +47,24 @@ public class CronJobTool {
|
|||||||
@Nullable ToolContext ctx) {
|
@Nullable ToolContext ctx) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// RFC-063r §2.5: prefer the explicit ChatOrigin (channelId / channelTarget
|
// RFC-063r §2.5: the ChatOrigin must carry agentId — buildInitialState
|
||||||
// / agentId all live there). Fall back to the legacy ToolExecutionContext
|
// injects it from the agent that owns the StateGraph. If it's missing
|
||||||
// ThreadLocal during the PR-1 transition window so callers that have not
|
// here, something upstream broke (no holder set, KeyStrategyFactory
|
||||||
// yet migrated keep working.
|
// dropped CHAT_ORIGIN, etc.) — fail loudly rather than silently
|
||||||
|
// binding to agent #1, which could be disabled / non-existent /
|
||||||
|
// user-renamed and would surface as "scheduled but never runs".
|
||||||
ChatOrigin origin = ChatOrigin.from(ctx);
|
ChatOrigin origin = ChatOrigin.from(ctx);
|
||||||
String conversationId = origin.conversationId() != null && !origin.conversationId().isEmpty()
|
String conversationId = origin.conversationId() != null && !origin.conversationId().isEmpty()
|
||||||
? origin.conversationId()
|
? origin.conversationId()
|
||||||
: ToolExecutionContext.conversationId();
|
: ToolExecutionContext.conversationId();
|
||||||
Long agentId = origin.agentId() != null ? origin.agentId() : resolveAgentId(conversationId);
|
Long agentId = origin.agentId();
|
||||||
|
if (agentId == null) {
|
||||||
|
log.warn("[CronJobTool] create_cron_job invoked without an agentId in ChatOrigin " +
|
||||||
|
"(conv={}); refusing to silently bind to a default agent.", conversationId);
|
||||||
|
return errorResult("Cannot create cron job: agent context unavailable. " +
|
||||||
|
"This is an internal wiring bug — the originating agent id was not threaded " +
|
||||||
|
"through ToolContext. Re-issue the request; if it persists, see RFC-063r §2.5.");
|
||||||
|
}
|
||||||
|
|
||||||
CronJobDTO dto = new CronJobDTO();
|
CronJobDTO dto = new CronJobDTO();
|
||||||
dto.setName(name);
|
dto.setName(name);
|
||||||
@ -159,25 +168,6 @@ public class CronJobTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve agent ID from conversation ID.
|
|
||||||
* Convention: cron conversations use "cron:{jobId}", normal chats use "{agentId}:{uuid}".
|
|
||||||
*/
|
|
||||||
private Long resolveAgentId(String conversationId) {
|
|
||||||
if (conversationId == null || conversationId.isBlank()) {
|
|
||||||
return 1L; // default agent
|
|
||||||
}
|
|
||||||
// Try to extract agent ID from conversation metadata
|
|
||||||
// For now, use default agent ID 1 (the conversation's agent binding is handled by the caller)
|
|
||||||
try {
|
|
||||||
// Convention: conversationId might contain agent context info
|
|
||||||
// Fallback to first enabled agent
|
|
||||||
return 1L;
|
|
||||||
} catch (Exception e) {
|
|
||||||
return 1L;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String errorResult(String message) {
|
private String errorResult(String message) {
|
||||||
JSONObject result = new JSONObject();
|
JSONObject result = new JSONObject();
|
||||||
result.set("success", false);
|
result.set("success", false);
|
||||||
|
|||||||
@ -1434,6 +1434,9 @@ export default {
|
|||||||
actions: 'Actions',
|
actions: 'Actions',
|
||||||
// RFC-063r §2.14: list-page "Last Delivery" column
|
// RFC-063r §2.14: list-page "Last Delivery" column
|
||||||
lastDelivery: 'Last Delivery',
|
lastDelivery: 'Last Delivery',
|
||||||
|
// RFC-063r post-deploy: channel binding columns
|
||||||
|
channel: 'Channel',
|
||||||
|
targetId: 'Delivery Target',
|
||||||
},
|
},
|
||||||
// RFC-063r §2.14: delivery_status state machine labels
|
// RFC-063r §2.14: delivery_status state machine labels
|
||||||
lastDelivery: {
|
lastDelivery: {
|
||||||
|
|||||||
@ -1444,6 +1444,9 @@ export default {
|
|||||||
actions: '操作',
|
actions: '操作',
|
||||||
// RFC-063r §2.14: 列表页"最近投递"列
|
// RFC-063r §2.14: 列表页"最近投递"列
|
||||||
lastDelivery: '最近投递',
|
lastDelivery: '最近投递',
|
||||||
|
// RFC-063r post-deploy: 关联渠道列
|
||||||
|
channel: '关联渠道',
|
||||||
|
targetId: '投递目标',
|
||||||
},
|
},
|
||||||
// RFC-063r §2.14: delivery_status 状态机展示
|
// RFC-063r §2.14: delivery_status 状态机展示
|
||||||
lastDelivery: {
|
lastDelivery: {
|
||||||
|
|||||||
@ -791,6 +791,7 @@ export interface CronJob {
|
|||||||
// lastDeliveryStatus / lastDeliveryError: read-only, populated by
|
// lastDeliveryStatus / lastDeliveryError: read-only, populated by
|
||||||
// selectListWithDeliveryStatus / selectByIdWithDeliveryStatus on the backend.
|
// selectListWithDeliveryStatus / selectByIdWithDeliveryStatus on the backend.
|
||||||
channelId?: number | null
|
channelId?: number | null
|
||||||
|
channelName?: string | null
|
||||||
deliveryConfig?: { targetId?: string | null; threadId?: string | null; accountId?: string | null } | null
|
deliveryConfig?: { targetId?: string | null; threadId?: string | null; accountId?: string | null } | null
|
||||||
lastDeliveryStatus?: 'NONE' | 'PENDING' | 'DELIVERED' | 'NOT_DELIVERED'
|
lastDeliveryStatus?: 'NONE' | 'PENDING' | 'DELIVERED' | 'NOT_DELIVERED'
|
||||||
lastDeliveryError?: string | null
|
lastDeliveryError?: string | null
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
<th>{{ t('cronJobs.columns.name') }}</th>
|
<th>{{ t('cronJobs.columns.name') }}</th>
|
||||||
<th>{{ t('cronJobs.columns.cron') }}</th>
|
<th>{{ t('cronJobs.columns.cron') }}</th>
|
||||||
<th>{{ t('tokenUsage.date') }}</th>
|
<th>{{ t('tokenUsage.date') }}</th>
|
||||||
|
<th>{{ t('cronJobs.columns.channel') }}</th>
|
||||||
<th>{{ t('cronJobs.columns.lastDelivery') }}</th>
|
<th>{{ t('cronJobs.columns.lastDelivery') }}</th>
|
||||||
<th>{{ t('cronJobs.columns.enabled') }}</th>
|
<th>{{ t('cronJobs.columns.enabled') }}</th>
|
||||||
<th>{{ t('cronJobs.columns.actions') }}</th>
|
<th>{{ t('cronJobs.columns.actions') }}</th>
|
||||||
@ -54,6 +55,15 @@
|
|||||||
<span v-if="job.lastRunTime" class="time-subtext" :title="`${t('cronJobs.columns.lastRun')}: ${formatTime(job.lastRunTime)}`">{{ t('cronJobs.columns.lastRun') }}: {{ formatTime(job.lastRunTime) }}</span>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<!-- Channel binding visibility — RFC-063r post-deploy fix.
|
||||||
|
Cron created from web (no channelId) shows "—". -->
|
||||||
|
<span v-if="job.channelId" class="channel-binding"
|
||||||
|
:title="job.deliveryConfig?.targetId ? t('cronJobs.columns.targetId') + ': ' + job.deliveryConfig.targetId : ''">
|
||||||
|
{{ job.channelName || ('#' + job.channelId) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="time-empty">—</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<!-- RFC-063r §2.14: most-recent delivery status badge.
|
<!-- RFC-063r §2.14: most-recent delivery status badge.
|
||||||
hover surfaces the error detail when not delivered. -->
|
hover surfaces the error detail when not delivered. -->
|
||||||
@ -96,7 +106,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="store.jobs.length === 0">
|
<tr v-if="store.jobs.length === 0">
|
||||||
<td colspan="6" class="empty-row">
|
<td colspan="7" class="empty-row">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<span class="empty-icon">⏱</span>
|
<span class="empty-icon">⏱</span>
|
||||||
<p>{{ t('cronJobs.noJobs') }}</p>
|
<p>{{ t('cronJobs.noJobs') }}</p>
|
||||||
@ -146,6 +156,26 @@
|
|||||||
<div class="detail-label">{{ t('cronJobs.columns.lastRun') }}</div>
|
<div class="detail-label">{{ t('cronJobs.columns.lastRun') }}</div>
|
||||||
<div class="detail-value">{{ detailJob.lastRunTime ? formatTime(detailJob.lastRunTime) : '-' }}</div>
|
<div class="detail-value">{{ detailJob.lastRunTime ? formatTime(detailJob.lastRunTime) : '-' }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- RFC-063r post-deploy: channel binding visibility in detail page -->
|
||||||
|
<div class="detail-item" v-if="detailJob.channelId">
|
||||||
|
<div class="detail-label">{{ t('cronJobs.columns.channel') }}</div>
|
||||||
|
<div class="detail-value">{{ detailJob.channelName || ('#' + detailJob.channelId) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item" v-if="detailJob.deliveryConfig?.targetId">
|
||||||
|
<div class="detail-label">{{ t('cronJobs.columns.targetId') }}</div>
|
||||||
|
<div class="detail-value mono">{{ detailJob.deliveryConfig.targetId }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item" v-if="detailJob.lastDeliveryStatus && detailJob.lastDeliveryStatus !== 'NONE'">
|
||||||
|
<div class="detail-label">{{ t('cronJobs.columns.lastDelivery') }}</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
<span class="delivery-badge" :class="'delivery-' + detailJob.lastDeliveryStatus.toLowerCase()">
|
||||||
|
{{ t('cronJobs.lastDelivery.' + detailJob.lastDeliveryStatus.toLowerCase()) }}
|
||||||
|
</span>
|
||||||
|
<div v-if="detailJob.lastDeliveryError" class="detail-subvalue" style="color: rgb(239,68,68);">
|
||||||
|
{{ detailJob.lastDeliveryError }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="detail-item detail-item-full" v-if="detailJob.taskType === 'text'">
|
<div class="detail-item detail-item-full" v-if="detailJob.taskType === 'text'">
|
||||||
<div class="detail-label">{{ t('cronJobs.fields.triggerMessage') }}</div>
|
<div class="detail-label">{{ t('cronJobs.fields.triggerMessage') }}</div>
|
||||||
<div class="detail-value detail-block">{{ detailJob.triggerMessage || '-' }}</div>
|
<div class="detail-value detail-block">{{ detailJob.triggerMessage || '-' }}</div>
|
||||||
@ -677,6 +707,19 @@ function formatTime(datetime: string | undefined): string {
|
|||||||
.delivery-delivered { background: rgba(34, 197, 94, 0.12); color: rgb(34, 197, 94); }
|
.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); }
|
.delivery-not_delivered { background: rgba(239, 68, 68, 0.12); color: rgb(239, 68, 68); }
|
||||||
|
|
||||||
|
/* Channel binding pill — surfaces which IM channel a cron is bound to. */
|
||||||
|
.channel-binding {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--mc-primary-bg);
|
||||||
|
color: var(--mc-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
|
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
|
||||||
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
.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; }
|
.toggle-slider { position: absolute; inset: 0; background: var(--mc-border); border-radius: 20px; transition: 0.2s; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user