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:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user