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),
909 lines • 40.9 kB
JavaScript
/**
* #1655 — a widget the panel LISTS as promoted must be settable.
*
* `graph_set_widget` on a subgraph wrapper can refuse with:
*
* Cannot set widget on subgraph node 78: "width" is not a promoted widget
* on this subgraph (promoted: width, height, seed, …)
*
* The listing is `node.widgets[].name`. The write looks up host INPUT names /
* `_subgraphSlot` aliases. Those two sets disagree for proxyWidgets promotions
* (krea2-txt2img-manual node 78: width/height exist as widgets, not as inputs),
* so the refusal names the requested widget in its own "promoted:" list.
*
* Detection matches that contradiction only. A genuine miss ("foo" against
* `promoted: width, height`) is left alone. Resolution maps the displayed name
* to a UNIQUE inner node+widget from `graph_get_subgraph` — never by guessing
* among several inners that share the name, and never from a truncated read.
*/
import { isNodeIdString, normalizeNodeId } from "./node-id.js";
const CONTRADICTORY_RE = /Cannot set widget on subgraph node (\S+): "([^"]+)" is not a promoted widget on this subgraph \(promoted: ([^)]+)\)/i;
const AMBIGUOUS_RE = /(?:panel_set_widget refused "([^"]+)" on node (\S+)[\s\S]*?)?promoted widget "([^"]+)" is ambiguous\s*[—-]\s*(\d+)\s+promoted inputs? match/i;
const LINK_DRIVEN_RE = /will NOT change the render[\s\S]*widget "([^"]+)" on inner node is link-driven[\s\S]*ENCLOSING subgraph node/i;
const SCOPED_NODE_RE = /No node with id (\S+) in the current graph[\s\S]*?lives INSIDE a subgraph/i;
const ENTER_PATH_RE = /panel_enter_subgraph\((?:node_id=)?\s*([^)]+)\)/gi;
/** Exact name first; a unique case-insensitive hit is accepted so a listed
* `width` still matches a caller who sent `Width`. Several CI hits refuse. */
export function matchListedName(wanted, listed) {
if (listed.includes(wanted))
return wanted;
const ci = listed.filter((n) => n.toLowerCase() === wanted.toLowerCase());
return ci.length === 1 ? ci[0] : null;
}
/**
* Parse the panel's link-driven inner-write warning. Null unless the write was
* verified AND the panel named the enclosing subgraph node as the durable target.
* A success that does not carry both halves is left alone — that is often a
* genuine inner widget, not a promoted input.
*/
export function parseLinkDrivenPromotedWriteWarning(text) {
const m = LINK_DRIVEN_RE.exec(text);
if (!m)
return null;
const widget = m[1];
return widget ? { widget } : null;
}
/**
* Parse the panel's promoted-name/label ambiguity refusal. This is deliberately
* diagnostic-only: the refusal proves that a blind second write could target the
* wrong promoted rail, so callers must not turn the count into a guessed target.
*/
export function parseAmbiguousPromotedWidgetRefusal(text, requestedWidget, requestedNodeId) {
const m = AMBIGUOUS_RE.exec(text);
if (!m)
return null;
const widget = m[3] ?? m[1];
if (!widget)
return null;
if (requestedWidget != null && requestedWidget.toLowerCase() !== widget.toLowerCase()) {
return null;
}
const nodeId = m[2] ?? (requestedNodeId == null ? undefined : String(requestedNodeId));
if (!nodeId)
return null;
const matches = Number(m[4]);
if (!Number.isSafeInteger(matches) || matches < 2)
return null;
return { nodeId: nodeId.replace(/[,:]$/, ""), widget, matches };
}
/**
* Parse the panel's pre-executor scope diagnosis. The enter path is taken only
* from the panel's own `panel_enter_subgraph(...)` remedy, never inferred from a
* node id or from a generic missing-node error.
*/
export function parseSubgraphScopeRefusal(text, requestedNodeId) {
const missing = SCOPED_NODE_RE.exec(text);
if (!missing)
return null;
const nodeId = missing[1].replace(/[,:]$/, "");
if (requestedNodeId != null &&
String(requestedNodeId).replace(/[,:]$/, "") !== nodeId) {
return null;
}
const enterPath = [];
ENTER_PATH_RE.lastIndex = 0;
for (let m = ENTER_PATH_RE.exec(text); m; m = ENTER_PATH_RE.exec(text)) {
const id = m[1].trim().replace(/[,:]$/, "");
if (id)
enterPath.push(id);
}
return enterPath.length > 0 ? { nodeId, enterPath } : null;
}
function parseListed(raw) {
const trimmed = raw.trim();
if (!trimmed || /^none$/i.test(trimmed))
return [];
return trimmed
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
/**
* Parse the contradictory refusal. Returns null unless the requested widget
* actually appears in the diagnostic's own promoted list — that is the bug.
*/
export function parseContradictoryPromotedWidgetRefusal(text, requestedWidget) {
const m = CONTRADICTORY_RE.exec(text);
if (!m)
return null;
const listed = parseListed(m[3]);
const fromError = matchListedName(m[2], listed);
if (!fromError)
return null;
if (requestedWidget != null && matchListedName(requestedWidget, listed) == null) {
return null;
}
const widget = requestedWidget != null ? (matchListedName(requestedWidget, listed) ?? fromError) : fromError;
return { nodeId: m[1].replace(/[,:]$/, ""), widget, listed };
}
function terminalEntryMatchesDisplayedWidget(entry, displayedWidget) {
const wanted = displayedWidget.toLowerCase();
if (entry.widget.toLowerCase() === wanted)
return true;
if (entry.immediateWidget && entry.immediateWidget.toLowerCase() === wanted)
return true;
return entry.terminal?.widget.toLowerCase() === wanted;
}
function selectPromotedTerminalEntries(entries, displayedWidget) {
const wanted = displayedWidget.toLowerCase();
const byHostAlias = entries.filter((entry) => entry.widget.toLowerCase() === wanted);
// Prefer the host/displayed alias. Inner/terminal names are only consulted
// when no host alias matches, so a duplicated unrelated alias that happens
// to carry the same immediate widget cannot veto this write (#2393 vs #2488).
if (byHostAlias.length > 0)
return byHostAlias;
return entries.filter((entry) => terminalEntryMatchesDisplayedWidget(entry, displayedWidget));
}
function widgetNamesOnInner(node) {
const widgets = node.widgets;
if (!widgets || typeof widgets !== "object" || Array.isArray(widgets))
return [];
return Object.keys(widgets).filter((n) => n.length > 0);
}
/** True when `widget` is an inner input that is wired from another node — the
* subgraph input-rail shape official templates use for promoted COMBOs. */
function innerWidgetIsRailBacked(node, widget) {
const inputs = node.inputs;
if (!Array.isArray(inputs))
return false;
const wanted = widget.toLowerCase();
for (const raw of inputs) {
if (!isRecord(raw) || typeof raw.name !== "string" || raw.name.toLowerCase() !== wanted)
continue;
const from = raw.connected_from;
if (!isRecord(from))
continue;
if (from.node_id === undefined || from.node_id === null)
continue;
if (typeof from.node_id !== "number" && typeof from.node_id !== "string")
continue;
return true;
}
return false;
}
function terminalInputsFromInnerNode(node) {
if (!Object.prototype.hasOwnProperty.call(node, "inputs"))
return [];
const inputs = node.inputs;
if (!Array.isArray(inputs))
return null;
const out = [];
for (const raw of inputs) {
if (!isRecord(raw) || typeof raw.name !== "string" || raw.name.length === 0)
return null;
if (raw.type !== undefined && typeof raw.type !== "string")
return null;
out.push({ name: raw.name, ...(raw.type !== undefined ? { type: raw.type } : {}) });
}
return out;
}
/**
* #2393 recurrence — after an official template load, a promoted COMBO such as
* `unet_name` can publish an incomplete own-entry (the host rail is a name-only
* stub, so `_subgraphSlot` / parent-rail authentication fail) while the inner
* listing still uniquely names the rail-backed loader. That inner widget is the
* subgraph-definition store the queue reads when no per-instance widgetId exists.
* Do not reuse an error entry's `immediate_node_id`: the live slot can point at
* a different inner node after instance/definition input-count drift.
*/
function resolveRailBackedInnerFromEnvelope(envelope, displayedWidget) {
const hits = [];
for (const node of envelope.nodes) {
const id = innerNodeId(node);
if (id == null)
continue;
const matched = matchListedName(displayedWidget, widgetNamesOnInner(node));
if (!matched || !innerWidgetIsRailBacked(node, matched))
continue;
hits.push({ node, id, widget: matched });
}
if (hits.length !== 1)
return null;
const hit = hits[0];
const nodeType = hit.node.type;
if (typeof nodeType !== "string" || nodeType.length === 0)
return null;
const inputs = terminalInputsFromInnerNode(hit.node);
if (!inputs)
return null;
return {
innerNodeId: hit.id,
widget: hit.widget,
...(typeof hit.node.node_identity === "string" ? { nodeIdentity: hit.node.node_identity } : {}),
terminal: {
nodeId: hit.id,
nodeType,
widget: hit.widget,
inputs,
chainDepth: 0,
},
};
}
function inputName(value) {
if (!isRecord(value) || typeof value.name !== "string" || value.name.length === 0)
return null;
return value.name;
}
const PRIMITIVE_NODE_TYPE_RE = /^Primitive(?:Int|Float|Boolean|String|StringMultiline)$/;
function primitiveNodeType(innerNode, terminal) {
if (isRecord(innerNode) && typeof innerNode.type === "string" && innerNode.type.length > 0) {
return innerNode.type;
}
return terminal?.nodeType ?? null;
}
function innerHasNamedInput(innerNode, widget, terminal) {
const wanted = widget.toLowerCase();
if (isRecord(innerNode) && Array.isArray(innerNode.inputs)) {
for (const raw of innerNode.inputs) {
const name = inputName(raw);
if (name && name.toLowerCase() === wanted)
return true;
}
}
return terminal?.inputs.some((input) => input.name.toLowerCase() === wanted) === true;
}
/**
* #2500 — a Primitive* `value` widget that exists as a live input is driven by
* the subgraph's promoted rail. Writing that inner widget reports success and
* leaves the enclosing container (the value that serializes and renders)
* unchanged. The enclosing subgraph widget is the write target. Converted
* widgets on ordinary nodes (dynamic-combo children, etc.) are not this shape
* unless an authoritative parent rail proves the inner input is that rail.
* #2533 — same-name COMBO/STRING rails (unet_name, clip_name, labelled prompt)
* with a parent-rail witness are the same store: the inner widget is live and
* the container is what serializes. Incomplete own-entries (#2393) omit the
* rail and keep the inner COMBO write.
*/
export function promotedInnerWidgetIsLinkDriven(innerNode, innerWidget, terminal, parentRail) {
if (innerWidget.toLowerCase() === "value") {
const nodeType = primitiveNodeType(innerNode, terminal);
if (nodeType && PRIMITIVE_NODE_TYPE_RE.test(nodeType) && innerHasNamedInput(innerNode, "value", terminal)) {
return true;
}
}
if (!parentRail)
return false;
return innerHasNamedInput(innerNode, innerWidget, terminal);
}
function innerNodeId(node) {
const id = node.id;
return isNodeId(id) ? id : null;
}
function isRecord(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function isNodeId(value) {
if (typeof value === "number")
return Number.isSafeInteger(value);
if (typeof value !== "string" || !isNodeIdString(value))
return false;
const normalized = normalizeNodeId(value);
return typeof normalized === "string" || Number.isSafeInteger(normalized);
}
function canonicalPromotedNodeId(value) {
if (!isNodeId(value))
return null;
const normalized = normalizeNodeId(value);
return typeof normalized === "number"
? Number.isSafeInteger(normalized)
? String(normalized)
: null
: normalized;
}
function hostInputWidgetName(record) {
const widget = record.widget;
if (typeof widget === "string" && widget.length > 0)
return widget;
if (isRecord(widget) && typeof widget.name === "string" && widget.name.length > 0) {
return widget.name;
}
return null;
}
/**
* #2791 — official Qwen Image host node 76 lists input label `prompt` mapped to
* widget `text`. A unique host input that actually carries a widget binding is
* the promoted rail to write when the terminal witness is incomplete. Name,
* label, and widget-name aliases are accepted; two distinct host widgets that
* all match the request are not.
*/
export function resolveHostPromotedWidgetMapping(hostNode, requestedWidget) {
if (!isRecord(hostNode) || requestedWidget.length === 0)
return null;
const inputs = hostNode.inputs;
if (!Array.isArray(inputs))
return null;
const wanted = requestedWidget.toLowerCase();
const hits = [];
for (const raw of inputs) {
if (!isRecord(raw))
continue;
const widgetName = hostInputWidgetName(raw);
if (!widgetName)
continue;
const name = typeof raw.name === "string" && raw.name.length > 0 ? raw.name : null;
const label = typeof raw.label === "string" && raw.label.length > 0 ? raw.label : undefined;
const aliases = [name, label, widgetName];
if (!aliases.some((alias) => alias !== null && alias !== undefined && alias.toLowerCase() === wanted)) {
continue;
}
const type = typeof raw.type === "string" && raw.type.length > 0 ? raw.type : undefined;
hits.push({
hostWidget: widgetName,
...(label ? { label } : {}),
...(type ? { type } : {}),
});
}
if (hits.length === 0)
return null;
const hostWidgets = new Set(hits.map((hit) => hit.hostWidget.toLowerCase()));
if (hostWidgets.size !== 1)
return null;
const hit = hits[0];
const properties = isRecord(hostNode.properties) ? hostNode.properties : null;
const proxyWidgets = properties?.proxyWidgets;
if (Array.isArray(proxyWidgets)) {
const innerIds = [];
for (const relation of proxyWidgets) {
if (!Array.isArray(relation) || relation.length < 2)
continue;
const innerId = relation[0];
const innerWidget = relation[1];
if (!isNodeId(innerId) || typeof innerWidget !== "string" || innerWidget.length === 0)
continue;
if (innerWidget.toLowerCase() !== hit.hostWidget.toLowerCase())
continue;
innerIds.push(innerId);
}
if (innerIds.length === 1)
hit.innerNodeId = innerIds[0];
}
return hit;
}
function innerTargetFromEnvelopeNode(node, widget) {
const id = innerNodeId(node);
if (id == null)
return null;
const nodeType = node.type;
if (typeof nodeType !== "string" || nodeType.length === 0)
return null;
const matched = matchListedName(widget, widgetNamesOnInner(node)) ??
(innerHasNamedInput(node, widget) ? widget : null);
if (!matched)
return null;
const inputs = terminalInputsFromInnerNode(node);
if (!inputs)
return null;
return {
innerNodeId: id,
widget: matched,
...(typeof node.node_identity === "string" ? { nodeIdentity: node.node_identity } : {}),
terminal: {
nodeId: id,
nodeType,
widget: matched,
inputs,
chainDepth: 0,
},
};
}
/**
* Map a host-proven promoted STRING onto the unique inner terminal named by
* `proxyWidgets` or, failing that, the unique rail-backed inner widget. A miss
* is not an ordinary-widget proof.
*/
export function resolveInnerFromHostPromotedMapping(subgraph, mapping, ownerNodeId) {
const envelope = validatePromotedSubgraphEnvelope(subgraph, ownerNodeId);
if (!envelope)
return null;
if (mapping.innerNodeId !== undefined) {
const targetId = canonicalPromotedNodeId(mapping.innerNodeId);
if (!targetId)
return null;
const node = envelope.nodes.find((candidate) => {
const candidateId = innerNodeId(candidate);
return candidateId !== null && canonicalPromotedNodeId(candidateId) === targetId;
});
if (!node)
return null;
return innerTargetFromEnvelopeNode(node, mapping.hostWidget);
}
return resolveRailBackedInnerFromEnvelope(envelope, mapping.hostWidget);
}
/** #2791 — host detail uniquely proved a STRING rail, and the inner terminal
* agrees with that widget. Incomplete witnesses may then write the host. */
export function isHostProvenPromotedStringWrite(mapping, inner) {
if (!mapping || !inner)
return false;
if (mapping.type !== "STRING")
return false;
if (inner.widget.toLowerCase() !== mapping.hostWidget.toLowerCase())
return false;
if (!inner.terminal || inner.terminal.chainDepth !== 0)
return false;
if (mapping.innerNodeId !== undefined) {
const mapped = canonicalPromotedNodeId(mapping.innerNodeId);
const innerId = canonicalPromotedNodeId(inner.innerNodeId);
if (!mapped || !innerId || mapped !== innerId)
return false;
}
const named = inner.terminal.inputs.find((input) => input.name.toLowerCase() === inner.widget.toLowerCase());
if (named?.type !== undefined && named.type !== "STRING")
return false;
return true;
}
/** Parse the panel's structured current-graph identity without accepting a
* prose/detail fallback. The panel deliberately omits workflow_uuid when the
* live workflow identity cannot be read; owner_node_id remains the required
* subgraph witness for this promoted mapping. */
export function parsePromotedViewingIdentity(value) {
if (!isRecord(value))
return null;
const scope = value.scope;
if (scope !== "root" && scope !== "subgraph")
return null;
const workflowUuid = value.workflow_uuid;
if (workflowUuid !== undefined && (typeof workflowUuid !== "string" || workflowUuid.length === 0)) {
return null;
}
const graphIdentity = value.graph_identity;
if (graphIdentity !== undefined &&
(typeof graphIdentity !== "string" || graphIdentity.length === 0 || graphIdentity.length > 256)) {
return null;
}
const rawOwner = value.owner_node_id;
let ownerNodeId = null;
if (rawOwner !== undefined && rawOwner !== null) {
ownerNodeId = canonicalPromotedNodeId(rawOwner);
if (!ownerNodeId)
return null;
}
return {
scope,
ownerNodeId,
...(workflowUuid !== undefined ? { workflowUuid } : {}),
...(graphIdentity !== undefined ? { graphIdentity } : {}),
};
}
function envelopeInvariant(invariant) {
return { ok: false, invariant };
}
/**
* Validate the ownership and completeness claims made by graph_get_subgraph,
* and name the first claim that failed.
*
* A node list is useful for a promoted write only when it names the wrapper the
* caller asked about and contains exactly the advertised number of inner nodes.
* When that completeness proof holds (`node_count === nodes.length`), an omitted
* or null `truncated` flag is `false`. An asserted `truncated:true`, or a list
* that is actually short of `node_count`, stays fail-closed.
*/
export function describePromotedSubgraphEnvelope(subgraph, ownerNodeId) {
if (!isRecord(subgraph)) {
return envelopeInvariant("the reply was not a JSON object");
}
const viewing = Object.prototype.hasOwnProperty.call(subgraph, "viewing")
? parsePromotedViewingIdentity(subgraph.viewing)
: undefined;
if (Object.prototype.hasOwnProperty.call(subgraph, "viewing") && !viewing) {
return envelopeInvariant("`viewing` was present but did not parse as a graph-scope identity " +
'(`scope` must be "root" or "subgraph"; `owner_node_id` must be a node id or null; ' +
"`workflow_uuid` and `graph_identity`, when present, must be non-empty strings)");
}
const owner = subgraph.subgraph_of;
if (!isRecord(owner)) {
return envelopeInvariant("`subgraph_of` was missing or not an object");
}
if (!isNodeId(owner.node_id)) {
return envelopeInvariant("`subgraph_of.node_id` was missing or not a node id");
}
if (!isNodeId(ownerNodeId)) {
return envelopeInvariant("the addressed node id was not a usable node id");
}
if (!sameNodeId(owner.node_id, ownerNodeId)) {
return envelopeInvariant(`\`subgraph_of.node_id\` named node ${stripNodeId(String(owner.node_id))}, ` +
`not the addressed node ${stripNodeId(String(ownerNodeId))}`);
}
const targetGraphIdentity = owner.graph_identity;
if (targetGraphIdentity !== undefined &&
(typeof targetGraphIdentity !== "string" ||
targetGraphIdentity.length === 0 ||
targetGraphIdentity.length > 256)) {
return envelopeInvariant("`subgraph_of.graph_identity` was present but not a string of 1-256 characters");
}
const nodeCount = subgraph.node_count;
const nodes = subgraph.nodes;
if (!Number.isSafeInteger(nodeCount) || nodeCount < 0) {
return envelopeInvariant("`node_count` was missing or not a non-negative integer");
}
if (!Array.isArray(nodes)) {
return envelopeInvariant("`nodes` was missing or not an array");
}
if (nodes.length !== nodeCount) {
return envelopeInvariant(`\`node_count\` claimed ${nodeCount} inner node(s) but \`nodes\` carried ${nodes.length}`);
}
// Completeness is proven from the list, not from the flag. Omitted/null
// `truncated` is then false; an asserted or malformed flag is not.
if (subgraph.truncated != null && subgraph.truncated !== false) {
return envelopeInvariant("the reply's `truncated` flag was not `false`, so the inner node list it carried " +
"cannot be taken as the whole subgraph");
}
const normalized = [];
// Indexed, not `nodes.entries()`: the array is parsed from an untrusted reply
// and `entries` is an own-property a caller can shadow, which would iterate
// something other than what `nodes.length !== nodeCount` was checked against.
// The loops this replaced walked `Symbol.iterator`; an index walks neither.
for (let index = 0; index < nodes.length; index += 1) {
const raw = nodes[index];
if (!isRecord(raw) || innerNodeId(raw) == null) {
return envelopeInvariant(`\`nodes[${index}]\` was not an object carrying a usable \`id\``);
}
const nodeIdentity = raw.node_identity;
if (nodeIdentity !== undefined &&
(typeof nodeIdentity !== "string" || nodeIdentity.length === 0 || nodeIdentity.length > 256)) {
return envelopeInvariant(`\`nodes[${index}].node_identity\` was present but not a string of 1-256 characters`);
}
normalized.push(raw);
}
let promotedTerminals;
if (Object.prototype.hasOwnProperty.call(subgraph, "promoted_terminals")) {
const parsed = describePromotedTerminalEntries(subgraph.promoted_terminals);
if (!parsed.ok)
return envelopeInvariant(parsed.invariant);
promotedTerminals = parsed.entries;
}
return {
ok: true,
envelope: {
nodes: normalized,
nodeId: stripNodeId(String(owner.node_id)),
nodeCount: nodeCount,
...(targetGraphIdentity !== undefined ? { targetGraphIdentity } : {}),
...(viewing ? { viewing } : {}),
...(promotedTerminals ? { promotedTerminals } : {}),
},
};
}
/**
* The fence. Identical accept/reject to
* {@link describePromotedSubgraphEnvelope} by construction — it IS that
* function with the reason dropped, so a diagnostic change can never widen
* what authorizes a promoted write.
*/
export function validatePromotedSubgraphEnvelope(subgraph, ownerNodeId) {
const result = describePromotedSubgraphEnvelope(subgraph, ownerNodeId);
return result.ok ? result.envelope : null;
}
/**
* The witness array is ALL-OR-NOTHING: one unusable entry refuses every
* promoted write on the wrapper, including widgets whose own entry is fine.
* That is deliberate — an array that cannot be fully parsed is not evidence of
* a complete alias mapping, and a partial mapping is exactly what would let a
* renamed promotion resolve to the wrong terminal. So each reason below names
* the OFFENDING INDEX, because the caller's own widget is usually not it.
*/
function describePromotedTerminalEntries(value) {
if (!Array.isArray(value)) {
return envelopeInvariant("`promoted_terminals` was present but not an array");
}
const at = (index, detail) => envelopeInvariant(`\`promoted_terminals[${index}]\` ${detail}`);
const entries = [];
// Indexed for the same reason as the node loop above.
for (let index = 0; index < value.length; index += 1) {
const raw = value[index];
if (!isRecord(raw))
return at(index, "was not an object");
if (typeof raw.widget !== "string" || raw.widget.length === 0) {
return at(index, "had a missing or empty `widget`");
}
const immediateNodeId = raw.immediate_node_id;
if (immediateNodeId !== undefined && !isNodeId(immediateNodeId)) {
return at(index, "carried an `immediate_node_id` that is not a node id");
}
const immediateWidget = raw.immediate_widget;
if (immediateWidget !== undefined &&
(typeof immediateWidget !== "string" || immediateWidget.length === 0)) {
return at(index, "carried an `immediate_widget` that is not a non-empty string");
}
const error = raw.error;
if (error !== undefined && (typeof error !== "string" || error.length === 0)) {
return at(index, "carried an `error` that is not a non-empty string");
}
const terminalRaw = raw.terminal_node_id === undefined && raw.terminal_node_type === undefined ? undefined : raw;
const parentRailRaw = raw.parent_rail;
let parentRail;
let terminal;
if (terminalRaw) {
if (error !== undefined) {
return at(index, "published a terminal endpoint AND an `error`; a witness is one or the other");
}
if (immediateNodeId === undefined) {
return at(index, "published a terminal endpoint without `immediate_node_id`");
}
if (immediateWidget === undefined) {
return at(index, "published a terminal endpoint without `immediate_widget`");
}
if (!isRecord(parentRailRaw) ||
parentRailRaw.authoritative !== true ||
typeof parentRailRaw.widget !== "string" ||
parentRailRaw.widget.length === 0) {
return at(index, "published a terminal endpoint without a `parent_rail` asserting `authoritative:true` and a non-empty `widget`");
}
const parentRailWidgetId = parentRailRaw.widget_id;
if (parentRailWidgetId !== undefined &&
(typeof parentRailWidgetId !== "string" || parentRailWidgetId.length === 0)) {
return at(index, "carried a `parent_rail.widget_id` that is not a non-empty string");
}
parentRail = {
authoritative: true,
widget: parentRailRaw.widget,
...(parentRailWidgetId !== undefined ? { widgetId: parentRailWidgetId } : {}),
};
if (!isNodeId(terminalRaw.terminal_node_id)) {
return at(index, "carried a `terminal_node_id` that is missing or not a node id");
}
if (typeof terminalRaw.terminal_node_type !== "string" ||
terminalRaw.terminal_node_type.length === 0) {
return at(index, "carried a `terminal_node_type` that is missing or not a non-empty string");
}
if (typeof terminalRaw.terminal_widget !== "string" ||
terminalRaw.terminal_widget.length === 0) {
return at(index, "carried a `terminal_widget` that is missing or not a non-empty string");
}
const chainDepth = terminalRaw.chain_depth;
if (!Number.isSafeInteger(chainDepth) ||
chainDepth < 0 ||
chainDepth > 16) {
return at(index, "carried a `chain_depth` that is missing or outside 0-16");
}
if (!Array.isArray(terminalRaw.terminal_inputs)) {
return at(index, "carried a `terminal_inputs` that is missing or not an array");
}
const inputs = [];
const rawInputs = terminalRaw.terminal_inputs;
for (let inputIndex = 0; inputIndex < rawInputs.length; inputIndex += 1) {
const input = rawInputs[inputIndex];
if (!isRecord(input) || typeof input.name !== "string" || input.name.length === 0) {
return at(index, `carried a \`terminal_inputs[${inputIndex}]\` without a non-empty \`name\``);
}
if (input.type !== undefined && typeof input.type !== "string") {
return at(index, `carried a \`terminal_inputs[${inputIndex}].type\` that is not a string`);
}
inputs.push({ name: input.name, ...(input.type !== undefined ? { type: input.type } : {}) });
}
terminal = {
nodeId: terminalRaw.terminal_node_id,
nodeType: terminalRaw.terminal_node_type,
widget: terminalRaw.terminal_widget,
inputs,
chainDepth: chainDepth,
};
}
else if (error === undefined) {
return at(index, "published neither a terminal endpoint (`terminal_node_id`/`terminal_node_type`) nor an `error`, " +
"so the witness array cannot be read as a complete alias mapping");
}
entries.push({
widget: raw.widget,
...(parentRail ? { parentRail } : {}),
...(immediateNodeId !== undefined ? { immediateNodeId } : {}),
...(immediateWidget !== undefined ? { immediateWidget } : {}),
...(terminal ? { terminal } : {}),
...(error !== undefined ? { error } : {}),
});
}
return { ok: true, entries };
}
/** Extract the target owner and workflow identity from a validated promotion
* envelope. The caller must keep this witness with the resolved inner mapping;
* a later inner-id/type match without it is not sufficient because ids are
* local to the currently viewed graph. */
export function promotedScopeWitnessFromEnvelope(envelope) {
if (!envelope?.viewing)
return null;
const ownerNodeId = canonicalPromotedNodeId(envelope.nodeId);
if (!ownerNodeId || typeof envelope.targetGraphIdentity !== "string")
return null;
return {
...(envelope.viewing.workflowUuid !== undefined
? { workflowUuid: envelope.viewing.workflowUuid }
: {}),
ownerNodeId,
graphIdentity: envelope.targetGraphIdentity,
};
}
/** A post-entry graph query must prove that the panel is viewing the exact
* subgraph owned by the wrapper that produced the promotion mapping. */
export function promotedViewingMatchesScope(payload, expected) {
const viewing = parsePromotedViewingIdentity(payload?.viewing);
return (viewing?.scope === "subgraph" &&
viewing.ownerNodeId === expected.ownerNodeId &&
(expected.workflowUuid === undefined || viewing.workflowUuid === expected.workflowUuid) &&
viewing.graphIdentity === expected.graphIdentity);
}
/**
* Map a displayed promoted name to the unique inner node that owns a widget
* of that name. `graph_get_subgraph` does not ship a reliable promotion
* pairing across frontend versions, so uniqueness is the only attribution
* we will act on. A truncated inner list cannot prove uniqueness.
*/
export function resolveInnerPromotedTarget(subgraph, displayedWidget, ownerNodeId) {
const envelope = ownerNodeId === undefined
? isRecord(subgraph) && subgraph.truncated !== true && Array.isArray(subgraph.nodes)
? { nodes: subgraph.nodes.filter(isRecord) }
: null
: validatePromotedSubgraphEnvelope(subgraph, ownerNodeId);
if (!envelope)
return null;
const promotedTerminals = "promotedTerminals" in envelope ? envelope.promotedTerminals : undefined;
if (promotedTerminals !== undefined) {
// Current panels publish the authoritative alias -> immediate -> terminal
// relation. Prefer it over the legacy same-name scan: renamed outer and
// intermediate aliases are valid addresses, while the inner node summary
// intentionally contains only the concrete widget's programmatic name.
// #2488 — the caller may address the host with either the displayed alias
// (`frame_counts`) or the inner programmatic name (`length`). Both names
// identify the same unique promotion; uniqueness is still required.
const entries = selectPromotedTerminalEntries(promotedTerminals, displayedWidget);
if (entries.length !== 1)
return null;
const entry = entries[0];
// A complete own-entry is still the preferred mapping. An incomplete own-entry
// is not authorization to trust its immediate ids — those can be the drifted
// live slot. Fall back only to a unique rail-backed same-name inner widget.
if (entry.error)
return resolveRailBackedInnerFromEnvelope(envelope, displayedWidget);
if (!entry.terminal ||
!entry.parentRail ||
entry.immediateNodeId === undefined ||
!entry.immediateWidget) {
return null;
}
const immediateId = canonicalPromotedNodeId(entry.immediateNodeId);
if (!immediateId)
return null;
const immediateNode = envelope.nodes.find((node) => {
const candidateId = innerNodeId(node);
return candidateId !== null && canonicalPromotedNodeId(candidateId) === immediateId;
});
if (!immediateNode)
return null;
return {
innerNodeId: entry.immediateNodeId,
widget: entry.immediateWidget,
...(typeof immediateNode.node_identity === "string"
? { nodeIdentity: immediateNode.node_identity }
: {}),
parentRail: entry.parentRail,
terminal: entry.terminal,
};
}
const hits = [];
for (const node of envelope.nodes) {
const id = innerNodeId(node);
if (id == null)
continue;
const matched = matchListedName(displayedWidget, widgetNamesOnInner(node));
if (matched) {
hits.push({
innerNodeId: id,
widget: matched,
...(typeof node.node_identity === "string" ? { nodeIdentity: node.node_identity } : {}),
});
}
}
if (hits.length !== 1)
return null;
return hits[0];
}
/**
* Resolve only the legacy same-name relation. A payload from a receiver that
* does not advertise `promoted_terminals` is not allowed to use an unadvertised
* witness array as an ordinary-write proof: that array may be partial on a
* capability-skewed panel. Legacy compatibility is therefore limited to the
* old, positive inner-node/name match; a miss remains indeterminate.
*/
export function resolveLegacyInnerPromotedTarget(subgraph, displayedWidget, ownerNodeId) {
if (!isRecord(subgraph))
return null;
const { promoted_terminals: _untrustedWitnesses, ...legacyPayload } = subgraph;
return resolveInnerPromotedTarget(legacyPayload, displayedWidget, ownerNodeId);
}
/** True when the refusal listed `requestedWidget` as promoted. */
export function isContradictoryPromotedWidgetRefusal(text, requestedWidget) {
return parseContradictoryPromotedWidgetRefusal(text, requestedWidget) != null;
}
const WILL_NOT_PERSIST_RE = /will NOT persist/i;
const NOT_PROMOTED_RE = /is NOT promoted onto subgraph node (\S+)/i;
const ENTER_RE = /panel_enter_subgraph\(node_id=([^)]+)\)/gi;
const SET_FIXED_RE = /panel_set_widget\(node_id=([^,]+),\s*widget='([^']+)',\s*value='fixed'\)/i;
const EXIT_TIMES_RE = /panel_exit_subgraph\(\)\s+(\d+)\s+times/i;
const MODE_RE = /control_after_generate='([^']+)'/i;
function stripNodeId(raw) {
return raw.trim().replace(/[,:]$/, "");
}
function sameNodeId(a, b) {
return stripNodeId(String(a)) === stripNodeId(String(b));
}
/** Parse the panel#650 unpromoted-control persist warning. Null unless the
* value will not persist AND the control is only reachable by entering. */
export function parseUnpromotedControlPersistRemedy(text) {
if (!WILL_NOT_PERSIST_RE.test(text))
return null;
const notPromoted = NOT_PROMOTED_RE.exec(text);
if (!notPromoted)
return null;
const outerNodeId = stripNodeId(notPromoted[1]);
if (!outerNodeId)
return null;
const enterPath = [];
ENTER_RE.lastIndex = 0;
for (let m = ENTER_RE.exec(text); m; m = ENTER_RE.exec(text)) {
const id = stripNodeId(m[1]);
if (id)
enterPath.push(id);
}
if (enterPath.length === 0)
return null;
const setFixed = SET_FIXED_RE.exec(text);
if (!setFixed)
return null;
const innerNodeId = stripNodeId(setFixed[1]);
const controlWidget = setFixed[2];
if (!innerNodeId || !controlWidget)
return null;
if (sameNodeId(innerNodeId, outerNodeId))
return null;
const times = EXIT_TIMES_RE.exec(text);
const parsedTimes = times ? Number(times[1]) : NaN;
const exitCount = Number.isFinite(parsedTimes) && parsedTimes > 0 ? parsedTimes : enterPath.length;
const modeMatch = MODE_RE.exec(text);
const mode = modeMatch?.[1] && modeMatch[1] !== "fixed" ? modeMatch[1] : "randomize";
return { outerNodeId, enterPath, innerNodeId, controlWidget, exitCount, mode };
}
export function addressedNodeMatchesPersistRemedy(addressedNodeId, remedy) {
return (sameNodeId(addressedNodeId, remedy.outerNodeId) ||
sameNodeId(addressedNodeId, remedy.enterPath[0]));
}
/**
* #2514 — the panel's #1087 warning for a DIRECT write to a widget that is
* link-driven from a promoted subgraph input. That warning tells the caller
* to set the widget on the ENCLOSING subgraph node. It is contradictory when
* MCP already remapped a parent-addressed write onto that inner widget.
*/
const INNER_LINK_DRIVEN_WARNING_RE = /will NOT change the render[\s\S]*\blink-driven\b[\s\S]*ENCLOSING subgraph node/i;
export function isInnerLinkDrivenWriteWarning(text) {
return INNER_LINK_DRIVEN_WARNING_RE.test(text);
}
/**
* Shape a remapped promoted-write receipt around the parent the caller
* addressed. The inner link-driven warning is dropped only on that path;
* an unrelated warning (control_after_generate, etc.) is left in place.
* `set` is rewritten to the parent-facing widget only when the parent
* rail was actually synced — otherwise the inner assignment stands.
*/
export function shapeParentAuthoritativePromotedWrite(payload, parent) {
const next = { ...payload };
if (typeof next.warning === "string" && isInnerLinkDrivenWriteWarning(next.warning)) {
delete next.warning;
}
if (parent.synced) {
next.parent_widget_synced = true;
const set = next.set;
if (set && typeof set === "object" && !Array.isArray(set)) {
next.set = {
...set,
node_id: parent.nodeId,
widget: parent.widget,
};
}
const promotedFrom = next.promoted_from;
if (promotedFrom && typeof promotedFrom === "object" && !Array.isArray(promotedFrom)) {
next.promoted_from = {
...promotedFrom,
parent_widget_synced: true,
};
}
}
return next;
}
//# sourceMappingURL=promoted-widget.js.map