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),
227 lines • 9.98 kB
JavaScript
// Durable per-session record of SUBMITTED agent turns (#886).
//
// The claude-backend keeps its turn-trace FIFO in memory, so a session restart
// (the self-restart loop after a drop, or a full orchestrator respawn during
// the reconnect churn) forgets which turns were ever submitted. A late result
// from the dead session then arrives matching NO live trace, and an empty
// registry read as "no turn was ever submitted" is the "could not determine →
// determined not" fold: the turn WAS submitted, only its record was lost. This
// module is the record that survives, so a post-restart reader can tell "a
// turn is still outstanding" from "no turn was ever submitted" — the
// difference between "completed but unverified" and "this result came from
// nowhere".
//
// Scope: a RECOGNITION aid, not a classification input. A recorded id never
// makes a result ok:true — fail-closed (#745) is untouched. It only changes
// what we can honestly SAY about an unmatched result.
//
// Layout (codex gate, round 2): one file per (session, backend INSTANCE) —
// `turn-registry-<session>-<instance>.json` — so two live instances on one
// session can never erase each other's records with a lost update (there is
// no shared writer and no lock to go stale). Reported outcomes go to a shared
// TOMBSTONE file (`…-tombstones.json`): both a recognized late landing (spent)
// and a normally-resolved turn (round 3 — the only thing that invalidates a
// concurrent resumer's carried REPLICA of the resolved turn's record). A lost
// tombstone only revives an already-reported record (over-recognition — the
// hedged message), while a lost live record would produce the confident
// "matched to no submitted turn", a false assertion. Every failure side is
// chosen to land soft.
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { logger } from "../utils/logger.js";
import { writeFileDurable } from "../utils/durable-write.js";
/** Records older than this are pruned on load — a session resumed after this
* long is ancient history whose outstanding-turn record no longer justifies
* keeping the files (and the directory must not grow unbounded). */
const GC_TTL_MS = 7 * 24 * 60 * 60 * 1000;
/** Bound on remembered outstanding tags — a pathological session that never
* lands a result can't grow the record without limit. Oldest drop first,
* and the drop is recorded (incomplete) rather than silent. */
export const MAX_REGISTRY_IDS = 64;
/** Bound on the tombstone list — over it, the oldest tombstone drops, which
* can only revive a consumed record (over-recognition, the soft side). */
const MAX_TOMBSTONES = 256;
function registryDir() {
return process.env.COMFYUI_MCP_TURN_REGISTRY_DIR || join(tmpdir(), "comfyui-mcp-turn-registry");
}
function safe(s) {
// Session/instance ids are UUIDs; strip anything path-hostile anyway so a
// malformed id can never escape the registry dir.
return s.replace(/[^A-Za-z0-9_-]/g, "_");
}
function sessionPrefix(sessionId) {
return `turn-registry-${safe(sessionId)}-`;
}
function instancePath(sessionId, owner) {
return join(registryDir(), `${sessionPrefix(sessionId)}${safe(owner)}.json`);
}
function tombstonePath(sessionId) {
return join(registryDir(), `${sessionPrefix(sessionId)}tombstones.json`);
}
function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
/** Load every outstanding-turn record for a session being resumed: the union
* of all live instance files, minus spent tombstones. A file that can't be
* read or parsed degrades the load (its tags are lost, so the record is not
* provably complete) without discarding what the other files still prove. */
export function loadTurnRegistry(sessionId) {
gcTurnRegistries(sessionPrefix(sessionId));
let names;
try {
names = readdirSync(registryDir());
}
catch (err) {
// ONLY ENOENT proves absence (nothing was ever written). Any other
// failure (permissions, I/O, a file where a dir should be) means records
// may exist that we cannot read — degrade, never report "absent".
if (err?.code === "ENOENT") {
return { kind: "absent", tags: [], incomplete: false };
}
return { kind: "degraded", tags: [], incomplete: false, reason: String(err) };
}
const prefix = sessionPrefix(sessionId);
const mine = names.filter((n) => n.startsWith(prefix) && n.endsWith(".json"));
if (mine.length === 0)
return { kind: "absent", tags: [], incomplete: false };
let degraded = null;
let incomplete = false;
const spent = new Set();
const tombPath = tombstonePath(sessionId);
try {
const parsed = readJson(tombPath);
if (parsed.v === 1 && Array.isArray(parsed.spent)) {
for (const t of parsed.spent)
if (typeof t === "string")
spent.add(t);
}
else {
degraded = "tombstone file has an unrecognized shape";
}
}
catch (err) {
if (err?.code !== "ENOENT")
degraded = String(err);
}
const tags = new Set();
for (const name of mine) {
if (name === `${prefix}tombstones.json`)
continue;
try {
const parsed = readJson(join(registryDir(), name));
if (parsed.v !== 1 || typeof parsed.owner !== "string" || !Array.isArray(parsed.unresolved)) {
degraded = degraded ?? `${name}: unrecognized registry shape`;
continue;
}
if (parsed.incomplete === true)
incomplete = true;
for (const id of parsed.unresolved) {
if (typeof id === "number")
tags.add(`${parsed.owner}:${id}`);
}
if (Array.isArray(parsed.carried)) {
for (const t of parsed.carried)
if (typeof t === "string")
tags.add(t);
}
}
catch (err) {
degraded = degraded ?? `${name}: ${String(err)}`;
}
}
for (const t of spent)
tags.delete(t);
return {
kind: degraded ? "degraded" : "ok",
tags: [...tags],
incomplete,
...(degraded ? { reason: degraded } : {}),
};
}
/** Persist THIS instance's outstanding-turn record (its own file — never a
* shared one). THROWS on failure (writeFileDurable's contract); the caller
* decides what a durability failure means. An empty AND complete record
* deletes the file: absence is the clean state. An empty but INCOMPLETE
* record stays — the incompleteness itself is information a loader needs. */
export function saveTurnRegistry(sessionId, owner, unresolved, carried, incomplete) {
const path = instancePath(sessionId, owner);
if (unresolved.length === 0 && carried.length === 0 && !incomplete) {
try {
rmSync(path, { force: true });
}
catch (err) {
// A failed delete leaves a stale record that a later resume would read
// as outstanding turns — surface it, don't swallow it silently.
throw err;
}
return;
}
mkdirSync(registryDir(), { recursive: true });
const data = {
v: 1,
sessionId,
owner,
unresolved: unresolved.slice(-MAX_REGISTRY_IDS),
carried: carried.slice(-MAX_REGISTRY_IDS),
...(incomplete ? { incomplete: true } : {}),
updatedAt: Date.now(),
};
writeFileDurable(path, JSON.stringify(data));
}
/** Spend a record: a recognized late landing tombstones its tag so no later
* reader (of ANY instance's file) can recognize the same turn twice. THROWS
* on failure — a lost tombstone only revives an already-reported record
* (over-recognition, the soft side), so the caller logs and moves on. */
export function tombstoneTurn(sessionId, tag) {
const path = tombstonePath(sessionId);
let spent = [];
try {
const parsed = readJson(path);
if (parsed.v === 1 && Array.isArray(parsed.spent)) {
spent = parsed.spent.filter((t) => typeof t === "string");
}
// A corrupt tombstone file restarts empty: the cost of a lost tombstone is
// a repeated hedged disclosure, never a fabricated success.
}
catch {
// absent is the common case
}
if (!spent.includes(tag))
spent.push(tag);
const data = { v: 1, spent: spent.slice(-MAX_TOMBSTONES), updatedAt: Date.now() };
mkdirSync(registryDir(), { recursive: true });
writeFileDurable(path, JSON.stringify(data));
}
/** Best-effort prune of stale registry files — EXCEPT the session being
* loaded right now: pruning its records first and then reporting "absent"
* would turn a known-incomplete read into the confident negative. Never
* throws: GC must not break a load. */
function gcTurnRegistries(keepPrefix) {
let names;
try {
names = readdirSync(registryDir());
}
catch {
return; // dir doesn't exist yet — nothing to prune
}
const cutoff = Date.now() - GC_TTL_MS;
for (const name of names) {
// Registries AND crash-orphaned durable-write temp files (the 7-day cutoff
// makes a live writer's fresh temp unreachable here).
if (!name.startsWith("turn-registry-") || (!name.endsWith(".json") && !name.includes(".json.tmp-"))) {
continue;
}
if (keepPrefix && name.startsWith(keepPrefix))
continue;
try {
const path = join(registryDir(), name);
if (statSync(path).mtimeMs < cutoff)
rmSync(path, { force: true });
}
catch (err) {
logger.debug(`[turn-registry] gc: ${String(err)}`);
}
}
}
//# sourceMappingURL=turn-registry.js.map