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),
1,022 lines • 52.9 kB
JavaScript
// Cross-process download-progress channel.
//
// Model downloads run INSIDE the panel agent's comfyui MCP subprocess, but the
// panel bridge that renders the download tray lives in the ORCHESTRATOR process.
// To bridge them without a socket, the subprocess writes a small per-download
// progress JSON into COMFYUI_MCP_PROGRESS_DIR; the orchestrator watches that dir
// and broadcasts the rows to the panel (see src/orchestrator/index.ts).
//
// This no-ops entirely when COMFYUI_MCP_PROGRESS_DIR is unset — i.e. for every
// normal (non-panel) use of the MCP — so it costs nothing outside the panel.
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { createHash, randomBytes } from "node:crypto";
import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
/** Per-PROCESS owner nonce (#515/#529). Distinguishes THIS session's persisted job
* records from another concurrent session's — even when both run the SAME logical
* download (identical deterministic job id from the same URL/dest/auth). Each session
* writes its OWN record file (…-<owner>.json) instead of clobbering a shared one, so a
* cross-session sibling check can tell two live sessions apart by owner rather than id. */
export const PERSIST_OWNER = randomBytes(8).toString("hex");
/** A persisted in-flight record whose `updated` is older than this is treated as a
* crashed/dead session (its liveness heartbeat stopped). Such records neither block
* resolution/adoption nor count as a live sibling — only FRESH in-flight records do.
* Must exceed the writer's heartbeat interval by a generous margin. */
export const PERSISTED_INFLIGHT_STALE_MS = 60_000;
const PROGRESS_DIR = process.env.COMFYUI_MCP_PROGRESS_DIR || "";
/** Late-bound by the ORCHESTRATOR at startup (its own process has no env var —
* codex finding: the control channel was dead for in-process direct/mobile
* tool calls). progressEnabled() stays env-only on purpose: runpod.ts uses it
* as the spawned-child discriminator, and the orchestrator is NOT a child. */
let lateBoundDir = "";
export function setProgressDir(dir) {
lateBoundDir = dir;
}
/**
* A STABLE per-user directory for persisted job records, used when nothing else
* set one (#1148).
*
* Without it, a plain stdio MCP server — no panel, no orchestrator, no
* COMFYUI_MCP_PROGRESS_DIR — had NO channel dir, so `persistedRecordsEnabled()`
* was false and not one job record was ever written. Every cross-restart
* mechanism built for #1148 was inert in that configuration, while
* `download_model action:"status"` went on promising that an interrupted
* download stays resolvable by id. A reporter lost a 5 GB transfer to exactly
* that gap and was told "No download matching id".
*
* DELIBERATELY NOT UNDER tmpdir. The orchestrator nonces its progress dir per
* start and REAPS earlier ones there — the very deletion #1148's carry-over
* exists to survive. A records dir a later orchestrator start could sweep would
* reintroduce the bug from the other side.
*
* SAFE ONLY BECAUSE ADOPTION IS LIVENESS-CHECKED (#1275). Enabling the store
* also enables cross-session adoption, and before that fix a record left behind
* by a crashed process was adoptable for up to a minute — a new download would
* take over a job nobody was running. Turning this on without that guard traded
* one silent failure for a worse one.
*
* Created lazily and memoized: `channelDir()` runs on every persist, and a
* failure to create it degrades to "no persistence" — the previous behaviour —
* rather than throwing inside a download.
*/
let defaultRecordsDir;
let defaultRecordsDirTried = false;
function stableRecordsDir() {
if (defaultRecordsDirTried)
return defaultRecordsDir ?? "";
defaultRecordsDirTried = true;
try {
const dir = join(process.env.COMFYUI_MCP_DATA_DIR?.trim() || join(homedir(), ".comfyui-mcp"), "download-records");
mkdirSync(dir, { recursive: true, mode: 0o700 });
defaultRecordsDir = dir;
}
catch {
defaultRecordsDir = undefined;
}
return defaultRecordsDir ?? "";
}
/** Test seam: forget the memoized default so a case can point it somewhere else. */
export function __resetStableRecordsDir() {
defaultRecordsDir = undefined;
defaultRecordsDirTried = false;
}
function channelDir() {
return PROGRESS_DIR || lateBoundDir;
}
/**
* Where the persisted JOB RECORDS live — the channel dir when there is one, else
* the stable per-user default (#1148).
*
* Deliberately SEPARATE from `channelDir()`, which also drives the control
* channel (the MCP child's target-change requests, read by an orchestrator).
* Falling back for that one too would switch on a channel with nobody at the
* other end: a plain stdio server has no orchestrator to read it, and a test
* asserting the control channel is inactive without a progress dir caught
* exactly that over-reach.
*
* Only the RECORD store needs somewhere to write regardless of transport,
* because only it has to survive a restart.
*/
function recordsDir() {
return channelDir() || stableRecordsDir();
}
const lastWriteAt = new Map();
/**
* Carry a dead orchestrator's IN-FLIGHT job records into the new channel dir as
* terminal "interrupted" records, instead of deleting them (#1148).
*
* The persisted store exists so a session that reconnects can still resolve an
* in-flight download by id (#529), and download_model's own status text promises
* exactly that. But the orchestrator nonces its progress dir per start and reaps
* every earlier dir for the port — so an orchestrator restart deleted the store
* that promise depends on. A reporter's 12GB transfer answered "No download
* matching id" and "No downloads are being tracked", with no file and no partial
* on disk and no error event: 40 minutes gone, invisibly, while the documented
* contract told their agent to keep waiting rather than re-issue.
*
* What this does NOT do is decide whether the transfer is dead — the original
* framing, and WRONG for the case that matters. A download DISPATCHED to
* ComfyUI-Manager is a server-side fetch: the ComfyUI HOST is doing the work and
* a restart here does not touch it, so it is very likely still running, and
* telling that caller to re-issue writes a second copy to the same destination —
* a corrupt model (#1197). This function reads neither the `pid` nor the `owner`
* it persists, and `writerProcessGone()` exists to answer exactly the question it
* skips, so it is in no position to assert death for EITHER route.
*
* What it fixes is the SILENCE: a record saying we stopped WATCHING, which
* `status` can find by the id the caller was handed — worded per route, which is
* why `via_manager` has to survive the copy.
*
* Only `downloading` records migrate. A terminal record's outcome was already
* delivered, and re-landing it would replay a settled event. Fields are copied
* INDIVIDUALLY rather than spread: this reads a file written by a process that
* is gone, and the new record must be a record we constructed.
*
* Best-effort throughout — a failure here must never block startup.
*/
export function migrateInFlightJobs(fromDir, toDir) {
let migrated = 0;
let files;
try {
files = readdirSync(fromDir).filter((f) => f.startsWith(JOB_PREFIX) && f.endsWith(".json"));
}
catch {
return 0;
}
for (const f of files) {
try {
const raw = JSON.parse(readFileSync(join(fromDir, f), "utf8"));
if (!raw || typeof raw !== "object")
continue;
if (typeof raw.id !== "string" || raw.status !== "downloading")
continue;
const str = (k) => typeof raw[k] === "string" ? raw[k] : undefined;
const num = (k) => typeof raw[k] === "number" ? raw[k] : undefined;
// WHO was transferring decides what this record may say, so the flag has to
// survive the migration (#1197). A Manager dispatch is a server-side fetch:
// the ComfyUI HOST is doing the work and a restart here does not touch it.
// Dropping `via_manager` is what made the old text dangerous — it rendered
// as a plain local interruption, so a live 12 GB host transfer was reported
// stopped and the caller was told to re-issue, which writes a SECOND copy to
// the same destination and corrupts the model (node-management.ts:971-979).
const viaManager = raw.via_manager === true;
const downloadRoute = raw.download_route === "direct" || raw.download_route === "proxied"
? raw.download_route
: undefined;
// TYPED, not inferred: an annotated object literal gets excess-property
// checking, so a key that is NOT on PersistedDownloadJob is a COMPILE
// error. The four dead keys that lived here for months (`name`, `dest`,
// `total`, `received` — from the tray-row interface) are caught
// this way. `persistDownloadJob` cannot help: it spreads a variable, which
// defeats the check.
//
// The type gate and the round-trip test's key-set assertion are
// COMPLEMENTARY, and an earlier version of this comment wrongly said the
// type was "the only thing that catches this class":
// - the TYPE catches a key that is not on the interface at all;
// - the TEST catches a key that IS on the interface but that the writer
// never emits (e.g. `notes`), which the type cannot see.
// What is true, and worth stating precisely, is that no assertion on the
// PERSISTED record can see a dead key: it is always `undefined` and
// JSON.stringify drops it before it reaches disk. An assertion on the
// literal itself would see it.
const rec = {
id: raw.id,
status: "error",
url: str("url") ?? "",
dest_key: str("dest_key"),
req_key: str("req_key"),
// Carried so the record stays USABLE, not just readable: without trayId a
// caller passing the tray_id they were handed gets "not found" on a record
// that exists, the row renders `(tray undefined)`, and an absent
// started_at prints `NaN s ago` in the candidate listing.
// `trayId`, NOT `tray_id` — this record's one camelCase key (line ~631),
// and the ONLY one url lookup matches on. Getting it wrong yields a
// silent `undefined` that a fixture using the same wrong key would not
// catch, which is precisely how a test passes for the wrong reason.
// Required by the interface. A source record missing one is already
// unusable for lookup; an empty string keeps the record VALID and
// findable by id rather than emitting a malformed one.
trayId: str("trayId") ?? "",
filename: str("filename"),
target: str("target") ? redactUrl(str("target")) : undefined,
target_subfolder: str("target_subfolder") ?? "",
started_at: num("started_at") ?? Date.now(),
pid: num("pid"),
via_manager: viaManager,
download_route: downloadRoute,
// Real keys that were also being dropped. `resume` is what a re-issue
// needs to continue a partial rather than restart it, and `progressId`
// links the record back to its tray row. `partialPath` keeps status and
// recovery on the writer's exact authenticated/HF-rewritten cache key.
progressId: str("progressId"),
partialPath: str("partialPath"),
partial_identity: raw.partial_identity &&
typeof raw.partial_identity === "object" &&
raw.partial_identity.version === 1 &&
typeof raw.partial_identity.cache_key === "string" &&
(raw.partial_identity.auth_mode === "none" ||
raw.partial_identity.auth_mode === "configured" ||
raw.partial_identity.auth_mode === "explicit")
? raw.partial_identity
: undefined,
resume: raw.resume,
updated: Date.now(),
interrupted_by_restart: true,
// NEITHER branch asserts the transfer died. This function reads neither
// the `pid` nor the `owner` it persists, and `writerProcessGone()` exists
// to answer exactly that question — the cancel path refuses to close a
// stale record until that probe returns ESRCH (#761/#858). What is
// observed is only that we stopped watching.
error: viaManager
? `This download is no longer being WATCHED, and it was DISPATCHED to ` +
`ComfyUI-Manager — the fetch runs on the ComfyUI host, which the restart ` +
`here did not touch, so it is very likely STILL RUNNING. Do NOT re-issue ` +
`it: a second dispatch writes another copy to the same destination and ` +
`CORRUPTS the model. The file is not listed until it COMPLETES (which can ` +
`be hours for a multi-GB model), so an empty list_local_models proves ` +
`nothing and a timer is not a test — check the ComfyUI host's own logs or ` +
`disk if you need to know where it is.`
: `This download is no longer being WATCHED: the orchestrator process that ` +
`was streaming it exited, so nothing here is writing those bytes and no ` +
`further progress will be reported. Any partial file may have been ` +
`discarded. The old record is not evidence that a partial survived or that ` +
`a re-issue can resume it: check the persisted exact partial path and route/` +
`identity proof first. If that evidence is unavailable, treat a re-issue as ` +
`a fresh download and do not infer that it resumes.`,
};
writeFileSync(join(toDir, `${JOB_PREFIX}${sanitizeIdPart(raw.id)}-${PERSIST_OWNER}.json`), JSON.stringify(rec), { mode: 0o600 });
migrated += 1;
}
catch {
/* one unreadable record must not stop the rest */
}
}
return migrated;
}
/** True when running under the panel orchestrator (progress channel is active). */
export function progressEnabled() {
return !!PROGRESS_DIR;
}
/** True when the cross-session persisted job store is active (a channel dir exists,
* from the env var OR the orchestrator's late binding). Unlike progressEnabled(), this
* honors the late-bound dir — so the job registry only runs its persistence heartbeat
* when there is actually somewhere to persist/adopt (avoids a leaked no-op interval on
* plain non-panel downloads). */
export function persistedRecordsEnabled() {
return !!recordsDir();
}
function fileFor(id, target, attempt) {
// The id is a hex hash from callers, but stay defensive about the filename.
// Include a TARGET discriminator (codex finding: the same URL downloaded for
// local AND pod concurrently shared one file — the last writer's target won
// the per-pod idle veto and a pod could be auto-stopped mid-transfer).
const disc = createHash("sha1")
.update(target ?? `pid:${process.pid}`)
.digest("hex")
.slice(0, 8);
// Per-ATTEMPT file (panel#489): the id is URL-derived, so attempt N and a retry N+1
// share id AND target. Giving each attempt its OWN file (by its start epoch) means a
// retry can NEVER clobber the still-live row of the attempt it replaced — both rows
// coexist on disk, so the orchestrator deterministically drops the SUPERSEDED
// attempt's terminal instead of racing a shared-file overwrite. Readers scan the
// shared `${id}-` prefix, so readDownloadProgress/clearDownloadProgress still see
// every variant. Absent on rows with no attempt epoch (pre-fix / non-model reporters
// — they keep the single-file name, unchanged).
const attemptSeg = Number.isFinite(attempt) ? `-a${attempt}` : "";
// Per-PROCESS owner segment (codex finding): the attempt epoch alone is not guaranteed
// unique across DIFFERENT processes (two children starting the same download in the
// same millisecond could tie), which would put two live writers on ONE file and let a
// late terminal clobber the other's live row. Owner-scoping the filename — the same
// per-process nonce the persisted job store already uses (#515/#529) — makes two
// processes write distinct files (barring a ~2^-64 owner-nonce collision), so a
// clobber is effectively impossible regardless of any epoch tie. (Supersession
// ordering for a genuinely simultaneous same-ms cross-process
// double-start is bounded by the physical cache layer's deterministic staged-writer
// claim: a same-identity overlap is refused before it can observe or mutate a shared
// partial, rather than being treated as safe resume. Independent identities still use
// their own O_EXCL temp + atomic rename + payload validation.)
return join(PROGRESS_DIR, `${id.replace(/[^a-zA-Z0-9_.-]/g, "_")}-${disc}${attemptSeg}-o${PERSIST_OWNER}.json`);
}
/** Credential-free form of a target URL for persisted rows / control files:
* strips userinfo (https://user:pass@host) before anything hits disk or a
* bridge frame (codex finding — raw COMFYUI_URL was being broadcast). */
function redactUrl(raw) {
try {
const u = new URL(raw);
u.username = "";
u.password = "";
// Query/fragment can carry credentials too (?token=secret) — the contract
// is credential-free before disk/broadcast (codex finding).
u.search = "";
u.hash = "";
return u.toString().replace(/\/+$/, "");
}
catch {
return raw.split("@").pop() ?? raw; // unparseable — drop anything before the last @
}
}
/** The credential-free ComfyUI endpoint that scopes a production download. */
export function currentDownloadTarget(raw = process.env.COMFYUI_URL) {
const value = raw?.trim();
return value ? redactUrl(value) : undefined;
}
/**
* Write a progress snapshot for one download. The in-flight "downloading" state
* is throttled to ~3/sec to avoid hammering the disk; terminal states
* (done/error) always write so the final row is accurate.
*/
export function reportDownloadProgress(p, force = false) {
if (!PROGRESS_DIR)
return;
const now = Date.now();
if (!force && p.status === "downloading") {
if (now - (lastWriteAt.get(p.id) ?? 0) < 300)
return;
}
lastWriteAt.set(p.id, now);
try {
mkdirSync(PROGRESS_DIR, { recursive: true });
// Stamp the writer's OWN target (the spawned MCP child's COMFYUI_URL): the
// idle-stop veto scopes by it, and after a retarget the respawned child
// reports against the new host while stale rows age out (codex finding:
// a process-wide count let any download disable any pod's auto-stop).
// Redacted — target URLs can carry userinfo (codex finding).
const target = p.target ? redactUrl(p.target) : currentDownloadTarget();
// Stamp the panel tab that started this download (the spawned MCP child's own
// COMFYUI_MCP_TAB) so the orchestrator can wake EXACTLY that tab's agent when
// the download settles (#547), the same self-scoping trick `target` uses.
const tab = p.tab ?? (process.env.COMFYUI_MCP_TAB?.trim() || undefined);
// Per-attempt file (panel#489): a retry of the same URL/target writes its OWN file
// (keyed by attempt epoch), so it can never overwrite the still-live row of the
// attempt it superseded. The orchestrator sees both and drops the superseded one.
writeFileSync(fileFor(p.id, target, p.attempt), JSON.stringify({ ...p, target, tab, updated: now }));
}
catch {
// best-effort — progress is cosmetic, never fail a download over it
}
}
/**
* Read back one download's latest snapshot, so `download_model action:"status"` can report
* bytes/throughput instead of a bare "still going". Progress files are
* TARGET-SCOPED ({id}-{disc}.json — the same URL can download for local AND a
* pod at once), so scan every variant for this id and return the most recently
* updated snapshot. Returns null when progress reporting is off (no channel
* dir — neither COMFYUI_MCP_PROGRESS_DIR nor the orchestrator's late-bound
* dir) or nothing has been written yet — callers must treat byte counts as
* decoration, never as the source of truth for whether a download finished.
*/
export function readDownloadProgress(id) {
// `channelDir()`, not the import-time env capture: the orchestrator has no
// COMFYUI_MCP_PROGRESS_DIR and late-binds via setProgressDir. A respawn-time
// at-risk snapshot runs in THAT process (#1567); reading only PROGRESS_DIR
// made every transfer look like 0 bytes, so the hold treated live downloads
// as already stalled.
const dir = channelDir();
if (!dir)
return null;
try {
const prefix = `${id.replace(/[^a-zA-Z0-9_.-]/g, "_")}-`;
let best = null;
for (const f of readdirSync(dir)) {
if (!f.startsWith(prefix) || !f.endsWith(".json"))
continue;
try {
const parsed = JSON.parse(readFileSync(join(dir, f), "utf8"));
if (parsed && typeof parsed === "object" && typeof parsed.updated === "number") {
if (!best || parsed.updated > best.updated)
best = parsed;
}
}
catch {
// skip an absent/mid-write variant
}
}
return best;
}
catch {
return null; // absent or mid-write — not an error
}
}
/** Supersession scope key: (id, target). Two downloads with the SAME URL-derived id
* but DIFFERENT targets — e.g. the SAME model streamed to LOCAL and to a POD at once
* (#269) — are INDEPENDENT transfers, not retries of each other, so a terminal from
* one must NEVER be treated as superseded by the other. Attempts of the same logical
* download share id AND target. Returns null when the row carries no usable id. A
* missing target (in-process caller / no COMFYUI_URL) collapses to "" — two such
* attempts still share a scope, which is correct (same logical local destination). */
export function downloadAttemptKey(row) {
const id = typeof row?.id === "string" ? row.id : undefined;
if (!id)
return null;
const target = typeof row?.target === "string" ? row.target : "";
return `${id}\n${target}`;
}
/**
* Flag each settled FAILURE whose FILENAME is being downloaded right now (#1150).
*
* The supersession key above is (id, target), and it is right to be: it answers
* "is this the same transfer?", where a filename cannot. But a 404 retried with a
* CORRECTED URL is a different id writing the same filename, so the eviction
* cannot see it — and a reporter was woken with "Model download FAILED" for two
* files that `download_model action:"status"` showed streaming at 20% and 13%
* seconds later.
*
* This asks a different, narrower question: "is the thing I am about to call
* failed currently arriving?" For the human or agent reading that sentence, the
* NAME is the identity, so the name is the right key here even though it is the
* wrong key there. A false positive costs a hedge on a genuinely dead download; a
* false negative is the reported bug.
*
* Mutates and returns `settled` — the caller passes rows it just built.
*/
export function markSupersededByLive(settled, liveRows) {
const liveNames = new Set(liveRows
.filter((r) => r?.status === "downloading")
.map((r) => (typeof r?.name === "string" ? r.name : ""))
.filter((n) => n !== ""));
for (const s of settled) {
if (s.status !== "done" && liveNames.has(s.name))
s.supersededByLive = true;
}
return settled;
}
/** Newest attempt epoch per (id, target) across the given rows (ANY status — a
* superseding newer attempt may itself be downloading OR already terminal). Rows
* missing an `attempt` epoch (pre-fix writer / non-model reporter) are ignored —
* they never establish supersession. The caller passes rows that have survived the
* dead-writer freshness filter so a crashed attempt's stale row can't shadow a real
* terminal. */
export function newestAttemptEpochs(rows) {
const out = new Map();
for (const r of rows) {
const attempt = typeof r?.attempt === "number" ? r.attempt : undefined;
const k = downloadAttemptKey(r);
if (!k || attempt === undefined)
continue;
const cur = out.get(k);
if (cur === undefined || attempt > cur)
out.set(k, attempt);
}
return out;
}
/** True when `row` belongs to a SUPERSEDED attempt — a strictly-newer attempt for the
* SAME (id, target) exists (panel#489). Applies to ANY status: a superseded attempt's
* late TERMINAL (error/done) must not fire a stale panel event, and its abandoned
* "downloading" row must not linger as a duplicate tray/idle-veto row beside the live
* retry. Both `row` AND the newer attempt must carry `attempt` epochs for a
* supersession to be provable; otherwise returns false (conservative — a genuine
* current row still shows, and pre-fix rows are unaffected). Strictly-greater, so an
* attempt's OWN rows (equal epoch) never suppress themselves. */
export function isSupersededAttempt(row, newest) {
const attempt = typeof row?.attempt === "number" ? row.attempt : undefined;
const k = downloadAttemptKey(row);
if (!k || attempt === undefined)
return false;
const n = newest.get(k);
return n !== undefined && n > attempt;
}
/** Remove a download's progress file(s) (e.g. on cancel). Target-scoped files
* share the id prefix, so clear every variant for the logical download. */
export function clearDownloadProgress(id) {
const dir = channelDir();
if (!dir)
return;
lastWriteAt.delete(id);
try {
const prefix = `${id.replace(/[^a-zA-Z0-9_.-]/g, "_")}-`;
for (const f of readdirSync(dir)) {
if (f.startsWith(prefix) && f.endsWith(".json"))
rmSync(join(dir, f), { force: true });
}
}
catch {
// ignore
}
}
const CONTROL_TTL_MS = 60_000; // a request older than this is stale — ignore
/** Per-request control files are prefixed so the orchestrator consumes EXACTLY
* the file it read (no read-then-delete race between agent children, and no
* timestamp-collision identity problems — codex finding). Kept OUT of
* pollDownloads' tray rows via the "control-" prefix (applied-acks too). */
export const CONTROL_PREFIX = "control-";
const REQUEST_PREFIX = `${CONTROL_PREFIX}target-`;
const APPLIED_PREFIX = `${CONTROL_PREFIX}applied-`;
let controlSeq = 0;
/**
* Live tray rows that a tool-session respawn would kill, enumerated NOW.
*
* `downloadsAtRiskOfRespawn` used to read only the job registry. That list is
* what existed at save time in the orchestrator (usually nothing — downloads
* run in the MCP child, and persist can lag a heartbeat). The tray files are
* the child's live writes, so they are the ones that exist for a transfer
* started AFTER the save and BEFORE the queued respawn fires (#1567).
*
* Control-channel files are skipped: those are not downloads. Terminal rows
* are skipped: they have already settled and a respawn does not orphan them.
*/
export function listInFlightDownloadProgress() {
const dir = channelDir();
if (!dir)
return [];
const best = new Map();
let files = [];
try {
files = readdirSync(dir);
}
catch {
return [];
}
for (const f of files) {
if (!f.endsWith(".json") || f.startsWith(CONTROL_PREFIX))
continue;
try {
const parsed = JSON.parse(readFileSync(join(dir, f), "utf8"));
if (!parsed || typeof parsed !== "object")
continue;
if (typeof parsed.id !== "string" || !parsed.id)
continue;
if (parsed.status !== "downloading")
continue;
const downloaded = typeof parsed.downloaded === "number" ? parsed.downloaded : 0;
const updated = typeof parsed.updated === "number" ? parsed.updated : 0;
const prev = best.get(parsed.id);
if (!prev ||
downloaded > (typeof prev.downloaded === "number" ? prev.downloaded : 0) ||
(downloaded === prev.downloaded && updated > (prev.updated ?? 0))) {
best.set(parsed.id, parsed);
}
}
catch {
// mid-write or corrupt — not a download we can name
}
}
return [...best.values()];
}
function controlDirPath(dir = channelDir()) {
return dir || null;
}
/** Ask the orchestrator to retarget (+ optionally watch a pod). Returns the
* request file path (for awaitTargetApplied), or null when the channel is
* inactive (no progress dir). */
export function requestTargetChange(req) {
const dir = controlDirPath();
if (!dir)
return null;
try {
mkdirSync(dir, { recursive: true });
// Redact any credential-bearing target URL before it touches disk (codex).
const safe = {
...req,
...(req.url ? { url: redactUrl(req.url) } : {}),
...(req.connectWhenReady ? { connectWhenReady: { ...req.connectWhenReady, url: redactUrl(req.connectWhenReady.url) } } : {}),
};
// Unique-per-request file: consumption deletes exactly this name — a second
// child's request can never be clobbered by the first's delete.
const file = join(dir, `${REQUEST_PREFIX}${process.pid}-${Date.now()}-${controlSeq++}.json`);
writeFileSync(file, JSON.stringify({ ...safe, updated: Date.now() }));
return file;
}
catch {
return null; // best-effort — the caller's own retarget still happened
}
}
/** The ack file the orchestrator writes after applying a given request file. */
function appliedFileFor(requestFile) {
return join(dirname(requestFile), `${APPLIED_PREFIX}${basename(requestFile).slice(REQUEST_PREFIX.length)}`);
}
/** Child side: wait for the orchestrator to apply our request and report the
* RESULTING target (its own remembered LAN fallback included — the child
* can't compute it, codex finding). Returns the ack, or null on timeout —
* callers must then report honestly rather than guess 127.0.0.1. */
export async function awaitTargetApplied(requestFile, timeoutMs = 4_000) {
const ack = appliedFileFor(requestFile);
const start = Date.now();
for (;;) {
try {
const raw = JSON.parse(readFileSync(ack, "utf-8"));
if (typeof raw?.url === "string") {
rmSync(ack, { force: true }); // consumed — don't leak it into a later poll
return { url: raw.url, applied: raw.applied !== false };
}
}
catch {
// not applied yet
}
if (Date.now() - start > timeoutMs)
return null;
await new Promise((r) => setTimeout(r, 150));
}
}
/** Orchestrator side: all fresh pending requests (within TTL), oldest first.
* The dir is EXPLICIT: the orchestrator computes progressDir itself while the
* module-level env capture is unset in its own process (codex finding — the
* channel was write-only: children stamped, nobody read). */
export function listTargetChangeRequests(dir) {
const out = [];
let files = [];
try {
files = readdirSync(dir).filter((f) => f.startsWith(REQUEST_PREFIX) && f.endsWith(".json"));
}
catch {
return out;
}
for (const f of files) {
try {
const file = join(dir, f);
const raw = JSON.parse(readFileSync(file, "utf-8"));
if (typeof raw?.updated !== "number")
continue;
if (!raw.url && !raw.local && !raw.watchPodId && !raw.unwatch && !raw.connectWhenReady && !raw.stoppedPodId)
continue;
if (Date.now() - raw.updated > CONTROL_TTL_MS) {
rmSync(file, { force: true }); // reap stale requests while we're here
continue;
}
out.push({ req: raw, file });
}
catch {
// mid-write or corrupt — retry next tick
}
}
return out.sort((a, b) => a.req.updated - b.req.updated);
}
/** Orchestrator side: after applying a request, ack it with the RESULTING
* target so the requesting child can align its own process to the TRUE
* fallback/target (it can't compute the orchestrator's remembered LAN URL —
* codex finding). `applied` reports whether a guarded (onlyIfTarget) request
* was actually applied — a guarded-out request must not read as a successful
* local switch (codex finding). Best-effort; the child times out gracefully. */
export function ackTargetChange(requestFile, url, applied = true) {
try {
writeFileSync(appliedFileFor(requestFile), JSON.stringify({ url, applied, updated: Date.now() }));
}
catch {
// ignore
}
}
/** Orchestrator side: consume one applied request file (exactly the file that
* was read — never a newer replacement, since each request is its own file). */
export function consumeTargetChange(file) {
try {
rmSync(file, { force: true });
}
catch {
// ignore
}
}
// ── Persisted download-job records (cross-session adoption, #529) ─────────────
// The in-memory download-job registry (download-jobs.ts) is process-global, so a
// sidebar/tool-session RECONNECT — which respawns the MCP child — starts with an
// EMPTY registry and `download_model action:"status"(id)` can no longer resolve an id returned by
// a previous session ("Downloads are tracked per server session"). The live download
// itself keeps running and keeps writing its progress row, so the STATE exists on
// disk; it just isn't discoverable by the new session's registry.
//
// This persists a small per-job record into the SAME progress dir the tray already
// uses, so any session can rediscover (adopt) an in-flight job by its public id — or
// by URL/destination — after a reconnect. The record is prefixed with CONTROL_PREFIX
// so the orchestrator's tray poll (pollDownloads skips CONTROL_PREFIX) and its
// control-request reader (listTargetChangeRequests only reads REQUEST_PREFIX) both
// ignore it. No-ops entirely without a progress dir, exactly like reportDownloadProgress.
const JOB_PREFIX = `${CONTROL_PREFIX}job-`;
function sanitizeIdPart(id) {
return id.replace(/[^a-zA-Z0-9_.-]/g, "_");
}
/** Owner-scoped record file for a job id, so concurrent sessions write separate files. */
function jobFileFor(id, owner = PERSIST_OWNER) {
return join(recordsDir(), `${JOB_PREFIX}${sanitizeIdPart(id)}-${sanitizeIdPart(owner)}.json`);
}
let persistSeq = 0;
/** Persist (or update) a job record so another session can adopt it after a
* reconnect (#529). URL is redacted before it touches disk (it can carry query
* auth), matching the rest of this channel. Best-effort — a persistence failure
* never fails a download. No-op without a progress dir.
*
* ATOMIC: writes to a unique temp then renames it over the target, so a concurrent
* reader (another session polling for adoption, or this session's heartbeat) NEVER sees
* a half-written/truncated record and treats a live download as absent — which would let
* a reconnect-reissue start a SECOND writer (torn-write double-writer race). fs.rename
* atomically replaces on POSIX and on Windows (libuv MoveFileEx REPLACE_EXISTING). The
* `.tmp` name ends in `.tmp` (not `.json`) so no scanner ever picks up a temp.
*
* Returns TRUE iff the record was DURABLY replaced (the atomic rename succeeded), so the
* caller (the heartbeat) can keep retrying a transiently-failing TERMINAL persist until
* the terminal state is durable — otherwise a done/cancelled job could linger as a fresh
* "downloading" record (bounded by long record retention, but this recovers it sooner). Returns
* false when there is no channel dir (nothing to persist) or the replace didn't happen. */
/**
* Delays between the atomic-replace retries, in ms (#1545).
*
* The escalation is intent, not a guarantee: MEASURED on Windows, the platform
* timer granularity floors every sleep at ~15 ms, so this schedule totals ~73 ms
* while a flat [2,2,2,2] totals ~62 ms — nearly the same. What actually matters
* here is that a delay EXISTS at all (the loop previously had none, so all five
* attempts collided with one reader open) and that there are several of them.
* The ordering still helps where timers are finer-grained, and costs nothing.
*/
const RENAME_BACKOFF_MS = [2, 5, 10, 20];
/**
* Sleep without yielding the event loop.
*
* `commitDone` publishes the terminal status and persists it in ONE synchronous
* step precisely so no reader can observe a landed file with a "downloading"
* job, so this path may not await. `Atomics.wait` on a throwaway buffer is the
* supported way to block a Node thread; a busy spin would burn a core for the
* same delay. Falls back to a bounded spin where `Atomics.wait` is disallowed
* (it throws on the main thread in some embeddings).
*/
function sleepSyncMs(ms) {
if (!(ms > 0))
return;
try {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
catch {
// performance.now() is MONOTONIC; Date.now() is not. A backward wall-clock
// adjustment (NTP step, DST on some platforms) during this loop would make
// the exit condition unreachable and freeze the event loop in the very
// fallback meant to be safe (review finding). The iteration cap is a second
// belt: even a pathological clock cannot make this run forever.
const until = performance.now() + ms;
for (let i = 0; performance.now() < until && i < 5_000_000; i++) {
/* bounded spin — only reached where Atomics.wait is unavailable */
}
}
}
/** Persist under the supplied owner when reconciling a reconstructed record; ordinary
* callers use the current session owner by default. */
export function persistDownloadJob(job, owner = PERSIST_OWNER) {
const dir = recordsDir();
if (!dir)
return false;
try {
mkdirSync(dir, { recursive: true });
const persistOwner = owner.trim() || PERSIST_OWNER;
const safe = {
...job,
owner: persistOwner,
pid: process.pid,
url: job.url ? redactUrl(job.url) : job.url,
target: job.target ? redactUrl(job.target) : job.target,
updated: Date.now(),
};
const body = JSON.stringify(safe);
const finalPath = jobFileFor(job.id, persistOwner);
const tmpPath = `${finalPath}.${process.pid}-${persistSeq++}.tmp`;
writeFileSync(tmpPath, body);
// Atomic replace, retried a few times for a TRANSIENT Windows sharing violation (a
// reader briefly holding the target open during its own readFileSync). We must NEVER
// fall back to an in-place writeFileSync(finalPath): that truncate+rewrite is exactly
// the torn-read that lets another process see the record as absent and start a SECOND
// writer. On persistent failure we KEEP the prior COMPLETE record untouched and drop
// the temp; the next ~15s heartbeat retries the atomic replace (self-healing). A
// terminal persist that can't replace ages out via long record retention instead
// of ever corrupting the scanned .json.
// #1545 — the retries need to be SPREAD OUT to be retries at all. This loop
// had no delay, so all five attempts completed within microseconds of each
// other and lost to the same reader's `readFileSync` open: "retried a few
// times" was effectively one attempt. A reporter polling
// `download_model action:"status"` — i.e. opening this exact file — while a
// download completed saw the terminal record fail to land, and `status` kept
// answering "downloading" from the prior record until the ~15s heartbeat.
//
// The backoff is SYNCHRONOUS on purpose: `commitDone` publishes the terminal
// state and persists it in one step with no await in between, so nothing may
// yield here. Worst case is ~37 ms, and only under actual contention — the
// first attempt is unchanged and does not sleep.
let renamed = false;
for (let attempt = 0; attempt < 5 && !renamed; attempt++) {
if (attempt > 0)
sleepSyncMs(RENAME_BACKOFF_MS[attempt - 1] ?? 20);
try {
renameSync(tmpPath, finalPath); // readers see old or new complete record, never torn
renamed = true;
}
catch {
/* transient (e.g. Windows sharing violation) — retry */
}
}
if (!renamed) {
try {
rmSync(tmpPath, { force: true });
}
catch {
/* ignore — a stray temp is never scanned (ends in .tmp) */
}
}
return renamed;
}
catch {
// best-effort — adoption is a convenience, never fail a download over it
return false;
}
}
/** Remove a persisted job record (e.g. once it's fully retired). Best-effort. */
export function removePersistedDownloadJob(id) {
const dir = recordsDir();
if (!dir)
return;
try {
rmSync(jobFileFor(id), { force: true });
}
catch {
// ignore
}
}
/** Remove a SPECIFIC session's persisted record, named by its owner nonce — the
* reclaim half of #858. This is the ONE destructive operation on another
* session's state: call it only for a record whose writer is PROVEN dead (see
* cancelDownloadJob), and only after the replacement terminal record is durable,
* so a failure here never leaves the download with no record at all.
*
* Returns TRUE only when the file is gone. A transient Windows sharing violation
* (a reader briefly holding the file) is retried, mirroring the persist rename;
* a persistent failure is reported to the caller so it can DISCLOSE the leftover
* instead of claiming a clean close it did not observe (codex gate, round 2). */
export function removePersistedDownloadJobFor(id, owner) {
const dir = recordsDir();
if (!dir || !owner)
return false;
const path = join(dir, `${JOB_PREFIX}${sanitizeIdPart(id)}-${sanitizeIdPart(owner)}.json`);
for (let attempt = 0; attempt < 5; attempt++) {
try {
rmSync(path, { force: true });
return true;
}
catch {
/* transient — retry */
}
}
return false;
}
// A missed heartbeat is only a liveness hint, not proof the transfer stopped:
// an orchestrator/session reconnect can interrupt persistence while the HTTP
// stream continues. Keep in-flight records for the same bounded retention as
// terminal records so read paths cannot delete a live owner's state (#761).
const PERSISTED_JOB_TTL_MS = 6 * 60 * 60 * 1000; // 6h
function parseJobFile(dir, f) {
try {
const raw = JSON.parse(readFileSync(join(dir, f), "utf8"));
if (!raw || typeof raw !== "object" || typeof raw.id !== "string")
return null;
const age = typeof raw.updated === "number" ? Date.now() - raw.updated : 0;
// This parser backs read paths (including download_model action:"status"). A heartbeat gap
// beyond the short freshness window is not sufficient evidence to destructively
// delete a potentially live transfer; adoption/cancel freshness checks below
// still treat it as non-live. Reap only after the bounded long retention.
if (age > PERSISTED_JOB_TTL_MS) {
try {
rmSync(join(dir, f), { force: true });
}
catch {
/* ignore */
}
return null;
}
if (raw.status === "downloading" && age > PERSISTED_INFLIGHT_STALE_MS) {
return { ...raw, staleInflight: true, staleForMs: age };
}
return raw;
}
catch {
return null; // absent / mid-write / corrupt — skip
}
}
/** Read the best persisted record for a public id, or null when absent/expired. More
* than one record can exist for one id — one per session that ran it (owner-scoped
* files) — so scan all matches and prefer an in-flight one, then the most recent. */
export function readPersistedDownloadJob(id) {
const matches = listPersistedDownloadJobs().filter((j) => j.id === id);
if (matches.length === 0)
return null;
const now = Date.now();
// A LIVE download is a FRESH in-flight record (heartbeat recent). Ambiguity that
// matters is >1 distinct trayId among LIVE records — two distinct URLs resolving to
// the same dest+auth are distinct concurrent physical downloads the id can't
// disambiguate. Dead in-flight records are already reaped in parseJobFile, so
// `matches` holds only fresh in-flight and terminal records; the freshness filter
// here is defense-in-depth against a record that aged out between scan and use.
const live = matches.filter((j) => j.status === "downloading" && now - (j.updated ?? 0) < PERSISTED_INFLIGHT_STALE_MS);
if (live.length > 0) {
if (new Set(live.map((j) => j.trayId)).size > 1)
return null; // ambiguous live download
return live.sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))[0];
}
// No live download. INTEGRITY TRUTH (cross-session): a terminal DONE means a validated
// file landed at the destination. It must WIN over a later cancelled/error record for
// the SAME id — a cancel/error never lands a file, so it must NEVER override another
// session's validated-complete file (cancelled-over-complete). Prefer the most-recent
// DONE; only if there is none do we report the most-recent terminal record.
const done = matches.filter((j) => j.status === "done");
const pool = done.length > 0 ? done : matches;
return pool.sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))[0];
}
/** Every persisted job record (freshest not guaranteed; caller sorts). Used to
* list in-flight downloads after a reconnect and to look one up by URL/destination. */
/**
* When was THIS session's record store created, and where is it? (#1420)
*
* The store is nonced per orchestrator start and earlier ones are reaped, so a
* restart or reconnect gives a session a brand-new, EMPTY store while transfers
* begun under the old one keep streaming inside the processes that own them. An
* empty listing therefore says as much about the store's age as about the world,
* and a reader deserves to be told which.
*
* `createdMs` is undefined when the directory cannot be stat'd — unknown, which is
* reported as unknown rather than as "old".
*/
/**
* The creation time of a record store, from its stat — or UNDEFINED (#1420).
*
* BIRTHTIME ONLY. ctime was written as a fallback and removed: where birthtime is
* unavailable, ctime moves whenever a record is written into the directory — which
* is constantly, since that is what the directory is for — so a long-lived store
* would report itself as seconds old, and the sentence built on it ("anything
* started before then was never in it") would be FALSE exactly when it is most
* load-bearing. Unknown is reported as unknown.
*
* Extracted so the rule is testable: on a filesystem that supplies birthtime — as
* this project's own does — no test could otherwise reach the fallback, and a
* mutation putting ctime back survived because of it.
*/
export function storeCreatedFrom(st) {
const born = st.birthtimeMs;
return Number.isFinite(born) && born > 0 ? born : undefined;
}
export function describeRecordStore() {
const dir = recordsDir();
if (!dir)
return { dir: "" };
try {
return { dir, createdMs: storeCreatedFrom(statSync(dir)) };
}
catch {
return { dir };
}
}
export function listPersistedDownloadJobs() {
const dir = recordsDir();
if (!dir)
return [];
const out = [];
let files = [];
try {
files = readdirSync(dir).filter((f) => f.startsWith(JOB_PREFIX) && f.endsWith(".json"));
}
catch {
return out;
}
for (const f of files) {
const rec = parseJobFile(dir, f);
if (rec)
out.push(rec);
}
return out;
}
/** Find a persisted job by TRAY id or by destination key — so a caller can adopt an
* in-flight download after a reconnect WITHOUT starting a duplicate (#529).
*
* Matching is on `trayId` (a hash of the FULL raw source URL, query included — the
* caller derives it via downloadIdFor), NOT the persisted `url` string: the persisted
* url is credential-redacted (query stripped), so comparing it would conflate two
* distinct signed/versioned URLs that differ only by query. Hashing the raw url keeps
* the match exact AND credential-free. Prefers an in-flight ("downloading") match,
* then the most recently updated (a niche same-exact-URL-two-destinations case is
* inherently ambiguous from a URL alone — the id selector disambiguates it). */
export function findPersistedDownloadJob(query) {
const { trayId, destKey, target } = query;
if (!trayId && !destKey)
return null;
const matches = listPersistedDownloadJobs().filter((j) => ((trayId && j.trayId === trayId) || (destKey && j.dest_key === destKey)) &&
(target === undefined || j.target === target));
if (matches.length === 0)
return null;
// AMBIGUITY GUARD: one URL can legitimately drive TWO jobs to different destinations
// (they share a trayId), and one auth-free destination can back two different-auth
// jobs (they share a dest_key). Adopting by URL/destination alone then can't tell them
// apart — so REFUSE to guess when more than one DISTINCT LIVE job matches; the caller
// must use the exact id. Distinctness is (id, trayId, target). Ambiguity is judged over LIVE
// (fresh in-flight) records ONLY: a heartbeat-stale record may still be streaming,
// but must not block adoption or force a false decline.
const distinctKey = (j) => `${j.id}\n${j.trayId}\n${j.target ?? ""}`;
// A URL/destination lookup without an explicit target cannot safely select
// between local, pod, and legacy targetless terminal rows. Keep the same
// fail-closed rule used by the in-flight path for the terminal fallback too.
if (target === undefined && new Set(matches.map(distinctKey)).size > 1)
return null;
const now = Date.now();
const live = matches.filter((j) => j.status === "downloading" && now - (j.updated ?? 0) < PERSISTED_INFLIGHT_STALE_MS);
if (live.length > 0) {
if (new Set(live.map(distinctKey)).size > 1)
return null; // ambiguous live download
return live.sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))[0];
}
// No live download. INTEGRITY TRUTH: a terminal DONE (validated file landed) must WIN
// over a later cancelled/error record for the same destination — never override a
// validated-complete file. Prefer the most-recent DONE, else the most-recent terminal.
const done = matches.filter((j) => j.status === "done");
const pool = done.length > 0 ? done : matches;
return pool.sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))[0];
}
//# sourceMappingURL=download-progress.js.map