import { getImageComfyHost, setImageComfyHostOverride, imageComfyConfigured, getBeastImageHost, getSidecarImageHost, withImageComfyHost, type ImageEditBox } from '~/server/utils/imageComfy' export type ImageComfyState = 'online' | 'booting' | 'busy' | 'starting' | 'offline' export interface ImageComfyStatus { state: ImageComfyState message: string host: string box?: ImageEditBox processRunning?: boolean queueRunning?: number queuePending?: number } export interface ImageEditTarget { host: string box: ImageEditBox label: string } type StatusFn = (status: ImageComfyStatus) => void let gate: Promise = Promise.resolve() let lastStartAt = 0 function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) } function log(event: string, detail: Record = {}) { console.log(JSON.stringify({ src: 'image-comfy-lifecycle', event, at: new Date().toISOString(), ...detail })) } function settings() { const config = useRuntimeConfig() return { host: getImageComfyHost(), launchCmd: String(config.imageComfyLaunchCmd || process.env.IMAGE_COMFY_LAUNCH_CMD || '').trim(), controlUrl: String(config.imageComfyControlUrl || process.env.IMAGE_COMFY_CONTROL_URL || '').replace(/\/$/, ''), controlToken: String(config.imageComfyControlToken || process.env.IMAGE_COMFY_CONTROL_TOKEN || ''), healthTimeoutMs: 2500, bootPollAttempts: Number(process.env.IMAGE_COMFY_BOOT_POLL_ATTEMPTS || 20), bootPollDelayMs: 3000, startTimeoutMs: Number(process.env.IMAGE_COMFY_START_TIMEOUT_MS || 180_000), busyWaitMs: Number(process.env.IMAGE_COMFY_BUSY_WAIT_MS || 180_000) } } export async function checkImageComfyHttp(timeoutMs = 5000, host?: string) { let resolved = host || '' try { if (!resolved) { if (!imageComfyConfigured()) return { ok: false as const, host: '', status: 0, stats: null, timedOut: false } resolved = getImageComfyHost() } if (!resolved) return { ok: false as const, host: '', status: 0, stats: null, timedOut: false } const res = await fetch(`${resolved}/system_stats`, { signal: AbortSignal.timeout(timeoutMs) }) if (!res.ok) return { ok: false as const, host: resolved, status: res.status, stats: null, timedOut: false } const stats = await res.json().catch(() => null) return { ok: true as const, host: resolved, status: res.status, stats, timedOut: false } } catch (error) { const timedOut = error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError' || /timeout/i.test(error.message)) return { ok: false as const, host: resolved, status: 0, stats: null, timedOut } } } export async function fetchQueueAt(host: string) { if (!host) return { running: 0, pending: 0, ok: false } try { const res = await fetch(`${host}/queue`, { signal: AbortSignal.timeout(5000) }) if (!res.ok) return { running: 0, pending: 0, ok: false } const payload = await res.json() as { queue_running?: unknown[]; queue_pending?: unknown[] } return { running: Array.isArray(payload.queue_running) ? payload.queue_running.length : 0, pending: Array.isArray(payload.queue_pending) ? payload.queue_pending.length : 0, ok: true } } catch { return { running: 0, pending: 0, ok: false } } } export async function fetchImageQueue() { return fetchQueueAt(getImageComfyHost()) } async function controlRequest(path: string, method = 'GET', timeoutMs = 5000) { const { controlUrl, controlToken } = settings() if (!controlUrl) return null try { const res = await fetch(`${controlUrl}${path}`, { method, headers: { Accept: 'application/json', ...(controlToken ? { Authorization: `Bearer ${controlToken}` } : {}) }, signal: AbortSignal.timeout(timeoutMs) }) if (!res.ok) return { ok: false, status: res.status } return await res.json() as { ok?: boolean http?: boolean process?: boolean started?: boolean already?: boolean port?: number | null proxyPort?: number | null } } catch { return null } } async function waitWhileBusy(onStatus: StatusFn) { const { busyWaitMs, host } = settings() const started = Date.now() while (Date.now() - started < busyWaitMs) { const queue = await fetchImageQueue() if (queue.running === 0) return onStatus({ state: 'busy', message: `Image ComfyUI is busy with another prompt (${queue.running} running, ${queue.pending} queued). Waiting up to 3 minutes.`, host, queueRunning: queue.running, queuePending: queue.pending }) await sleep(2000) } throw createError({ statusCode: 409, statusMessage: 'Image ComfyUI is still busy after 3 minutes.', data: { code: 'IMAGE_COMFY_BUSY' } }) } function hostWithPort(host: string, port: number) { try { const url = new URL(host) url.port = String(port) return url.origin } catch { return host } } async function startImageComfy() { const now = Date.now() if (now - lastStartAt < 15_000) { log('start-skipped', { reason: 'recent-start' }) return { started: false, reason: 'recent-start' as const } } lastStartAt = now const remote = await controlRequest('/start', 'POST', 25_000) if (remote?.ok || remote?.started) { log('start-remote', { port: remote.port || null }) return { started: true, reason: 'remote' as const } } return { started: false, reason: 'no-launcher' as const } } async function ensureUnlocked(onStatus: StatusFn) { const cfg = settings() const host = cfg.host onStatus({ state: 'offline', message: 'Checking image ComfyUI...', host }) const health = await checkImageComfyHttp(cfg.healthTimeoutMs) if (health.ok) { await waitWhileBusy(onStatus) onStatus({ state: 'online', message: 'Image ComfyUI online', host }) return } if (health.timedOut) { log('http-busy', { host, timeoutMs: cfg.healthTimeoutMs }) onStatus({ state: 'busy', message: 'Image ComfyUI is loaded but slow to answer. Not starting another instance.', host, processRunning: true }) await waitWhileBusy(onStatus) onStatus({ state: 'online', message: 'Image ComfyUI online', host, processRunning: true }) return } const remote = await controlRequest('/status') if (remote?.http && remote.proxyPort) { const proxyUrl = hostWithPort(host, remote.proxyPort) const viaProxy = await checkImageComfyHttp(cfg.healthTimeoutMs, proxyUrl) if (viaProxy.ok) { setImageComfyHostOverride(proxyUrl) await waitWhileBusy(onStatus) onStatus({ state: 'online', message: 'Image ComfyUI online', host: proxyUrl, processRunning: true }) return } } onStatus({ state: 'starting', message: 'Image ComfyUI is offline. Starting the Beast instance...', host, processRunning: Boolean(remote?.process) }) const started = await startImageComfy() if (!started.started && started.reason === 'no-launcher') { throw createError({ statusCode: 503, statusMessage: 'Image ComfyUI is offline and the Beast host agent did not start it. Confirm COMFY_CONTROL_URL is running.' }) } const deadline = Date.now() + cfg.startTimeoutMs let attempt = 0 while (Date.now() < deadline) { attempt += 1 onStatus({ state: 'starting', message: `Starting image ComfyUI... waiting for /system_stats (${attempt})`, host }) const next = await checkImageComfyHttp() if (next.ok) { await waitWhileBusy(onStatus) onStatus({ state: 'online', message: 'Image ComfyUI online', host, processRunning: true }) return } await sleep(2000) } throw createError({ statusCode: 503, statusMessage: 'Started image ComfyUI but the API never answered. Headless Qwen Edit can take a couple of minutes to load.' }) } export function ensureImageComfyReady(onStatus: StatusFn = () => undefined) { const run = gate.then(() => ensureUnlocked(onStatus)) gate = run.then(() => undefined, () => undefined) return run } let sidecarGate: Promise = Promise.resolve() async function ensureSidecarUnlocked(onStatus: StatusFn) { const host = getSidecarImageHost() if (!host) { throw createError({ statusCode: 503, statusMessage: 'Qwen VL is not configured. Set COMFY_HOST or IMAGE_COMFY_HOST to Beast.' }) } onStatus({ state: 'offline', message: 'Checking Beast Qwen VL…', host, box: 'beast' }) const health = await checkImageComfyHttp(2500, host) if (health.ok) { onStatus({ state: 'online', message: 'Beast Qwen VL online', host, box: 'beast' }) return } throw createError({ statusCode: 503, statusMessage: `Beast ComfyUI is offline (${host}). Recommend needs Qwen VL on that instance and will not start a sidecar.` }) } export function ensureSidecarReady(onStatus: StatusFn = () => undefined) { const run = sidecarGate.then(() => ensureSidecarUnlocked(onStatus)) sidecarGate = run.then(() => undefined, () => undefined) return run } export async function pickImageEditHost(onStatus: StatusFn = () => undefined): Promise { const beast = getBeastImageHost() if (!beast) { throw createError({ statusCode: 503, statusMessage: 'Beast ComfyUI is not configured. Set COMFY_HOST.' }) } const health = await checkImageComfyHttp(2500, beast) if (!health.ok) { throw createError({ statusCode: 503, statusMessage: 'Beast is offline. Image edit runs on the 5080 only.' }) } const queue = await fetchQueueAt(beast) if (queue.running === 0) { onStatus({ state: 'online', message: 'Beast is free. Editing on this machine.', host: beast, box: 'beast', queueRunning: 0, queuePending: queue.pending }) return { host: beast, box: 'beast', label: 'Beast' } } onStatus({ state: 'busy', message: `Beast is busy (${queue.running} running). Waiting for a free slot...`, host: beast, box: 'beast', queueRunning: queue.running, queuePending: queue.pending }) await withImageComfyHost(beast, () => waitWhileBusy(onStatus)) return { host: beast, box: 'beast', label: 'Beast' } }