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