fa51a3455f
Desktop Client Build / Resolve Build Metadata (push) Successful in 41s
Backend CI / Backend (push) Failing after 7m14s
Desktop Client Build / Build Desktop Client (push) Successful in 23m46s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 56s
The desktop publish queue could stall for a long time and end users assumed the software was broken. Root cause: a hung adapter held its execution slot with no wall-clock timeout while auto-renewing its lease forever, so the server never reclaimed it and every queued task behind it stayed 等待发布. Auto-recovery also risked silently re-posting a non-idempotent article. Client (Electron): - per-task wall-clock deadline + abort; progress-gated lease renewal that stops and aborts a stalled task instead of renewing it forever - decouple the concurrency cap from CDP-induced CPU/memory pressure (admission gate instead of self-throttling collapse); 15s watchdog pump - all adapter network I/O now has fetch timeouts and honors context.signal; bounded image-upload concurrency with per-image timeout - surface live adapter progress, elapsed time, queue position and a working-vs-queued distinction in the publish view Server (tenant-api): - publish lease-recovery worker (every 3m) + supporting index: reclaim expired in_progress publish leases, requeue (<3 attempts) or terminal-fail - 3-minute lease TTL with client-presence-gated extension; max 3 attempts Idempotency (production-grade core): - durable publish_submit_started_at marker, set before the irreversible platform submit POST; recovery and abort route a maybe-submitted task to unknown (manual reconcile, kept in the dedup set) instead of re-posting - desktop UI requires explicit confirmation before retrying a possibly- already-published task Verified: go build/vet/test (incl. resolvePublishRecoveryOutcome), vue-tsc, vitest 141/141, gofmt all clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
760 lines
22 KiB
TypeScript
760 lines
22 KiB
TypeScript
import { createHash } from 'node:crypto'
|
|
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
import { dirname, join } from 'node:path'
|
|
|
|
import { ApiClientError, createApiClient, type ApiClient } from '@geo/http-client'
|
|
import type {
|
|
ApiEnvelope,
|
|
ApiErrorEnvelope,
|
|
CancelDesktopTaskRequest,
|
|
CompleteDesktopTaskRequest,
|
|
CreatePublishJobResponse,
|
|
DesktopAccountInfo,
|
|
DesktopArticleContent,
|
|
DesktopClientHeartbeatRequest,
|
|
DesktopClientHeartbeatResponse,
|
|
DesktopClientReleaseCheckResponse,
|
|
DesktopClientRotateResponse,
|
|
DesktopPublishTaskListResponse,
|
|
DesktopRuntimeSessionSyncRequest,
|
|
DesktopTaskInfo,
|
|
ExtendDesktopTaskRequest,
|
|
LeaseDesktopTaskRequest,
|
|
LeaseDesktopTaskResponse,
|
|
ListDesktopPublishTasksParams,
|
|
MonitoringLeaseTasksPayload,
|
|
MonitoringLeaseTasksResponse,
|
|
MonitoringResumeTasksPayload,
|
|
MonitoringResumeTasksResponse,
|
|
MonitoringSkipTaskPayload,
|
|
MonitoringSkipTaskResponse,
|
|
MonitoringTaskResultPayload,
|
|
MonitoringTaskResultResponse,
|
|
ReportDesktopAccountHealthRequest,
|
|
ReportDesktopAccountHealthResponse,
|
|
UpsertDesktopAccountRequest,
|
|
} from '@geo/shared-types'
|
|
import { app } from 'electron/main'
|
|
|
|
import { normalizeDesktopApiBaseURL } from '../../shared/api-base-url'
|
|
import {
|
|
failObservedRequest,
|
|
resolveObservedRequest,
|
|
startObservedRequest,
|
|
} from '../network-observer'
|
|
import { canUseRendererDevtoolsProxy, rendererDevtoolsFetch } from '../renderer-devtools-proxy'
|
|
|
|
type TransportAuthState = 'pending' | 'registered' | 'expired'
|
|
type TransportDispatchState = 'idle' | 'connecting' | 'streaming'
|
|
|
|
type ObservedAxiosConfig = {
|
|
method?: string
|
|
url?: string
|
|
baseURL?: string
|
|
headers?: Record<string, unknown>
|
|
__observedRequestId?: string
|
|
}
|
|
|
|
interface DesktopTransportSession {
|
|
baseURL: string
|
|
clientToken: string | null
|
|
}
|
|
|
|
interface MonitoringCancelTaskPayload {
|
|
lease_token: string
|
|
reason?: string | null
|
|
}
|
|
|
|
interface MonitoringCancelTaskResponse {
|
|
task_id: number
|
|
task_status: string
|
|
}
|
|
|
|
let desktopApiClient: ApiClient | null = null
|
|
let transportSession: DesktopTransportSession = {
|
|
baseURL: normalizeDesktopApiBaseURL(process.env.DESKTOP_API_BASE_URL),
|
|
clientToken: null,
|
|
}
|
|
const ACCOUNT_UPSERT_CACHE_TTL_MS = 5 * 60_000
|
|
const ACCOUNT_UPSERT_DIAGNOSTIC_FILE = 'desktop-account-upsert-errors.jsonl'
|
|
|
|
interface AccountUpsertCacheEntry {
|
|
signature: string
|
|
expiresAt: number
|
|
response: DesktopAccountInfo
|
|
inFlight: Promise<DesktopAccountInfo> | null
|
|
}
|
|
|
|
const accountUpsertCache = new Map<string, AccountUpsertCacheEntry>()
|
|
|
|
const transportState = {
|
|
baseURL: transportSession.baseURL,
|
|
initializedAt: 0,
|
|
mode: 'rabbitmq-first' as const,
|
|
requestDebugMode: 'main-process' as 'main-process' | 'renderer-devtools',
|
|
auth: 'pending' as TransportAuthState,
|
|
dispatchState: 'idle' as TransportDispatchState,
|
|
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}`
|
|
}
|
|
|
|
const observedConfig = config as ObservedAxiosConfig
|
|
observedConfig.__observedRequestId = startObservedRequest({
|
|
source: 'transport',
|
|
label: 'desktop.api',
|
|
method: config.method,
|
|
url: resolveObservedRequestURL(config.url, config.baseURL ?? baseURL),
|
|
})
|
|
return config
|
|
})
|
|
|
|
client.raw.interceptors.response.use(
|
|
(response) => {
|
|
const observedConfig = response.config as ObservedAxiosConfig
|
|
if (observedConfig.__observedRequestId) {
|
|
resolveObservedRequest(observedConfig.__observedRequestId, {
|
|
phase: 'response',
|
|
status: response.status,
|
|
})
|
|
}
|
|
return response
|
|
},
|
|
(error: unknown) => {
|
|
const responseError = error as {
|
|
config?: ObservedAxiosConfig
|
|
response?: { status?: number }
|
|
}
|
|
const requestId = responseError.config?.__observedRequestId
|
|
if (requestId) {
|
|
if (typeof responseError.response?.status === 'number') {
|
|
resolveObservedRequest(requestId, {
|
|
phase: 'response',
|
|
status: responseError.response.status,
|
|
})
|
|
} else {
|
|
failObservedRequest(requestId, error)
|
|
}
|
|
}
|
|
return Promise.reject(error)
|
|
},
|
|
)
|
|
|
|
return client
|
|
}
|
|
|
|
function stableJson(value: unknown): string {
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map((item) => stableJson(item)).join(',')}]`
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
return `{${Object.entries(value as Record<string, unknown>)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
|
|
.join(',')}}`
|
|
}
|
|
return JSON.stringify(value)
|
|
}
|
|
|
|
function accountUpsertCacheKey(payload: UpsertDesktopAccountRequest): string {
|
|
return [payload.platform.trim(), payload.platform_uid.trim()].join('::')
|
|
}
|
|
|
|
function accountUpsertSignature(payload: UpsertDesktopAccountRequest): string {
|
|
return stableJson({
|
|
platform: payload.platform.trim(),
|
|
platform_uid: payload.platform_uid.trim(),
|
|
account_fingerprint: payload.account_fingerprint ?? null,
|
|
display_name: payload.display_name.trim(),
|
|
avatar_url: payload.avatar_url ?? null,
|
|
health: payload.health,
|
|
verified_at: payload.verified_at ?? null,
|
|
tags: payload.tags ?? [],
|
|
if_sync_version: payload.if_sync_version ?? null,
|
|
})
|
|
}
|
|
|
|
function clearAccountUpsertCache(): void {
|
|
accountUpsertCache.clear()
|
|
}
|
|
|
|
function diagnosticHash(value: string | null | undefined): string | null {
|
|
const normalized = value?.trim()
|
|
if (!normalized) {
|
|
return null
|
|
}
|
|
return createHash('sha256').update(normalized).digest('hex').slice(0, 16)
|
|
}
|
|
|
|
function normalizeErrorForDiagnostics(error: unknown): Record<string, unknown> {
|
|
if (error instanceof ApiClientError) {
|
|
return {
|
|
name: error.name,
|
|
message: error.message,
|
|
code: error.code,
|
|
status: error.status ?? null,
|
|
detail: error.detail ?? null,
|
|
requestId: error.requestId ?? null,
|
|
handled: error.handled === true,
|
|
}
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
return {
|
|
name: error.name,
|
|
message: error.message,
|
|
}
|
|
}
|
|
|
|
return {
|
|
name: typeof error,
|
|
message: String(error),
|
|
}
|
|
}
|
|
|
|
function appendAccountUpsertDiagnostic(payload: UpsertDesktopAccountRequest, error: unknown): void {
|
|
const target = join(app.getPath('userData'), ACCOUNT_UPSERT_DIAGNOSTIC_FILE)
|
|
const event = {
|
|
at: new Date().toISOString(),
|
|
action: 'desktop_account_upsert_failed',
|
|
transport: {
|
|
baseURL: transportState.baseURL,
|
|
auth: transportState.auth,
|
|
requestDebugMode: transportState.requestDebugMode,
|
|
hasClient: desktopApiClient !== null,
|
|
hasClientToken: Boolean(transportSession.clientToken),
|
|
},
|
|
request: {
|
|
platform: payload.platform,
|
|
platformUidHash: diagnosticHash(payload.platform_uid),
|
|
accountFingerprintHash: diagnosticHash(payload.account_fingerprint ?? null),
|
|
displayName: payload.display_name,
|
|
health: payload.health,
|
|
verifiedAt: payload.verified_at ?? null,
|
|
tagCount: payload.tags?.length ?? 0,
|
|
hasIfSyncVersion: typeof payload.if_sync_version === 'number',
|
|
},
|
|
error: normalizeErrorForDiagnostics(error),
|
|
}
|
|
|
|
try {
|
|
mkdirSync(dirname(target), { recursive: true })
|
|
appendFileSync(target, `${JSON.stringify(event)}\n`, 'utf8')
|
|
} catch {
|
|
// Best-effort diagnostics. Never block account binding on local log writes.
|
|
}
|
|
}
|
|
|
|
function currentRequestDebugMode(): 'main-process' | 'renderer-devtools' {
|
|
return canUseRendererDevtoolsProxy() ? 'renderer-devtools' : 'main-process'
|
|
}
|
|
|
|
function resolveDesktopURL(path: string): string {
|
|
return new URL(path, transportSession.baseURL).toString()
|
|
}
|
|
|
|
export function resolveDesktopApiURL(path: string): string {
|
|
return resolveDesktopURL(path)
|
|
}
|
|
|
|
export async function fetchDesktopApiURL(
|
|
input: string | URL,
|
|
init: RequestInit = {},
|
|
): Promise<Response> {
|
|
const headers = new Headers(init.headers)
|
|
if (transportSession.clientToken && !headers.has('Authorization')) {
|
|
headers.set('Authorization', `Bearer ${transportSession.clientToken}`)
|
|
}
|
|
|
|
const url =
|
|
input instanceof URL
|
|
? input.toString()
|
|
: /^https?:\/\//i.test(input)
|
|
? input
|
|
: resolveDesktopURL(input)
|
|
return fetch(url, {
|
|
...init,
|
|
headers,
|
|
})
|
|
}
|
|
|
|
function resolveObservedRequestURL(path: string | undefined, baseURL: string): string {
|
|
if (!path) {
|
|
return baseURL
|
|
}
|
|
|
|
try {
|
|
return new URL(path, baseURL).toString()
|
|
} catch {
|
|
return path
|
|
}
|
|
}
|
|
|
|
function shouldFallbackToMainProcess(error: unknown): boolean {
|
|
if (error instanceof ApiClientError) {
|
|
if (typeof error.status === 'number' && error.status > 0) {
|
|
return false
|
|
}
|
|
return /Failed to fetch|renderer_devtools_proxy_/i.test(error.message)
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
return /Failed to fetch|renderer_devtools_proxy_/i.test(error.message)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
async function proxyDesktopRequest<T, B = unknown>(
|
|
method: 'GET' | 'POST' | 'DELETE',
|
|
path: string,
|
|
body?: B,
|
|
): Promise<T> {
|
|
const headers: Record<string, string> = {
|
|
Accept: 'application/json',
|
|
}
|
|
|
|
let serializedBody: string | undefined
|
|
if (body !== undefined) {
|
|
headers['Content-Type'] = 'application/json'
|
|
serializedBody = JSON.stringify(body)
|
|
}
|
|
if (transportSession.clientToken) {
|
|
headers.Authorization = `Bearer ${transportSession.clientToken}`
|
|
}
|
|
|
|
const response = await rendererDevtoolsFetch({
|
|
url: resolveDesktopURL(path),
|
|
method,
|
|
headers,
|
|
body: serializedBody,
|
|
})
|
|
|
|
if (response.error) {
|
|
throw new ApiClientError({ message: response.error })
|
|
}
|
|
|
|
let payload: ApiEnvelope<T> | Partial<ApiErrorEnvelope> | null = null
|
|
if (response.bodyText) {
|
|
try {
|
|
payload = JSON.parse(response.bodyText) as ApiEnvelope<T> | Partial<ApiErrorEnvelope>
|
|
} catch {
|
|
payload = null
|
|
}
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorPayload = payload as Partial<ApiErrorEnvelope> | null
|
|
throw new ApiClientError({
|
|
message: errorPayload?.message ?? `desktop_http_${response.status}`,
|
|
code: errorPayload?.code ?? 50000,
|
|
status: response.status,
|
|
detail: errorPayload?.detail,
|
|
requestId: errorPayload?.request_id,
|
|
})
|
|
}
|
|
|
|
if (!payload || typeof payload !== 'object' || !('data' in payload)) {
|
|
throw new ApiClientError({
|
|
message: 'desktop_invalid_api_envelope',
|
|
status: response.status,
|
|
})
|
|
}
|
|
|
|
return payload.data as T
|
|
}
|
|
|
|
async function desktopGet<T>(path: string): Promise<T> {
|
|
transportState.requestDebugMode = currentRequestDebugMode()
|
|
if (transportState.requestDebugMode === 'renderer-devtools') {
|
|
try {
|
|
return await proxyDesktopRequest<T>('GET', path)
|
|
} catch (error) {
|
|
if (!shouldFallbackToMainProcess(error)) {
|
|
throw error
|
|
}
|
|
transportState.requestDebugMode = 'main-process'
|
|
}
|
|
}
|
|
return getDesktopApiClient().get<T>(path)
|
|
}
|
|
|
|
async function desktopPost<T, B = unknown>(path: string, body?: B): Promise<T> {
|
|
transportState.requestDebugMode = currentRequestDebugMode()
|
|
if (transportState.requestDebugMode === 'renderer-devtools') {
|
|
try {
|
|
return await proxyDesktopRequest<T, B>('POST', path, body)
|
|
} catch (error) {
|
|
if (!shouldFallbackToMainProcess(error)) {
|
|
throw error
|
|
}
|
|
transportState.requestDebugMode = 'main-process'
|
|
}
|
|
}
|
|
return getDesktopApiClient().post<T, B>(path, body)
|
|
}
|
|
|
|
async function desktopDelete<T>(path: string): Promise<T> {
|
|
transportState.requestDebugMode = currentRequestDebugMode()
|
|
if (transportState.requestDebugMode === 'renderer-devtools') {
|
|
try {
|
|
return await proxyDesktopRequest<T>('DELETE', path)
|
|
} catch (error) {
|
|
if (!shouldFallbackToMainProcess(error)) {
|
|
throw error
|
|
}
|
|
transportState.requestDebugMode = 'main-process'
|
|
}
|
|
}
|
|
return getDesktopApiClient().remove<T>(path)
|
|
}
|
|
|
|
export function initTransport(): ApiClient {
|
|
if (desktopApiClient) {
|
|
return desktopApiClient
|
|
}
|
|
|
|
transportState.initializedAt = transportState.initializedAt || Date.now()
|
|
transportState.baseURL = transportSession.baseURL
|
|
transportState.requestDebugMode = currentRequestDebugMode()
|
|
desktopApiClient = createDesktopClient(transportSession.baseURL)
|
|
return desktopApiClient
|
|
}
|
|
|
|
export function configureTransport(
|
|
session: DesktopRuntimeSessionSyncRequest | null,
|
|
): ApiClient | null {
|
|
if (!session) {
|
|
transportSession = {
|
|
baseURL: normalizeDesktopApiBaseURL(process.env.DESKTOP_API_BASE_URL),
|
|
clientToken: null,
|
|
}
|
|
transportState.baseURL = transportSession.baseURL
|
|
transportState.requestDebugMode = currentRequestDebugMode()
|
|
transportState.auth = 'pending'
|
|
transportState.dispatchState = 'idle'
|
|
desktopApiClient = null
|
|
clearAccountUpsertCache()
|
|
return null
|
|
}
|
|
|
|
transportSession = {
|
|
baseURL: normalizeDesktopApiBaseURL(session.api_base_url),
|
|
clientToken: session.client_token,
|
|
}
|
|
transportState.baseURL = transportSession.baseURL
|
|
transportState.requestDebugMode = currentRequestDebugMode()
|
|
transportState.auth = session.client_token ? 'registered' : 'pending'
|
|
transportState.dispatchState = session.client_token ? 'connecting' : 'idle'
|
|
transportState.initializedAt = Date.now()
|
|
desktopApiClient = createDesktopClient(transportSession.baseURL)
|
|
clearAccountUpsertCache()
|
|
return desktopApiClient
|
|
}
|
|
|
|
export function setTransportAuthState(next: TransportAuthState): void {
|
|
transportState.auth = next
|
|
}
|
|
|
|
export function setTransportDispatchState(next: TransportDispatchState): void {
|
|
transportState.dispatchState = 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 desktopPost<DesktopClientHeartbeatResponse, DesktopClientHeartbeatRequest>(
|
|
'/api/desktop/clients/heartbeat',
|
|
payload,
|
|
)
|
|
}
|
|
|
|
export async function rotateDesktopClient(): Promise<DesktopClientRotateResponse> {
|
|
return desktopPost<DesktopClientRotateResponse, Record<string, never>>(
|
|
'/api/desktop/clients/rotate',
|
|
{},
|
|
)
|
|
}
|
|
|
|
export async function checkDesktopClientRelease(params: {
|
|
platform: string
|
|
arch: string
|
|
version: string
|
|
channel?: string
|
|
}): Promise<DesktopClientReleaseCheckResponse> {
|
|
const search = new URLSearchParams({
|
|
platform: params.platform,
|
|
arch: params.arch,
|
|
version: params.version,
|
|
})
|
|
if (params.channel?.trim()) {
|
|
search.set('channel', params.channel.trim())
|
|
}
|
|
return desktopGet<DesktopClientReleaseCheckResponse>(
|
|
`/api/desktop/clients/releases/latest?${search.toString()}`,
|
|
)
|
|
}
|
|
|
|
export async function resolveDesktopClientReleaseDownloadURL(params: {
|
|
platform: string
|
|
arch: string
|
|
version: string
|
|
channel?: string
|
|
}): Promise<{ download_url: string; expires_in?: number | null }> {
|
|
const search = new URLSearchParams({
|
|
platform: params.platform,
|
|
arch: params.arch,
|
|
version: params.version,
|
|
})
|
|
if (params.channel?.trim()) {
|
|
search.set('channel', params.channel.trim())
|
|
}
|
|
return desktopGet<{ download_url: string; expires_in?: number | null }>(
|
|
`/api/desktop/clients/releases/download-url?${search.toString()}`,
|
|
)
|
|
}
|
|
|
|
export async function offlineDesktopClient(): Promise<void> {
|
|
await desktopPost<{ server_time: string }, Record<string, never>>(
|
|
'/api/desktop/clients/offline',
|
|
{},
|
|
)
|
|
}
|
|
|
|
export async function revokeDesktopClient(): Promise<void> {
|
|
await desktopPost<{ id: string }, Record<string, never>>('/api/desktop/clients/revoke', {})
|
|
}
|
|
|
|
export async function listDesktopAccounts(): Promise<DesktopAccountInfo[]> {
|
|
return desktopGet<DesktopAccountInfo[]>('/api/desktop/accounts')
|
|
}
|
|
|
|
export async function getDesktopArticleContent(articleId: number): Promise<DesktopArticleContent> {
|
|
return desktopGet<DesktopArticleContent>(`/api/desktop/content/articles/${articleId}`)
|
|
}
|
|
|
|
export async function listDesktopPublishTasks(
|
|
params: ListDesktopPublishTasksParams = {},
|
|
): Promise<DesktopPublishTaskListResponse> {
|
|
const search = new URLSearchParams()
|
|
if (params.page) {
|
|
search.set('page', String(params.page))
|
|
}
|
|
if (params.page_size) {
|
|
search.set('page_size', String(params.page_size))
|
|
}
|
|
if (params.title?.trim()) {
|
|
search.set('title', params.title.trim())
|
|
}
|
|
|
|
const query = search.toString()
|
|
const suffix = query ? `?${query}` : ''
|
|
return desktopGet<DesktopPublishTaskListResponse>(`/api/desktop/publish-tasks${suffix}`)
|
|
}
|
|
|
|
export async function retryDesktopPublishTask(taskId: string): Promise<CreatePublishJobResponse> {
|
|
return desktopPost<CreatePublishJobResponse, Record<string, never>>(
|
|
`/api/desktop/publish-tasks/${encodeURIComponent(taskId)}/retry`,
|
|
{},
|
|
)
|
|
}
|
|
|
|
export async function upsertDesktopAccount(
|
|
payload: UpsertDesktopAccountRequest,
|
|
): Promise<DesktopAccountInfo> {
|
|
const now = Date.now()
|
|
const cacheKey = accountUpsertCacheKey(payload)
|
|
const signature = accountUpsertSignature(payload)
|
|
const cached = accountUpsertCache.get(cacheKey)
|
|
if (cached && cached.signature === signature && cached.expiresAt > now) {
|
|
if (cached.inFlight) {
|
|
return cached.inFlight
|
|
}
|
|
return cached.response
|
|
}
|
|
|
|
const request = desktopPost<DesktopAccountInfo, UpsertDesktopAccountRequest>(
|
|
'/api/desktop/accounts',
|
|
payload,
|
|
)
|
|
accountUpsertCache.set(cacheKey, {
|
|
signature,
|
|
expiresAt: now + ACCOUNT_UPSERT_CACHE_TTL_MS,
|
|
response: cached?.response ?? ({} as DesktopAccountInfo),
|
|
inFlight: request,
|
|
})
|
|
|
|
try {
|
|
const response = await request
|
|
accountUpsertCache.set(cacheKey, {
|
|
signature,
|
|
expiresAt: Date.now() + ACCOUNT_UPSERT_CACHE_TTL_MS,
|
|
response,
|
|
inFlight: null,
|
|
})
|
|
return response
|
|
} catch (error) {
|
|
if (accountUpsertCache.get(cacheKey)?.inFlight === request) {
|
|
accountUpsertCache.delete(cacheKey)
|
|
}
|
|
appendAccountUpsertDiagnostic(payload, error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function reportDesktopAccountHealth(
|
|
payload: ReportDesktopAccountHealthRequest,
|
|
): Promise<ReportDesktopAccountHealthResponse> {
|
|
return desktopPost<ReportDesktopAccountHealthResponse, ReportDesktopAccountHealthRequest>(
|
|
'/api/desktop/accounts/health-reports',
|
|
payload,
|
|
)
|
|
}
|
|
|
|
export async function deleteDesktopAccount(
|
|
desktopId: string,
|
|
ifSyncVersion: number,
|
|
): Promise<DesktopAccountInfo> {
|
|
const params = new URLSearchParams({ if_sync_version: String(ifSyncVersion) })
|
|
const response = await desktopDelete<DesktopAccountInfo>(
|
|
`/api/desktop/accounts/${encodeURIComponent(desktopId)}?${params.toString()}`,
|
|
)
|
|
clearAccountUpsertCache()
|
|
return response
|
|
}
|
|
|
|
export async function leaseDesktopTask(
|
|
request: LeaseDesktopTaskRequest,
|
|
): Promise<LeaseDesktopTaskResponse> {
|
|
const taskId = request.task_id?.trim()
|
|
const path = taskId ? `/api/desktop/tasks/${taskId}/lease` : '/api/desktop/tasks/lease'
|
|
return desktopPost<LeaseDesktopTaskResponse, LeaseDesktopTaskRequest>(
|
|
path,
|
|
taskId ? { kind: request.kind } : request,
|
|
)
|
|
}
|
|
|
|
export async function extendDesktopTask(
|
|
taskId: string,
|
|
payload: ExtendDesktopTaskRequest,
|
|
): Promise<DesktopTaskInfo> {
|
|
return desktopPost<DesktopTaskInfo, ExtendDesktopTaskRequest>(
|
|
`/api/desktop/tasks/${taskId}/extend`,
|
|
payload,
|
|
)
|
|
}
|
|
|
|
export async function completeDesktopTask(
|
|
taskId: string,
|
|
payload: CompleteDesktopTaskRequest,
|
|
): Promise<DesktopTaskInfo> {
|
|
return desktopPost<DesktopTaskInfo, CompleteDesktopTaskRequest>(
|
|
`/api/desktop/tasks/${taskId}/result`,
|
|
payload,
|
|
)
|
|
}
|
|
|
|
export async function markDesktopTaskPublishSubmitStarted(
|
|
taskId: string,
|
|
leaseToken: string,
|
|
): Promise<void> {
|
|
await desktopPost<{ ok: boolean }, { lease_token: string }>(
|
|
`/api/desktop/tasks/${taskId}/publish-submit-marker`,
|
|
{ lease_token: leaseToken },
|
|
)
|
|
}
|
|
|
|
export async function cancelDesktopTask(
|
|
taskId: string,
|
|
payload: CancelDesktopTaskRequest,
|
|
): Promise<DesktopTaskInfo> {
|
|
return desktopPost<DesktopTaskInfo, CancelDesktopTaskRequest>(
|
|
`/api/desktop/tasks/${taskId}/cancel`,
|
|
payload,
|
|
)
|
|
}
|
|
|
|
export async function leaseMonitoringTasks(
|
|
payload: MonitoringLeaseTasksPayload = {},
|
|
): Promise<MonitoringLeaseTasksResponse> {
|
|
return desktopPost<MonitoringLeaseTasksResponse, MonitoringLeaseTasksPayload>(
|
|
'/api/desktop/monitoring/tasks/lease',
|
|
payload,
|
|
)
|
|
}
|
|
|
|
export async function resumeMonitoringTasks(
|
|
payload: MonitoringResumeTasksPayload = {},
|
|
): Promise<MonitoringResumeTasksResponse> {
|
|
return desktopPost<MonitoringResumeTasksResponse, MonitoringResumeTasksPayload>(
|
|
'/api/desktop/monitoring/tasks/resume',
|
|
payload,
|
|
)
|
|
}
|
|
|
|
export async function submitMonitoringTaskResult(
|
|
taskId: number,
|
|
payload: MonitoringTaskResultPayload,
|
|
): Promise<MonitoringTaskResultResponse> {
|
|
return desktopPost<MonitoringTaskResultResponse, MonitoringTaskResultPayload>(
|
|
`/api/desktop/monitoring/tasks/${taskId}/result`,
|
|
payload,
|
|
)
|
|
}
|
|
|
|
export async function skipMonitoringTask(
|
|
taskId: number,
|
|
payload: MonitoringSkipTaskPayload,
|
|
): Promise<MonitoringSkipTaskResponse> {
|
|
return desktopPost<MonitoringSkipTaskResponse, MonitoringSkipTaskPayload>(
|
|
`/api/desktop/monitoring/tasks/${taskId}/skip`,
|
|
payload,
|
|
)
|
|
}
|
|
|
|
export async function cancelMonitoringTask(
|
|
taskId: number,
|
|
payload: MonitoringCancelTaskPayload,
|
|
): Promise<MonitoringCancelTaskResponse> {
|
|
return desktopPost<MonitoringCancelTaskResponse, MonitoringCancelTaskPayload>(
|
|
`/api/desktop/monitoring/tasks/${taskId}/cancel`,
|
|
payload,
|
|
)
|
|
}
|