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),
234 lines • 9.18 kB
JavaScript
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { logger } from "../utils/logger.js";
import { writeFileDurable } from "../utils/durable-write.js";
/** A prompt id is already bounded by the panel protocol; keep the disk key bounded too. */
const MAX_ID_LENGTH = 512;
/** A restarted orchestrator must remember a completion long enough to outlive reconnect churn. */
export const DEFAULT_COMPLETION_FENCE_TTL_MS = 6 * 60 * 60_000;
/** Bound the persisted map even if a panel manufactures many distinct prompt ids. */
export const DEFAULT_COMPLETION_FENCE_MAX_ENTRIES = 2048;
function usable(value) {
return typeof value === "string" && value.trim().length > 0 && value.length <= MAX_ID_LENGTH;
}
/**
* Stable scheduling identity for a panel completion.
*
* The route/session is deliberately outside the panel tab id: a stale frontend
* reconnect gets a new tab address, but it is still the same agent conversation.
* A panel completion key is the only identity durable across orchestrator
* restarts. Journal ticket generations are process-local, so prompt_id alone is
* never a fence key here.
*/
export function completionFenceIdentity(route, payload) {
const routeId = typeof route === "string" ? route.trim() : "";
if (!routeId || routeId.length > MAX_ID_LENGTH)
return null;
if (usable(payload.completion_key)) {
return JSON.stringify(["panel_run", routeId, "completion_key", payload.completion_key]);
}
return null;
}
function defaultFencePath() {
return join(homedir(), ".comfyui-mcp", "run-completion-fence.json");
}
function validEntry(value) {
if (!value || typeof value !== "object")
return false;
const entry = value;
return ((entry.state === "seen" || entry.state === "accepted" || entry.state === "delivered") &&
typeof entry.at === "number" &&
Number.isFinite(entry.at) &&
entry.at >= 0);
}
/**
* Small, synchronous, atomic fence used immediately around agent scheduling.
*
* `seen` is written before the queue hand-off; `accepted` is written after the
* manager accepts the event; `delivered` is written only from the journal's
* actual turn-ack hook. `accepted` is process-local suppression only: a fresh
* fence instance treats a persisted `accepted` or `seen` entry as reclaimable,
* so a restart cannot lose a completion that was queued but never read.
*/
export class RunCompletionIdempotencyFence {
filePath;
now;
ttlMs;
maxEntries;
entries = new Map();
/** Reservations accepted by THIS orchestrator, but not yet turn-acked. */
active = new Set();
constructor(options = {}) {
this.filePath = options.path ?? defaultFencePath();
this.now = options.now ?? Date.now;
this.ttlMs = Math.max(1, options.ttlMs ?? DEFAULT_COMPLETION_FENCE_TTL_MS);
this.maxEntries = Math.max(1, Math.floor(options.maxEntries ?? DEFAULT_COMPLETION_FENCE_MAX_ENTRIES));
this.load();
}
/** Claim an identity before creating a new agent turn. */
claim(identity) {
return this.claimResult(identity) === "claimed";
}
/** Distinguish a duplicate from a failed durability write. */
claimResult(identity) {
if (!identity)
return "unavailable";
const now = this.now();
const previous = new Map(this.entries);
this.prune(now);
const existing = this.entries.get(identity);
if (existing?.state === "delivered" && now - existing.at < this.ttlMs) {
return "duplicate";
}
if (this.active.has(identity))
return "duplicate";
this.entries.set(identity, { state: "seen", at: now });
this.trim();
if (this.persist()) {
this.active.add(identity);
return "claimed";
}
this.entries = previous;
return "unavailable";
}
/** Mark a successfully accepted queue hand-off, without claiming it was read. */
markAccepted(identity) {
const entry = this.entries.get(identity);
if (!entry)
return false;
const previous = entry.state;
entry.state = "accepted";
if (this.persist())
return true;
// The pre-injection `seen` write is still durable. Keep the local active
// reservation so a same-process stale frame cannot mint another turn; a
// restart will reclaim the persisted seen entry.
entry.state = previous;
return false;
}
/** Mark a queue hand-off delivered only after the agent turn actually acks it. */
markDelivered(identity) {
const entry = this.entries.get(identity);
if (!entry)
return false;
const previous = entry.state;
entry.state = "delivered";
if (this.persist()) {
this.active.delete(identity);
return true;
}
// Keep the already-written accepted/seen fence if the second write fails.
// It remains process-local suppression, while a restart can reclaim it.
entry.state = previous;
return false;
}
/** Remove a reservation when the manager refused the hand-off. */
release(identity) {
if (!this.entries.has(identity)) {
this.active.delete(identity);
return true;
}
const previous = this.entries;
this.entries = new Map(previous);
this.entries.delete(identity);
if (this.persist()) {
this.active.delete(identity);
return true;
}
this.entries = previous;
// Let a later retry re-attempt the durable release/claim. Keeping the
// in-memory reservation here would turn a persistence failure into a
// permanent same-process refusal.
this.active.delete(identity);
return false;
}
/** Test/diagnostic seam. */
state(identity) {
return this.entries.get(identity)?.state;
}
load() {
if (!existsSync(this.filePath))
return;
try {
const parsed = JSON.parse(readFileSync(this.filePath, "utf8"));
if (parsed.version !== 1 || !parsed.entries || typeof parsed.entries !== "object")
return;
const loaded = Object.entries(parsed.entries)
.filter(([, value]) => validEntry(value))
.sort(([, a], [, b]) => a.at - b.at);
for (const [identity, entry] of loaded)
this.entries.set(identity, entry);
const before = this.entries.size;
this.prune(this.now());
if (this.entries.size !== before)
this.persist();
}
catch (error) {
logger.warn(`[run-completion-fence] ignoring unreadable fence ${this.filePath}: ${String(error)}`);
this.entries.clear();
}
}
prune(now) {
for (const [identity, entry] of this.entries) {
if (now - entry.at >= this.ttlMs || now < entry.at) {
this.entries.delete(identity);
this.active.delete(identity);
}
}
this.trim();
}
trim() {
while (this.entries.size > this.maxEntries) {
const oldest = this.entries.keys().next().value;
if (oldest === undefined)
break;
this.entries.delete(oldest);
this.active.delete(oldest);
}
}
persist() {
try {
mkdirSync(dirname(this.filePath), { recursive: true });
const file = { version: 1, entries: Object.fromEntries(this.entries) };
writeFileDurable(this.filePath, JSON.stringify(file) + "\n");
return true;
}
catch (error) {
logger.warn(`[run-completion-fence] could not persist ${this.filePath}: ${String(error)}`);
return false;
}
}
}
/**
* The one scheduling gate for recovered and live `panel_run` completions.
* Returning true for a suppressed entry tells the journal that the queue offer
* was handled; `suppress` removes the duplicate, so it cannot be retried into a
* new turn on every reconnect.
*/
export function scheduleRunCompletion(options) {
const identity = completionFenceIdentity(options.route, options.payload);
if (!identity) {
return options.inject();
}
const claim = options.fence.claimResult(identity);
if (claim === "unavailable")
return false;
if (claim === "duplicate") {
options.suppress(options.token);
options.log?.(options.payload.possible_repeat === true
? `suppressed a POSSIBLE_REPEAT with a durable delivered fence for ${identity}`
: `suppressed a replay for ${identity}`);
return true;
}
const handedOff = options.inject();
if (handedOff) {
options.fence.markAccepted(identity);
options.onAccepted?.(identity);
}
else {
options.fence.release(identity);
}
return handedOff;
}
//# sourceMappingURL=run-completion-idempotency.js.map