feat(desktop): implement workspace foundation + desktop-client skeleton

Plan 0 (workspaces): add workspaces + workspace_memberships schema, extend
JWT/Actor/claims with primary_workspace_id, seed default workspace per tenant,
thread workspace_id through tenant monitoring quota.

Plan A (desktop skeleton): new Electron app (apps/desktop-client) with main/
preload/renderer, shared Vue component package (packages/ui-shared), and server
surface — desktop client registration + token rotation + heartbeat, SSE task
event stream, desktop accounts/tasks/content handlers, publish job endpoint,
and supporting repositories, services, sqlc queries, and migrations.

Hard cutover per plan: remove browser-extension monitoring callback endpoints,
stub legacy media API in admin-web, and delete monitoring_callback_handler.go.
This commit is contained in:
2026-04-19 14:18:20 +08:00
parent 98f9e95875
commit b16e9f0bd1
141 changed files with 21533 additions and 357 deletions
@@ -0,0 +1,191 @@
import { createApiClient, type ApiClient } from "@geo/http-client";
import type {
CompleteDesktopTaskRequest,
DesktopArticleContent,
DesktopAccountInfo,
DesktopClientHeartbeatRequest,
DesktopClientHeartbeatResponse,
DesktopTaskInfo,
DesktopRuntimeSessionSyncRequest,
ExtendDesktopTaskRequest,
LeaseDesktopTaskRequest,
LeaseDesktopTaskResponse,
ParkDesktopTaskRequest,
UpsertDesktopAccountRequest,
} from "@geo/shared-types";
type TransportAuthState = "pending" | "registered" | "expired";
type TransportSseState = "idle" | "connecting" | "streaming";
interface DesktopTransportSession {
baseURL: string;
clientToken: string | null;
}
let desktopApiClient: ApiClient | null = null;
let transportSession: DesktopTransportSession = {
baseURL: process.env.DESKTOP_API_BASE_URL ?? "http://localhost:8080",
clientToken: null,
};
const transportState = {
baseURL: transportSession.baseURL,
initializedAt: 0,
mode: "rabbitmq-first" as const,
auth: "pending" as TransportAuthState,
sseState: "idle" as TransportSseState,
lastHeartbeatAt: 0,
lastHeartbeatStatus: "idle" as "idle" | "success" | "failed",
lastPullAt: 0,
lastPullStatus: "idle" as "idle" | "success" | "failed",
};
function createDesktopClient(baseURL: string): ApiClient {
const client = createApiClient({
baseURL,
timeoutMs: 15000,
});
client.raw.interceptors.request.use((config) => {
if (transportSession.clientToken) {
config.headers = config.headers ?? {};
(config.headers as Record<string, string>).Authorization = `Bearer ${transportSession.clientToken}`;
}
return config;
});
return client;
}
export function initTransport(): ApiClient {
if (desktopApiClient) {
return desktopApiClient;
}
transportState.initializedAt = transportState.initializedAt || Date.now();
transportState.baseURL = transportSession.baseURL;
desktopApiClient = createDesktopClient(transportSession.baseURL);
return desktopApiClient;
}
export function configureTransport(session: DesktopRuntimeSessionSyncRequest | null): ApiClient | null {
if (!session) {
transportSession = {
baseURL: process.env.DESKTOP_API_BASE_URL ?? "http://localhost:8080",
clientToken: null,
};
transportState.baseURL = transportSession.baseURL;
transportState.auth = "pending";
desktopApiClient = null;
return null;
}
transportSession = {
baseURL: session.api_base_url.trim() || "http://localhost:8080",
clientToken: session.client_token,
};
transportState.baseURL = transportSession.baseURL;
transportState.auth = session.client_token ? "registered" : "pending";
transportState.initializedAt = Date.now();
desktopApiClient = createDesktopClient(transportSession.baseURL);
return desktopApiClient;
}
export function setTransportAuthState(next: TransportAuthState): void {
transportState.auth = next;
}
export function setTransportSseState(next: TransportSseState): void {
transportState.sseState = next;
}
export function noteTransportHeartbeat(success: boolean): void {
transportState.lastHeartbeatAt = Date.now();
transportState.lastHeartbeatStatus = success ? "success" : "failed";
}
export function noteTransportPull(success: boolean): void {
transportState.lastPullAt = Date.now();
transportState.lastPullStatus = success ? "success" : "failed";
}
export function getDesktopApiClient(): ApiClient {
return desktopApiClient ?? initTransport();
}
export function getTransportSnapshot() {
return {
...transportState,
hasClient: desktopApiClient !== null,
hasClientToken: Boolean(transportSession.clientToken),
};
}
export async function heartbeatDesktopClient(
payload: DesktopClientHeartbeatRequest,
): Promise<DesktopClientHeartbeatResponse> {
return getDesktopApiClient().post<DesktopClientHeartbeatResponse, DesktopClientHeartbeatRequest>(
"/api/desktop/clients/heartbeat",
payload,
);
}
export async function listDesktopAccounts(): Promise<DesktopAccountInfo[]> {
return getDesktopApiClient().get<DesktopAccountInfo[]>("/api/desktop/accounts");
}
export async function getDesktopArticleContent(articleId: number): Promise<DesktopArticleContent> {
return getDesktopApiClient().get<DesktopArticleContent>(`/api/desktop/content/articles/${articleId}`);
}
export async function upsertDesktopAccount(
payload: UpsertDesktopAccountRequest,
): Promise<DesktopAccountInfo> {
return getDesktopApiClient().post<DesktopAccountInfo, UpsertDesktopAccountRequest>(
"/api/desktop/accounts",
payload,
);
}
export async function leaseDesktopTask(
request: LeaseDesktopTaskRequest,
options: { fromParked?: boolean } = {},
): Promise<LeaseDesktopTaskResponse> {
const taskId = request.task_id?.trim();
const path = taskId ? `/api/desktop/tasks/${taskId}/lease` : "/api/desktop/tasks/lease";
const query = options.fromParked ? "?from_parked=true" : "";
return getDesktopApiClient().post<LeaseDesktopTaskResponse, LeaseDesktopTaskRequest>(
`${path}${query}`,
taskId ? { kind: request.kind } : request,
);
}
export async function extendDesktopTask(
taskId: string,
payload: ExtendDesktopTaskRequest,
): Promise<DesktopTaskInfo> {
return getDesktopApiClient().post<DesktopTaskInfo, ExtendDesktopTaskRequest>(
`/api/desktop/tasks/${taskId}/extend`,
payload,
);
}
export async function parkDesktopTask(
taskId: string,
payload: ParkDesktopTaskRequest,
): Promise<DesktopTaskInfo> {
return getDesktopApiClient().post<DesktopTaskInfo, ParkDesktopTaskRequest>(
`/api/desktop/tasks/${taskId}/park`,
payload,
);
}
export async function completeDesktopTask(
taskId: string,
payload: CompleteDesktopTaskRequest,
): Promise<DesktopTaskInfo> {
return getDesktopApiClient().post<DesktopTaskInfo, CompleteDesktopTaskRequest>(
`/api/desktop/tasks/${taskId}/result`,
payload,
);
}
@@ -0,0 +1,237 @@
export type SseEventHandler = (payload: unknown) => void;
export interface SseClientOptions {
url: string;
headers?: Record<string, string>;
initialRetryDelayMs?: number;
maxRetryDelayMs?: number;
}
export class SseClientError extends Error {
status?: number;
constructor(message: string, options: { status?: number } = {}) {
super(message);
this.name = "SseClientError";
this.status = options.status;
}
}
type SseClientState = "idle" | "connecting" | "streaming";
interface ParsedSseEvent {
event: string;
data: unknown;
}
export class SseClient {
#listeners = new Map<string, Set<SseEventHandler>>();
#options: SseClientOptions;
#running = false;
#abortController: AbortController | null = null;
#retryDelayMs: number;
constructor(options: SseClientOptions) {
this.#options = options;
this.#retryDelayMs = options.initialRetryDelayMs ?? 1000;
}
on(event: string, handler: SseEventHandler): () => void {
const bucket = this.#listeners.get(event) ?? new Set<SseEventHandler>();
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;
void this.#run();
}
stop(): void {
this.#running = false;
this.#setState("idle");
this.#abortController?.abort();
this.#abortController = null;
}
emit(event: string, payload: unknown): void {
for (const handler of this.#listeners.get(event) ?? []) {
handler(payload);
}
}
async #run(): Promise<void> {
while (this.#running) {
try {
await this.#connectOnce();
this.#retryDelayMs = this.#options.initialRetryDelayMs ?? 1000;
} catch (error) {
if (!this.#running) {
break;
}
this.emit("error", error);
this.#setState("connecting");
const waitMs = this.#retryDelayMs;
this.emit("reconnect", { delayMs: waitMs });
await sleep(waitMs);
this.#retryDelayMs = Math.min(
this.#retryDelayMs * 2,
this.#options.maxRetryDelayMs ?? 30_000,
);
}
}
}
async #connectOnce(): Promise<void> {
this.#setState("connecting");
this.#abortController = new AbortController();
const response = await fetch(this.#options.url, {
method: "GET",
headers: {
Accept: "text/event-stream",
...(this.#options.headers ?? {}),
},
signal: this.#abortController.signal,
});
if (!response.ok) {
throw new SseClientError(`desktop_events_http_${response.status}`, {
status: response.status,
});
}
if (!response.body) {
throw new SseClientError("desktop_events_body_missing");
}
this.#setState("streaming");
this.emit("open", null);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (this.#running) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
buffer = this.#flushBuffer(buffer);
}
buffer += decoder.decode();
this.#flushRemaining(buffer);
if (this.#running && !this.#abortController.signal.aborted) {
throw new SseClientError("desktop_events_stream_closed");
}
}
#flushBuffer(buffer: string): string {
const normalized = buffer.replace(/\r\n/g, "\n");
let cursor = 0;
while (cursor >= 0) {
const boundary = normalized.indexOf("\n\n", cursor);
if (boundary === -1) {
return normalized.slice(cursor);
}
const chunk = normalized.slice(cursor, boundary);
cursor = boundary + 2;
const parsed = parseSseChunk(chunk);
if (!parsed) {
continue;
}
this.emit(parsed.event, parsed.data);
this.emit("message", parsed);
}
return "";
}
#flushRemaining(buffer: string): void {
const normalized = buffer.replace(/\r\n/g, "\n").trim();
if (!normalized) {
return;
}
const parsed = parseSseChunk(normalized);
if (!parsed) {
return;
}
this.emit(parsed.event, parsed.data);
this.emit("message", parsed);
}
#setState(state: SseClientState): void {
this.emit("state", state);
}
}
function parseSseChunk(chunk: string): ParsedSseEvent | null {
if (!chunk.trim()) {
return null;
}
let event = "message";
const dataLines: string[] = [];
for (const rawLine of chunk.split("\n")) {
const line = rawLine.trimEnd();
if (!line || line.startsWith(":")) {
continue;
}
if (line.startsWith("event:")) {
event = line.slice(6).trim() || "message";
continue;
}
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart());
}
}
const rawData = dataLines.join("\n");
if (!rawData) {
return { event, data: null };
}
try {
return {
event,
data: JSON.parse(rawData),
};
} catch {
return {
event,
data: rawData,
};
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
globalThis.setTimeout(resolve, ms);
});
}