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),

264 lines • 11.5 kB
import { z } from "zod"; export const TRACEBACK_MAX_CHARS = 2000; /** * Affirmative-success test for a history entry — the ONE eligibility gate both * asset-registration paths (JobWatcher.handleCompletion and the get_image (action:"list_assets") * history reconcile) share, so a run can never become an asset without real * success evidence (#751 codex gate r3). * * It deliberately does NOT trust buildCompletionNotification's derived status: * that derivation defaults to "success" whenever messages carry no well-formed * execution_error/interrupted event, which silently promotes a run ComfyUI * marked status_str:"error" (or any entry with missing/malformed status or * messages) to "success". Here the entry's OWN status_str must affirm success * AND no error/interrupt message may exist; anything short fails toward NOT * registering. */ export function hasAffirmativeSuccessStatus(entry) { if (entry?.status?.status_str !== "success") return false; const messages = normalizeHistoryMessages(entry); return !messages.some((m) => m[0] === "execution_error" || m[0] === "execution_interrupted"); } /** ComfyUI history timestamps are epoch seconds or milliseconds depending on * version — infer from magnitude, mirroring durationMs below. */ function normalizeEpochMs(ts) { if (typeof ts !== "number" || !Number.isFinite(ts)) return undefined; if (ts > 1_000_000_000_000) return ts; // epoch ms if (ts > 1_000_000_000) return ts * 1000; // epoch s return undefined; } /** * The run's real completion time (ms epoch), taken ONLY from the * execution_success message — the one truthful completion timestamp recorded * by ComfyUI. execution_start is deliberately not a fallback: it is start * time, and using it would backdate a long render past the TTL / a `since` * boundary and hide it. Values implausibly far in the future (clock skew, * non-epoch garbage) are rejected. Returns undefined when history carries no * trustworthy value — the caller must then use its own truthful observation * time (watched path) or skip the record (reconcile path). Shared by both * asset-registration paths so "real completion time" means the same thing on * each (#751 codex gate r4). */ export function historyCompletionTimeMs(entry, now) { const success = normalizeHistoryMessages(entry).find((m) => m[0] === "execution_success"); const ms = normalizeEpochMs(success?.[1]?.timestamp); if (ms === undefined || ms > now + 60_000) return undefined; return ms; } /** Terminal history message: error wins over interrupt, interrupt over success. */ export function historyTerminalMessage(entry) { const messages = normalizeHistoryMessages(entry); return (messages.find((m) => m[0] === "execution_error") ?? messages.find((m) => m[0] === "execution_interrupted") ?? messages.find((m) => m[0] === "execution_success")); } /** * Real finish time from any terminal history message (success, error, or * interrupt). `historyCompletionTimeMs` stays success-only for asset * registration; interrupt/error timing is a display/duration fact (#2512). */ export function historyTerminalTimeMs(entry, now) { const ms = normalizeEpochMs(historyTerminalMessage(entry)?.[1]?.timestamp); if (ms === undefined || ms > now + 60_000) return undefined; return ms; } const executionErrorSchema = z.object({ node_id: z.union([z.string(), z.number()]).optional(), node_type: z.string().optional(), exception_message: z.string().optional(), exception_type: z.string().optional(), traceback: z.union([z.string(), z.array(z.string())]).optional(), current_inputs: z.unknown().optional(), }).passthrough(); export function normalizeHistoryMessages(entry) { const rawMessages = entry.status.messages; if (!Array.isArray(rawMessages)) return []; const messages = []; for (const rawMessage of rawMessages) { if (!Array.isArray(rawMessage) || rawMessage.length < 2) continue; const [type, data] = rawMessage; if (typeof type !== "string") continue; if (data === null || typeof data !== "object" || Array.isArray(data)) continue; messages.push([type, data]); } return messages; } function messageData(entry, type) { const msg = normalizeHistoryMessages(entry).find((m) => m[0] === type); return msg?.[1]; } function timestamp(data) { const raw = data?.timestamp; return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined; } function durationMs(start, end) { const delta = end - start; if (!Number.isFinite(delta) || delta < 0) return undefined; // ComfyUI history commonly uses epoch seconds; some websocket/event docs use // epoch milliseconds. Infer the unit from absolute magnitude, then fall back // to seconds for short relative durations. if (start > 1_000_000_000_000 || end > 1_000_000_000_000) { return Math.round(delta); } if (start > 1_000_000_000 || end > 1_000_000_000 || delta < 1000) { return Math.round(delta * 1000); } return Math.round(delta); } function tracebackText(value) { if (typeof value === "string") return value; if (Array.isArray(value)) return value.map((line) => String(line)).join(""); return undefined; } /** * #809: a `traceback_truncated: true` boolean next to a traceback that just STOPS is * indistinguishable, to a reader, from a traceback that ended. Mark the cut inline and * say what to do — this cap is FIXED (no parameter raises it), so the remedy is the * tool that holds the untruncated text, not a lever that does not exist. */ function truncateTraceback(text) { if (text.length <= TRACEBACK_MAX_CHARS) return { text, truncated: false }; const dropped = text.length - TRACEBACK_MAX_CHARS; return { // The remedy is deliberately a MAY, not a promise (codex gate): the full text came // from ComfyUI's /history and no tool re-serves it, so claiming get_system_stats (action:"logs") "has" it // would be the same lie as naming a parameter that does not exist. What IS certain: // this is the HEAD, and a Python traceback prints the exception LAST — so the line // that names the failure may be among the cut frames. Say that; it changes what the // reader does next. text: text.slice(0, TRACEBACK_MAX_CHARS) + `\n[... ${dropped} more char(s) cut at the fixed ${TRACEBACK_MAX_CHARS}-char traceback cap — no parameter raises it, and the rest is not retained in this result. This is the HEAD, so the final exception line is likely among the cut frames; get_system_stats (action:"logs") may still hold the full traceback if the run is recent ...]`, truncated: true, }; } function isOomError(error) { const haystack = [ error.exception_type, error.exception_message, error.traceback, ].filter(Boolean).join("\n").toLowerCase(); return haystack.includes("out of memory") || haystack.includes("cuda oom"); } export function extractExecutionError(entry) { const parsed = executionErrorSchema.safeParse(messageData(entry, "execution_error")); if (!parsed.success) return undefined; const data = parsed.data; const rawTraceback = tracebackText(data.traceback); const traceback = rawTraceback ? truncateTraceback(rawTraceback) : undefined; const error = { node_id: data.node_id === undefined ? "" : String(data.node_id), node_type: data.node_type ?? "", exception_message: data.exception_message ?? "", exception_type: data.exception_type, traceback: traceback?.text, traceback_truncated: traceback?.truncated || undefined, current_inputs: data.current_inputs, }; if (isOomError(error)) error.is_oom = true; return error; } export function extractExecutionStats(entry) { const messages = normalizeHistoryMessages(entry); const startTs = timestamp(messageData(entry, "execution_start")); const endTs = timestamp(historyTerminalMessage(entry)?.[1]); const nodes = {}; let previousTs = startTs; for (const [type, data] of messages) { if (type !== "executed") continue; const executedTs = timestamp(data); const nodeId = data.node ?? data.node_id ?? data.display_node; if (executedTs === undefined || nodeId === undefined) continue; if (previousTs !== undefined) { const nodeDuration = durationMs(previousTs, executedTs); if (nodeDuration !== undefined) { nodes[String(nodeId)] = { duration_ms: nodeDuration }; } } previousTs = executedTs; } const totalDuration = startTs !== undefined && endTs !== undefined ? durationMs(startTs, endTs) : undefined; if (totalDuration === undefined && Object.keys(nodes).length === 0) { return undefined; } return { total_duration_ms: totalDuration, nodes, }; } /** * Pull TEXT results out of a finished run. * * Text-preview nodes (ComfyUI's "Preview as Text", ShowText, and the many pack * equivalents) have no file on disk — they publish their result into the node's * `ui` dict, which is exactly what /history stores under * `outputs[nodeId]`. We only ever harvested `images` / `videos` there, so an * LLM-caption / prompt-builder / text workflow completed with the agent seeing * NOTHING — it would say it was going to report back and then have nothing to * report (help-thread report from seanmcmagic). * * Shapes in the wild: `{ text: ["hi"] }` (the common one), `{ text: "hi" }`, and * packs that use `string` instead of `text`. Non-string scalars are stringified; * empty strings are PRESERVED — a ShowText node that legitimately emitted "" is a * real (present-but-empty) output and must be reported, not silently dropped. A * node with no `text`/`string` field at all still stays absent (nothing pushed). */ export function extractTextOutputs(entry) { const results = []; const outputs = entry.outputs; if (!outputs || typeof outputs !== "object") return results; for (const [nodeId, nodeOutput] of Object.entries(outputs)) { if (!nodeOutput || typeof nodeOutput !== "object") continue; const record = nodeOutput; const text = []; for (const key of ["text", "string"]) { const value = record[key]; if (typeof value === "string") { text.push(value); } else if (Array.isArray(value)) { for (const item of value) { if (typeof item === "string") { text.push(item); } else if (typeof item === "number" || typeof item === "boolean") { text.push(String(item)); } } } } if (text.length > 0) results.push({ node_id: nodeId, text }); } return results; } export function analyzeHistoryEntry(entry) { const text_outputs = extractTextOutputs(entry); return { error: extractExecutionError(entry), execution_stats: extractExecutionStats(entry), ...(text_outputs.length > 0 ? { text_outputs } : {}), }; } //# sourceMappingURL=job-history.js.map