comfyui-mcp 0.52.180 → 0.52.182

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.
@@ -47,6 +47,7 @@ import { isPreExecutorRefusal } from "../services/panel-refusal.js";
47
47
  import { hostLinksAlreadyReindexed, isLandedUnexpose, laterSlotsAfterUnexpose, laterSlotsFromUnexposePayload, unexposeHostLinkShiftNote, } from "../services/unexpose-host-link-shift.js";
48
48
  import { retryConnectAgainstLiveGraph } from "../services/connect-live-graph.js";
49
49
  import { verifyPrimitiveForceInputAfterConnect } from "../services/primitive-force-input-connect.js";
50
+ import { retryRailSlotConnect } from "../services/rail-slot-connect.js";
50
51
  import { retryWildcardSlotConnect } from "../services/wildcard-slot-connect.js";
51
52
  import { retryExposeSubgraphInput } from "../services/expose-ae-wildcard.js";
52
53
  import { applyLiveRootViewing, callAndRememberViewing, callWithRememberedSubgraph, clearStaleSubgraphIdentity, noteConfirmedViewingFromToolResult, parseViewingScope, } from "../services/subgraph-viewing-scope.js";
@@ -5911,6 +5912,35 @@ function forgetStaleSubgraphIdentity(ctx) {
5911
5912
  clearStaleSubgraphIdentity(ctx.tabId);
5912
5913
  ctx.bridge?.clearPromotedSubgraphIdentity?.(ctx.tabId);
5913
5914
  }
5915
+ /** Tabs whose promoted-container mapping is unverified after a queue-busy
5916
+ * mutation refusal. `graph_get_subgraph` can stay indeterminate for ordinary
5917
+ * root nodes until something walks the live graph (#2730). */
5918
+ const staleSubgraphMappingTabs = new Set();
5919
+ function noteStaleSubgraphMapping(tabId) {
5920
+ if (tabId)
5921
+ staleSubgraphMappingTabs.add(tabId);
5922
+ }
5923
+ export function __resetStaleSubgraphMappingForTest() {
5924
+ staleSubgraphMappingTabs.clear();
5925
+ }
5926
+ /** Root graph identity from a live query even when `is_subgraph` is missing.
5927
+ * A missing container bit is indeterminate, but a published root identity is
5928
+ * still enough to spend one mapping refresh before failing closed (#2730). */
5929
+ function parseRootGraphIdentity(payload) {
5930
+ if (!payload)
5931
+ return null;
5932
+ const viewing = payload.viewing;
5933
+ if (!viewing || typeof viewing !== "object" || Array.isArray(viewing))
5934
+ return null;
5935
+ const rec = viewing;
5936
+ if (rec.scope !== "root")
5937
+ return null;
5938
+ const graphIdentity = rec.graph_identity;
5939
+ if (typeof graphIdentity !== "string" || graphIdentity.length === 0 || graphIdentity.length > 256) {
5940
+ return null;
5941
+ }
5942
+ return graphIdentity;
5943
+ }
5914
5944
  /** Read the active viewing scope and node's explicit container bit before
5915
5945
  * attempting promoted-widget resolution. The pinpoint detail projection carries
5916
5946
  * the node bit even when the widget list is empty (which is how a fresh rgthree
@@ -6395,6 +6425,23 @@ function definitiveNonPromotedNodeType(res) {
6395
6425
  const unwrapped = type.slice(1, -1).trim();
6396
6426
  return unwrapped || null;
6397
6427
  }
6428
+ /** Panel `graph_get_subgraph` flattens parentheses in the node type before
6429
+ * embedding it in the definitive ordinary-node refusal (panel#1941), so a live
6430
+ * `Power Lora Loader (rgthree)` is reported as `Power Lora Loader rgthree`.
6431
+ * The scope probe still publishes the real type. Compare after that flatten;
6432
+ * the write fence must keep the unflattened scope type (#2394). */
6433
+ function flattenDefinitiveOrdinaryNodeType(type) {
6434
+ return type.replace(/[()]/g, " ").replace(/\s+/g, " ").trim();
6435
+ }
6436
+ function sameDefinitiveOrdinaryNodeType(fromSubgraphRead, fromScopeProbe) {
6437
+ if (!fromSubgraphRead)
6438
+ return false;
6439
+ if (fromSubgraphRead === fromScopeProbe)
6440
+ return true;
6441
+ const left = flattenDefinitiveOrdinaryNodeType(fromSubgraphRead);
6442
+ const right = flattenDefinitiveOrdinaryNodeType(fromScopeProbe);
6443
+ return left.length > 0 && left === right;
6444
+ }
6398
6445
  /** The promoted-write refresh is deliberately narrower than the broad panel
6399
6446
  * call retry classifier. Only known transport/reconnect shapes may consume the
6400
6447
  * one explicit refresh; application refusals must not be replayed. */
@@ -6627,12 +6674,15 @@ async function readPromotedTargetScope(ctx, nodeId, options = PROMOTED_PREFLIGHT
6627
6674
  limit: 1,
6628
6675
  }, undefined, undefined, undefined, options);
6629
6676
  if (probe.isError)
6630
- return null;
6677
+ return { scope: null, rootGraphIdentity: null };
6631
6678
  const payload = parseToolResultJson(probe);
6632
6679
  if (parseViewingScope(payload?.viewing)?.scope === "root") {
6633
6680
  rememberLiveRootViewing(ctx, payload?.viewing);
6634
6681
  }
6635
- return parseVerifiedQueriedNodeScope(payload, nodeId);
6682
+ return {
6683
+ scope: parseVerifiedQueriedNodeScope(payload, nodeId),
6684
+ rootGraphIdentity: parseRootGraphIdentity(payload),
6685
+ };
6636
6686
  }
6637
6687
  function currentPromotedBindingError(ctx, binding) {
6638
6688
  if (ctx.tabId !== binding.tabId)
@@ -7158,12 +7208,43 @@ async function preparePromotedWidgetWrite(ctx, nodeId, widget) {
7158
7208
  return promotedWriteRefusal(widget, "the panel connection identity could not be read");
7159
7209
  }
7160
7210
  }
7211
+ // #2730 — after a queue-busy mutation refusal the panel's subgraph registry
7212
+ // can stay stale until a graph walk. Refresh once before classifying, then
7213
+ // re-probe so an ordinary root node can take the fast path without a manual
7214
+ // panel_graph_outline.
7215
+ let mappingRefreshed = false;
7216
+ const refreshSubgraphMapping = async () => {
7217
+ if (mappingRefreshed)
7218
+ return null;
7219
+ mappingRefreshed = true;
7220
+ const outline = await ctx.call({ cmd: "graph_outline" }, undefined, undefined, undefined, PROMOTED_PREFLIGHT_READ_OPTIONS);
7221
+ if (outline.isError) {
7222
+ return promotedWriteRefusal(widget, "graph_outline could not refresh the subgraph mapping");
7223
+ }
7224
+ staleSubgraphMappingTabs.delete(ctx.tabId);
7225
+ const payload = parseToolResultJson(outline);
7226
+ if (parseViewingScope(payload?.viewing)?.scope === "root") {
7227
+ rememberLiveRootViewing(ctx, payload?.viewing);
7228
+ forgetStaleSubgraphIdentity(ctx);
7229
+ }
7230
+ const drift = panelBindingDriftReason(ctx, "after the subgraph mapping refresh", hasIdentityApi, identityBefore, tabBefore);
7231
+ if (drift)
7232
+ return ordinaryBindingRefusal(widget, drift);
7233
+ return null;
7234
+ };
7235
+ if (staleSubgraphMappingTabs.has(ctx.tabId)) {
7236
+ const refreshError = await refreshSubgraphMapping();
7237
+ if (refreshError)
7238
+ return refreshError;
7239
+ }
7161
7240
  // #2394 — a root-level rgthree Power Lora Loader may have no lora_N widget
7162
7241
  // yet. Prove the addressed node is an ordinary root node before asking the
7163
7242
  // promoted-container classifier to resolve a target that is intentionally
7164
7243
  // absent. An unreadable scope probe is not permission for an outer write;
7165
7244
  // it falls through to the existing conservative graph_get_subgraph path.
7166
- const targetScope = await readPromotedTargetScope(ctx, nodeId, PROMOTED_PREFLIGHT_READ_OPTIONS);
7245
+ let targetProbe = await readPromotedTargetScope(ctx, nodeId, PROMOTED_PREFLIGHT_READ_OPTIONS);
7246
+ let targetScope = targetProbe.scope;
7247
+ const rootGraphIdentity = targetProbe.rootGraphIdentity;
7167
7248
  // #2518 — a live root query names the current root workflow instance. Do not
7168
7249
  // enter the promoted-subgraph identity path for an ordinary (or unproven)
7169
7250
  // root node; a truncated StringConcatenate pinpoint used to fall through,
@@ -7199,7 +7280,7 @@ async function preparePromotedWidgetWrite(ctx, nodeId, widget) {
7199
7280
  if (!targetScope.nodeIdentity)
7200
7281
  return null;
7201
7282
  const expectedNodeType = definitiveNonPromotedNodeType(sub);
7202
- if (expectedNodeType !== targetScope.nodeType) {
7283
+ if (!sameDefinitiveOrdinaryNodeType(expectedNodeType, targetScope.nodeType)) {
7203
7284
  return promotedWriteRefusal(widget, "the ordinary node type changed between the scope and subgraph reads");
7204
7285
  }
7205
7286
  if (ctx.tabExpectedNodeTypeFenceCapability?.() !== true) {
@@ -7219,6 +7300,23 @@ async function preparePromotedWidgetWrite(ctx, nodeId, widget) {
7219
7300
  return promotedSubgraphReadRefusal(widget, sub, "graph_get_subgraph could not determine whether the addressed node is a promoted container");
7220
7301
  }
7221
7302
  if (firstReadKind === "permanent") {
7303
+ // #2730 — a stale subgraph registry is a permanent-looking miss for an
7304
+ // ordinary root node. Refresh the mapping once and re-probe. Still
7305
+ // unverifiable → refuse. Do not spend the transient subgraph re-read on
7306
+ // a permanent application error.
7307
+ if (!mappingRefreshed && rootGraphIdentity !== null) {
7308
+ const refreshError = await refreshSubgraphMapping();
7309
+ if (refreshError)
7310
+ return refreshError;
7311
+ targetProbe = await readPromotedTargetScope(ctx, nodeId, PROMOTED_PREFLIGHT_READ_OPTIONS);
7312
+ targetScope = targetProbe.scope;
7313
+ if (targetScope?.activeView === "root" && targetScope.node === "ordinary") {
7314
+ const driftAfterMapping = panelBindingDriftReason(ctx, "after the subgraph mapping refresh", hasIdentityApi, identityBefore, tabBefore);
7315
+ if (driftAfterMapping)
7316
+ return ordinaryBindingRefusal(widget, driftAfterMapping);
7317
+ return ordinaryWritePlanFromScope(widget, targetScope, tabBefore, identityBefore);
7318
+ }
7319
+ }
7222
7320
  return promotedSubgraphReadRefusal(widget, sub, "graph_get_subgraph could not determine whether the addressed node is a promoted container");
7223
7321
  }
7224
7322
  // A binding/subgraph registration transition can make the first read
@@ -9985,7 +10083,21 @@ function refreshFenceFromOwnReply(ctx, reply) {
9985
10083
  if (!uuid)
9986
10084
  return null;
9987
10085
  try {
9988
- return refreshWorkflowUuid(ctx, parsed) ? { status: "refreshed", uuid, before } : null;
10086
+ const destPath = saveDestWorkflowPath(parsed);
10087
+ const destTab = destPath ? uniqueLiveTabForSaveDest(ctx, destPath) : undefined;
10088
+ const adopted = refreshWorkflowUuid(ctx, parsed);
10089
+ // #2768 — a SCOPE ctx.tabId never becomes dest. workflow_list stamps the
10090
+ // routed tab's advertised identity (#1815), so dest must hold the new uuid
10091
+ // or the next list/current-mode call is refused as a stale instance.
10092
+ if (destTab && destTab !== ctx.tabId) {
10093
+ try {
10094
+ ctx.bridge.refreshWorkflowUuid?.(destTab, uuid);
10095
+ }
10096
+ catch {
10097
+ // dest restamp is best-effort; the session fence is the ctx adoption
10098
+ }
10099
+ }
10100
+ return adopted ? { status: "refreshed", uuid, before } : null;
9989
10101
  }
9990
10102
  catch {
9991
10103
  return null; // never surface a throw here as worse than the existing fallback
@@ -10071,6 +10183,25 @@ function routingIsUnsavedPredecessor(ctx) {
10071
10183
  const pin = ctx.workflowTarget?.get(ctx.tabId);
10072
10184
  return typeof pin?.path === "string" && pin.path.startsWith("tmp:");
10073
10185
  }
10186
+ /** The saved path this session was bound to before Save-As, if any. */
10187
+ function sessionSavedPath(ctx) {
10188
+ const pin = ctx.workflowTarget?.get(ctx.tabId);
10189
+ if (typeof pin?.path === "string" && pin.path.trim() && !pin.path.startsWith("tmp:")) {
10190
+ return canonicalSavedWorkflowPath(pin.path) ?? pin.path.trim();
10191
+ }
10192
+ return savedPathFromTabId(ctx.tabId) ?? undefined;
10193
+ }
10194
+ /**
10195
+ * #2768 — this Save-As replaced the canvas this session was editing.
10196
+ * `workflow_instance_changed` is the panel's own signal; dest path ≠ the
10197
+ * session's saved path is the same fact on a reply that omits the flag.
10198
+ */
10199
+ function saveReplacedSessionCanvas(ctx, parsed, destPath) {
10200
+ if (parsed.workflow_instance_changed === true)
10201
+ return true;
10202
+ const from = sessionSavedPath(ctx);
10203
+ return Boolean(from && from !== destPath);
10204
+ }
10074
10205
  function destPinnedTarget(pinned, destPath, parsed) {
10075
10206
  let filename;
10076
10207
  for (const v of [parsed.filename, parsed.workflow]) {
@@ -10106,9 +10237,14 @@ function adoptPinnedDestPath(ctx, destPath, parsed, destTab) {
10106
10237
  * (`panel_set_todo`, `panel_canvas`) still address the old id.
10107
10238
  *
10108
10239
  * Follow dest when the current address is dead, when it already aliases onto
10109
- * dest (same-socket tmp:→wf: / Save-As rename), or when it is the unsaved
10240
+ * dest (same-socket tmp:→wf: / Save-As rename), when it is the unsaved
10110
10241
  * predecessor of dest (pinned tmp: canvas whose first save published a
10111
- * `workflows/…` routing key). A live pin on a DIFFERENT saved tab is left
10242
+ * `workflows/…` routing key), or when this Save-As replaced the canvas this
10243
+ * session was editing (#2768 — dest path differs, or the panel set
10244
+ * `workflow_instance_changed`). Leaving the session on the source id after
10245
+ * that is a stale fence: dest is live, the source id may still canReach, and
10246
+ * panel_list_workflows / mode:"current" then fail with instance mismatch.
10247
+ * A live pin on a DIFFERENT saved tab that this save did not replace is left
10112
10248
  * alone — that is #1917 / #884.
10113
10249
  *
10114
10250
  * Matching is on the dest path the save reply proved, and only when exactly
@@ -10132,7 +10268,10 @@ function repointRoutingAfterSave(ctx, reply) {
10132
10268
  return;
10133
10269
  }
10134
10270
  const live = liveRoutingTab(ctx);
10135
- const follow = !live || live === destTab || routingIsUnsavedPredecessor(ctx);
10271
+ const follow = !live ||
10272
+ live === destTab ||
10273
+ routingIsUnsavedPredecessor(ctx) ||
10274
+ saveReplacedSessionCanvas(ctx, parsed, destPath);
10136
10275
  if (!follow)
10137
10276
  return;
10138
10277
  if (isScopeAddress(ctx.tabId)) {
@@ -13810,8 +13949,13 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets, onRunTicketOpen
13810
13949
  // panel#1489 — reads are NOT refused here: they carry no unknown outcome,
13811
13950
  // so they are dispatched on the bounded budget above instead.
13812
13951
  const blocked = graphCmdBlockedByRunningPrompt(cmd);
13813
- if (blocked)
13952
+ if (blocked) {
13953
+ // #2730 — a fenced mutation never reached the panel, so the subgraph
13954
+ // registry can stay stale until something walks the live graph. Mark
13955
+ // this tab so the next idle write refreshes mapping once.
13956
+ noteStaleSubgraphMapping(ctx.tabId);
13814
13957
  return fail(blocked);
13958
+ }
13815
13959
  // #2527 — a timed-out mutation may still be applying. Graph reads wait for
13816
13960
  // that settlement (or disclose the outstanding receipt) so they cannot
13817
13961
  // certify a stale widget value as current.
@@ -17643,7 +17787,7 @@ export function buildPanelToolDefs() {
17643
17787
  return fail(err);
17644
17788
  }
17645
17789
  }),
17646
- def("panel_connect", "Connect an output slot of one node to an input slot of another in the user's open graph. Slots accept a name ('MODEL', 'samples') or numeric index. If both slot args are omitted the panel picks the first type-compatible pairing. On failure the error lists every slot with its type and [connected] flag — re-check with panel_query_graph ({ids:[node_id], fields:'detail'}). LiteGraph wildcard-to-wildcard (`*` → `*`) pairings are compatible (a PrimitiveNode 'connect to widget input' output can land on LogicIF.when_true / when_false so the primitive becomes typed from the destination). A frontend PrimitiveNode only serializes through a target widget; connecting one to a forceInput-only / non-widget STRING is refused (panel_run would omit the required input) — use a backend STRING producer such as PrimitiveStringMultiline instead. Undoable.", {
17790
+ def("panel_connect", "Connect an output slot of one node to an input slot of another in the user's open graph. Slots accept a name ('MODEL', 'samples') or numeric index. If both slot args are omitted the panel picks the first type-compatible pairing. On failure the error lists every slot with its type and [connected] flag — re-check with panel_query_graph ({ids:[node_id], fields:'detail'}). LiteGraph wildcard-to-wildcard (`*` → `*`) pairings are compatible (a PrimitiveNode 'connect to widget input' output can land on LogicIF.when_true / when_false so the primitive becomes typed from the destination). An exposed subgraph INT rail (e.g. Scene Seed) is compatible with another INT widget input (LocalWildcardText.seed) — numeric widget min/max/step on the rail socket are not a different type. A frontend PrimitiveNode only serializes through a target widget; connecting one to a forceInput-only / non-widget STRING is refused (panel_run would omit the required input) — use a backend STRING producer such as PrimitiveStringMultiline instead. Undoable.", {
17647
17791
  from_node_id: nodeId().describe("Source node id."),
17648
17792
  from_output: slotRef
17649
17793
  .optional()
@@ -17676,7 +17820,8 @@ export function buildPanelToolDefs() {
17676
17820
  };
17677
17821
  const call = (cmd, timeoutMs) => ctx.call(cmd, timeoutMs);
17678
17822
  const connected = await retryConnectAgainstLiveGraph(connectArgs, call);
17679
- const afterWildcard = await retryWildcardSlotConnect(connectArgs, connected, call);
17823
+ const afterRail = await retryRailSlotConnect(connectArgs, connected, call);
17824
+ const afterWildcard = await retryWildcardSlotConnect(connectArgs, afterRail, call);
17680
17825
  return verifyPrimitiveForceInputAfterConnect(connectArgs, afterWildcard, call);
17681
17826
  }),
17682
17827
  def("panel_disconnect", "Disconnect an input slot of a node in the user's open graph. Undoable with Ctrl+Z.", {
@@ -20505,13 +20650,14 @@ export function buildPanelToolDefs() {
20505
20650
  // attempting rebindWorkflowFence's independent workflow_list round trip,
20506
20651
  // which can be refused by the exact fence this repairs.
20507
20652
  //
20508
- // #2419 — BEFORE the fence refresh so the stamp lands on the dest
20509
- // address. Save-As mints a new tab id; the fence repair re-stamps
20510
- // graph commands, but session-scoped commands (set_todo, graph_canvas)
20511
- // still address the old id unless routing is re-pointed. Follow dest
20512
- // when the current address is dead, aliases onto dest, or is the
20513
- // unsaved tmp: predecessor of dest. A live pin on a different saved
20514
- // tab is left alone (#1917 / #884).
20653
+ // #2419 / #2768 — BEFORE the fence refresh so the stamp lands on the
20654
+ // dest address. Save-As mints a new tab id; the fence repair re-stamps
20655
+ // graph commands, but session-scoped commands (set_todo, graph_canvas,
20656
+ // workflow_list) still address the old id unless routing is re-pointed.
20657
+ // Follow dest when the current address is dead, aliases onto dest, is
20658
+ // the unsaved tmp: predecessor of dest, or this Save-As replaced the
20659
+ // canvas this session was editing. A live pin on a different saved tab
20660
+ // that this save did not replace is left alone (#1917 / #884).
20515
20661
  repointRoutingAfterSave(ctx, res);
20516
20662
  const fenceRebind = refreshFenceFromOwnReply(ctx, res) ?? (await rebindWorkflowFence(ctx));
20517
20663
  let canMutateNow;