feat(desktop): push publish tasks via AMQP topic dispatch WebSocket
Introduce a desktop.task.dispatch topic exchange with per-client routing keys. Tenant-api publishes a task_available frame keyed by target client ID when a publish job is created; every instance binds its own transient queue and forwards to the matching WebSocket (/api/desktop/dispatch) it owns. Desktop-client now prefers this push channel and only falls back to HTTP /lease polling when the socket is down. Also drop admin-web monitoring-plugin remnants and scope the publish modal account list to publish platforms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,7 @@ import {
|
||||
upsertDesktopAccount,
|
||||
} from "./transport/api-client";
|
||||
import { SseClient, SseClientError } from "./transport/sse-client";
|
||||
import { DispatchWsClient } from "./transport/dispatch-ws-client";
|
||||
|
||||
type RuntimeTone = "info" | "success" | "warn" | "danger";
|
||||
type RuntimeTaskStatus =
|
||||
@@ -184,6 +185,8 @@ interface RuntimeState {
|
||||
heartbeatTimer: ReturnType<typeof setInterval> | null;
|
||||
pullTimer: ReturnType<typeof setInterval> | null;
|
||||
sseClient: SseClient | null;
|
||||
dispatchWsClient: DispatchWsClient | null;
|
||||
dispatchWsConnected: boolean;
|
||||
lastHeartbeatAt: number;
|
||||
lastHeartbeatStatus: "idle" | "success" | "failed";
|
||||
lastPullAt: number;
|
||||
@@ -210,6 +213,8 @@ const state: RuntimeState = {
|
||||
heartbeatTimer: null,
|
||||
pullTimer: null,
|
||||
sseClient: null,
|
||||
dispatchWsClient: null,
|
||||
dispatchWsConnected: false,
|
||||
lastHeartbeatAt: 0,
|
||||
lastHeartbeatStatus: "idle",
|
||||
lastPullAt: 0,
|
||||
@@ -354,6 +359,7 @@ function startRuntime(): void {
|
||||
recordActivity("success", "调度节点已启动", "已启动 SSE、心跳上报和兜底拉取。");
|
||||
|
||||
connectEventStream();
|
||||
connectDispatchWs();
|
||||
scheduleHeartbeatLoop();
|
||||
schedulePullLoop();
|
||||
|
||||
@@ -383,6 +389,11 @@ function stopRuntime(): void {
|
||||
state.sseClient.stop();
|
||||
state.sseClient = null;
|
||||
}
|
||||
if (state.dispatchWsClient) {
|
||||
state.dispatchWsClient.stop();
|
||||
state.dispatchWsClient = null;
|
||||
}
|
||||
state.dispatchWsConnected = false;
|
||||
|
||||
setTransportSseState("idle");
|
||||
setSchedulerPhase("idle");
|
||||
@@ -521,6 +532,63 @@ function connectEventStream(): void {
|
||||
sseClient.start();
|
||||
}
|
||||
|
||||
function connectDispatchWs(): void {
|
||||
if (!canRunLive(state.session)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.dispatchWsClient?.stop();
|
||||
|
||||
const wsUrl = buildApiUrl(state.session.api_base_url, "/api/desktop/dispatch")
|
||||
.replace(/^http:\/\//, "ws://")
|
||||
.replace(/^https:\/\//, "wss://");
|
||||
|
||||
const client = new DispatchWsClient({
|
||||
url: wsUrl,
|
||||
headers: {
|
||||
Authorization: `Bearer ${state.session.client_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
client.on("open", () => {
|
||||
state.dispatchWsConnected = true;
|
||||
setSchedulerError(null);
|
||||
recordActivity("success", "任务分发通道已连接", "AMQP 直推 WebSocket 已就绪,发布任务将以低延迟到达。");
|
||||
});
|
||||
|
||||
client.on("connected", () => {
|
||||
state.dispatchWsConnected = true;
|
||||
});
|
||||
|
||||
client.on("task_available", (payload) => {
|
||||
if (!isDesktopTaskEvent(payload)) {
|
||||
return;
|
||||
}
|
||||
state.dispatchWsConnected = true;
|
||||
handleTaskEvent(payload);
|
||||
});
|
||||
|
||||
client.on("error", (payload) => {
|
||||
state.dispatchWsConnected = false;
|
||||
const message = errorMessage(payload);
|
||||
recordActivity("warn", "任务分发通道异常", message);
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
state.dispatchWsConnected = false;
|
||||
});
|
||||
|
||||
client.on("reconnect", (payload) => {
|
||||
if (!isReconnectPayload(payload)) {
|
||||
return;
|
||||
}
|
||||
recordActivity("info", "任务分发通道退避等待", `将在 ${payload.delayMs}ms 后重连 WebSocket。`);
|
||||
});
|
||||
|
||||
state.dispatchWsClient = client;
|
||||
client.start();
|
||||
}
|
||||
|
||||
function handleTaskEvent(event: DesktopTaskEventMessage): void {
|
||||
const existing = state.tasks.get(event.task_id);
|
||||
const next: RuntimeTaskRecord = {
|
||||
@@ -871,11 +939,18 @@ function pumpExecutionLoop(): void {
|
||||
}
|
||||
|
||||
if (state.activeExecutions.size === 0) {
|
||||
void pullNextTask("publish");
|
||||
// Publish task_available is push-delivered over the dispatch WebSocket. We
|
||||
// only fall back to the HTTP pull when the push channel is down, otherwise
|
||||
// every scheduler tick would wake the /lease endpoint for no reason.
|
||||
if (!state.dispatchWsConnected) {
|
||||
void pullNextTask("publish");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (canStartAnotherMonitorExecution()) {
|
||||
// Monitor has not been migrated to the dispatch channel yet, so it still
|
||||
// relies on the periodic pull as its primary signal.
|
||||
void pullNextTask("monitor");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user