comfyui-mcp 0.49.2 → 0.49.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/orchestrator/agent-backend.js +8 -0
- package/dist/orchestrator/agent-backend.js.map +1 -1
- package/dist/orchestrator/codex-backend.js +49 -0
- package/dist/orchestrator/codex-backend.js.map +1 -1
- package/dist/orchestrator/grok-backend.js +5 -0
- package/dist/orchestrator/grok-backend.js.map +1 -1
- package/dist/orchestrator/index.js +317 -22
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/ollama-backend.js +11 -2
- package/dist/orchestrator/ollama-backend.js.map +1 -1
- package/dist/orchestrator/panel-agent.js +591 -67
- package/dist/orchestrator/panel-agent.js.map +1 -1
- package/dist/orchestrator/panel-tools.js +518 -65
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/orchestrator/run-completion-journal.js +914 -0
- package/dist/orchestrator/run-completion-journal.js.map +1 -0
- package/dist/orchestrator/session-store.js +11 -3
- package/dist/orchestrator/session-store.js.map +1 -1
- package/dist/services/asset-reconcile.js +83 -0
- package/dist/services/asset-reconcile.js.map +1 -0
- package/dist/services/asset-registry.js +9 -2
- package/dist/services/asset-registry.js.map +1 -1
- package/dist/services/download-jobs.js +178 -14
- package/dist/services/download-jobs.js.map +1 -1
- package/dist/services/download-progress.js +16 -18
- package/dist/services/download-progress.js.map +1 -1
- package/dist/services/extra-paths.js +61 -9
- package/dist/services/extra-paths.js.map +1 -1
- package/dist/services/hello-retarget.js +165 -0
- package/dist/services/hello-retarget.js.map +1 -0
- package/dist/services/job-history.js +50 -0
- package/dist/services/job-history.js.map +1 -1
- package/dist/services/job-watcher.js +38 -7
- package/dist/services/job-watcher.js.map +1 -1
- package/dist/services/manifest.js +92 -9
- package/dist/services/manifest.js.map +1 -1
- package/dist/services/model-resolver.js +616 -17
- package/dist/services/model-resolver.js.map +1 -1
- package/dist/services/output-dir.js +63 -15
- package/dist/services/output-dir.js.map +1 -1
- package/dist/services/panel-pin-guard.js +6 -62
- package/dist/services/panel-pin-guard.js.map +1 -1
- package/dist/services/ui-bridge.js +111 -14
- package/dist/services/ui-bridge.js.map +1 -1
- package/dist/services/workspace-env.js +179 -3
- package/dist/services/workspace-env.js.map +1 -1
- package/dist/tools/assets.js +34 -2
- package/dist/tools/assets.js.map +1 -1
- package/dist/tools/extra-paths.js +6 -4
- package/dist/tools/extra-paths.js.map +1 -1
- package/dist/tools/model-extras.js +26 -7
- package/dist/tools/model-extras.js.map +1 -1
- package/dist/tools/model-management.js +36 -8
- package/dist/tools/model-management.js.map +1 -1
- package/dist/tools/report-issue.js +12 -2
- package/dist/tools/report-issue.js.map +1 -1
- package/dist/tools/vocabulary.js +21 -0
- package/dist/tools/vocabulary.js.map +1 -1
- package/package.json +1 -1
- package/scripts/gen-tool-docs.ts +138 -4
- package/scripts/tool-doc-examples.ts +794 -0
|
@@ -24,20 +24,21 @@
|
|
|
24
24
|
// so parity is automatic — neither path reimplements a tool.
|
|
25
25
|
import { z } from "zod";
|
|
26
26
|
import { randomUUID } from "node:crypto";
|
|
27
|
-
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
27
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
28
28
|
import { extname, isAbsolute, join, resolve, sep } from "node:path";
|
|
29
29
|
import { fileURLToPath } from "node:url";
|
|
30
30
|
import { comfyuiFetch } from "../comfyui/fetch.js";
|
|
31
31
|
import { assertPanelNotTargetedUnverifiable } from "../services/panel-pin-guard.js";
|
|
32
32
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
33
33
|
import { parse as parseYaml } from "yaml";
|
|
34
|
-
import { dispatchOutcomeOf, isPanelCmdUnsupportedError, isReplyTimeoutTagged, requiresWorkflowStampEnforcement, } from "../services/ui-bridge.js";
|
|
34
|
+
import { dispatchOutcomeOf, isCapabilityRefusal, isPanelCmdUnsupportedError, isReplyTimeoutTagged, requiresWorkflowStampEnforcement, } from "../services/ui-bridge.js";
|
|
35
35
|
import { withWorkflowTarget, } from "../services/workflow-target-store.js";
|
|
36
36
|
import { addUserMcpServer, readUserMcpServers, removeUserMcpServer, setUserMcpServerSecret, } from "../services/user-mcp-config.js";
|
|
37
37
|
import { setComfyuiSecret, setAgentSecret, isAllowedAgentSecretKey } from "../services/panel-secrets.js";
|
|
38
38
|
import { flattenUiWorkflow } from "../services/flatten-workflow.js";
|
|
39
39
|
import { getNsfwConsent, setNsfwConsent } from "../services/panel-settings.js";
|
|
40
40
|
import { QueueMonitor } from "../services/queue-monitor.js";
|
|
41
|
+
import { RunCompletions } from "./run-completion-journal.js";
|
|
41
42
|
import { getClient, getObjectInfo, backfillObjectInfo, resetClient, resetObjectInfoCache, } from "../comfyui/client.js";
|
|
42
43
|
import { convertUiToApi, collectNodeTypes } from "../services/workflow-converter.js";
|
|
43
44
|
import { restartComfyUI, preflightLocalRestart, recordRestartDispatch, clearRestartDispatch, getRestartDispatchRecord, RESTART_DISPATCH_CAUSATION_WINDOW_MS, PROCESS_WIDE_RESTART_DISPATCH_TOKEN, __processControlTestHooks, } from "../services/process-control.js";
|
|
@@ -1889,7 +1890,21 @@ function readPackWorkflow(packName) {
|
|
|
1889
1890
|
// ComfyUI workflows dir always resolves. It does NOT work against a remote
|
|
1890
1891
|
// ComfyUI whose files the orchestrator can't see — use the inline `graph` option
|
|
1891
1892
|
// for that.
|
|
1892
|
-
/**
|
|
1893
|
+
/**
|
|
1894
|
+
* Candidate ComfyUI workflows directories, RECONSTRUCTED from COMFYUI_PATH.
|
|
1895
|
+
*
|
|
1896
|
+
* These are GUESSES at the DEFAULT layout. ComfyUI's `--user-directory` moves the
|
|
1897
|
+
* whole user tree somewhere unguessable, so a hit under one of these dirs is only
|
|
1898
|
+
* ever the file the caller meant when the server happens to run the default
|
|
1899
|
+
* layout. They are therefore a LAST resort, used only when the connected ComfyUI
|
|
1900
|
+
* could not be asked at all (#202).
|
|
1901
|
+
*
|
|
1902
|
+
* `process.cwd()` is deliberately NOT in this list: resolving a library name
|
|
1903
|
+
* against whatever directory the orchestrator happened to be launched from can
|
|
1904
|
+
* only ever produce a file that is not the named library workflow — the
|
|
1905
|
+
* load-the-wrong-graph hazard. Callers who mean a file outside the library pass
|
|
1906
|
+
* an absolute path.
|
|
1907
|
+
*/
|
|
1893
1908
|
function comfyWorkflowsDirs() {
|
|
1894
1909
|
const base = process.env.COMFYUI_PATH;
|
|
1895
1910
|
if (!base)
|
|
@@ -1899,6 +1914,125 @@ function comfyWorkflowsDirs() {
|
|
|
1899
1914
|
join(base, "user", "workflows"),
|
|
1900
1915
|
];
|
|
1901
1916
|
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Issue ONE userdata GET and hand back the raw Response.
|
|
1919
|
+
*
|
|
1920
|
+
* This deliberately bypasses the client's own `fetchApi` (codex/gate MAJOR).
|
|
1921
|
+
* `fetchApi` throws for every non-2xx AND calls `response.json()` while building
|
|
1922
|
+
* that error — so a refusal with a non-JSON body (a `403 text/plain` from a proxy,
|
|
1923
|
+
* an HTML 502) rejects with a STATUSLESS SyntaxError. There is then no way to tell
|
|
1924
|
+
* "the server refused" from "the server was never reached", and the resolver's
|
|
1925
|
+
* local fallback would re-open for a server that had explicitly refused, loading a
|
|
1926
|
+
* different graph (#202).
|
|
1927
|
+
*
|
|
1928
|
+
* Building the request from the client's own `apiURL` + `apiHeaders` keeps every
|
|
1929
|
+
* transport concern identical (host, ssl, base path, clientId, Comfy-User, the
|
|
1930
|
+
* injected COMFYUI_AUTH_* fetch) while making the outcome unambiguous:
|
|
1931
|
+
* - a returned Response = the server ANSWERED; classify by status, decode later
|
|
1932
|
+
* - a thrown error = no Response exists, so the request never got one
|
|
1933
|
+
* Nothing here reads a body, so a body that cannot be decoded can never be
|
|
1934
|
+
* mistaken for a transport failure.
|
|
1935
|
+
*/
|
|
1936
|
+
async function userdataFetch(route) {
|
|
1937
|
+
const client = getClient();
|
|
1938
|
+
return await client.fetch(client.apiURL(route), { headers: client.apiHeaders() });
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1941
|
+
* Drop the one leading "workflows/" segment from CALLER input, leaving a key that
|
|
1942
|
+
* is relative to the workflow store root.
|
|
1943
|
+
*
|
|
1944
|
+
* This exists only because panel_list_workflows reports store keys that already
|
|
1945
|
+
* carry the prefix (#414), so a caller may legitimately paste either spelling. It
|
|
1946
|
+
* is applied EXACTLY ONCE, to the caller's `path`, and NEVER to an entry from the
|
|
1947
|
+
* server's listing — those are already store-relative and stripping them again is
|
|
1948
|
+
* what let a nested folder named `workflows` impersonate the store root (gate
|
|
1949
|
+
* MAJOR); see matchName.
|
|
1950
|
+
*
|
|
1951
|
+
* Exactly one separator is consumed (codex MAJOR). "workflows//foo.json" keeps its
|
|
1952
|
+
* second slash and stays "/foo.json" — collapsing runs of slashes would make it an
|
|
1953
|
+
* alias for the different key "foo.json". A leading "/" is not removed either;
|
|
1954
|
+
* caller input can never reach here with one (that is an absolute path, handled
|
|
1955
|
+
* earlier).
|
|
1956
|
+
*
|
|
1957
|
+
* Only the literal lowercase FORWARD-slash spelling counts (codex MAJOR). ComfyUI
|
|
1958
|
+
* store keys are always "/"-separated and lowercase here, so `workflowsoo.json`
|
|
1959
|
+
* and `WORKFLOWS/foo.json` are ordinary names — a real subfolder on a
|
|
1960
|
+
* case-sensitive host, or a legal literal filename on POSIX — and stripping either
|
|
1961
|
+
* would rewrite a request into one for a different graph.
|
|
1962
|
+
*/
|
|
1963
|
+
const stripLibraryPrefix = (key) => key.replace(/^workflows\//, "");
|
|
1964
|
+
/**
|
|
1965
|
+
* The form two store-relative names are compared in when deciding they are the
|
|
1966
|
+
* SAME NAME. Unicode normalization is the only equivalence applied: NFD "é" and
|
|
1967
|
+
* NFC "é" are one character sequence written two ways, so a name pasted from a
|
|
1968
|
+
* listing (or produced on another OS) can be byte-different yet denote the same
|
|
1969
|
+
* file, and a raw byte comparison 404s on a workflow that visibly exists.
|
|
1970
|
+
*
|
|
1971
|
+
* Nothing is STRIPPED here, which is what keeps request depth matched to entry
|
|
1972
|
+
* depth (gate MAJOR). A recursive listing reports the root file "x.json" bare and
|
|
1973
|
+
* the nested file workflows/workflows/x.json as "workflows/x.json"; stripping a
|
|
1974
|
+
* prefix from the entry made those two collide, so a bare request could match the
|
|
1975
|
+
* nested entry and then fetch the DIFFERENT root file. Compared verbatim, a
|
|
1976
|
+
* depth-0 request can only ever match a depth-0 entry, and a request naming a
|
|
1977
|
+
* subfolder matches only that exact path.
|
|
1978
|
+
*
|
|
1979
|
+
* Path separators are deliberately NOT folded (codex MAJOR): on POSIX a file
|
|
1980
|
+
* literally named `diroo.json` is a DIFFERENT file from `dir/foo.json`. Case is
|
|
1981
|
+
* not folded either, for the same reason. Both are reported as near misses instead.
|
|
1982
|
+
*/
|
|
1983
|
+
const matchName = (name) => name.normalize("NFC");
|
|
1984
|
+
/** The looser form used ONLY to describe a near miss in an error message — never
|
|
1985
|
+
* to select a file to load. Folds the equivalences that hold on some filesystems
|
|
1986
|
+
* and not others: separator flavour, letter case and normalization. Like matchName
|
|
1987
|
+
* it strips nothing, so it cannot report a nested entry as a near miss for a root
|
|
1988
|
+
* name. */
|
|
1989
|
+
const looseName = (name) => name.replace(/\\/g, "/").normalize("NFC").toLowerCase();
|
|
1990
|
+
/**
|
|
1991
|
+
* The connected ComfyUI's OWN list of saved workflow store keys, or null when the
|
|
1992
|
+
* listing could not be read. This is the same source list_workflows reports (the
|
|
1993
|
+
* SAVED library, not the open tabs), so it reflects the server's runtime
|
|
1994
|
+
* `--user-directory` — it is asked, never reconstructed. Used only to turn an
|
|
1995
|
+
* authoritative "no such name" into either the server's EXACT key or an explicit
|
|
1996
|
+
* refusal. Never throws: an unreadable listing simply means "no extra
|
|
1997
|
+
* information", and the refusal says the listing could not be read.
|
|
1998
|
+
*
|
|
1999
|
+
* `recurse=true` is VERIFIED against the installed ComfyUI (0.29.2): a workflow in
|
|
2000
|
+
* a SUBFOLDER is absent from the plain listing and present as "sub/name.json"
|
|
2001
|
+
* under recurse, while root entries stay bare. Without it, a nested name that
|
|
2002
|
+
* differs only by Unicode normalization has no listing entry to match and is
|
|
2003
|
+
* refused. The parameter is safe on builds that do not implement it — an unknown
|
|
2004
|
+
* query arg is ignored and the flat list comes back, which is exactly the
|
|
2005
|
+
* pre-existing behaviour (over-refusal of nested near-misses, never a wrong file),
|
|
2006
|
+
* so this needs no version gate.
|
|
2007
|
+
*/
|
|
2008
|
+
async function listUserdataWorkflowKeys() {
|
|
2009
|
+
try {
|
|
2010
|
+
const res = await userdataFetch("/api/userdata?dir=workflows&recurse=true");
|
|
2011
|
+
if (!res.ok)
|
|
2012
|
+
return null;
|
|
2013
|
+
const body = await res.json();
|
|
2014
|
+
if (!Array.isArray(body))
|
|
2015
|
+
return null;
|
|
2016
|
+
// Tolerate both shapes ComfyUI builds return: bare strings (the default on
|
|
2017
|
+
// 0.29.2), and {path} objects (what full_info=true emits, in case a build
|
|
2018
|
+
// returns that shape by default).
|
|
2019
|
+
return body
|
|
2020
|
+
.map((entry) => {
|
|
2021
|
+
if (typeof entry === "string")
|
|
2022
|
+
return entry;
|
|
2023
|
+
const rec = entry;
|
|
2024
|
+
if (rec && typeof rec.path === "string")
|
|
2025
|
+
return rec.path;
|
|
2026
|
+
if (rec && typeof rec.name === "string")
|
|
2027
|
+
return rec.name;
|
|
2028
|
+
return null;
|
|
2029
|
+
})
|
|
2030
|
+
.filter((k) => typeof k === "string" && k.length > 0);
|
|
2031
|
+
}
|
|
2032
|
+
catch {
|
|
2033
|
+
return null;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
1902
2036
|
/** Validate a parsed value is a UI/litegraph workflow (a top-level `nodes`
|
|
1903
2037
|
* array), throwing a source-labelled error otherwise. */
|
|
1904
2038
|
function assertUiWorkflow(parsed, sourceLabel) {
|
|
@@ -1911,16 +2045,39 @@ function assertUiWorkflow(parsed, sourceLabel) {
|
|
|
1911
2045
|
}
|
|
1912
2046
|
return parsed;
|
|
1913
2047
|
}
|
|
1914
|
-
/**
|
|
1915
|
-
*
|
|
1916
|
-
*
|
|
1917
|
-
*
|
|
1918
|
-
*
|
|
1919
|
-
*
|
|
1920
|
-
*
|
|
1921
|
-
*
|
|
1922
|
-
*
|
|
1923
|
-
*
|
|
2048
|
+
/**
|
|
2049
|
+
* Read + parse a UI workflow JSON by path.
|
|
2050
|
+
*
|
|
2051
|
+
* An ABSOLUTE path is read off the orchestrator's own disk, unchanged.
|
|
2052
|
+
*
|
|
2053
|
+
* A RELATIVE name is resolved AUTHORITATIVELY by the CONNECTED ComfyUI's userdata
|
|
2054
|
+
* API — the same source list_workflows / panel_open_workflow read. That
|
|
2055
|
+
* server resolves the name under its RUNTIME `--user-directory`, so a custom user
|
|
2056
|
+
* directory just works and a same-named file under a reconstructed default-layout
|
|
2057
|
+
* path can never shadow it (#202).
|
|
2058
|
+
*
|
|
2059
|
+
* The governing rule is that loading the WRONG graph is worse than loading none,
|
|
2060
|
+
* so the resolver never guesses:
|
|
2061
|
+
* - server serves the file → load it
|
|
2062
|
+
* - server refuses (401/403/5xx) → error, no local fallback
|
|
2063
|
+
* - server says it has no such name → retry the server's OWN exact key if its
|
|
2064
|
+
* listing has a single UNICODE-NORMALIZATION
|
|
2065
|
+
* match (case and separator differences are
|
|
2066
|
+
* named, never substituted), otherwise
|
|
2067
|
+
* REFUSE — an absence from the authority
|
|
2068
|
+
* means any local hit is a DIFFERENT file
|
|
2069
|
+
* - name matches several library keys → REFUSE as ambiguous, naming them
|
|
2070
|
+
* - NO HTTP RESPONSE AT ALL → best-effort reconstructed local dirs,
|
|
2071
|
+
* and REFUSE if more than one file matches
|
|
2072
|
+
*
|
|
2073
|
+
* That last line is the only branch that guesses, and it is deliberately narrow: a
|
|
2074
|
+
* reply that ARRIVED but could not be decoded is a refusal, not an absence of
|
|
2075
|
+
* authority. See userdataFetch for why the request bypasses the client's
|
|
2076
|
+
* throw-on-non-2xx wrapper to make that distinction sound.
|
|
2077
|
+
*
|
|
2078
|
+
* Guards: must be .json and must parse to a UI workflow (a top-level `nodes`
|
|
2079
|
+
* array). Every failure names exactly what was tried.
|
|
2080
|
+
*/
|
|
1924
2081
|
async function readWorkflowFromPath(rawPath) {
|
|
1925
2082
|
const p = (rawPath ?? "").trim();
|
|
1926
2083
|
if (!p)
|
|
@@ -1955,7 +2112,7 @@ async function readWorkflowFromPath(rawPath) {
|
|
|
1955
2112
|
// path all normalize to the same store-relative name. (A genuinely absolute path
|
|
1956
2113
|
// — including a Windows leading-slash path — was already handled and returned
|
|
1957
2114
|
// above, so it never reaches this relative-name normalization.)
|
|
1958
|
-
const rel = p
|
|
2115
|
+
const rel = stripLibraryPrefix(p);
|
|
1959
2116
|
// Refuse traversal / drive-relative escapes (codex): a real workflow name is a
|
|
1960
2117
|
// plain relative path whose segments are filenames/subfolders. Stripping the
|
|
1961
2118
|
// "workflows/" prefix must never turn the input into something that ESCAPES the
|
|
@@ -1971,67 +2128,245 @@ async function readWorkflowFromPath(rawPath) {
|
|
|
1971
2128
|
throw new Error(`"${p}" is not a valid workflow name — pass a name relative to the ComfyUI ` +
|
|
1972
2129
|
`workflows folder (no "..", drive letters, or absolute paths), or an absolute path.`);
|
|
1973
2130
|
}
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
2131
|
+
// One userdata GET, classified. `key` is the STORE key (already "workflows/…").
|
|
2132
|
+
//
|
|
2133
|
+
// The request goes through userdataFetch, which returns the raw Response instead
|
|
2134
|
+
// of the client's throw-on-non-2xx wrapper. That is what makes this split sound
|
|
2135
|
+
// (gate MAJOR): the ONLY way to land in the catch is for no Response to exist at
|
|
2136
|
+
// all, so "the server refused with a body we could not decode" can never be
|
|
2137
|
+
// mistaken for "the server was never reached" and re-open the local fallback.
|
|
2138
|
+
const fetchUserdataKey = async (key) => {
|
|
2139
|
+
let res;
|
|
2140
|
+
try {
|
|
2141
|
+
res = await userdataFetch(`/api/userdata/${encodeURIComponent(key)}`);
|
|
2142
|
+
}
|
|
2143
|
+
catch (err) {
|
|
2144
|
+
// No Response object exists — the request never received an answer
|
|
2145
|
+
// (connection refused, DNS, TLS, timeout), or no client could be built at
|
|
2146
|
+
// all. This is the ONLY outcome that leaves the orchestrator without an
|
|
2147
|
+
// authority to defer to.
|
|
2148
|
+
return {
|
|
2149
|
+
kind: "unreachable",
|
|
2150
|
+
detail: `the connected ComfyUI's workflow library could not be reached (${err instanceof Error ? err.message : String(err)})`,
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
// From here the server ANSWERED. Every remaining outcome is authoritative, and
|
|
2154
|
+
// none of them may fall back to a reconstructed local path.
|
|
2155
|
+
if (!res.ok) {
|
|
2156
|
+
if (res.status === 404) {
|
|
2157
|
+
return { kind: "absent", detail: `is not in the connected ComfyUI's workflow library (HTTP 404 for "${key}")` };
|
|
1995
2158
|
}
|
|
2159
|
+
return { kind: "refused", detail: `ComfyUI userdata library returned HTTP ${res.status} for "${key}"` };
|
|
1996
2160
|
}
|
|
1997
|
-
|
|
1998
|
-
|
|
2161
|
+
// Read the body as TEXT and classify HERE so a malformed 2xx surfaces its OWN
|
|
2162
|
+
// error, while ComfyUI's "200 + EMPTY body = file does not exist" convention
|
|
2163
|
+
// (some builds; see parseWorkflowLock) is an ABSENCE — an authoritative "no
|
|
2164
|
+
// such name", not a malformed file. A body that cannot be read is a REFUSAL:
|
|
2165
|
+
// the server answered, we simply could not decode it.
|
|
2166
|
+
let body;
|
|
2167
|
+
try {
|
|
2168
|
+
body = (await res.text()).trim();
|
|
1999
2169
|
}
|
|
2000
|
-
|
|
2001
|
-
|
|
2170
|
+
catch (err) {
|
|
2171
|
+
return {
|
|
2172
|
+
kind: "refused",
|
|
2173
|
+
detail: `ComfyUI answered ${res.status} for "${key}" but the response body could not be read ` +
|
|
2174
|
+
`(${err instanceof Error ? err.message : String(err)})`,
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
if (body === "") {
|
|
2178
|
+
return { kind: "absent", detail: `is not in the connected ComfyUI's workflow library (empty 200 response for "${key}")` };
|
|
2179
|
+
}
|
|
2180
|
+
try {
|
|
2181
|
+
return { kind: "found", parsed: JSON.parse(body) };
|
|
2182
|
+
}
|
|
2183
|
+
catch (err) {
|
|
2184
|
+
return { kind: "malformed", detail: err instanceof Error ? err.message : String(err) };
|
|
2185
|
+
}
|
|
2186
|
+
};
|
|
2187
|
+
const requestedKey = `workflows/${rel}`;
|
|
2188
|
+
let outcome = await fetchUserdataKey(requestedKey);
|
|
2189
|
+
let resolvedKey = requestedKey;
|
|
2190
|
+
// Every store key this call asked for, in order, so a refusal can name them all.
|
|
2191
|
+
const attempted = [requestedKey];
|
|
2192
|
+
/** Re-ask the server under a different spelling of the SAME name, without letting
|
|
2193
|
+
* the follow-up weaken what the server already told us. Returns true when the
|
|
2194
|
+
* retry settled the outcome (i.e. produced something other than another
|
|
2195
|
+
* authoritative absence). */
|
|
2196
|
+
const reask = async (key, onUnreachable) => {
|
|
2197
|
+
attempted.push(key);
|
|
2198
|
+
const retry = await fetchUserdataKey(key);
|
|
2199
|
+
if (retry.kind === "unreachable") {
|
|
2200
|
+
// The server ALREADY answered. A transport blip on the follow-up does not
|
|
2201
|
+
// un-answer that (codex MAJOR): letting it become "unreachable" would re-open
|
|
2202
|
+
// the reconstructed-local-dir fallback after the authority had spoken.
|
|
2203
|
+
outcome = { kind: "refused", detail: onUnreachable(retry.detail) };
|
|
2204
|
+
return true;
|
|
2205
|
+
}
|
|
2206
|
+
outcome = retry;
|
|
2207
|
+
if (retry.kind !== "absent") {
|
|
2208
|
+
// Remember which key was actually read, so a malformed / non-UI file names the
|
|
2209
|
+
// file that was consulted rather than the caller's spelling.
|
|
2210
|
+
resolvedKey = key;
|
|
2211
|
+
return true;
|
|
2212
|
+
}
|
|
2213
|
+
return false;
|
|
2214
|
+
};
|
|
2215
|
+
// SEPARATOR SPELLING, asked SERVER-FIRST (gate MEDIUM). The store key space is
|
|
2216
|
+
// "/"-separated, so a backslash in the name is ONE literal character to the server
|
|
2217
|
+
// — and on POSIX that is a legal filename — so the literal spelling must be asked
|
|
2218
|
+
// for FIRST. Only once the server has authoritatively said it has no such file is
|
|
2219
|
+
// the Windows-style reading tried, and both attempts are named in any refusal.
|
|
2220
|
+
//
|
|
2221
|
+
// That keeps a Windows caller's "recipes\name.json" working: it used to resolve
|
|
2222
|
+
// through the local fallback, so refusing it outright was a regression on a path
|
|
2223
|
+
// that worked, for panel_strip_workflow as much as panel_load_workflow. And it
|
|
2224
|
+
// never substitutes a file while the literally-named one might still exist,
|
|
2225
|
+
// because the literal key is disproved by the authority before the retry happens.
|
|
2226
|
+
let matchRel = rel;
|
|
2227
|
+
if (outcome.kind === "absent" && rel.includes("\\")) {
|
|
2228
|
+
const slashRel = rel.replace(/\\/g, "/");
|
|
2229
|
+
const slashKey = `workflows/${slashRel}`;
|
|
2230
|
+
if (slashKey !== requestedKey) {
|
|
2231
|
+
const settled = await reask(slashKey, (d) => `"${rel}" is not in the library, and re-reading it as "${slashRel}" failed: ${d}`);
|
|
2232
|
+
// Absent under BOTH spellings: the literal reading is disproved, so the
|
|
2233
|
+
// forward-slash reading is the only one left for the listing lookup below.
|
|
2234
|
+
if (!settled)
|
|
2235
|
+
matchRel = slashRel;
|
|
2002
2236
|
}
|
|
2003
2237
|
}
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2238
|
+
// The server ANSWERED "no such name". Before giving up, ask it for its OWN listing
|
|
2239
|
+
// and look for the same name in a different Unicode normal form — a name pasted
|
|
2240
|
+
// from a listing (or produced on another OS) can be byte-different yet denote the
|
|
2241
|
+
// same file, which is how a workflow that list_workflows plainly shows still 404s
|
|
2242
|
+
// here. Only the SERVER's own entry is retried, so this resolves the name the
|
|
2243
|
+
// connected ComfyUI itself reports; it never reconstructs a path. More than one
|
|
2244
|
+
// match is AMBIGUOUS and is refused rather than guessed at.
|
|
2245
|
+
//
|
|
2246
|
+
// Listing entries are store-RELATIVE and carry NO prefix — verified on 0.29.2,
|
|
2247
|
+
// where root files come back bare and nested ones as "sub/name.json" — so the
|
|
2248
|
+
// store key is simply the entry under the library root, and nothing about the
|
|
2249
|
+
// entry is rewritten (gate MAJOR). Stripping a "workflows/" prefix from an entry
|
|
2250
|
+
// was how a subfolder literally named `workflows` came to impersonate the store
|
|
2251
|
+
// root: its file lists as "workflows/x.json", which stripped to "x.json" and
|
|
2252
|
+
// collided with the ROOT file of that name, so a bare request matched the nested
|
|
2253
|
+
// entry and then fetched the different root file.
|
|
2254
|
+
const storeKeyForListed = (listedKey) => `workflows/${listedKey}`;
|
|
2255
|
+
let listedButUnserved = null;
|
|
2256
|
+
let nearMisses = [];
|
|
2257
|
+
let listingUnreadable = false;
|
|
2258
|
+
if (outcome.kind === "absent") {
|
|
2259
|
+
const rawListed = await listUserdataWorkflowKeys();
|
|
2260
|
+
listingUnreadable = rawListed === null;
|
|
2261
|
+
// A listing entry is server-supplied data, so it gets the SAME escape guard as
|
|
2262
|
+
// caller input before it is echoed back as a request key. The split is
|
|
2263
|
+
// deliberately liberal about separators — that is a REJECTION rule, where being
|
|
2264
|
+
// over-inclusive can only refuse more, never resolve to another file.
|
|
2265
|
+
const listed = rawListed?.filter((k) => {
|
|
2266
|
+
const segs = k.split(/[\\/]+/);
|
|
2267
|
+
return !segs.includes("..") && !segs.some((s) => /^[A-Za-z]:/.test(s));
|
|
2268
|
+
});
|
|
2269
|
+
if (listed) {
|
|
2270
|
+
// Compared VERBATIM apart from Unicode normalization, which is what keeps
|
|
2271
|
+
// request depth matched to entry depth: a name with no separator can only
|
|
2272
|
+
// equal a depth-0 entry, and a name that includes a subfolder can only equal
|
|
2273
|
+
// that exact path. Letter case and separator flavour are deliberately not
|
|
2274
|
+
// folded — on a case-sensitive host "Foo.json" and "foo.json" are two
|
|
2275
|
+
// different files — so those are NAMED as near misses below, never served.
|
|
2276
|
+
const want = matchName(matchRel);
|
|
2277
|
+
const matches = listed.filter((k) => matchName(k) === want);
|
|
2278
|
+
if (matches.length > 1) {
|
|
2279
|
+
throw new Error(`"${p}" is ambiguous in the connected ComfyUI's workflow library — it matches ` +
|
|
2280
|
+
`${matches.length} saved workflows (${matches.map((k) => `"${k}"`).join(", ")}). ` +
|
|
2281
|
+
`Refusing to guess which one you meant: pass the exact name from list_workflows, ` +
|
|
2282
|
+
`or an absolute path.`);
|
|
2283
|
+
}
|
|
2284
|
+
if (matches.length === 1) {
|
|
2285
|
+
const retryKey = storeKeyForListed(matches[0]);
|
|
2286
|
+
// Only re-ask when the server's spelling actually differs from every key
|
|
2287
|
+
// already sent — otherwise this repeats a request that just failed.
|
|
2288
|
+
if (!attempted.includes(retryKey)) {
|
|
2289
|
+
await reask(retryKey, (d) => `the library lists "${matches[0]}", but re-reading it failed: ${d}`);
|
|
2290
|
+
}
|
|
2291
|
+
// Still absent after retrying the server's OWN entry: the library lists the
|
|
2292
|
+
// name but will not serve it. Say so — that is a server-side condition the
|
|
2293
|
+
// user must see, not a cue to go hunting for a local file.
|
|
2294
|
+
if (outcome.kind === "absent")
|
|
2295
|
+
listedButUnserved = matches[0];
|
|
2296
|
+
}
|
|
2297
|
+
else {
|
|
2298
|
+
// Reported only. A key that differs by separator flavour, letter case or
|
|
2299
|
+
// normalization is a DIFFERENT file on some filesystems, so it is never
|
|
2300
|
+
// substituted.
|
|
2301
|
+
nearMisses = listed.filter((k) => looseName(k) === looseName(matchRel));
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2009
2304
|
}
|
|
2010
2305
|
if (outcome.kind === "found") {
|
|
2011
2306
|
// A found-but-non-UI file must surface its own honest error, not silence.
|
|
2012
|
-
return assertUiWorkflow(outcome.parsed, `The workflow "${p}" from the ComfyUI userdata library`);
|
|
2307
|
+
return assertUiWorkflow(outcome.parsed, `The workflow "${p}" from the ComfyUI userdata library (read as "${resolvedKey}")`);
|
|
2013
2308
|
}
|
|
2014
2309
|
if (outcome.kind === "malformed") {
|
|
2015
|
-
throw new Error(`The workflow "${p}" in the ComfyUI userdata library
|
|
2310
|
+
throw new Error(`The workflow "${p}" in the ComfyUI userdata library (read as "${resolvedKey}") is not ` +
|
|
2311
|
+
`valid JSON: ${outcome.detail}`);
|
|
2016
2312
|
}
|
|
2017
2313
|
if (outcome.kind === "refused") {
|
|
2018
2314
|
// Server is reachable but did not serve the file — do NOT fall back to a
|
|
2019
|
-
// possibly-
|
|
2315
|
+
// possibly-different local file; report the status honestly.
|
|
2020
2316
|
throw new Error(`Could not read "${p}" from the connected ComfyUI: ${outcome.detail}. ` +
|
|
2021
|
-
`Pass an absolute path, or a name shown by
|
|
2317
|
+
`Pass an absolute path, or a name shown by list_workflows.`);
|
|
2022
2318
|
}
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2319
|
+
if (outcome.kind === "absent") {
|
|
2320
|
+
// AUTHORITATIVE absence. The connected ComfyUI resolved the name under its own
|
|
2321
|
+
// runtime `--user-directory` and said it has no such workflow — so any file the
|
|
2322
|
+
// orchestrator could still find by RECONSTRUCTING a default-layout path is, by
|
|
2323
|
+
// construction, a DIFFERENT file from the one the caller named. Loading it
|
|
2324
|
+
// would hand the agent the wrong graph to edit, which is worse than failing
|
|
2325
|
+
// (#202). Refuse, naming exactly what was tried.
|
|
2326
|
+
throw new Error(`No workflow named "${p}" — it ${outcome.detail}.` +
|
|
2327
|
+
(attempted.length > 1
|
|
2328
|
+
? ` Asked for ${attempted.map((k) => `"${k}"`).join(" and then ")}.`
|
|
2329
|
+
: "") +
|
|
2330
|
+
(listingUnreadable
|
|
2331
|
+
? ` Its workflow listing could not be read either, so no close match could be checked.`
|
|
2332
|
+
: "") +
|
|
2333
|
+
(listedButUnserved
|
|
2334
|
+
? ` Its library DOES list "${listedButUnserved}", but the server would not serve that key —` +
|
|
2335
|
+
` the file may have been removed or be unreadable on the ComfyUI machine.`
|
|
2336
|
+
: "") +
|
|
2337
|
+
(nearMisses.length
|
|
2338
|
+
? ` The library does list ${nearMisses.map((k) => `"${k}"`).join(", ")}, which differs only` +
|
|
2339
|
+
` in letter case, path separator, or Unicode normalization — a DIFFERENT file on some` +
|
|
2340
|
+
` filesystems, so it was NOT substituted. Retype the name exactly if that is the one` +
|
|
2341
|
+
` you meant.`
|
|
2342
|
+
: "") +
|
|
2343
|
+
` The connected ComfyUI is the authority on its own user directory (it may have been started` +
|
|
2344
|
+
` with --user-directory), so the orchestrator will NOT guess at a local path that could be a` +
|
|
2345
|
+
` different file. Use a name exactly as shown by list_workflows (which reads the same library),` +
|
|
2346
|
+
` or pass an absolute path.`);
|
|
2347
|
+
}
|
|
2348
|
+
// outcome.kind is "unreachable": the connected ComfyUI gave no answer at all, so
|
|
2349
|
+
// there is no authority to defer to. Only here does the orchestrator fall back to
|
|
2350
|
+
// the RECONSTRUCTED default-layout workflows dirs — best-effort, and only when
|
|
2351
|
+
// the name resolves to exactly ONE file (two hits mean two different candidate
|
|
2352
|
+
// graphs, which is refused rather than guessed at).
|
|
2027
2353
|
// Each candidate is the store-relative name resolved UNDER a base dir. Only the
|
|
2028
2354
|
// NORMALIZED `rel` is used (never the raw `workflows/…` key), so nothing nests a
|
|
2029
2355
|
// second workflows/ (codex P1/P2). Belt-and-suspenders containment: an existing
|
|
2030
2356
|
// candidate is only accepted if its REAL path (symlinks/junctions resolved)
|
|
2031
2357
|
// stays beneath the base's REAL path — so a link under the workflows dir that
|
|
2032
|
-
// targets an external directory can't be read on the
|
|
2033
|
-
//
|
|
2034
|
-
//
|
|
2358
|
+
// targets an external directory can't be read on the fallback (codex).
|
|
2359
|
+
// Canonicalizing BOTH sides keeps a legitimately-symlinked workflows dir working
|
|
2360
|
+
// (its base resolves too), while blocking a per-file escape.
|
|
2361
|
+
//
|
|
2362
|
+
// KNOWN AND ACCEPTED: this check is check-then-open, so it does not survive a
|
|
2363
|
+
// concurrent swap of the checked file for a symlink between the realpath and the
|
|
2364
|
+
// read (codex MAJOR). Closing that needs an fd-based open + fstat, and the threat
|
|
2365
|
+
// it defends against is another process on the user's OWN machine racing their
|
|
2366
|
+
// own workflow load — outside this project's trust model, where the orchestrator,
|
|
2367
|
+
// the panel and the user are one trust domain. The check remains as a guard
|
|
2368
|
+
// against a statically mis-linked workflows dir, which is the accidental case
|
|
2369
|
+
// that actually happens.
|
|
2035
2370
|
const realBaseUnder = (base, candidate) => {
|
|
2036
2371
|
try {
|
|
2037
2372
|
const rb = realpathSync(base);
|
|
@@ -2042,17 +2377,95 @@ async function readWorkflowFromPath(rawPath) {
|
|
|
2042
2377
|
return false;
|
|
2043
2378
|
}
|
|
2044
2379
|
};
|
|
2045
|
-
|
|
2046
|
-
|
|
2380
|
+
/**
|
|
2381
|
+
* Is `name` the name the file on disk ACTUALLY has, segment for segment? When it
|
|
2382
|
+
* is not, report the on-disk spelling that came closest so the refusal can name
|
|
2383
|
+
* it.
|
|
2384
|
+
*
|
|
2385
|
+
* `resolve(dir, name)` answers in the FILESYSTEM's terms, not the store's (codex
|
|
2386
|
+
* MAJOR). It collapses repeated separators, so "recipes//foo.json" — a distinct
|
|
2387
|
+
* key everywhere else in this resolver — silently opens recipes/foo.json; and on
|
|
2388
|
+
* a case-insensitive volume `foo.json` opens an on-disk `Foo.json`, the very
|
|
2389
|
+
* substitution the server path refuses. That left the two branches applying
|
|
2390
|
+
* different rules to the same input depending only on whether ComfyUI answered.
|
|
2391
|
+
*
|
|
2392
|
+
* Walking the directory entries restores one rule: every segment must appear
|
|
2393
|
+
* VERBATIM in its parent listing. An empty segment (from a repeated separator)
|
|
2394
|
+
* can never match, and a case- or normalization-different on-disk name is refused
|
|
2395
|
+
* exactly as it would be against the server.
|
|
2396
|
+
*
|
|
2397
|
+
* The segmentation follows the PLATFORM, matching whatever `resolve()` just did
|
|
2398
|
+
* (codex MAJOR). On Windows a backslash is a separator — and a file literally
|
|
2399
|
+
* named `recipes\foo.json` cannot exist — so the folder reading is the only
|
|
2400
|
+
* reading. On POSIX a backslash is an ordinary filename character, `resolve()`
|
|
2401
|
+
* produces the LITERAL path, and this must too: splitting it there would bless a
|
|
2402
|
+
* folder reading that the server-first rule never authorised, letting an
|
|
2403
|
+
* unreachable server's `recipes/foo.json` stand in for a literal
|
|
2404
|
+
* `recipes\foo.json` that may well exist.
|
|
2405
|
+
*/
|
|
2406
|
+
const platformSegments = (name) => name.split(sep === "\\" ? /[\\/]/ : /\//);
|
|
2407
|
+
const localSpelling = (base, name) => {
|
|
2408
|
+
let dir = base;
|
|
2409
|
+
const segs = platformSegments(name);
|
|
2410
|
+
for (let i = 0; i < segs.length; i++) {
|
|
2411
|
+
let entries;
|
|
2412
|
+
try {
|
|
2413
|
+
entries = readdirSync(dir);
|
|
2414
|
+
}
|
|
2415
|
+
catch {
|
|
2416
|
+
return { exact: false };
|
|
2417
|
+
}
|
|
2418
|
+
if (entries.includes(segs[i])) {
|
|
2419
|
+
dir = join(dir, segs[i]);
|
|
2420
|
+
continue;
|
|
2421
|
+
}
|
|
2422
|
+
// Not spelled the way it was asked for. Name the entry it would have been, so
|
|
2423
|
+
// the caller can retype it — reported only, never loaded.
|
|
2424
|
+
const near = entries.find((e) => looseName(e) === looseName(segs[i]));
|
|
2425
|
+
return { exact: false, onDisk: near ? join(dir, near, ...segs.slice(i + 1)) : undefined };
|
|
2426
|
+
}
|
|
2427
|
+
return { exact: true };
|
|
2428
|
+
};
|
|
2429
|
+
// Spellings that exist on disk but are NOT what was asked for — reported so the
|
|
2430
|
+
// refusal is actionable, never loaded.
|
|
2431
|
+
const misspelled = [];
|
|
2432
|
+
const hits = comfyWorkflowsDirs()
|
|
2047
2433
|
.map((dir) => ({ dir, path: resolve(dir, rel) }))
|
|
2048
2434
|
.filter(({ path }) => existsSync(path) && statSync(path).isFile())
|
|
2049
2435
|
.filter(({ dir, path }) => realBaseUnder(dir, path))
|
|
2050
|
-
.
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2436
|
+
.filter(({ dir }) => {
|
|
2437
|
+
const spelling = localSpelling(dir, rel);
|
|
2438
|
+
if (spelling.exact)
|
|
2439
|
+
return true;
|
|
2440
|
+
if (spelling.onDisk)
|
|
2441
|
+
misspelled.push(spelling.onDisk);
|
|
2442
|
+
return false;
|
|
2443
|
+
})
|
|
2444
|
+
.map(({ path }) => path);
|
|
2445
|
+
// De-duplicate by REAL path: the two guessed layouts can be the same directory
|
|
2446
|
+
// (a symlink/junction), which is one candidate, not an ambiguity.
|
|
2447
|
+
const distinct = [...new Set(hits.map((h) => { try {
|
|
2448
|
+
return realpathSync(h);
|
|
2449
|
+
}
|
|
2450
|
+
catch {
|
|
2451
|
+
return h;
|
|
2452
|
+
} }))];
|
|
2453
|
+
if (distinct.length > 1) {
|
|
2454
|
+
throw new Error(`"${p}" is ambiguous: the connected ComfyUI could not be reached, and the name matches ` +
|
|
2455
|
+
`${distinct.length} different local files (${distinct.map((f) => `"${f}"`).join(", ")}). ` +
|
|
2456
|
+
`Refusing to guess which one you meant — pass an absolute path.`);
|
|
2457
|
+
}
|
|
2458
|
+
if (distinct.length === 1)
|
|
2459
|
+
return readLocal(distinct[0]);
|
|
2460
|
+
throw new Error(`No workflow file at "${p}". It ${outcome.detail}, and it is not under the orchestrator's ` +
|
|
2461
|
+
`reconstructed workflows dir (${comfyWorkflowsDirs().join(" or ") || "COMFYUI_PATH not set"}).` +
|
|
2462
|
+
(misspelled.length
|
|
2463
|
+
? ` A file exists on disk as ${misspelled.map((f) => `"${f}"`).join(", ")}, which is` +
|
|
2464
|
+
` not how you spelled it (letter case, repeated separator, or Unicode` +
|
|
2465
|
+
` normalization), and with ComfyUI unreachable there is no authority to confirm they are` +
|
|
2466
|
+
` the same file — so it was NOT loaded. Retype the name exactly, or pass an absolute path.`
|
|
2467
|
+
: "") +
|
|
2468
|
+
` Pass an absolute path, or a name shown by list_workflows.`);
|
|
2056
2469
|
}
|
|
2057
2470
|
// IMPORTANT (Codex parity): use `z.array(z.number())` — NOT `z.tuple([...])` — for
|
|
2058
2471
|
// fixed-length coordinate vectors. zod's `.tuple()` emits JSON-Schema draft-04
|
|
@@ -2307,6 +2720,17 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
|
|
|
2307
2720
|
// preserving the raw cause. Keying on the TYPED flag (not error text) means a
|
|
2308
2721
|
// POST-dispatch executor ok:false reply that merely quotes "no connected tab" is
|
|
2309
2722
|
// never mis-wrapped as "nothing applied".
|
|
2723
|
+
// #709: a CAPABILITY refusal (the tab's panel does not enforce the workflow-stamp
|
|
2724
|
+
// contract) shares the dispatched:false flag with transient routing failures, but
|
|
2725
|
+
// the generic recovery below — "retry in a moment / rebind with
|
|
2726
|
+
// panel_set_workflow_target({mode:"current"})" — can NEVER clear it (the refusal's
|
|
2727
|
+
// own text says so: rebinding cannot add the missing capability). Appending it
|
|
2728
|
+
// sent agents into a futile retry/rebind loop that contradicted the embedded
|
|
2729
|
+
// guidance. Key on the TYPED marker and surface the cause verbatim instead; it
|
|
2730
|
+
// already names the real recovery (update + restart + browser hard-refresh).
|
|
2731
|
+
if (isCapabilityRefusal(err)) {
|
|
2732
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
2733
|
+
}
|
|
2310
2734
|
if (dispatchOutcomeOf(err) === false) {
|
|
2311
2735
|
const name = typeof cmd.cmd === "string" ? cmd.cmd : "panel command";
|
|
2312
2736
|
// Neutral wording: a dispatched:false flag proves only that the command was NOT
|
|
@@ -2682,6 +3106,19 @@ let askTimingOverride = null;
|
|
|
2682
3106
|
// total ask budget — applied even when env overrides ask for more, so a
|
|
2683
3107
|
// misconfigured COMFYUI_PANEL_ASK_DEADLINE_S/GRACE_S can never recreate #486.
|
|
2684
3108
|
const ASK_TOTAL_BUDGET_CAP_MS = 285_000;
|
|
3109
|
+
/**
|
|
3110
|
+
* The per-request timeout an INTERNAL MCP client must use when calling panel_*
|
|
3111
|
+
* tools (#325). Several panel tools are DESIGNED to block on a human well past
|
|
3112
|
+
* the MCP SDK's 60s default request timeout: panel_ask / the confirm / consent
|
|
3113
|
+
* cards wait up to ASK_TOTAL_BUDGET_CAP_MS (285s), and panel_request_secret
|
|
3114
|
+
* waits up to 300s on its masked input. A client that calls them with the SDK
|
|
3115
|
+
* default (the ollama-family backends' loopback panel client did) kills the
|
|
3116
|
+
* request at 60s with `MCP error -32001: Request timed out` — the user picks an
|
|
3117
|
+
* option minutes later, the server delivers it, and the model never sees it.
|
|
3118
|
+
* Sized above the LONGEST blocking card (the 300s secret card) with margin for
|
|
3119
|
+
* loopback transport + processing. Fast tools are unaffected: this is only an
|
|
3120
|
+
* upper bound, never a wait. */
|
|
3121
|
+
export const PANEL_TOOL_MCP_TIMEOUT_MS = 315_000;
|
|
2685
3122
|
function getAskTiming() {
|
|
2686
3123
|
if (askTimingOverride)
|
|
2687
3124
|
return askTimingOverride;
|
|
@@ -3336,7 +3773,7 @@ export function buildPanelToolDefs() {
|
|
|
3336
3773
|
path: z
|
|
3337
3774
|
.string()
|
|
3338
3775
|
.optional()
|
|
3339
|
-
.describe("Path to a workflow .json on the ComfyUI machine's disk
|
|
3776
|
+
.describe("Path to a workflow .json — an ABSOLUTE path on the ComfyUI machine's disk, or a name from list_workflows, which is looked up in the connected ComfyUI's own saved-workflow library (so a custom --user-directory resolves correctly). Read + parsed server-side and loaded onto the canvas (keeps a large JSON out of chat). A name the library does not have is REFUSED rather than guessed at from a local path."),
|
|
3340
3777
|
graph: z
|
|
3341
3778
|
.union([z.string(), z.record(z.string(), z.unknown())])
|
|
3342
3779
|
.optional()
|
|
@@ -3558,9 +3995,25 @@ export function buildPanelToolDefs() {
|
|
|
3558
3995
|
return typeof pid === "string" && pid ? pid : null;
|
|
3559
3996
|
})();
|
|
3560
3997
|
QueueMonitor.markSelfQueued(queuedId);
|
|
3998
|
+
// #468 — open a run ticket so the render's completion can be CORRELATED
|
|
3999
|
+
// to this exact call by prompt id. This is what lets the orchestrator
|
|
4000
|
+
// journal an undelivered completion and replay it into the right run
|
|
4001
|
+
// instead of losing it while the agent works through a goal.
|
|
4002
|
+
const correlatable = RunCompletions.openRun(queuedId, {
|
|
4003
|
+
tabId: ctx.tabId,
|
|
4004
|
+
...(typeof args.to_node_id === "number" ? { toNodeId: args.to_node_id } : {}),
|
|
4005
|
+
});
|
|
3561
4006
|
// Append anti-poll guidance: the agent should go idle after queuing so the
|
|
3562
4007
|
// executed event auto-injects the output image, rather than busy-polling.
|
|
3563
|
-
|
|
4008
|
+
//
|
|
4009
|
+
// HONEST WHEN WE CAN'T PROMISE IT (#468): the anti-poll instruction is only
|
|
4010
|
+
// safe because the completion is correlated by prompt id. Without one — the
|
|
4011
|
+
// panel forwarded no `prompt_id` — a later completion can only be reported as
|
|
4012
|
+
// UNDETERMINED, so telling the agent to idle and wait would park it on a
|
|
4013
|
+
// promise we can't keep. Say so and point at the verification path instead.
|
|
4014
|
+
const note = correlatable
|
|
4015
|
+
? "\n\n[IMPORTANT] You will be notified automatically with the output image(s)/video when the render finishes — do NOT poll queue (action:\"list\"), get_history, or list_output_images. Just end your turn now and wait for the result to be delivered to you."
|
|
4016
|
+
: "\n\n[IMPORTANT] The run was queued, but the panel reported NO prompt id for it, so a completion event CANNOT be correlated back to this run — its outcome will be reported to you as UNDETERMINED. Do NOT simply idle and wait indefinitely: end your turn, and if nothing arrives, confirm the outcome with get_history before acting on it.";
|
|
3564
4017
|
// Backpressure note. A backlog is only alarming when it's a job we did NOT
|
|
3565
4018
|
// queue (possibly foreign/stuck). Deliberately batching renders — a sweep,
|
|
3566
4019
|
// a multi-variant comparison — is a NORMAL workflow, so a queue made of our
|