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:
2026-04-20 19:46:59 +08:00
parent 7abac1e9c4
commit 9fa091c150
20 changed files with 1083 additions and 177 deletions
+3 -1
View File
@@ -24,11 +24,13 @@
"pino": "^9.0.0",
"playwright-core": "^1.55.0",
"vue": "^3.5.31",
"vue-router": "^4.5.1"
"vue-router": "^4.5.1",
"ws": "^8.20.0"
},
"devDependencies": {
"@playwright/test": "^1.0.0",
"@types/node": "^24.0.0",
"@types/ws": "^8.18.1",
"@vitejs/plugin-vue": "^5.2.4",
"electron": "41.2.0",
"electron-builder": "^25.0.0",
@@ -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");
}
}
@@ -0,0 +1,211 @@
import WebSocket, { type RawData } from "ws";
export type DispatchEventHandler = (payload: unknown) => void;
export interface DispatchWsClientOptions {
url: string;
headers?: Record<string, string>;
initialRetryDelayMs?: number;
maxRetryDelayMs?: number;
}
type DispatchWsState = "idle" | "connecting" | "open";
/**
* DispatchWsClient receives per-client task dispatch messages from the server
* over a single authenticated WebSocket. It mirrors {@link SseClient} so the
* runtime-controller can treat it as a drop-in replacement for the publish
* side of the SSE channel.
*
* The underlying contract is intentionally narrow:
* - The server pushes newline-free JSON frames whose shape matches
* `DesktopDispatchEvent` on the Go side.
* - The client never sends application frames back; acknowledgement is done
* out-of-band via `/tasks/lease`.
*
* Reconnect uses exponential backoff with jitter. On every fresh TCP
* connection the server replays a synthetic "connected" frame so callers can
* observe the auth + transport handshake is healthy.
*/
export class DispatchWsClient {
readonly #listeners = new Map<string, Set<DispatchEventHandler>>();
readonly #options: DispatchWsClientOptions;
#running = false;
#socket: WebSocket | null = null;
#retryDelayMs: number;
#reconnectTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
constructor(options: DispatchWsClientOptions) {
this.#options = options;
this.#retryDelayMs = options.initialRetryDelayMs ?? 1_000;
}
on(event: string, handler: DispatchEventHandler): () => void {
const bucket = this.#listeners.get(event) ?? new Set<DispatchEventHandler>();
bucket.add(handler);
this.#listeners.set(event, bucket);
return () => {
bucket.delete(handler);
if (bucket.size === 0) {
this.#listeners.delete(event);
}
};
}
start(): void {
if (this.#running) {
return;
}
this.#running = true;
this.#connect();
}
stop(): void {
this.#running = false;
this.#clearReconnect();
this.#setState("idle");
const socket = this.#socket;
this.#socket = null;
if (!socket) {
return;
}
// Detach every listener we registered so their handlers don't re-fire our
// own reconnect / error callbacks during teardown, but keep a noop error
// listener around: `ws` emits an asynchronous "error" when a socket still
// in the CONNECTING state is closed, and without a listener Node turns it
// into an uncaughtException.
socket.removeAllListeners("open");
socket.removeAllListeners("message");
socket.removeAllListeners("close");
socket.removeAllListeners("error");
socket.on("error", () => {
// swallow: teardown path, we no longer care about the socket's fate.
});
try {
if (socket.readyState === WebSocket.CONNECTING) {
// `close()` on a CONNECTING socket throws asynchronously; `terminate()`
// destroys the underlying TCP connection and is always safe.
socket.terminate();
} else if (socket.readyState === WebSocket.OPEN) {
socket.close(1000, "client_stop");
}
} catch {
// Fallback: ignore; the reference is already detached.
}
}
emit(event: string, payload: unknown): void {
for (const handler of this.#listeners.get(event) ?? []) {
try {
handler(payload);
} catch {
// Swallow handler errors; listeners must not kill the transport.
}
}
}
#connect(): void {
if (!this.#running) {
return;
}
this.#setState("connecting");
let socket: WebSocket;
try {
socket = new WebSocket(this.#options.url, {
headers: this.#options.headers,
handshakeTimeout: 15_000,
});
} catch (error) {
this.emit("error", error);
this.#scheduleReconnect();
return;
}
this.#socket = socket;
socket.on("open", () => {
this.#retryDelayMs = this.#options.initialRetryDelayMs ?? 1_000;
this.#setState("open");
this.emit("open", null);
});
socket.on("message", (data: RawData, isBinary: boolean) => {
if (isBinary) {
return;
}
const raw = typeof data === "string" ? data : data.toString("utf-8");
if (!raw) {
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
this.emit("message", { event: "message", data: raw });
return;
}
const event = extractEventType(parsed) ?? "message";
this.emit(event, parsed);
this.emit("message", { event, data: parsed });
});
socket.on("error", (error: Error) => {
this.emit("error", error);
});
socket.on("close", (code: number, reason: Buffer) => {
this.#socket = null;
this.emit("close", { code, reason: reason.toString() });
if (!this.#running) {
return;
}
this.#scheduleReconnect();
});
}
#scheduleReconnect(): void {
if (!this.#running) {
return;
}
this.#clearReconnect();
const baseDelay = this.#retryDelayMs;
const maxDelay = this.#options.maxRetryDelayMs ?? 30_000;
const jitter = Math.floor(baseDelay * 0.2 * Math.random());
const delay = Math.min(baseDelay + jitter, maxDelay);
this.emit("reconnect", { delayMs: delay });
this.#reconnectTimer = globalThis.setTimeout(() => {
this.#reconnectTimer = null;
this.#connect();
}, delay);
this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, maxDelay);
}
#clearReconnect(): void {
if (this.#reconnectTimer) {
globalThis.clearTimeout(this.#reconnectTimer);
this.#reconnectTimer = null;
}
}
#setState(state: DispatchWsState): void {
this.emit("state", state);
}
}
function extractEventType(payload: unknown): string | null {
if (!payload || typeof payload !== "object") {
return null;
}
const maybe = (payload as { type?: unknown }).type;
if (typeof maybe === "string" && maybe.length > 0) {
return maybe;
}
return null;
}