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
@@ -11,7 +11,12 @@ import { articlesApi, mediaApi, publishJobsApi, resolveApiURL, tenantAccountsApi
import { coverUploadRequired, deriveCoverFileName } from "@/lib/cover-requirements"; import { coverUploadRequired, deriveCoverFileName } from "@/lib/cover-requirements";
import { formatDateTime } from "@/lib/display"; import { formatDateTime } from "@/lib/display";
import { formatError } from "@/lib/errors"; import { formatError } from "@/lib/errors";
import { getPublishPlatformMeta, normalizePublishPlatformId, normalizePublishPlatformIds } from "@/lib/publish-platforms"; import {
getPublishPlatformMeta,
isPublishPlatformId,
normalizePublishPlatformId,
normalizePublishPlatformIds,
} from "@/lib/publish-platforms";
type PublishState = "immediate" | "queued" | "unavailable"; type PublishState = "immediate" | "queued" | "unavailable";
@@ -109,8 +114,19 @@ const platformMap = computed(() => {
); );
}); });
const publishAccounts = computed(() => {
return (accountsQuery.data.value ?? []).filter((account) => {
if (account.deleted_at) {
return false;
}
const platformId = normalizePublishPlatformId(account.platform);
return platformMap.value.has(platformId) || isPublishPlatformId(platformId);
});
});
const rawAccountMap = computed(() => { const rawAccountMap = computed(() => {
return new Map((accountsQuery.data.value ?? []).map((account) => [account.id, account])); return new Map(publishAccounts.value.map((account) => [account.id, account]));
}); });
const articlePlatformIds = computed(() => normalizePublishPlatformIds(detailQuery.data.value?.platforms ?? [])); const articlePlatformIds = computed(() => normalizePublishPlatformIds(detailQuery.data.value?.platforms ?? []));
@@ -118,8 +134,7 @@ const articlePlatformIds = computed(() => normalizePublishPlatformIds(detailQuer
const accountCards = computed<PublishAccountCard[]>(() => { const accountCards = computed<PublishAccountCard[]>(() => {
const preferredPlatforms = new Set(articlePlatformIds.value); const preferredPlatforms = new Set(articlePlatformIds.value);
return [...(accountsQuery.data.value ?? [])] return [...publishAccounts.value]
.filter((account) => !account.deleted_at)
.map((account) => { .map((account) => {
const platformId = normalizePublishPlatformId(account.platform); const platformId = normalizePublishPlatformId(account.platform);
const platform = platformMap.value.get(platformId); const platform = platformMap.value.get(platformId);
+1 -1
View File
@@ -349,7 +349,7 @@ const zhCN = {
collectBrandRequired: "请先选择品牌,再发起采集", collectBrandRequired: "请先选择品牌,再发起采集",
collectKeywordRequired: "请先选择关键词,再立即采集当前问题", collectKeywordRequired: "请先选择关键词,再立即采集当前问题",
collectClientChecking: "正在检查当前账号的桌面客户端状态", collectClientChecking: "正在检查当前账号的桌面客户端状态",
collectClientOnlineRequired: "当前登录账号的 desktop-client 未在线,暂时不能立即采集", collectClientOnlineRequired: "当前登录账号的桌面客户端未在线,暂时不能立即采集",
pluginMode: "插件采样", pluginMode: "插件采样",
pluginHint: "插件模式下结果受设备在线率影响,低覆盖样本仅供趋势参考。", pluginHint: "插件模式下结果受设备在线率影响,低覆盖样本仅供趋势参考。",
awaitingSnapshot: "等待下一次有效快照", awaitingSnapshot: "等待下一次有效快照",
+1 -6
View File
@@ -59,12 +59,7 @@ const errorMessageMap: Record<string, string> = {
publisher_plugin_empty_response: "浏览器插件未返回数据,请刷新当前页面后重试", publisher_plugin_empty_response: "浏览器插件未返回数据,请刷新当前页面后重试",
publisher_plugin_invalid_response: "浏览器插件返回了无效数据,请刷新当前页面后重试", publisher_plugin_invalid_response: "浏览器插件返回了无效数据,请刷新当前页面后重试",
publisher_plugin_unknown_error: "浏览器插件调用失败,请稍后重试", publisher_plugin_unknown_error: "浏览器插件调用失败,请稍后重试",
monitoring_plugin_timeout: "监测插件响应超时,请稍后重试", installation_offline: "桌面客户端未在线,请打开桌面客户端后重试",
monitoring_plugin_invalid_response: "监测插件返回了无效数据,请稍后重试",
monitoring_plugin_unknown_error: "监测插件调用失败,请稍后重试",
monitoring_plugin_required: "请先安装并打开浏览器插件",
monitoring_plugin_register_required: "浏览器插件正在初始化,请稍后重试",
installation_offline: "监测插件离线,请打开浏览器插件后重试",
network_error: "网络连接失败,请稍后重试", network_error: "网络连接失败,请稍后重试",
unknown_error: "发生未知错误", unknown_error: "发生未知错误",
}; };
-155
View File
@@ -1,155 +0,0 @@
import type {
MonitoringCycleKickoffResponse,
MonitoringCycleResult,
MonitoringHeartbeatSyncResult,
MonitoringLocalPlatformState,
MonitoringPluginPingResponse,
MonitoringRuntimeStateSnapshot,
} from "@geo/shared-types";
type MonitoringAction = "ping" | "detectPlatforms" | "syncHeartbeat" | "runCycle" | "kickoffCycle" | "getState";
type MonitoringPluginSuccessPayload<T> = {
source: "geo-extension";
type: "geo.monitoring.response";
requestId: string;
success: true;
data: T;
};
type MonitoringPluginErrorPayload = {
source: "geo-extension";
type: "geo.monitoring.response";
requestId: string;
success: false;
error: string;
};
type MonitoringPluginPayloadMap = {
ping: MonitoringPluginPingResponse;
detectPlatforms: MonitoringLocalPlatformState[];
syncHeartbeat: MonitoringHeartbeatSyncResult;
runCycle: MonitoringCycleResult;
kickoffCycle: MonitoringCycleKickoffResponse;
getState: MonitoringRuntimeStateSnapshot;
};
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isMonitoringPingPayload(value: unknown): value is MonitoringPluginPingResponse {
return isPlainObject(value) && typeof value.installed === "boolean";
}
function isMonitoringDetectPayload(value: unknown): value is MonitoringLocalPlatformState[] {
return Array.isArray(value);
}
function isMonitoringHeartbeatPayload(value: unknown): value is MonitoringHeartbeatSyncResult {
return isPlainObject(value) && typeof value.success === "boolean" && Array.isArray(value.platform_states);
}
function isMonitoringCyclePayload(value: unknown): value is MonitoringCycleResult {
return (
isPlainObject(value) &&
typeof value.success === "boolean" &&
typeof value.leased_task_count === "number" &&
typeof value.processed_task_count === "number" &&
typeof value.failed_task_count === "number" &&
typeof value.skipped_task_count === "number"
);
}
function isMonitoringKickoffPayload(value: unknown): value is MonitoringCycleKickoffResponse {
return isPlainObject(value) && value.accepted === true && typeof value.status === "string";
}
function isMonitoringStatePayload(value: unknown): value is MonitoringRuntimeStateSnapshot {
return isPlainObject(value) && typeof value.installation_key === "string" && Array.isArray(value.monitoring_platforms);
}
function isMonitoringPayloadValid<T extends MonitoringAction>(action: T, value: unknown): value is MonitoringPluginPayloadMap[T] {
switch (action) {
case "ping":
return isMonitoringPingPayload(value);
case "detectPlatforms":
return isMonitoringDetectPayload(value);
case "syncHeartbeat":
return isMonitoringHeartbeatPayload(value);
case "runCycle":
return isMonitoringCyclePayload(value);
case "kickoffCycle":
return isMonitoringKickoffPayload(value);
case "getState":
return isMonitoringStatePayload(value);
default:
return false;
}
}
function nextRequestId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `geo-monitoring-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
async function requestMonitoringPlugin<T extends MonitoringAction>(action: T, timeoutMs = 5000): Promise<MonitoringPluginPayloadMap[T]> {
if (typeof window === "undefined") {
throw new Error("plugin bridge is only available in browser contexts");
}
const requestId = nextRequestId();
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
cleanup();
reject(new Error("monitoring_plugin_timeout"));
}, timeoutMs);
const handleMessage = (event: MessageEvent<MonitoringPluginSuccessPayload<MonitoringPluginPayloadMap[T]> | MonitoringPluginErrorPayload>) => {
if (event.source !== window || !event.data || event.data.type !== "geo.monitoring.response") {
return;
}
if (event.data.requestId !== requestId || event.data.source !== "geo-extension") {
return;
}
cleanup();
if (event.data.success) {
if (!isMonitoringPayloadValid(action, event.data.data)) {
reject(new Error("monitoring_plugin_invalid_response"));
return;
}
resolve(event.data.data);
return;
}
reject(new Error(event.data.error || "monitoring_plugin_unknown_error"));
};
const cleanup = () => {
window.clearTimeout(timer);
window.removeEventListener("message", handleMessage);
};
window.addEventListener("message", handleMessage);
window.postMessage(
{
source: "geo-admin",
type: "geo.monitoring.request",
requestId,
action,
},
window.location.origin,
);
});
}
export async function runMonitoringCycle(): Promise<MonitoringCycleResult> {
return requestMonitoringPlugin("runCycle", 30000);
}
export async function kickoffMonitoringCycle(): Promise<MonitoringCycleKickoffResponse> {
return requestMonitoringPlugin("kickoffCycle", 5000);
}
+3 -1
View File
@@ -24,11 +24,13 @@
"pino": "^9.0.0", "pino": "^9.0.0",
"playwright-core": "^1.55.0", "playwright-core": "^1.55.0",
"vue": "^3.5.31", "vue": "^3.5.31",
"vue-router": "^4.5.1" "vue-router": "^4.5.1",
"ws": "^8.20.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.0.0", "@playwright/test": "^1.0.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/ws": "^8.18.1",
"@vitejs/plugin-vue": "^5.2.4", "@vitejs/plugin-vue": "^5.2.4",
"electron": "41.2.0", "electron": "41.2.0",
"electron-builder": "^25.0.0", "electron-builder": "^25.0.0",
@@ -89,6 +89,7 @@ import {
upsertDesktopAccount, upsertDesktopAccount,
} from "./transport/api-client"; } from "./transport/api-client";
import { SseClient, SseClientError } from "./transport/sse-client"; import { SseClient, SseClientError } from "./transport/sse-client";
import { DispatchWsClient } from "./transport/dispatch-ws-client";
type RuntimeTone = "info" | "success" | "warn" | "danger"; type RuntimeTone = "info" | "success" | "warn" | "danger";
type RuntimeTaskStatus = type RuntimeTaskStatus =
@@ -184,6 +185,8 @@ interface RuntimeState {
heartbeatTimer: ReturnType<typeof setInterval> | null; heartbeatTimer: ReturnType<typeof setInterval> | null;
pullTimer: ReturnType<typeof setInterval> | null; pullTimer: ReturnType<typeof setInterval> | null;
sseClient: SseClient | null; sseClient: SseClient | null;
dispatchWsClient: DispatchWsClient | null;
dispatchWsConnected: boolean;
lastHeartbeatAt: number; lastHeartbeatAt: number;
lastHeartbeatStatus: "idle" | "success" | "failed"; lastHeartbeatStatus: "idle" | "success" | "failed";
lastPullAt: number; lastPullAt: number;
@@ -210,6 +213,8 @@ const state: RuntimeState = {
heartbeatTimer: null, heartbeatTimer: null,
pullTimer: null, pullTimer: null,
sseClient: null, sseClient: null,
dispatchWsClient: null,
dispatchWsConnected: false,
lastHeartbeatAt: 0, lastHeartbeatAt: 0,
lastHeartbeatStatus: "idle", lastHeartbeatStatus: "idle",
lastPullAt: 0, lastPullAt: 0,
@@ -354,6 +359,7 @@ function startRuntime(): void {
recordActivity("success", "调度节点已启动", "已启动 SSE、心跳上报和兜底拉取。"); recordActivity("success", "调度节点已启动", "已启动 SSE、心跳上报和兜底拉取。");
connectEventStream(); connectEventStream();
connectDispatchWs();
scheduleHeartbeatLoop(); scheduleHeartbeatLoop();
schedulePullLoop(); schedulePullLoop();
@@ -383,6 +389,11 @@ function stopRuntime(): void {
state.sseClient.stop(); state.sseClient.stop();
state.sseClient = null; state.sseClient = null;
} }
if (state.dispatchWsClient) {
state.dispatchWsClient.stop();
state.dispatchWsClient = null;
}
state.dispatchWsConnected = false;
setTransportSseState("idle"); setTransportSseState("idle");
setSchedulerPhase("idle"); setSchedulerPhase("idle");
@@ -521,6 +532,63 @@ function connectEventStream(): void {
sseClient.start(); 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 { function handleTaskEvent(event: DesktopTaskEventMessage): void {
const existing = state.tasks.get(event.task_id); const existing = state.tasks.get(event.task_id);
const next: RuntimeTaskRecord = { const next: RuntimeTaskRecord = {
@@ -871,11 +939,18 @@ function pumpExecutionLoop(): void {
} }
if (state.activeExecutions.size === 0) { 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; return;
} }
if (canStartAnotherMonitorExecution()) { 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"); 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;
}
+27
View File
@@ -165,6 +165,9 @@ importers:
vue-router: vue-router:
specifier: ^4.5.1 specifier: ^4.5.1
version: 4.6.4(vue@3.5.31(typescript@5.9.3)) version: 4.6.4(vue@3.5.31(typescript@5.9.3))
ws:
specifier: ^8.20.0
version: 8.20.0
devDependencies: devDependencies:
'@playwright/test': '@playwright/test':
specifier: ^1.0.0 specifier: ^1.0.0
@@ -172,6 +175,9 @@ importers:
'@types/node': '@types/node':
specifier: ^24.0.0 specifier: ^24.0.0
version: 24.12.0 version: 24.12.0
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^5.2.4 specifier: ^5.2.4
version: 5.2.4(vite@5.4.21(@types/node@24.12.0)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.9.3)) version: 5.2.4(vite@5.4.21(@types/node@24.12.0)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.9.3))
@@ -1366,6 +1372,9 @@ packages:
'@types/web-bluetooth@0.0.21': '@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
@@ -4462,6 +4471,18 @@ packages:
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.20.0:
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
wsl-utils@0.3.1: wsl-utils@0.3.1:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -5923,6 +5944,10 @@ snapshots:
'@types/web-bluetooth@0.0.21': {} '@types/web-bluetooth@0.0.21': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.12.0
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
dependencies: dependencies:
'@types/node': 24.12.0 '@types/node': 24.12.0
@@ -9555,6 +9580,8 @@ snapshots:
wrappy@1.0.2: {} wrappy@1.0.2: {}
ws@8.20.0: {}
wsl-utils@0.3.1: wsl-utils@0.3.1:
dependencies: dependencies:
is-wsl: 3.1.1 is-wsl: 3.1.1
+1
View File
@@ -30,6 +30,7 @@ func main() {
app.GenerationStreams.Run(workerCtx) app.GenerationStreams.Run(workerCtx)
app.DesktopTaskStreams.Run(workerCtx) app.DesktopTaskStreams.Run(workerCtx)
app.DesktopDispatch.Run(workerCtx)
monitoringCallbackService := tenantapp.NewMonitoringCallbackService(app.DB, app.MonitoringDB, app.RabbitMQ, app.LLM, app.Logger) monitoringCallbackService := tenantapp.NewMonitoringCallbackService(app.DB, app.MonitoringDB, app.RabbitMQ, app.LLM, app.Logger)
tenantapp.NewMonitoringResultIngestWorker(monitoringCallbackService, app.RabbitMQ, app.Logger, app.Config.MonitoringWorkers).Start(workerCtx) tenantapp.NewMonitoringResultIngestWorker(monitoringCallbackService, app.RabbitMQ, app.Logger, app.Config.MonitoringWorkers).Start(workerCtx)
+4
View File
@@ -43,6 +43,10 @@ rabbitmq:
generation_dlq: generation.task.run.dlq generation_dlq: generation.task.run.dlq
generation_dlq_routing_key: generation.task.run.dlq generation_dlq_routing_key: generation.task.run.dlq
generation_event_exchange: generation.event generation_event_exchange: generation.event
desktop_task_event_exchange: desktop.task.event
desktop_dispatch_exchange: desktop.task.dispatch
desktop_dispatch_publish_prefix: publish
desktop_dispatch_monitor_prefix: monitor
template_assist_exchange: template.assist template_assist_exchange: template.assist
template_assist_routing_key: template.assist.run template_assist_routing_key: template.assist.run
template_assist_queue: template.assist.run template_assist_queue: template.assist.run
+2 -1
View File
@@ -1,6 +1,6 @@
module github.com/geo-platform/tenant-api module github.com/geo-platform/tenant-api
go 1.22.2 go 1.23
require ( require (
github.com/alicebob/miniredis/v2 v2.37.0 github.com/alicebob/miniredis/v2 v2.37.0
@@ -10,6 +10,7 @@ require (
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.5.5 github.com/jackc/pgx/v5 v5.5.5
github.com/ledongthuc/pdf v0.0.0-20240314090751-a2a84ec735c3 github.com/ledongthuc/pdf v0.0.0-20240314090751-a2a84ec735c3
github.com/minio/minio-go/v7 v7.0.83 github.com/minio/minio-go/v7 v7.0.83
+2 -6
View File
@@ -83,6 +83,8 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -223,8 +225,6 @@ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjs
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -240,8 +240,6 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -250,8 +248,6 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+8 -2
View File
@@ -45,8 +45,9 @@ type App struct {
VectorStore retrieval.VectorStore VectorStore retrieval.VectorStore
ObjectStorage objectstorage.Client ObjectStorage objectstorage.Client
AuditLogs *auditlog.AsyncWriter AuditLogs *auditlog.AsyncWriter
GenerationStreams *stream.GenerationHub GenerationStreams *stream.GenerationHub
DesktopTaskStreams *stream.DesktopTaskHub DesktopTaskStreams *stream.DesktopTaskHub
DesktopDispatch *stream.DesktopDispatchHub
Cache cache.Cache Cache cache.Cache
KolProfiles repository.KolProfileRepository KolProfiles repository.KolProfileRepository
KolPackages repository.KolPackageRepository KolPackages repository.KolPackageRepository
@@ -111,6 +112,10 @@ func New(configPath string) (*App, error) {
auditLogs := auditlog.NewAsyncWriter(pool, logger) auditLogs := auditlog.NewAsyncWriter(pool, logger)
generationStreams := stream.NewGenerationHub(mqClient) generationStreams := stream.NewGenerationHub(mqClient)
desktopTaskStreams := stream.NewDesktopTaskHub(mqClient) desktopTaskStreams := stream.NewDesktopTaskHub(mqClient)
desktopDispatchBindings := []string{
cfg.RabbitMQ.DesktopDispatchPublishPrefix + ".*",
}
desktopDispatch := stream.NewDesktopDispatchHub(mqClient, logger, desktopDispatchBindings)
appCache := cache.New(cfg.Cache.Driver, rdb) appCache := cache.New(cfg.Cache.Driver, rdb)
kolProfiles := repository.NewKolProfileRepository(pool) kolProfiles := repository.NewKolProfileRepository(pool)
kolPackages := repository.NewKolPackageRepository(pool) kolPackages := repository.NewKolPackageRepository(pool)
@@ -170,8 +175,9 @@ func New(configPath string) (*App, error) {
VectorStore: vectorStore, VectorStore: vectorStore,
ObjectStorage: objectStorageClient, ObjectStorage: objectStorageClient,
AuditLogs: auditLogs, AuditLogs: auditLogs,
GenerationStreams: generationStreams, GenerationStreams: generationStreams,
DesktopTaskStreams: desktopTaskStreams, DesktopTaskStreams: desktopTaskStreams,
DesktopDispatch: desktopDispatch,
Cache: appCache, Cache: appCache,
KolProfiles: kolProfiles, KolProfiles: kolProfiles,
KolPackages: kolPackages, KolPackages: kolPackages,
+12
View File
@@ -78,6 +78,9 @@ type RabbitMQConfig struct {
GenerationDLQRouteKey string `mapstructure:"generation_dlq_routing_key"` GenerationDLQRouteKey string `mapstructure:"generation_dlq_routing_key"`
GenerationEventExchange string `mapstructure:"generation_event_exchange"` GenerationEventExchange string `mapstructure:"generation_event_exchange"`
DesktopTaskEventExchange string `mapstructure:"desktop_task_event_exchange"` DesktopTaskEventExchange string `mapstructure:"desktop_task_event_exchange"`
DesktopDispatchExchange string `mapstructure:"desktop_dispatch_exchange"`
DesktopDispatchPublishPrefix string `mapstructure:"desktop_dispatch_publish_prefix"`
DesktopDispatchMonitorPrefix string `mapstructure:"desktop_dispatch_monitor_prefix"`
TemplateAssistExchange string `mapstructure:"template_assist_exchange"` TemplateAssistExchange string `mapstructure:"template_assist_exchange"`
TemplateAssistRoutingKey string `mapstructure:"template_assist_routing_key"` TemplateAssistRoutingKey string `mapstructure:"template_assist_routing_key"`
TemplateAssistQueue string `mapstructure:"template_assist_queue"` TemplateAssistQueue string `mapstructure:"template_assist_queue"`
@@ -475,6 +478,15 @@ func normalizeRabbitMQConfig(cfg *RabbitMQConfig) {
if strings.TrimSpace(cfg.DesktopTaskEventExchange) == "" { if strings.TrimSpace(cfg.DesktopTaskEventExchange) == "" {
cfg.DesktopTaskEventExchange = "desktop.task.event" cfg.DesktopTaskEventExchange = "desktop.task.event"
} }
if strings.TrimSpace(cfg.DesktopDispatchExchange) == "" {
cfg.DesktopDispatchExchange = "desktop.task.dispatch"
}
if strings.TrimSpace(cfg.DesktopDispatchPublishPrefix) == "" {
cfg.DesktopDispatchPublishPrefix = "publish"
}
if strings.TrimSpace(cfg.DesktopDispatchMonitorPrefix) == "" {
cfg.DesktopDispatchMonitorPrefix = "monitor"
}
if strings.TrimSpace(cfg.TemplateAssistExchange) == "" { if strings.TrimSpace(cfg.TemplateAssistExchange) == "" {
cfg.TemplateAssistExchange = "template.assist" cfg.TemplateAssistExchange = "template.assist"
} }
@@ -46,6 +46,16 @@ func New(_ context.Context, cfg config.RabbitMQConfig) (*Client, error) {
return client, nil return client, nil
} }
// Config returns a copy of the effective rabbitmq configuration. Services that
// need to derive routing keys (e.g. desktop dispatch) consult this instead of
// hard-coding string constants.
func (c *Client) Config() config.RabbitMQConfig {
if c == nil {
return config.RabbitMQConfig{}
}
return c.cfg
}
func (c *Client) Close() error { func (c *Client) Close() error {
if c == nil { if c == nil {
return nil return nil
@@ -102,6 +112,91 @@ func (c *Client) PublishDesktopTaskEvent(ctx context.Context, body []byte) error
return c.publishFanout(ctx, c.cfg.DesktopTaskEventExchange, body) return c.publishFanout(ctx, c.cfg.DesktopTaskEventExchange, body)
} }
func (c *Client) PublishDesktopDispatch(ctx context.Context, routingKey string, body []byte) error {
return c.publish(ctx, c.cfg.DesktopDispatchExchange, routingKey, body)
}
// ConsumeDesktopDispatch declares a transient per-process queue bound to the
// desktop dispatch topic exchange using bindingKeys, and begins consuming from
// it. Every tenant-api instance gets its own queue so every instance sees every
// dispatch message; the hub inside the instance then filters by target client
// presence. Messages are auto-ack (transient delivery is OK: if the instance
// dies, the client reconnects and the server re-drives the task via the
// existing lease/recovery path).
func (c *Client) ConsumeDesktopDispatch(consumerName string, bindingKeys []string) (<-chan amqp.Delivery, *amqp.Channel, error) {
if len(bindingKeys) == 0 {
return nil, nil, fmt.Errorf("consume rabbitmq desktop dispatch: bindingKeys is required")
}
for attempt := 0; attempt < 2; attempt++ {
conn, ch, err := c.openChannel()
if err != nil {
return nil, nil, err
}
queue, err := ch.QueueDeclare(
"",
false,
true,
true,
false,
nil,
)
if err != nil {
_ = ch.Close()
c.invalidateConnection(conn)
if attempt == 1 {
return nil, nil, fmt.Errorf("declare rabbitmq desktop dispatch queue: %w", err)
}
continue
}
bindFailed := false
for _, key := range bindingKeys {
if err := ch.QueueBind(
queue.Name,
key,
c.cfg.DesktopDispatchExchange,
false,
nil,
); err != nil {
_ = ch.Close()
c.invalidateConnection(conn)
if attempt == 1 {
return nil, nil, fmt.Errorf("bind rabbitmq desktop dispatch queue: %w", err)
}
bindFailed = true
break
}
}
if bindFailed {
continue
}
deliveries, err := ch.Consume(
queue.Name,
consumerName,
true,
true,
false,
false,
nil,
)
if err != nil {
_ = ch.Close()
c.invalidateConnection(conn)
if attempt == 1 {
return nil, nil, fmt.Errorf("consume rabbitmq desktop dispatch queue: %w", err)
}
continue
}
return deliveries, ch, nil
}
return nil, nil, fmt.Errorf("consume rabbitmq desktop dispatch queue: retry exhausted")
}
func (c *Client) PublishTemplateAssistTask(ctx context.Context, body []byte) error { func (c *Client) PublishTemplateAssistTask(ctx context.Context, body []byte) error {
return c.publish(ctx, c.cfg.TemplateAssistExchange, c.cfg.TemplateAssistRoutingKey, body) return c.publish(ctx, c.cfg.TemplateAssistExchange, c.cfg.TemplateAssistRoutingKey, body)
} }
@@ -578,6 +673,18 @@ func (c *Client) ensureTopology(conn *amqp.Connection) error {
return fmt.Errorf("declare rabbitmq exchange %s: %w", c.cfg.DesktopTaskEventExchange, err) return fmt.Errorf("declare rabbitmq exchange %s: %w", c.cfg.DesktopTaskEventExchange, err)
} }
if err := ch.ExchangeDeclare(
c.cfg.DesktopDispatchExchange,
"topic",
true,
false,
false,
false,
nil,
); err != nil {
return fmt.Errorf("declare rabbitmq exchange %s: %w", c.cfg.DesktopDispatchExchange, err)
}
if err := c.ensureWorkQueue( if err := c.ensureWorkQueue(
ch, ch,
c.cfg.TemplateAssistExchange, c.cfg.TemplateAssistExchange,
@@ -0,0 +1,274 @@
package stream
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
amqp "github.com/rabbitmq/amqp091-go"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
)
// DesktopDispatchEvent is the payload pushed to a desktop client over the
// dispatch WebSocket. The shape intentionally mirrors DesktopTaskEvent so that
// the existing SSE handler on the desktop client can treat both channels the
// same way and dedupe by task_id.
type DesktopDispatchEvent struct {
Type string `json:"type"`
TaskID string `json:"task_id"`
JobID string `json:"job_id,omitempty"`
WorkspaceID int64 `json:"workspace_id"`
TargetAccountID string `json:"target_account_id,omitempty"`
TargetClientID string `json:"target_client_id"`
Platform string `json:"platform,omitempty"`
Title *string `json:"title,omitempty"`
BusinessDate *string `json:"business_date,omitempty"`
SchedulerGroupKey *string `json:"scheduler_group_key,omitempty"`
QuestionText *string `json:"question_text,omitempty"`
Status string `json:"status"`
Kind string `json:"kind"`
Priority int `json:"priority,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// DispatchSubscriber represents a live WebSocket connection bound to a single
// desktop client id. A client may have multiple subscribers (e.g. during a
// reconnect race), so the hub stores them as a set per client id.
type DispatchSubscriber struct {
id uint64
out chan DesktopDispatchEvent
closed chan struct{}
}
// Outbound returns the channel the handler goroutine should read from to write
// frames to the WebSocket. The channel is closed when the subscriber is
// unregistered; readers should range over it.
func (s *DispatchSubscriber) Outbound() <-chan DesktopDispatchEvent {
return s.out
}
type dispatchClientStream struct {
subscribers map[uint64]*DispatchSubscriber
}
// DesktopDispatchHub delivers per-client dispatch messages pulled from the
// desktop.task.dispatch topic exchange. Every tenant-api instance owns its own
// autodelete queue bound to the exchange, so every instance sees every message
// and routes it to the subscribers it currently owns. Clients not connected to
// this instance are simply filtered out; the client reconnects via the load
// balancer and lands on whichever instance currently holds its WebSocket.
type DesktopDispatchHub struct {
mu sync.RWMutex
clients map[string]*dispatchClientStream
rabbitMQ *rabbitmq.Client
logger *zap.Logger
instanceID string
consumerName string
bindingKeys []string
nextSubID uint64
}
// NewDesktopDispatchHub wires the hub to the shared rabbitmq client. The
// supplied binding keys decide which dispatch routing keys this instance
// receives; initially that is just `publish.*`, and the monitor slice will be
// added when the monitor chain is migrated.
func NewDesktopDispatchHub(rabbitMQClient *rabbitmq.Client, logger *zap.Logger, bindingKeys []string) *DesktopDispatchHub {
instanceID := fmt.Sprintf("%d-%d", os.Getpid(), time.Now().UnixNano())
keys := make([]string, 0, len(bindingKeys))
for _, key := range bindingKeys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
continue
}
keys = append(keys, trimmed)
}
if len(keys) == 0 {
keys = []string{"publish.*"}
}
return &DesktopDispatchHub{
clients: make(map[string]*dispatchClientStream),
rabbitMQ: rabbitMQClient,
logger: logger,
instanceID: instanceID,
consumerName: "desktop-dispatch-" + instanceID,
bindingKeys: keys,
}
}
// Run starts the background AMQP consumer. It is safe to call Run on a hub
// without a rabbitmq client — the hub simply behaves as an in-process broker
// used in tests, useful for unit-level wiring.
func (h *DesktopDispatchHub) Run(ctx context.Context) {
if h == nil || h.rabbitMQ == nil {
return
}
go h.run(ctx)
}
// Subscribe registers a new WebSocket consumer for the given client id. The
// returned cancel closes the outbound channel and removes the subscriber; the
// WebSocket handler must call it on disconnect.
func (h *DesktopDispatchHub) Subscribe(clientID string) (*DispatchSubscriber, func()) {
if h == nil || clientID == "" {
return nil, func() {}
}
h.mu.Lock()
stream, ok := h.clients[clientID]
if !ok {
stream = &dispatchClientStream{subscribers: make(map[uint64]*DispatchSubscriber)}
h.clients[clientID] = stream
}
h.nextSubID++
sub := &DispatchSubscriber{
id: h.nextSubID,
out: make(chan DesktopDispatchEvent, 32),
closed: make(chan struct{}),
}
stream.subscribers[sub.id] = sub
h.mu.Unlock()
cancel := func() {
h.mu.Lock()
stream, ok := h.clients[clientID]
if !ok {
h.mu.Unlock()
return
}
existing, present := stream.subscribers[sub.id]
if !present {
h.mu.Unlock()
return
}
delete(stream.subscribers, sub.id)
if len(stream.subscribers) == 0 {
delete(h.clients, clientID)
}
h.mu.Unlock()
select {
case <-existing.closed:
// already closed
default:
close(existing.closed)
close(existing.out)
}
}
return sub, cancel
}
// Publish delivers the event to every subscriber currently registered for the
// event's target client id on this instance. Events for clients that are not
// on this instance are dropped silently, by design.
func (h *DesktopDispatchHub) Publish(event DesktopDispatchEvent) {
if h == nil || event.TargetClientID == "" {
return
}
h.mu.RLock()
stream, ok := h.clients[event.TargetClientID]
if !ok {
h.mu.RUnlock()
return
}
subs := make([]*DispatchSubscriber, 0, len(stream.subscribers))
for _, sub := range stream.subscribers {
subs = append(subs, sub)
}
h.mu.RUnlock()
for _, sub := range subs {
select {
case <-sub.closed:
continue
case sub.out <- event:
default:
// Subscriber's outbound channel is full: the reader has fallen
// behind. We drop the message rather than block the dispatch
// consumer — the client will resync via the lease API on
// reconnect.
if h.logger != nil {
h.logger.Warn("desktop dispatch subscriber slow, dropping event",
zap.String("client_id", event.TargetClientID),
zap.String("task_id", event.TaskID),
)
}
}
}
}
// ActiveClientIDs returns the set of client ids currently connected on this
// instance. Intended for diagnostics and the /api/health/ready endpoint.
func (h *DesktopDispatchHub) ActiveClientIDs() []string {
if h == nil {
return nil
}
h.mu.RLock()
defer h.mu.RUnlock()
out := make([]string, 0, len(h.clients))
for id := range h.clients {
out = append(out, id)
}
return out
}
func (h *DesktopDispatchHub) run(ctx context.Context) {
for {
deliveries, ch, err := h.rabbitMQ.ConsumeDesktopDispatch(h.consumerName, h.bindingKeys)
if err != nil {
if h.logger != nil {
h.logger.Warn("desktop dispatch consume failed, retrying",
zap.Error(err),
)
}
select {
case <-ctx.Done():
return
case <-time.After(2 * time.Second):
continue
}
}
closed := false
for !closed {
select {
case <-ctx.Done():
_ = ch.Close()
return
case delivery, ok := <-deliveries:
if !ok {
closed = true
continue
}
h.handleDelivery(delivery)
}
}
_ = ch.Close()
}
}
func (h *DesktopDispatchHub) handleDelivery(delivery amqp.Delivery) {
var event DesktopDispatchEvent
if err := json.Unmarshal(delivery.Body, &event); err != nil {
if h.logger != nil {
h.logger.Warn("desktop dispatch event decode failed",
zap.Error(err),
zap.String("routing_key", delivery.RoutingKey),
)
}
return
}
if event.TargetClientID == "" {
// Fall back to the routing key: `{prefix}.{client_id}` -> client_id.
if idx := strings.LastIndex(delivery.RoutingKey, "."); idx >= 0 && idx < len(delivery.RoutingKey)-1 {
event.TargetClientID = delivery.RoutingKey[idx+1:]
}
}
h.Publish(event)
}
@@ -0,0 +1,135 @@
package stream
import (
"testing"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
func TestDispatchHub_PublishRoutesToLocalSubscriber(t *testing.T) {
t.Parallel()
hub := NewDesktopDispatchHub(nil, nil, []string{"publish.*"})
sub, cancel := hub.Subscribe("client-a")
defer cancel()
event := DesktopDispatchEvent{
Type: "task_available",
TaskID: "task-1",
TargetClientID: "client-a",
Kind: "publish",
Status: "queued",
}
hub.Publish(event)
select {
case got := <-sub.Outbound():
if got.TaskID != "task-1" {
t.Fatalf("unexpected task id: %s", got.TaskID)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("expected to receive event within 200ms")
}
}
func TestDispatchHub_DropsEventsForUnknownClient(t *testing.T) {
t.Parallel()
hub := NewDesktopDispatchHub(nil, nil, []string{"publish.*"})
// Subscribe for a different client, publish for another one.
_, cancel := hub.Subscribe("client-b")
defer cancel()
hub.Publish(DesktopDispatchEvent{
Type: "task_available",
TargetClientID: "client-a",
})
ids := hub.ActiveClientIDs()
if len(ids) != 1 || ids[0] != "client-b" {
t.Fatalf("expected only client-b subscribed, got %v", ids)
}
}
func TestDispatchHub_CancelRemovesSubscriber(t *testing.T) {
t.Parallel()
hub := NewDesktopDispatchHub(nil, nil, []string{"publish.*"})
sub, cancel := hub.Subscribe("client-a")
cancel()
// Subsequent publish must not panic even though the subscriber is gone;
// channel is closed, reads must yield zero value with !ok.
hub.Publish(DesktopDispatchEvent{TargetClientID: "client-a"})
select {
case _, ok := <-sub.Outbound():
if ok {
t.Fatal("expected outbound channel to be closed after cancel")
}
case <-time.After(50 * time.Millisecond):
t.Fatal("expected closed channel to yield immediately")
}
if ids := hub.ActiveClientIDs(); len(ids) != 0 {
t.Fatalf("expected no subscribers after cancel, got %v", ids)
}
}
func TestDispatchHub_MultipleSubscribersSameClient(t *testing.T) {
t.Parallel()
hub := NewDesktopDispatchHub(nil, nil, []string{"publish.*"})
subA, cancelA := hub.Subscribe("client-a")
defer cancelA()
subB, cancelB := hub.Subscribe("client-a")
defer cancelB()
hub.Publish(DesktopDispatchEvent{
Type: "task_available",
TaskID: "task-1",
TargetClientID: "client-a",
})
for i, ch := range []<-chan DesktopDispatchEvent{subA.Outbound(), subB.Outbound()} {
select {
case got := <-ch:
if got.TaskID != "task-1" {
t.Fatalf("subscriber %d got unexpected task id: %s", i, got.TaskID)
}
case <-time.After(200 * time.Millisecond):
t.Fatalf("subscriber %d did not receive event", i)
}
}
}
func TestDispatchHub_HandleDeliveryFallsBackToRoutingKey(t *testing.T) {
t.Parallel()
hub := NewDesktopDispatchHub(nil, nil, []string{"publish.*"})
sub, cancel := hub.Subscribe("client-c")
defer cancel()
// Body intentionally omits target_client_id; the hub should recover it
// from the routing key "<prefix>.<client_id>".
delivery := amqp.Delivery{
RoutingKey: "publish.client-c",
Body: []byte(`{"type":"task_available","task_id":"task-9","kind":"publish","status":"queued"}`),
}
hub.handleDelivery(delivery)
select {
case got := <-sub.Outbound():
if got.TargetClientID != "client-c" {
t.Fatalf("expected fallback to set target client id, got %q", got.TargetClientID)
}
if got.TaskID != "task-9" {
t.Fatalf("unexpected task id: %s", got.TaskID)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("expected fallback-routed event")
}
}
@@ -2,7 +2,9 @@ package app
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt"
"strings" "strings"
"time" "time"
@@ -16,6 +18,7 @@ import (
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache" sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq" "github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
"github.com/geo-platform/tenant-api/internal/shared/response" "github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/shared/stream"
"github.com/geo-platform/tenant-api/internal/tenant/repository" "github.com/geo-platform/tenant-api/internal/tenant/repository"
) )
@@ -232,6 +235,23 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
}); publishErr != nil { }); publishErr != nil {
s.logWarn("desktop task available event publish failed", publishErr, zap.String("task_id", task.DesktopID.String())) s.logWarn("desktop task available event publish failed", publishErr, zap.String("task_id", task.DesktopID.String()))
} }
s.publishDispatchAvailable(task, stream.DesktopDispatchEvent{
Type: "task_available",
TaskID: task.DesktopID.String(),
JobID: task.JobID.String(),
WorkspaceID: task.WorkspaceID,
TargetAccountID: task.TargetAccountID.String(),
TargetClientID: task.TargetClientID.String(),
Platform: task.Platform,
Title: title,
BusinessDate: businessDate,
SchedulerGroupKey: schedulerGroupKey,
QuestionText: questionText,
Status: task.Status,
Kind: task.Kind,
UpdatedAt: task.UpdatedAt,
})
} }
return &CreatePublishJobResponse{ return &CreatePublishJobResponse{
@@ -351,6 +371,43 @@ func resolveRetryArticleIDFromPayload(payload map[string]any) (int64, bool) {
return 0, false return 0, false
} }
// publishDispatchAvailable pushes a per-client dispatch frame onto the topic
// exchange. Every tenant-api instance consumes the exchange and forwards the
// frame to the WebSocket owning the target client id. This is the primary
// signal the desktop client reacts to; the fanout event above is kept only so
// that other UIs (admin-web) see the same task lifecycle.
func (s *PublishJobService) publishDispatchAvailable(task *repository.DesktopTask, event stream.DesktopDispatchEvent) {
if s == nil || s.messaging == nil || task == nil {
return
}
cfg := s.messaging.Config()
prefix := strings.TrimSpace(cfg.DesktopDispatchPublishPrefix)
if prefix == "" {
prefix = "publish"
}
targetClientID := task.TargetClientID.String()
if targetClientID == "" {
return
}
routingKey := fmt.Sprintf("%s.%s", prefix, targetClientID)
body, err := json.Marshal(event)
if err != nil {
s.logWarn("desktop dispatch event encode failed", err,
zap.String("task_id", task.DesktopID.String()),
)
return
}
if err := s.messaging.PublishDesktopDispatch(context.Background(), routingKey, body); err != nil {
s.logWarn("desktop dispatch publish failed", err,
zap.String("task_id", task.DesktopID.String()),
zap.String("routing_key", routingKey),
)
}
}
func (s *PublishJobService) logWarn(message string, err error, fields ...zap.Field) { func (s *PublishJobService) logWarn(message string, err error, fields ...zap.Field) {
if s.logger == nil || err == nil { if s.logger == nil || err == nil {
return return
@@ -0,0 +1,141 @@
package transport
import (
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/stream"
)
const (
dispatchWriteWait = 10 * time.Second
dispatchPongWait = 60 * time.Second
dispatchPingPeriod = 25 * time.Second
dispatchReadLimitBytes = 2048
dispatchCloseGracePause = 500 * time.Millisecond
)
type DesktopDispatchHandler struct {
app *bootstrap.App
upgrader websocket.Upgrader
}
func NewDesktopDispatchHandler(a *bootstrap.App) *DesktopDispatchHandler {
return &DesktopDispatchHandler{
app: a,
upgrader: websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(_ *http.Request) bool {
// Origin enforcement happens at the gateway / LB layer; this
// endpoint is already gated by the bearer token middleware.
return true
},
},
}
}
// Dispatch upgrades the HTTP request into a WebSocket and streams dispatch
// events for the current desktop client. The contract intentionally mirrors
// the existing SSE events endpoint: connect with a bearer token, receive JSON
// frames whose shape matches DesktopDispatchEvent, and reconnect on drop.
func (h *DesktopDispatchHandler) Dispatch(c *gin.Context) {
client := MustDesktopClient(c.Request.Context())
hub := h.app.DesktopDispatch
if hub == nil {
c.AbortWithStatus(http.StatusServiceUnavailable)
return
}
conn, err := h.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
if h.app.Logger != nil {
h.app.Logger.Warn("desktop dispatch upgrade failed",
zap.String("client_id", client.ID.String()),
zap.Error(err),
)
}
return
}
subscriber, cancel := hub.Subscribe(client.ID.String())
defer cancel()
conn.SetReadLimit(dispatchReadLimitBytes)
_ = conn.SetReadDeadline(time.Now().Add(dispatchPongWait))
conn.SetPongHandler(func(string) error {
_ = conn.SetReadDeadline(time.Now().Add(dispatchPongWait))
return nil
})
// Reader goroutine: drains control frames and signals disconnect. We do
// not expect application data from the client over this socket.
readerDone := make(chan struct{})
go func() {
defer close(readerDone)
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}()
connected := stream.DesktopDispatchEvent{
Type: "connected",
TargetClientID: client.ID.String(),
WorkspaceID: client.WorkspaceID,
UpdatedAt: time.Now().UTC(),
}
if err := writeDispatchFrame(conn, connected); err != nil {
_ = conn.Close()
return
}
pingTicker := time.NewTicker(dispatchPingPeriod)
defer pingTicker.Stop()
defer func() {
_ = conn.SetWriteDeadline(time.Now().Add(dispatchWriteWait))
_ = conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
time.Sleep(dispatchCloseGracePause)
_ = conn.Close()
}()
outbound := subscriber.Outbound()
for {
select {
case <-c.Request.Context().Done():
return
case <-readerDone:
return
case event, ok := <-outbound:
if !ok {
return
}
if err := writeDispatchFrame(conn, event); err != nil {
return
}
case <-pingTicker.C:
_ = conn.SetWriteDeadline(time.Now().Add(dispatchWriteWait))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
func writeDispatchFrame(conn *websocket.Conn, event stream.DesktopDispatchEvent) error {
body, err := json.Marshal(event)
if err != nil {
return err
}
_ = conn.SetWriteDeadline(time.Now().Add(dispatchWriteWait))
return conn.WriteMessage(websocket.TextMessage, body)
}
@@ -29,6 +29,7 @@ func RegisterRoutes(app *bootstrap.App) {
desktopAccountHandler := NewDesktopAccountHandler(app) desktopAccountHandler := NewDesktopAccountHandler(app)
desktopTaskHandler := NewDesktopTaskHandler(app) desktopTaskHandler := NewDesktopTaskHandler(app)
desktopEventsHandler := NewDesktopEventsHandler(app) desktopEventsHandler := NewDesktopEventsHandler(app)
desktopDispatchHandler := NewDesktopDispatchHandler(app)
desktopContentHandler := NewDesktopContentHandler(app) desktopContentHandler := NewDesktopContentHandler(app)
publishJobHandler := NewPublishJobHandler(app) publishJobHandler := NewPublishJobHandler(app)
protected.POST("/desktop/clients/register", desktopClientHandler.Register) protected.POST("/desktop/clients/register", desktopClientHandler.Register)
@@ -40,6 +41,7 @@ func RegisterRoutes(app *bootstrap.App) {
desktopAuth.POST("/clients/offline", desktopClientHandler.Offline) desktopAuth.POST("/clients/offline", desktopClientHandler.Offline)
desktopAuth.POST("/clients/revoke", desktopClientHandler.Revoke) desktopAuth.POST("/clients/revoke", desktopClientHandler.Revoke)
desktopAuth.GET("/events", desktopEventsHandler.Stream) desktopAuth.GET("/events", desktopEventsHandler.Stream)
desktopAuth.GET("/dispatch", desktopDispatchHandler.Dispatch)
desktopAuth.GET("/accounts", desktopAccountHandler.List) desktopAuth.GET("/accounts", desktopAccountHandler.List)
desktopAuth.GET("/content/articles/:id", desktopContentHandler.Article) desktopAuth.GET("/content/articles/:id", desktopContentHandler.Article)
desktopAuth.GET("/publish-tasks", desktopTaskHandler.ListPublish) desktopAuth.GET("/publish-tasks", desktopTaskHandler.ListPublish)