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

386 lines • 18.2 kB
/** * #1699 — leftover custom_node entries apply_manifest never submitted. * * The ComfyUI-Manager queue only knows about tasks it was given. When * apply_manifest's time budget expires, later custom_nodes are reported * "pending" / "not started" and are NEVER enqueued; an accepted-but-ambiguous * operation can also remain unresolved. panel_node_queue_status then reports a * drained queue (total_count: 0, is_processing: false) which agents read as * "all installs finished". This module is the fast process-local record of * both unsubmitted and unresolved names. Panel-spawned children also publish * the same record through manifest-outcome-channel.ts so apply_manifest and * panel_node_queue_status share one source of truth across their process * boundary. */ import { randomUUID } from "node:crypto"; import { canonicalManifestOutcomeTarget, publishManifestOutcome, } from "./manifest-outcome-channel.js"; const operations = new Map(); let legacyOperation; function clonePartial(partial) { if (!partial) return null; return { ...partial, not_started: [...partial.not_started], still_installing: [...partial.still_installing], ...(partial.outcome_unknown ? { outcome_unknown: [...partial.outcome_unknown] } : {}), ...(partial.local_fallback ? { local_fallback: [...partial.local_fallback] } : {}), ...(partial.local_fallback_failed ? { local_fallback_failed: [...partial.local_fallback_failed] } : {}), }; } function normalizePartial(partial) { const copy = clonePartial(partial); if (!copy) return null; if (copy.not_started.length === 0 && copy.still_installing.length === 0 && (copy.outcome_unknown?.length ?? 0) === 0 && (copy.local_fallback?.length ?? 0) === 0 && (copy.local_fallback_failed?.length ?? 0) === 0) { return null; } if (!copy.outcome_unknown?.length) delete copy.outcome_unknown; if (!copy.local_fallback?.length) delete copy.local_fallback; if (!copy.local_fallback_failed?.length) delete copy.local_fallback_failed; return copy; } function without(items, id) { return (items ?? []).filter((item) => item !== id); } function withItem(items, id) { return items?.includes(id) ? [...items] : [...(items ?? []), id]; } function phaseIds(state, phase) { return [...state.fallbackPhases.entries()] .filter(([, value]) => value === phase) .map(([id]) => id); } function syntheticPartial(state) { const selected = phaseIds(state, "selected"); const failed = phaseIds(state, "failed"); if (selected.length === 0 && failed.length === 0) return null; return { kind: "custom_nodes_not_started", source: state.source, not_started: [], still_installing: selected, ...(selected.length ? { local_fallback: selected } : {}), ...(failed.length ? { local_fallback_failed: failed } : {}), message: `PARTIAL INSTALL of ${state.source}: a local direct-install fallback was selected ` + `after the Manager outcome became unresolved. Do not use Manager queue status or ` + `re-issue the node; wait for the fallback to settle and verify custom_nodes.`, }; } function augmentedPartial(state) { const base = normalizePartial(state.basePartial) ?? syntheticPartial(state); if (!base) return null; let partial = base; for (const [id, phase] of state.fallbackPhases) { if (phase === "selected") { partial = { ...partial, still_installing: withItem(partial.still_installing, id), outcome_unknown: without(partial.outcome_unknown, id), local_fallback: withItem(partial.local_fallback, id), local_fallback_failed: without(partial.local_fallback_failed, id), message: `${partial.message} Reconciliation: the earlier UNKNOWN outcome has now selected ` + `a local direct-install fallback for "${id}". It is in progress; do not use ` + `Manager queue status or re-issue the node while it runs.`, }; } else if (phase === "applied") { partial = { ...partial, still_installing: without(partial.still_installing, id), outcome_unknown: without(partial.outcome_unknown, id), local_fallback: without(partial.local_fallback, id), local_fallback_failed: without(partial.local_fallback_failed, id), message: `${partial.message} Reconciliation: the local fallback for "${id}" completed; ` + `the entry is no longer unresolved.`, }; } else { partial = { ...partial, still_installing: without(partial.still_installing, id), outcome_unknown: without(partial.outcome_unknown, id), local_fallback: without(partial.local_fallback, id), local_fallback_failed: withItem(partial.local_fallback_failed, id), message: `${partial.message} Reconciliation: the local direct-install fallback for "${id}" ` + `finished unsuccessfully. Inspect the filesystem/error before retrying.`, }; } } return normalizePartial(partial); } function publishOperation(state) { publishManifestOutcome(state.partial, { operationId: state.operationId, scope: state.scope, target: state.target, targetGeneration: state.targetGeneration, }); } function supersedePriorOperation(state) { for (const prior of [...operations.values()]) { if (prior === state || prior.scope !== state.scope || prior.target !== state.target || prior.source !== state.source) { continue; } operations.delete(prior.operationId); publishManifestOutcome(null, { operationId: prior.operationId, scope: prior.scope, target: prior.target, targetGeneration: prior.targetGeneration, }); } } function operationHandle(state) { const handle = { operationId: state.operationId, scope: state.scope, target: state.target, targetGeneration: state.targetGeneration, bindItem(itemId) { const normalized = itemId.trim(); const existing = state.itemBindings.get(normalized); if (existing) return existing; const binding = Object.freeze({ operationId: state.operationId, itemId: normalized, scope: state.scope, target: state.target, targetGeneration: state.targetGeneration, }); state.itemBindings.set(normalized, binding); return binding; }, record(partial) { if (operations.get(state.operationId) !== state) return null; // Preserve the historical "this apply replaces the previous partial" // behavior, but do it by operation identity. Any callback from a prior // apply now fails its binding check rather than changing this aggregate. supersedePriorOperation(state); state.basePartial = normalizePartial(partial); state.partial = augmentedPartial(state); state.updated = Date.now(); publishOperation(state); return clonePartial(state.partial); }, reconcile(binding, phase) { if (operations.get(state.operationId) !== state) return false; const expected = state.itemBindings.get(binding.itemId); if (binding !== expected || !expected || expected.operationId !== binding.operationId || expected.scope !== binding.scope || expected.target !== binding.target || expected.targetGeneration !== binding.targetGeneration) { return false; } state.fallbackPhases.set(binding.itemId, phase); state.partial = augmentedPartial(state); state.updated = Date.now(); // A callback may arrive before the final aggregate is recorded. Keep the // phase in the operation state, but do not publish a null/empty record that // could clear another observation from this process. if (state.basePartial !== null || state.partial !== null) publishOperation(state); return true; }, clear() { if (operations.get(state.operationId) === state) operations.delete(state.operationId); publishManifestOutcome(null, { operationId: state.operationId, scope: state.scope, target: state.target, targetGeneration: state.targetGeneration, }); }, }; return handle; } export function createManifestPartialOperation(opts) { const operationId = opts.operationId?.trim() || randomUUID(); const target = canonicalManifestOutcomeTarget(opts.target.trim()) ?? ""; const state = { operationId, source: opts.source, scope: opts.scope.trim(), target, targetGeneration: opts.targetGeneration, itemBindings: new Map(), fallbackPhases: new Map(), basePartial: null, partial: null, updated: Date.now(), }; operations.set(operationId, state); return operationHandle(state); } /** Read only operation records bound to this exact scope and target. */ export function readManifestPartials(target, scope, targetGeneration) { const expectedTarget = canonicalManifestOutcomeTarget(target.trim()); const expectedScope = scope.trim(); if (!expectedTarget || !expectedScope || !Number.isSafeInteger(targetGeneration) || targetGeneration < 0) { return []; } return [...operations.values()] .filter((state) => state.target === expectedTarget && state.scope === expectedScope && state.targetGeneration === targetGeneration && state.partial !== null) .sort((a, b) => b.updated - a.updated) .map((state) => clonePartial(state.partial)) .filter((partial, index, all) => { const key = JSON.stringify(partial); return all.findIndex((candidate) => JSON.stringify(candidate) === key) === index; }); } /** Compatibility seam retained for existing local callers and tests. */ export function recordManifestPartial(partial, options = {}) { const target = options.target ?? process.env.COMFYUI_URL?.trim() ?? ""; const scope = options.scope ?? process.env.COMFYUI_MCP_TAB?.trim() ?? `legacy:${process.pid}`; if (!legacyOperation || legacyOperation.target !== (canonicalManifestOutcomeTarget(target) ?? "") || legacyOperation.scope !== scope.trim()) { legacyOperation = createManifestPartialOperation({ operationId: options.operationId ?? "legacy", source: partial?.source ?? "this inline manifest", scope, target, targetGeneration: options.targetGeneration ?? 0, }); } legacyOperation.record(partial); } export function getManifestPartialLeftover() { return [...operations.values()] .sort((a, b) => b.updated - a.updated) .map((state) => clonePartial(state.partial)) .find((partial) => partial !== null) ?? null; } /** Test seam — remove every operation-local record, including its child files. */ export function clearManifestPartialLeftover() { for (const state of operations.values()) { publishManifestOutcome(null, { operationId: state.operationId, scope: state.scope, target: state.target, targetGeneration: state.targetGeneration, }); } operations.clear(); legacyOperation = undefined; } export function describeManifestSource(opts) { const pack = opts.pack?.trim(); if (pack) return `pack "${pack}"`; const path = opts.path?.trim(); if (path) return `path "${path}"`; return "this inline manifest"; } export function formatNotStartedMessage(item) { return (`PARTIAL INSTALL — "${item}" was NOT STARTED and was NEVER submitted to the ` + `ComfyUI-Manager queue. The apply_manifest time budget elapsed while earlier ` + `packs were still installing. A drained panel_node_queue_status is NOT ` + `completion of this entry (the queue cannot include work it was never given) ` + `and is NOT a reason to restart ComfyUI. Re-run apply_manifest with the same ` + `pack/path/manifest to submit the remaining unsubmitted custom_node entries.`); } export function formatStillInstallingMessage(opts = {}) { if (opts.outcomeUnknown) { return ("Install outcome is UNKNOWN because the apply_manifest time budget elapsed before " + "the Manager operation settled. It may still be running on the ComfyUI-Manager " + "queue, or it may later settle to a result that does not authorize cloning. No " + "local direct-install fallback is authorized from this unresolved result. This " + "is NOT a failure and must not be re-issued. Do not use Manager queue status alone " + "to decide completion; verify the custom_nodes directory after the operation " + "settles."); } return ("Still installing on the ComfyUI-Manager queue when the apply_manifest time " + "budget elapsed. This is NOT a failure — the install continues server-side. " + "Poll panel_node_queue_status for THIS entry only. Do not treat a drained " + "queue as proof that later unsubmitted entries installed, and do not re-issue " + "this node."); } export function formatLocalFallbackMessage() { return ("A local direct-install fallback is still in progress in this MCP process when the " + "apply_manifest time budget elapsed. This is NOT queued server-side work and is NOT " + "on the ComfyUI-Manager queue. Do not use Manager queue status to track this local " + "work or re-issue this node while the fallback is running; verify the custom_nodes " + "directory after it finishes."); } export function buildManifestPartial(opts) { const outcomeUnknown = (opts.outcomeUnknown ?? []).filter((id) => opts.stillInstalling.includes(id)); if (opts.notStarted.length === 0 && opts.stillInstalling.length === 0) return null; const names = opts.notStarted.join(", "); const still = opts.stillInstalling.length ? outcomeUnknown.length ? ` Still unresolved: ${opts.stillInstalling.join(", ")}. For ${outcomeUnknown.join(", ")}, ` + `the install outcome is UNKNOWN and no local direct-install fallback is ` + `authorized from this result; do not use queue status alone to decide completion.` : ` Still installing (ON the queue, pollable): ${opts.stillInstalling.join(", ")}.` : ""; const n = opts.notStarted.length; const unsubmitted = n > 0 ? `${n} custom_node ${n === 1 ? "entry was" : "entries were"} NEVER submitted to ` + `ComfyUI-Manager (${names}). panel_node_queue_status going idle does NOT ` + `mean they installed — they are not on that queue. Do not restart ComfyUI ` + `yet. Re-run apply_manifest with the same pack/path/manifest to submit the ` + `remaining entries.` : `No custom_node entries were left unsubmitted, but the submitted entries ` + `below remain unresolved; panel_node_queue_status going idle does NOT prove ` + `this apply_manifest completed.`; return { kind: "custom_nodes_not_started", source: opts.source, not_started: [...opts.notStarted], still_installing: [...opts.stillInstalling], ...(outcomeUnknown.length ? { outcome_unknown: [...outcomeUnknown] } : {}), message: `PARTIAL INSTALL of ${opts.source}: ${unsubmitted}` + still, }; } export function formatQueueStatusPartialNote(partial) { const unsubmitted = partial.not_started.length ? `apply_manifest of ${partial.source} never submitted: ${partial.not_started.join(", ")}. ` + `Those entries are NOT on the ComfyUI-Manager queue, so this status cannot ` + `account for them. Re-run apply_manifest to submit the remaining custom_nodes.` : "No entries were left unsubmitted, but submitted entries remain unresolved."; const unresolved = partial.still_installing.length ? ` Unresolved submitted entries: ${partial.still_installing.join(", ")}.` + (partial.outcome_unknown?.length ? ` For ${partial.outcome_unknown.join(", ")}, the install outcome is UNKNOWN; ` + `no local direct-install fallback is authorized and queue idle is not proof ` + `of completion. Verify the custom_nodes directory before reissuing.` : " Do not treat queue idle as proof that this apply_manifest completed.") : ""; const local = partial.local_fallback?.length ? ` Local direct-install fallback is in progress for ${partial.local_fallback.join(", ")}. ` + `This work is NOT on the Manager queue; wait for it to settle and do not re-issue it.` : ""; const failed = partial.local_fallback_failed?.length ? ` Local direct-install fallback failed for ${partial.local_fallback_failed.join(", ")}. ` + `Queue status cannot determine that result; inspect the install error before retrying.` : ""; return `WARNING — PARTIAL INSTALL, QUEUE DRAIN IS NOT COMPLETION. ${unsubmitted}${unresolved}${local}${failed}`; } //# sourceMappingURL=manifest-partial.js.map