UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

200 lines • 9.47 kB
import { getHistory } from "../comfyui/client.js"; import { buildCompletionNotification } from "./job-watcher.js"; import { extractWorkflowGraph } from "./history-select.js"; import { hasAffirmativeSuccessStatus, historyCompletionTimeMs } from "./job-history.js"; import { AssetRegistry, normalizeAssetImage } from "./asset-registry.js"; import { getOutputImage } from "./image-management.js"; import { logger } from "../utils/logger.js"; import { raceAbort } from "../comfyui/fetch.js"; /** Only the newest N completed prompts are reconciled per call. */ const DEFAULT_MAX_PROMPTS = 25; /** Bound successfully validated history images even when one prompt is unbounded. */ const DEFAULT_MAX_IMAGE_PROBES = 16; /** Keep failed availability probes bounded without consuming the success budget. */ const DEFAULT_MAX_FAILED_PROBES = 8; /** Hard ceiling across both successful and failed availability probes. */ export const MAX_RECONCILIATION_PROBE_ATTEMPTS = 16; /** A list operation must not wait on a wedged history image indefinitely. */ export const RECONCILIATION_DEADLINE_MS = 5_000; /** A single unavailable ref must not block later useful refs for the full HTTP budget. */ export const RECONCILIATION_PROBE_TIMEOUT_MS = 1_000; function boundedImageProbeLimit(value) { if (value === undefined || !Number.isFinite(value)) return DEFAULT_MAX_IMAGE_PROBES; return Math.min(DEFAULT_MAX_IMAGE_PROBES, Math.max(0, Math.floor(value))); } function boundedFailedProbeLimit(value) { if (value === undefined || !Number.isFinite(value)) return DEFAULT_MAX_FAILED_PROBES; return Math.min(DEFAULT_MAX_FAILED_PROBES, Math.max(0, Math.floor(value))); } function boundedProbeAttemptLimit(value) { if (value === undefined || !Number.isFinite(value)) return MAX_RECONCILIATION_PROBE_ATTEMPTS; return Math.min(MAX_RECONCILIATION_PROBE_ATTEMPTS, Math.max(0, Math.floor(value))); } function boundedDuration(value, fallback) { if (value === undefined || !Number.isFinite(value)) return fallback; return Math.min(60_000, Math.max(1, Math.floor(value))); } function queueNumberOf(entry) { const p = entry?.prompt; return Array.isArray(p) ? Number(p[0]) || 0 : 0; } export async function reconcileAssetsFromHistory(opts = {}) { const maxPrompts = opts.maxPrompts ?? DEFAULT_MAX_PROMPTS; const maxImageProbes = boundedImageProbeLimit(opts.maxImageProbes); const maxFailedProbes = boundedFailedProbeLimit(opts.maxFailedProbes); const maxProbeAttempts = boundedProbeAttemptLimit(opts.maxProbeAttempts); const deadlineMs = boundedDuration(opts.deadlineMs, RECONCILIATION_DEADLINE_MS); const probeTimeoutMs = boundedDuration(opts.probeTimeoutMs, RECONCILIATION_PROBE_TIMEOUT_MS); const now = opts.now ?? Date.now; const reconciliationSignal = AbortSignal.timeout(deadlineMs); let history; try { history = await raceAbort(reconciliationSignal, () => getHistory(undefined, { signal: reconciliationSignal })); } catch (error) { if (!reconciliationSignal.aborted) throw error; logger.debug("History reconciliation deadline expired while reading /history"); return { scanned: 0, registered: 0, skippedExisting: 0, skippedUnavailable: 0, probeLimitReached: true, }; } const completed = Object.entries(history) .filter(([, entry]) => entry?.status?.completed === true) .sort((a, b) => queueNumberOf(b[1]) - queueNumberOf(a[1])) .slice(0, maxPrompts); let registered = 0; let skippedExisting = 0; let skippedUnavailable = 0; let validatedImages = 0; let failedProbes = 0; let probeAttempts = 0; let probeLimitReached = false; reconcilePrompts: for (const [promptId, entry] of completed) { // Eligibility keys on the HISTORY entry's own status via the shared // affirmative-success predicate (job-history) — the SAME gate the watched // path registers through, never the notification builder's default-success. if (!hasAffirmativeSuccessStatus(entry)) continue; // Parse outputs exactly like the watched path does — same extraction and // URL building. const notification = buildCompletionNotification(promptId, entry, now()); if (notification.outputs.length === 0) continue; // The recorded graph is the provenance generate_image (action:"regenerate") / // get_image (action:"asset_metadata") // rely on; without it there is nothing truthful to register. const workflow = extractWorkflowGraph(entry); if (!workflow) continue; // createdAt must be the run's REAL completion time — an entry without a // trustworthy execution_success timestamp is skipped rather than guessed // (see historyCompletionTimeMs; the watched path can fall back to its own // observed finish time, but a reconciler running long after the fact has // no such observation). const createdAt = historyCompletionTimeMs(entry, now()); if (createdAt === undefined) { logger.debug("Skipping history entry with no usable completion timestamp", { prompt_id: promptId, }); continue; } const fresh = []; let stopAfterPrompt = false; for (const output of notification.outputs) { const images = []; for (const img of output.images) { const normalizedImg = normalizeAssetImage(img); // Keep the original (watched or earlier-reconciled) record: its // createdAt and any already-handed-out asset_id stay stable. if (AssetRegistry.has(promptId, normalizedImg)) { skippedExisting++; continue; } // History can outlive the file it describes (for example, if the // output was moved or cleaned up immediately after completion). The // registry is consumed by get_image (action:"view"), so only add a // newly reconciled image after the same guarded consumer succeeds. // Keep both sides of the work bounded even if one history entry contains // an attacker-controlled or unexpectedly large image list. Failed refs // have their own budget so they cannot consume the budget for later // valid records requested by the caller. if (validatedImages >= maxImageProbes || failedProbes >= maxFailedProbes || probeAttempts >= maxProbeAttempts || reconciliationSignal.aborted) { probeLimitReached = true; stopAfterPrompt = true; break; } probeAttempts++; const probeSignal = AbortSignal.any([ reconciliationSignal, AbortSignal.timeout(Math.min(probeTimeoutMs, deadlineMs)), ]); try { // Race as well as signal: the production consumer observes the signal // and cancels its HTTP/body read, while a test double or an older // consumer that ignores it still cannot hold the list operation open. await raceAbort(probeSignal, () => getOutputImage(normalizedImg.filename, normalizedImg.type, normalizedImg.subfolder, { requireImageContent: true, signal: probeSignal })); } catch (error) { failedProbes++; skippedUnavailable++; logger.debug("Skipping history image that ComfyUI /view could not fetch", { prompt_id: promptId, filename: normalizedImg.filename, subfolder: normalizedImg.subfolder, type: normalizedImg.type, error: error instanceof Error ? error.message : String(error), }); continue; } if (reconciliationSignal.aborted) { probeLimitReached = true; stopAfterPrompt = true; break; } validatedImages++; images.push(normalizedImg); } if (images.length > 0) fresh.push({ node_id: output.node_id, images }); if (stopAfterPrompt) break; } if (fresh.length > 0) { const records = AssetRegistry.register({ promptId, workflow, outputs: fresh, source: "history-reconcile", createdAt, createdAtSource: "history", }); registered += records.length; } if (probeLimitReached) break reconcilePrompts; } const result = { scanned: completed.length, registered, skippedExisting, skippedUnavailable, probeLimitReached, }; if (result.registered > 0) { logger.info("Reconciled assets from ComfyUI history", result); } return result; } //# sourceMappingURL=asset-reconcile.js.map