Files
aigen/server/utils/imageComfyLifecycle.ts
T
TowstyandCursor c55a48a57e Ship MiniMax v2 identity refs and Qwen sidecar edits.
Keep v1 as the default video graph, relay image edits to the Qwen Lightning box, and keep /api/health up if one Comfy host is unset.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 14:29:15 -05:00

219 lines
6.9 KiB
TypeScript

import { getImageComfyHost, setImageComfyHostOverride, imageComfyConfigured } from '~/server/utils/imageComfy'
export type ImageComfyState = 'online' | 'booting' | 'busy' | 'starting' | 'offline'
export interface ImageComfyStatus {
state: ImageComfyState
message: string
host: string
processRunning?: boolean
queueRunning?: number
queuePending?: number
}
type StatusFn = (status: ImageComfyStatus) => void
let gate: Promise<unknown> = Promise.resolve()
let lastStartAt = 0
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function log(event: string, detail: Record<string, unknown> = {}) {
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 = 2500, host?: string) {
let resolved = host || ''
try {
if (!resolved) {
if (!imageComfyConfigured()) return { ok: false as const, host: '', status: 0, stats: null }
resolved = getImageComfyHost()
}
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 }
const stats = await res.json().catch(() => null)
return { ok: true as const, host: resolved, status: res.status, stats }
} catch {
return { ok: false as const, host: resolved, status: 0, stats: null }
}
}
export async function fetchImageQueue() {
try {
const host = getImageComfyHost()
const res = await fetch(`${host}/queue`, { signal: AbortSignal.timeout(2500) })
if (!res.ok) return { running: 0, pending: 0 }
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
}
} catch {
return { running: 0, pending: 0 }
}
}
async function controlRequest(path: string, method = 'GET') {
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(5000)
})
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')
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
}
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 sidecar 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 sidecar host agent did not start it. Confirm the agent on 192.168.77.101:8199 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
}