comfyui-mcp 0.50.76 → 0.50.78

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.
@@ -2367,6 +2367,118 @@ function activeMatchesOpenRefreshTarget(active, path) {
2367
2367
  const targetIdentity = canonicalRequestedSavedIdentity(path);
2368
2368
  return !!targetIdentity && canonicalSavedRecordIdentity(active) === targetIdentity;
2369
2369
  }
2370
+ /**
2371
+ * Would this active record plausibly BE the requested workflow? Used only to
2372
+ * SUPPRESS a drift warning, never to prove sameness, so it is deliberately
2373
+ * generous: every normalization that could make two spellings the same file is
2374
+ * applied, and ties break toward silence. A miss here costs a warning we could
2375
+ * have given; a false positive here FAILS a healthy open, which is far worse.
2376
+ *
2377
+ * `activeMatchesTarget` alone is not enough — it is raw string equality, so
2378
+ * `./workflows/a.json`, `workflows\a.json` and `Workflows/A.json` all read as
2379
+ * different files from `workflows/a.json`. This file already warns against
2380
+ * exactly that conflation for the recovery path. Case folding is safe HERE
2381
+ * (and only here) because the generous direction is the silent one.
2382
+ */
2383
+ function plausiblySameSavedWorkflow(active, path) {
2384
+ if (activeMatchesTarget(active, path))
2385
+ return true;
2386
+ if (!active || typeof active !== "object")
2387
+ return false;
2388
+ const want = canonicalSavedWorkflowPath(path);
2389
+ if (!want)
2390
+ return false;
2391
+ const fold = (s) => s.toLowerCase();
2392
+ const wantFolded = fold(want);
2393
+ const wantNoExt = stripJsonExt(wantFolded);
2394
+ const a = active;
2395
+ for (const raw of [a.path, a.filename, a.key]) {
2396
+ const got = canonicalSavedWorkflowPath(raw);
2397
+ if (!got)
2398
+ continue;
2399
+ const gotFolded = fold(got);
2400
+ if (gotFolded === wantFolded)
2401
+ return true;
2402
+ if (wantNoExt !== null && stripJsonExt(gotFolded) === wantNoExt)
2403
+ return true;
2404
+ }
2405
+ return false;
2406
+ }
2407
+ export function readOpenActiveAgainstTarget(active, path, activeConfirmed) {
2408
+ // #433/#3014 — `active_confirmed:false` is the panel telling us this value is
2409
+ // untrustworthy. It is already refused as adoption evidence; it cannot be
2410
+ // better evidence for failing an open than it is for stamping one.
2411
+ if (activeConfirmed === false)
2412
+ return "indeterminate";
2413
+ if (activeMatchesOpenRefreshTarget(active, path))
2414
+ return "same";
2415
+ if (!canonicalRequestedSavedIdentity(path))
2416
+ return "indeterminate"; // our own side is unreadable
2417
+ if (!active || typeof active !== "object")
2418
+ return "indeterminate";
2419
+ // The STRICT gate above failing is not enough to warn. It requires a canonical
2420
+ // saved identity on both sides, and it refuses a record whose routing_key is
2421
+ // absent, replayed, or malformed (#716 P1) — even when that record's PATH is the
2422
+ // very workflow we opened. Declining to adopt a uuid on that evidence is right;
2423
+ // telling the caller a different canvas is mounted would be a false alarm on the
2424
+ // same workflow.
2425
+ if (plausiblySameSavedWorkflow(active, path))
2426
+ return "indeterminate";
2427
+ const a = active;
2428
+ const identified = (typeof a.path === "string" && a.path !== "") ||
2429
+ (typeof a.filename === "string" && a.filename !== "") ||
2430
+ (typeof a.key === "string" && a.key !== "");
2431
+ return identified ? "different" : "indeterminate";
2432
+ }
2433
+ /**
2434
+ * #887 — observe what is active after an open, WITHOUT adopting anything.
2435
+ *
2436
+ * Split out of `refreshOpenWorkflowUuid` because the two questions have different
2437
+ * preconditions. Adoption is gated on the open reply corroborating the requested
2438
+ * identity — a reply that cannot prove which workflow it opened must never
2439
+ * authorize a fence refresh. Pure observation needs none of that: "what does the
2440
+ * panel say is active right now" is answerable regardless of what the open replied,
2441
+ * and on the path that matters most the open reply is an ERROR carrying no JSON at
2442
+ * all. Requiring corroboration there is what kept the reporter's own case
2443
+ * unexamined.
2444
+ */
2445
+ async function observeActiveAfterOpen(ctx, requestedPath) {
2446
+ let list = null;
2447
+ try {
2448
+ const res = await ctx.call({ cmd: "workflow_list" }, 6000);
2449
+ if (!res?.isError)
2450
+ list = parseToolResultJson(res);
2451
+ }
2452
+ catch {
2453
+ return null; // could not ask — nothing was observed, so nothing is claimed
2454
+ }
2455
+ if (!list)
2456
+ return null;
2457
+ if (readOpenActiveAgainstTarget(list.active, requestedPath, list.active_confirmed) !== "different") {
2458
+ return null;
2459
+ }
2460
+ return { drifted: true, activeLabel: describeActiveRecord(list.active) };
2461
+ }
2462
+ /**
2463
+ * Human-readable name for whatever the panel says is active, for the #887 warning.
2464
+ *
2465
+ * PATH FIRST, deliberately. The case this guard exists for is a same-basename
2466
+ * workflow in another directory, and naming it by its bare `filename` produces a
2467
+ * sentence that refutes itself — "workflows/a/foo.json was opened, but foo.json is
2468
+ * active now" reads as the same file. The directory IS the distinguishing
2469
+ * information. `title` precedes the bare filename for the unsaved case, where the
2470
+ * panel puts the human label there and leaves path/filename null.
2471
+ */
2472
+ export function describeActiveRecord(active) {
2473
+ if (!active || typeof active !== "object")
2474
+ return "another workflow";
2475
+ const a = active;
2476
+ for (const v of [a.path, a.title, a.filename, a.key]) {
2477
+ if (typeof v === "string" && v !== "")
2478
+ return v;
2479
+ }
2480
+ return "another workflow";
2481
+ }
2370
2482
  /** Normalizes only syntax the panel's saved-path/routing identity normalizes. */
2371
2483
  function canonicalSavedWorkflowPath(value) {
2372
2484
  if (typeof value !== "string" || !value)
@@ -3076,7 +3188,7 @@ async function refreshOpenWorkflowUuid(ctx, requestedPath, openResult) {
3076
3188
  if (requestedUnsaved && requestedUnsaved === parsedOpen?.routing_key) {
3077
3189
  refreshWorkflowUuid(ctx, parsedOpen);
3078
3190
  }
3079
- return;
3191
+ return null;
3080
3192
  }
3081
3193
  // THREE outcomes for this corroborating read, not two — and conflating the last
3082
3194
  // two is #1071 (also #932/#1043).
@@ -3111,10 +3223,23 @@ async function refreshOpenWorkflowUuid(ctx, requestedPath, openResult) {
3111
3223
  // value, which is fresher than the reply — or it names a DIFFERENT active
3112
3224
  // workflow, in which case another tab won the slot and adopting our target's
3113
3225
  // uuid would fence this session to a canvas that is not mounted. Adopt nothing.
3114
- if (!activeMatchesOpenRefreshTarget(list.active, requestedPath))
3115
- return;
3226
+ //
3227
+ // #887 — declining to adopt was right and was never enough. This is the ONLY
3228
+ // genuinely later observation in the whole open path: it sits behind a real
3229
+ // `await ctx.call(...)` round trip to the panel, so unlike anything inside the
3230
+ // panel's own synchronous post-load window it can actually see the active
3231
+ // pointer having settled somewhere else. It saw the contradiction, kept the
3232
+ // session safe, and told the CALLER nothing — the tool result still reported a
3233
+ // plain success naming the path the agent asked for. The agent then had every
3234
+ // reason to Save-As onto what it believed was that canvas.
3235
+ const reading = readOpenActiveAgainstTarget(list.active, requestedPath, list.active_confirmed);
3236
+ if (reading === "different") {
3237
+ return { drifted: true, activeLabel: describeActiveRecord(list.active) };
3238
+ }
3239
+ if (reading === "indeterminate")
3240
+ return null;
3116
3241
  refreshWorkflowUuid(ctx, list.active) || refreshWorkflowUuid(ctx, parsedOpen);
3117
- return;
3242
+ return null;
3118
3243
  }
3119
3244
  // COULD NOT ASK — the wedged case. Fall back to the reply's proven uuid rather
3120
3245
  // than leaving the session fenced to a dead instance forever. This is strictly
@@ -3123,7 +3248,12 @@ async function refreshOpenWorkflowUuid(ctx, requestedPath, openResult) {
3123
3248
  // actually mounted, whereas doing nothing here guarantees every subsequent
3124
3249
  // command is refused. A reply that carries no uuid (the panel could not prove
3125
3250
  // it) still refreshes nothing, so fail-closed is preserved.
3251
+ //
3252
+ // #887 — and NO drift notice from here, deliberately. This branch is reached
3253
+ // precisely because the corroborating read could not be made, so nothing was
3254
+ // observed about what is active. Warning here would be inventing the finding.
3126
3255
  refreshWorkflowUuid(ctx, parsedOpen);
3256
+ return null;
3127
3257
  }
3128
3258
  /** Exact resolved-path check for an open receipt. A filename/basename is not a
3129
3259
  * workflow identity: `other/foo.json` must never confirm `wanted/foo.json`. */
@@ -3250,8 +3380,59 @@ async function openWorkflowWithVerify(path, ctx) {
3250
3380
  // #716 — re-read the active record after this exact successful open before
3251
3381
  // refreshing the next command's stamp. This prevents a late reply from an
3252
3382
  // earlier open from overwriting the fence after another tab became active.
3253
- if (!res.isError)
3254
- await refreshOpenWorkflowUuid(ctx, path, res);
3383
+ if (res.isError) {
3384
+ // #887 THE REPORTER'S OWN PATH. The panel throws for every open verdict
3385
+ // short of PROVEN, so the case in the ticket ("Tool errors saying the
3386
+ // requested workflow is active") arrives here, not below. Gating the
3387
+ // corroborating read on success left exactly the reported scenario
3388
+ // unexamined: the panel's message asserts the target IS active, and nothing
3389
+ // in this process ever checked whether it still was.
3390
+ //
3391
+ // OBSERVE ONLY — never adopt. The open did not prove itself, and the panel
3392
+ // deliberately withholds `workflow_uuid` in that case to keep the fence
3393
+ // fail-closed. Reading what is active cannot change that; it only adds a
3394
+ // fact to a message that is already an error.
3395
+ //
3396
+ // AND ONLY FOR THE CLASS WHERE THE LOAD ACTUALLY RAN. A genuine acked
3397
+ // failure — a missing file, a real executor error — means nothing was
3398
+ // opened, so another workflow being active is the expected state, not
3399
+ // drift; warning there would be noise, and this repo already pins that a
3400
+ // genuine error must not trigger a workflow_list round trip at all. Every
3401
+ // rebind-unproven message the panel emits opens with `workflow_open RAN`
3402
+ // (its own contract wording for "the load executed, the proof did not"),
3403
+ // which is exactly the class that can assert a false active workflow.
3404
+ if (!/workflow_open RAN/i.test(toolResultText(res)))
3405
+ return res;
3406
+ const drift = await observeActiveAfterOpen(ctx, path);
3407
+ if (!drift)
3408
+ return res;
3409
+ return fail(`${toolResultText(res)}\n\nAND — checked after that failure — ${drift.activeLabel} is the ` +
3410
+ `ACTIVE workflow now, not ${path}. Whatever that message says about ${path} being active, ` +
3411
+ `it is not: the canvas you would read or write is ${drift.activeLabel}. Do NOT save, ` +
3412
+ `save-as, or edit expecting ${path}. Call panel_list_workflows to see the current state.`);
3413
+ }
3414
+ {
3415
+ const drift = await refreshOpenWorkflowUuid(ctx, path, res);
3416
+ // #887 — the read above is the ONLY observation in this whole path taken
3417
+ // after a real round trip, so it is the only thing that can catch the active
3418
+ // pointer having settled elsewhere. It already declined to adopt the uuid;
3419
+ // reporting a plain success alongside that silence is what let an agent
3420
+ // Save-As onto the wrong canvas.
3421
+ //
3422
+ // This FAILS the open rather than annotating it. The caller asked for a
3423
+ // workflow to be made active and it is not active — a success naming the
3424
+ // requested path is false on the one point the caller acts on, and the next
3425
+ // act is typically a write. The session's fence is untouched (nothing was
3426
+ // adopted), so the tab that IS active keeps its own protection.
3427
+ if (drift) {
3428
+ return fail(`workflow_open: ${path} was opened, but ${drift.activeLabel} is the ACTIVE workflow now — ` +
3429
+ `the panel confirmed this on a re-read after the open completed. This session's ` +
3430
+ `workflow identity was NOT re-pointed at ${path}, so the canvas you would read or ` +
3431
+ `write is ${drift.activeLabel}, not ${path}. Do NOT save, save-as, or edit expecting ` +
3432
+ `${path}. Call panel_list_workflows to see the current state, then re-open ${path} ` +
3433
+ `if you still need it — another tab became active during or after the open.`);
3434
+ }
3435
+ }
3255
3436
  return res;
3256
3437
  }
3257
3438
  if (!dispatchedRid) {