comfyui-mcp 0.49.0 → 0.49.2
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/README.md +5 -10
- package/dist/comfyui/cloud-client.js +2 -2
- package/dist/comfyui/cloud-client.js.map +1 -1
- package/dist/config.js +22 -0
- package/dist/config.js.map +1 -1
- package/dist/orchestrator/agent-backend.js +9 -0
- package/dist/orchestrator/agent-backend.js.map +1 -1
- package/dist/orchestrator/antigravity-backend.js +3 -2
- package/dist/orchestrator/antigravity-backend.js.map +1 -1
- package/dist/orchestrator/call-tool-admission.js +160 -0
- package/dist/orchestrator/call-tool-admission.js.map +1 -0
- package/dist/orchestrator/chatgpt-oauth-backend.js +3 -2
- package/dist/orchestrator/chatgpt-oauth-backend.js.map +1 -1
- package/dist/orchestrator/claude-backend.js +300 -3
- package/dist/orchestrator/claude-backend.js.map +1 -1
- package/dist/orchestrator/codex-backend.js +3 -2
- package/dist/orchestrator/codex-backend.js.map +1 -1
- package/dist/orchestrator/gemini-backend.js +3 -2
- package/dist/orchestrator/gemini-backend.js.map +1 -1
- package/dist/orchestrator/grok-backend.js +5 -3
- package/dist/orchestrator/grok-backend.js.map +1 -1
- package/dist/orchestrator/index.js +169 -137
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/ollama-backend.js +3 -2
- package/dist/orchestrator/ollama-backend.js.map +1 -1
- package/dist/orchestrator/panel-agent.js +89 -19
- package/dist/orchestrator/panel-agent.js.map +1 -1
- package/dist/orchestrator/panel-tools.js +955 -81
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/orchestrator/pi-backend.js +3 -2
- package/dist/orchestrator/pi-backend.js.map +1 -1
- package/dist/orchestrator/session-store.js +22 -0
- package/dist/orchestrator/session-store.js.map +1 -1
- package/dist/services/civitai-resolver.js +22 -7
- package/dist/services/civitai-resolver.js.map +1 -1
- package/dist/services/comfy-cli.js +1 -1
- package/dist/services/download-jobs.js +1 -1
- package/dist/services/extra-paths.js +7 -7
- package/dist/services/extra-paths.js.map +1 -1
- package/dist/services/graph-query.js +10 -4
- package/dist/services/graph-query.js.map +1 -1
- package/dist/services/manager-config.js +11 -2
- package/dist/services/manager-config.js.map +1 -1
- package/dist/services/model-resolver.js +4 -4
- package/dist/services/model-resolver.js.map +1 -1
- package/dist/services/node-authoring.js +3 -3
- package/dist/services/node-authoring.js.map +1 -1
- package/dist/services/node-dev.js +3 -3
- package/dist/services/node-dev.js.map +1 -1
- package/dist/services/node-management.js +185 -9
- package/dist/services/node-management.js.map +1 -1
- package/dist/services/node-snapshots.js +117 -12
- package/dist/services/node-snapshots.js.map +1 -1
- package/dist/services/node-verify.js +1 -1
- package/dist/services/node-verify.js.map +1 -1
- package/dist/services/output-dir.js +1 -1
- package/dist/services/output-dir.js.map +1 -1
- package/dist/services/panel-installer.js +466 -6
- package/dist/services/panel-installer.js.map +1 -1
- package/dist/services/panel-pending-cancel.js +446 -0
- package/dist/services/panel-pending-cancel.js.map +1 -0
- package/dist/services/panel-pin-guard.js +50 -3
- package/dist/services/panel-pin-guard.js.map +1 -1
- package/dist/services/panel-sync.js +3 -20
- package/dist/services/panel-sync.js.map +1 -1
- package/dist/services/process-control.js +132 -8
- package/dist/services/process-control.js.map +1 -1
- package/dist/services/queue-manager.js +1 -1
- package/dist/services/queue-manager.js.map +1 -1
- package/dist/services/queue-monitor.js +1 -1
- package/dist/services/queue-monitor.js.map +1 -1
- package/dist/services/queue-monitor.self-attribution.test.js +1 -1
- package/dist/services/queue-monitor.self-attribution.test.js.map +1 -1
- package/dist/services/ui-bridge.js +180 -17
- package/dist/services/ui-bridge.js.map +1 -1
- package/dist/services/update-comfyui.js +29 -2
- package/dist/services/update-comfyui.js.map +1 -1
- package/dist/services/workspace-env.js +3 -3
- package/dist/services/workspace-env.js.map +1 -1
- package/dist/tools/api-nodes.js +1 -1
- package/dist/tools/api-nodes.js.map +1 -1
- package/dist/tools/batches.js +1 -1
- package/dist/tools/batches.js.map +1 -1
- package/dist/tools/compact.js +32 -6
- package/dist/tools/compact.js.map +1 -1
- package/dist/tools/extra-paths.js +1 -1
- package/dist/tools/extra-paths.js.map +1 -1
- package/dist/tools/generate-3d.js +2 -2
- package/dist/tools/generate-3d.js.map +1 -1
- package/dist/tools/install-panel.js +62 -22
- package/dist/tools/install-panel.js.map +1 -1
- package/dist/tools/model-explorer.js +175 -117
- package/dist/tools/model-explorer.js.map +1 -1
- package/dist/tools/queue-management.js +117 -135
- package/dist/tools/queue-management.js.map +1 -1
- package/dist/tools/run-template.js +4 -4
- package/dist/tools/run-template.js.map +1 -1
- package/dist/tools/vocabulary.js +123 -19
- package/dist/tools/vocabulary.js.map +1 -1
- package/dist/tools/workflow-autoload.js +5 -5
- package/dist/tools/workflow-autoload.js.map +1 -1
- package/dist/tools/workflow-execute.js +1 -1
- package/dist/tools/workflow-execute.js.map +1 -1
- package/dist/tools/workspace-env.js +53 -28
- package/dist/tools/workspace-env.js.map +1 -1
- package/docs/design/panel-surface.txt +1 -0
- package/docs/design/tool-surface.txt +3 -0
- package/package.json +2 -2
- package/plugin/commands/batch.md +1 -1
- package/plugin/commands/gen.md +2 -2
- package/plugin/scripts/monitor-progress.mjs +1 -1
- package/plugin/skills/comfyui-core/SKILL.md +15 -13
- package/plugin/skills/director/SKILL.md +1 -1
- package/plugin/skills/ltxv2-video/SKILL.md +1 -1
- package/plugin/skills/troubleshooting/SKILL.md +1 -1
- package/plugin/skills/video-extend/SKILL.md +1 -1
- package/plugin/skills/workflow-layout/SKILL.md +4 -5
- package/scripts/arena-scenarios.mjs +5 -5
- package/scripts/check-tool-vocabulary.mts +9 -0
- package/scripts/gen-tool-docs.ts +31 -10
|
@@ -31,7 +31,7 @@ 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, } from "../services/ui-bridge.js";
|
|
34
|
+
import { dispatchOutcomeOf, 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";
|
|
@@ -40,9 +40,9 @@ import { getNsfwConsent, setNsfwConsent } from "../services/panel-settings.js";
|
|
|
40
40
|
import { QueueMonitor } from "../services/queue-monitor.js";
|
|
41
41
|
import { getClient, getObjectInfo, backfillObjectInfo, resetClient, resetObjectInfoCache, } from "../comfyui/client.js";
|
|
42
42
|
import { convertUiToApi, collectNodeTypes } from "../services/workflow-converter.js";
|
|
43
|
-
import { restartComfyUI } from "../services/process-control.js";
|
|
43
|
+
import { restartComfyUI, preflightLocalRestart, recordRestartDispatch, clearRestartDispatch, getRestartDispatchRecord, RESTART_DISPATCH_CAUSATION_WINDOW_MS, PROCESS_WIDE_RESTART_DISPATCH_TOKEN, __processControlTestHooks, } from "../services/process-control.js";
|
|
44
44
|
import { resetManagerApiCache } from "../services/manager-api-cache.js";
|
|
45
|
-
import { isRemoteMode, isCloudMode, getBootLocalComfyUIBaseUrl, getComfyUIBaseUrl, } from "../config.js";
|
|
45
|
+
import { isRemoteMode, isCloudMode, getBootLocalComfyUIBaseUrl, getComfyUIBaseUrl, getComfyuiTargetGeneration, } from "../config.js";
|
|
46
46
|
import { sliceWorkflow } from "../services/workflow-slicer.js";
|
|
47
47
|
import { validateA2UISpecServer } from "../services/a2ui-spec.js";
|
|
48
48
|
/** Treat these as an affirmative answer to a yes/no confirm card (destructive-op
|
|
@@ -206,6 +206,19 @@ const MAX_REBOOT_BUDGET_MS = 240_000; // 240s → settle+budget ≤ 250s < 300s
|
|
|
206
206
|
// fast with an actionable retry / restart_comfyui hint. It NEVER shortcuts the
|
|
207
207
|
// confirmation itself (no auto-confirm): it bounds only how long we wait for the answer.
|
|
208
208
|
const RESTART_CONFIRM_TIMEOUT_MS = 90_000; // 90s
|
|
209
|
+
// #742 decline-probe recheck window: a single ECONNREFUSED is NOT proof of a
|
|
210
|
+
// lost server — a genuinely restarting instance is refused during its normal
|
|
211
|
+
// down window (codex gate). When the decline path probes before reporting, it
|
|
212
|
+
// rechecks over this SHORT, bounded window and only declares DOWN when the
|
|
213
|
+
// endpoint is STILL refused at the end of it; a recovery inside the window is
|
|
214
|
+
// reported as such. Deliberately small: the turn already ended on the user's
|
|
215
|
+
// decline, so this is a report-time check and must not stall.
|
|
216
|
+
const DECLINE_PROBE_WINDOW_MS = 6_000; // 6s total
|
|
217
|
+
const DECLINE_PROBE_INTERVAL_MS = 2_000; // 2s between samples
|
|
218
|
+
const DECLINE_PROBE_TIMEOUT_MS = 2_000; // per-sample probe ceiling
|
|
219
|
+
// #742 r4/r5: the decline path may name restart causation only against a
|
|
220
|
+
// dispatch RECORDED within RESTART_DISPATCH_CAUSATION_WINDOW_MS (imported from
|
|
221
|
+
// process-control) whose token THIS session holds — see sessionRestartDispatch.
|
|
209
222
|
function parsePositiveNumberEnv(name, fallback) {
|
|
210
223
|
const raw = process.env[name];
|
|
211
224
|
if (raw == null || raw === "")
|
|
@@ -245,6 +258,30 @@ export const __panelToolsTestHooks = {
|
|
|
245
258
|
setHealthProbe(fn) {
|
|
246
259
|
healthProbeOverride = fn;
|
|
247
260
|
},
|
|
261
|
+
/** Inject a fake #742 restart preflight so reboot tests don't probe real
|
|
262
|
+
* processes/ports. null restores the live preflightLocalRestart. */
|
|
263
|
+
setLocalRestartPreflight(fn) {
|
|
264
|
+
localRestartPreflightOverride = fn;
|
|
265
|
+
},
|
|
266
|
+
/** Inject a fast #742 decline-probe recheck window so decline-path tests
|
|
267
|
+
* don't wait the real ~6s. null restores the DECLINE_PROBE_* constants. */
|
|
268
|
+
setDeclineProbeTiming(timing) {
|
|
269
|
+
declineProbeTimingOverride = timing;
|
|
270
|
+
},
|
|
271
|
+
/** Direct access to the #742 decline recheck loop so its hard-deadline
|
|
272
|
+
* guarantee (codex gate r2) can be unit-tested with a custom deadline. */
|
|
273
|
+
probeDeclineRecovery,
|
|
274
|
+
/** r5: the restart-dispatch record THIS session (ctx) holds, or null. */
|
|
275
|
+
getSessionRestartDispatch(ctx) {
|
|
276
|
+
return sessionRestartDispatch(ctx);
|
|
277
|
+
},
|
|
278
|
+
/** r5: seed a restart-dispatch record HELD BY this session (ctx) — with an
|
|
279
|
+
* explicit `at` so fresh/stale shapes don't need a real restart. */
|
|
280
|
+
seedSessionRestartDispatch(ctx, record) {
|
|
281
|
+
const token = randomUUID();
|
|
282
|
+
__processControlTestHooks.setRestartDispatchRecord(token, record);
|
|
283
|
+
sessionRestartDispatchTokens.set(ctx, token);
|
|
284
|
+
},
|
|
248
285
|
looksLikeSystemStats,
|
|
249
286
|
probeComfyHealth,
|
|
250
287
|
probeComfyEndpoint,
|
|
@@ -357,12 +394,15 @@ const MUTATING_GRAPH_EDIT_CMDS = new Set([
|
|
|
357
394
|
"graph_disconnect",
|
|
358
395
|
"graph_set_widget",
|
|
359
396
|
"graph_set_node_property",
|
|
397
|
+
// Legacy bridge commands remain behind compatibility tool names so panels that
|
|
398
|
+
// predate graph_edit_node continue to receive commands they actually implement.
|
|
360
399
|
"graph_move_node",
|
|
361
400
|
"graph_resize_node",
|
|
362
401
|
"graph_set_title",
|
|
363
|
-
"graph_set_node_mode",
|
|
364
|
-
"graph_set_node_color",
|
|
365
402
|
"graph_set_node_collapsed",
|
|
403
|
+
"graph_set_node_color",
|
|
404
|
+
"graph_edit_node",
|
|
405
|
+
"graph_set_node_mode",
|
|
366
406
|
"graph_update_node",
|
|
367
407
|
"graph_create_group",
|
|
368
408
|
"graph_edit_group",
|
|
@@ -645,6 +685,66 @@ function normalizeProbe(v) {
|
|
|
645
685
|
return "down";
|
|
646
686
|
return v;
|
|
647
687
|
}
|
|
688
|
+
/**
|
|
689
|
+
* The #742 decline-path recheck: poll `base` over a short, bounded window
|
|
690
|
+
* (clamped to `deadline`) so a server that is merely mid-restart is not
|
|
691
|
+
* falsely declared lost on a single ECONNREFUSED. Samples immediately, then
|
|
692
|
+
* sleeps intervalMs between samples — "still refused at the END of the
|
|
693
|
+
* window" is the only down verdict, and ONLY when the full window ran: a
|
|
694
|
+
* deadline that truncates the window downgrades the verdict to "ambiguous"
|
|
695
|
+
* regardless of the samples taken (r3). The deadline is HARD (codex gate
|
|
696
|
+
* r2): no awaited probe may START once the remaining budget is exhausted —
|
|
697
|
+
* the loop stops and the verdict falls to the last known state (or
|
|
698
|
+
* "ambiguous" when nothing was sampled). Never throws.
|
|
699
|
+
*/
|
|
700
|
+
async function probeDeclineRecovery(base, windowMs, intervalMs, probeTimeoutMs, deadline) {
|
|
701
|
+
const start = Date.now();
|
|
702
|
+
const windowEnd = start + Math.max(1, windowMs);
|
|
703
|
+
const end = Math.min(windowEnd, deadline);
|
|
704
|
+
// r3: a window the deadline cuts short can never prove permanence — DOWN
|
|
705
|
+
// requires the FULL recheck window to have run its course.
|
|
706
|
+
const truncated = end < windowEnd;
|
|
707
|
+
const probe = healthProbeOverride ?? probeComfyEndpoint;
|
|
708
|
+
let attempts = 0;
|
|
709
|
+
let sawDown = false;
|
|
710
|
+
let last = "unknown";
|
|
711
|
+
for (;;) {
|
|
712
|
+
// HARD deadline: NEVER begin an awaited probe with an exhausted remainder
|
|
713
|
+
// (clamping an expired remainder to a 1ms timeout would still start — and
|
|
714
|
+
// await — a probe PAST the deadline, defeating the guarantee).
|
|
715
|
+
const remaining = end - Date.now();
|
|
716
|
+
if (remaining <= 0)
|
|
717
|
+
break;
|
|
718
|
+
attempts++;
|
|
719
|
+
const t = Math.min(probeTimeoutMs, remaining);
|
|
720
|
+
let status = "unknown";
|
|
721
|
+
try {
|
|
722
|
+
status = normalizeProbe(await probe(base, t));
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
status = "unknown";
|
|
726
|
+
}
|
|
727
|
+
if (status === "healthy") {
|
|
728
|
+
return {
|
|
729
|
+
status: sawDown ? "recovered" : "healthy",
|
|
730
|
+
attempts,
|
|
731
|
+
waited_ms: Date.now() - start,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
if (status === "down")
|
|
735
|
+
sawDown = true;
|
|
736
|
+
last = status;
|
|
737
|
+
const left = end - Date.now();
|
|
738
|
+
if (left <= 0)
|
|
739
|
+
break;
|
|
740
|
+
await new Promise((r) => setTimeout(r, Math.max(1, Math.min(intervalMs, left))));
|
|
741
|
+
}
|
|
742
|
+
return {
|
|
743
|
+
status: last === "down" && !truncated ? "down" : "ambiguous",
|
|
744
|
+
attempts,
|
|
745
|
+
waited_ms: Date.now() - start,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
648
748
|
/**
|
|
649
749
|
* The FIXED ComfyUI base URL to health-probe during a reboot readiness wait, or
|
|
650
750
|
* null when we must fall back to the panel round-trip (as before #509). Captured by
|
|
@@ -708,6 +808,49 @@ function captureRebootHealthBase(ctx) {
|
|
|
708
808
|
return loopbackProbeUrl(base);
|
|
709
809
|
}
|
|
710
810
|
let healthProbeOverride = null;
|
|
811
|
+
/** Test injection for the #742 refuse-safe restart preflight (the real one is
|
|
812
|
+
* preflightLocalRestart in process-control). null → the live preflight. */
|
|
813
|
+
let localRestartPreflightOverride = null;
|
|
814
|
+
/** Test injection for the #742 decline-probe recheck window, so tests don't
|
|
815
|
+
* wait the real ~6s. null → the DECLINE_PROBE_* constants. */
|
|
816
|
+
let declineProbeTimingOverride = null;
|
|
817
|
+
// #742 r5: restart-dispatch tokens held PER SESSION. Each MCP session gets its
|
|
818
|
+
// own PanelToolCtx (one per connection — see the session factory in
|
|
819
|
+
// panel-mcp-http), so keying by the ctx object scopes a dispatch record to the
|
|
820
|
+
// session that dispatched it: session A's failed restart can never ground
|
|
821
|
+
// causation for session B's decline, and A's recovery clears only A's record.
|
|
822
|
+
// WeakMap → entries die with their session's ctx.
|
|
823
|
+
const sessionRestartDispatchTokens = new WeakMap();
|
|
824
|
+
/** Stamp a restart dispatch and hold its token on THIS session (replacing any
|
|
825
|
+
* record the session previously held — a session has at most one live one).
|
|
826
|
+
* Returns the held token so clears can be CLEAR-IF-SAME (r15). */
|
|
827
|
+
function stampSessionRestartDispatch(ctx, base) {
|
|
828
|
+
const prev = sessionRestartDispatchTokens.get(ctx);
|
|
829
|
+
if (prev)
|
|
830
|
+
clearRestartDispatch(prev);
|
|
831
|
+
const token = recordRestartDispatch(base);
|
|
832
|
+
sessionRestartDispatchTokens.set(ctx, token);
|
|
833
|
+
return token;
|
|
834
|
+
}
|
|
835
|
+
/** The dispatch token THIS session currently holds, or undefined. */
|
|
836
|
+
function sessionRestartDispatchToken(ctx) {
|
|
837
|
+
return sessionRestartDispatchTokens.get(ctx);
|
|
838
|
+
}
|
|
839
|
+
/** The restart-dispatch record THIS session holds, or null. */
|
|
840
|
+
function sessionRestartDispatch(ctx) {
|
|
841
|
+
const held = sessionRestartDispatchToken(ctx);
|
|
842
|
+
return held ? getRestartDispatchRecord(held) : null;
|
|
843
|
+
}
|
|
844
|
+
/** Clear ONLY when the session STILL holds `token` (r15): a newer dispatch
|
|
845
|
+
* that landed since `token` was validated keeps its record — a
|
|
846
|
+
* read-current-then-clear would evict the newer dispatch's record and make a
|
|
847
|
+
* later persistent DOWN falsely report "no restart was dispatched". */
|
|
848
|
+
function clearSessionRestartDispatchIfSame(ctx, token) {
|
|
849
|
+
if (sessionRestartDispatchTokens.get(ctx) !== token)
|
|
850
|
+
return;
|
|
851
|
+
sessionRestartDispatchTokens.delete(ctx);
|
|
852
|
+
clearRestartDispatch(token);
|
|
853
|
+
}
|
|
711
854
|
/**
|
|
712
855
|
* Observe the boot endpoint's recovery AFTER a reboot was dispatched, and certify ONLY on
|
|
713
856
|
* an OBSERVED DOWN→UP cycle. Acceptance (dispatch confirmed/dropped) is the guard against
|
|
@@ -1116,6 +1259,30 @@ function activeMatchesTarget(active, path) {
|
|
|
1116
1259
|
return false;
|
|
1117
1260
|
return stripJsonExt(a.filename) === want || stripJsonExt(a.path) === want;
|
|
1118
1261
|
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Exact saved-workflow identity for command-fence refreshes. Unlike
|
|
1264
|
+
* activeMatchesTarget(), this deliberately does NOT accept a filename/basename:
|
|
1265
|
+
* a successful open of `workflows/a/foo.json` cannot safely adopt the UUID of
|
|
1266
|
+
* an active `workflows/b/foo.json`. The panel's saved workflow routing key is
|
|
1267
|
+
* `wf:<canonical path>`, which is the only pathless fallback that is still an
|
|
1268
|
+
* exact identity.
|
|
1269
|
+
*/
|
|
1270
|
+
function activeMatchesOpenRefreshTarget(active, path) {
|
|
1271
|
+
const targetIdentity = canonicalRequestedSavedIdentity(path);
|
|
1272
|
+
return !!targetIdentity && canonicalSavedRecordIdentity(active) === targetIdentity;
|
|
1273
|
+
}
|
|
1274
|
+
/** Normalizes only syntax the panel's saved-path/routing identity normalizes. */
|
|
1275
|
+
function canonicalSavedWorkflowPath(value) {
|
|
1276
|
+
if (typeof value !== "string" || !value)
|
|
1277
|
+
return null;
|
|
1278
|
+
const normalized = value.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+/g, "/");
|
|
1279
|
+
// `tmp:<uuid>` is a per-tab ephemeral routing handle, never a saved workflow
|
|
1280
|
+
// path. `wf:<path>` is likewise a routing token, not a path; accepting either
|
|
1281
|
+
// here could manufacture `wf:tmp:…` and refresh a durable command fence.
|
|
1282
|
+
if (!normalized || /^(?:tmp:|wf:)/i.test(normalized))
|
|
1283
|
+
return null;
|
|
1284
|
+
return normalized;
|
|
1285
|
+
}
|
|
1119
1286
|
let openVerifyTimingOverride = null;
|
|
1120
1287
|
function getOpenVerifyTiming() {
|
|
1121
1288
|
if (openVerifyTimingOverride)
|
|
@@ -1126,6 +1293,62 @@ function getOpenVerifyTiming() {
|
|
|
1126
1293
|
probeTimeoutMs: Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_OPEN_VERIFY_PROBE_S", 4) * 1000),
|
|
1127
1294
|
};
|
|
1128
1295
|
}
|
|
1296
|
+
// Strict canonical RFC UUID for a command-fence refresh: lowercase only, an
|
|
1297
|
+
// assigned RFC version, and the RFC variant. Do not normalize this transport
|
|
1298
|
+
// value — an uppercase or malformed producer value must leave the prior fence.
|
|
1299
|
+
// Keep this check at the command-response boundary: a missing, malformed, or
|
|
1300
|
+
// old-panel response must leave the existing command fence intact, never turn
|
|
1301
|
+
// a graph mutation into an unstamped send.
|
|
1302
|
+
const WORKFLOW_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
1303
|
+
function responseWorkflowUuid(value) {
|
|
1304
|
+
if (!value || typeof value !== "object")
|
|
1305
|
+
return undefined;
|
|
1306
|
+
const raw = value.workflow_uuid;
|
|
1307
|
+
if (typeof raw !== "string")
|
|
1308
|
+
return undefined;
|
|
1309
|
+
return WORKFLOW_UUID_RE.test(raw) ? raw : undefined;
|
|
1310
|
+
}
|
|
1311
|
+
/** Refresh only the bridge-owned command stamp, never caller data. */
|
|
1312
|
+
function refreshWorkflowUuid(ctx, value) {
|
|
1313
|
+
const uuid = responseWorkflowUuid(value);
|
|
1314
|
+
const refresh = ctx.bridge.refreshWorkflowUuid;
|
|
1315
|
+
return uuid && typeof refresh === "function" ? refresh.call(ctx.bridge, ctx.tabId, uuid) : false;
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* A successful open reply belongs to that exact bridge request, but another
|
|
1319
|
+
* navigation may have completed before the caller receives it. Re-read the
|
|
1320
|
+
* active object and accept the returned UUID only while it still names this
|
|
1321
|
+
* open's canonical target; otherwise leave the old stamp to fail closed.
|
|
1322
|
+
*/
|
|
1323
|
+
async function refreshOpenWorkflowUuid(ctx, requestedPath, openResult) {
|
|
1324
|
+
const parsedOpen = parseToolResultJson(openResult);
|
|
1325
|
+
const opened = parsedOpen?.opened;
|
|
1326
|
+
const openedPath = opened && typeof opened === "object" && typeof opened.path === "string"
|
|
1327
|
+
? opened.path
|
|
1328
|
+
: undefined;
|
|
1329
|
+
// The caller's original token is the fence target. A panel can resolve an
|
|
1330
|
+
// alias/basename to a path, but that reply must never retroactively turn the
|
|
1331
|
+
// alias into a UUID-refresh authorization. Require the reply to corroborate
|
|
1332
|
+
// the original exact saved identity before consulting the live active record.
|
|
1333
|
+
const requestedIdentity = canonicalRequestedSavedIdentity(requestedPath);
|
|
1334
|
+
const openedIdentity = openedPath
|
|
1335
|
+
? canonicalSavedRecordIdentity({ path: openedPath, routing_key: parsedOpen?.routing_key })
|
|
1336
|
+
: null;
|
|
1337
|
+
if (!requestedIdentity || requestedIdentity !== openedIdentity)
|
|
1338
|
+
return;
|
|
1339
|
+
try {
|
|
1340
|
+
const list = parseToolResultJson(await ctx.call({ cmd: "workflow_list" }, 6000));
|
|
1341
|
+
if (!list || !activeMatchesOpenRefreshTarget(list.active, requestedPath))
|
|
1342
|
+
return;
|
|
1343
|
+
// Prefer the just-read active UUID; use the command's correlated response as
|
|
1344
|
+
// a compatibility fallback only after that same active-target confirmation.
|
|
1345
|
+
refreshWorkflowUuid(ctx, list.active) || refreshWorkflowUuid(ctx, parseToolResultJson(openResult));
|
|
1346
|
+
}
|
|
1347
|
+
catch {
|
|
1348
|
+
// A failed read must not convert a confirmed open into a failure, nor should
|
|
1349
|
+
// it clear the old stamp. The next mutation remains safely fenced.
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1129
1352
|
/** Exact resolved-path check for an open receipt. A filename/basename is not a
|
|
1130
1353
|
* workflow identity: `other/foo.json` must never confirm `wanted/foo.json`. */
|
|
1131
1354
|
function resolvedOpenPathMatches(receipt, path) {
|
|
@@ -1172,7 +1395,26 @@ async function waitForOpenReceipt(ctx, path, rid, timing) {
|
|
|
1172
1395
|
return { receipt: "unknown", waited_ms: Date.now() - start, attempts };
|
|
1173
1396
|
}
|
|
1174
1397
|
if (receipt.applied === true) {
|
|
1175
|
-
|
|
1398
|
+
// A receipt proves this open applied, but a later user switch could
|
|
1399
|
+
// have made another canvas active before this probe. Refresh only
|
|
1400
|
+
// when the current active object still names this exact target.
|
|
1401
|
+
const active = parsed.active;
|
|
1402
|
+
const resolved = receipt.resolved;
|
|
1403
|
+
const resolvedPath = resolved.path;
|
|
1404
|
+
// The receipt has already proved the command's exact resolved path.
|
|
1405
|
+
// Still require its routing claim and the live active record to
|
|
1406
|
+
// corroborate the ORIGINAL request identity before refreshing.
|
|
1407
|
+
const requestedIdentity = canonicalRequestedSavedIdentity(path);
|
|
1408
|
+
const resolvedIdentity = canonicalSavedRecordIdentity({
|
|
1409
|
+
path: resolvedPath,
|
|
1410
|
+
routing_key: resolved.routing_key,
|
|
1411
|
+
});
|
|
1412
|
+
const workflowUuid = requestedIdentity &&
|
|
1413
|
+
requestedIdentity === resolvedIdentity &&
|
|
1414
|
+
activeMatchesOpenRefreshTarget(active, path)
|
|
1415
|
+
? responseWorkflowUuid(active)
|
|
1416
|
+
: undefined;
|
|
1417
|
+
return { receipt: "applied", workflowUuid, waited_ms: Date.now() - start, attempts };
|
|
1176
1418
|
}
|
|
1177
1419
|
if (receipt.applied === false) {
|
|
1178
1420
|
return {
|
|
@@ -1228,8 +1470,14 @@ async function openWorkflowWithVerify(path, ctx) {
|
|
|
1228
1470
|
// caller must see it as-is. A slow-ack TIMEOUT or a mid-command reconnect DROP
|
|
1229
1471
|
// ("OUTCOME UNKNOWN", #402) both warrant a receipt lookup, which can turn an
|
|
1230
1472
|
// unknown outcome into a definite one without inferring from active state.
|
|
1231
|
-
if (!isAckTimeout(res) && !isReconnectDrop(res))
|
|
1473
|
+
if (!isAckTimeout(res) && !isReconnectDrop(res)) {
|
|
1474
|
+
// #716 — re-read the active record after this exact successful open before
|
|
1475
|
+
// refreshing the next command's stamp. This prevents a late reply from an
|
|
1476
|
+
// earlier open from overwriting the fence after another tab became active.
|
|
1477
|
+
if (!res.isError)
|
|
1478
|
+
await refreshOpenWorkflowUuid(ctx, path, res);
|
|
1232
1479
|
return res;
|
|
1480
|
+
}
|
|
1233
1481
|
if (!dispatchedRid) {
|
|
1234
1482
|
return fail(`${toolResultText(res)}\n\nworkflow_open outcome is undetermined: the command may have been sent, but this ` +
|
|
1235
1483
|
`bridge/panel combination did not expose a request id for receipt correlation. Do not assume ` +
|
|
@@ -1238,6 +1486,8 @@ async function openWorkflowWithVerify(path, ctx) {
|
|
|
1238
1486
|
const timing = getOpenVerifyTiming();
|
|
1239
1487
|
const verify = await waitForOpenReceipt(ctx, path, dispatchedRid, timing);
|
|
1240
1488
|
if (verify.receipt === "applied") {
|
|
1489
|
+
if (verify.workflowUuid)
|
|
1490
|
+
refreshWorkflowUuid(ctx, { workflow_uuid: verify.workflowUuid });
|
|
1241
1491
|
return ok({
|
|
1242
1492
|
opened: { path },
|
|
1243
1493
|
recovered: true,
|
|
@@ -1324,11 +1574,32 @@ async function resolveOpenWorkflow(ctx, path) {
|
|
|
1324
1574
|
rec = exact.length === 1 ? exact[0] : activePreferred.length === 1 ? activePreferred[0] : undefined;
|
|
1325
1575
|
}
|
|
1326
1576
|
if (rec) {
|
|
1327
|
-
|
|
1577
|
+
const isActive = computeIsActive(rec, activeObj);
|
|
1578
|
+
// Only the top-level active object is the panel's direct current-canvas
|
|
1579
|
+
// report. An affirmatively-active per-record flag alone remains sufficient
|
|
1580
|
+
// to route a legacy pin, but is NOT enough to replace a command identity:
|
|
1581
|
+
// a stale/mixed list can say `rec.active:true` for A while top-level active
|
|
1582
|
+
// (and its UUID) is B. Refresh only when both expose the same exact saved
|
|
1583
|
+
// path/routing identity; aliases and routing tokens are deliberately
|
|
1584
|
+
// irrelevant. The caller-path check happens in resolvePinTarget(), where
|
|
1585
|
+
// the refreshed value is handed to the bridge-owned command fence.
|
|
1586
|
+
const workflowUuid = isActive === true &&
|
|
1587
|
+
activeRecordMatchesExactSavedIdentity(rec, activeObj)
|
|
1588
|
+
? responseWorkflowUuid(activeObj)
|
|
1589
|
+
: undefined;
|
|
1590
|
+
return { record: rec, isActive, activeLabel, workflowUuid };
|
|
1328
1591
|
}
|
|
1329
1592
|
// The active object is authoritative too, in case it isn't mirrored in the array.
|
|
1330
1593
|
if (activeMatchesTarget(activeObj, path)) {
|
|
1331
|
-
return {
|
|
1594
|
+
return {
|
|
1595
|
+
record: activeObj,
|
|
1596
|
+
isActive: true,
|
|
1597
|
+
activeLabel,
|
|
1598
|
+
// The top-level active object was not corroborated by a selected entry in
|
|
1599
|
+
// workflow_list. It may remain a compatibility-valid pin selector, but it
|
|
1600
|
+
// is never a safe source for replacing a command fence.
|
|
1601
|
+
workflowUuid: undefined,
|
|
1602
|
+
};
|
|
1332
1603
|
}
|
|
1333
1604
|
return NOT_OPEN;
|
|
1334
1605
|
}
|
|
@@ -1376,6 +1647,53 @@ function identityVerdict(rec, activeObj) {
|
|
|
1376
1647
|
}
|
|
1377
1648
|
return comparable ? false : undefined;
|
|
1378
1649
|
}
|
|
1650
|
+
/**
|
|
1651
|
+
* Exact saved-workflow identity required before an active-list UUID can refresh
|
|
1652
|
+
* a command fence. `activeMatchesTarget()` and an item's `active:true` are
|
|
1653
|
+
* intentionally alias/compatibility-friendly for pin resolution; neither can
|
|
1654
|
+
* prove that the record carrying the pin and top-level `active` name the same
|
|
1655
|
+
* saved canvas. Reject contradictory path/routing pairs and never fall back to
|
|
1656
|
+
* filename, basename, or key aliases here.
|
|
1657
|
+
*/
|
|
1658
|
+
function activeRecordMatchesExactSavedIdentity(rec, activeObj) {
|
|
1659
|
+
if (!activeObj || typeof activeObj !== "object")
|
|
1660
|
+
return false;
|
|
1661
|
+
const recordIdentity = canonicalSavedRecordIdentity(rec);
|
|
1662
|
+
const activeIdentity = canonicalSavedRecordIdentity(activeObj);
|
|
1663
|
+
return !!recordIdentity && recordIdentity === activeIdentity;
|
|
1664
|
+
}
|
|
1665
|
+
/** Canonical `wf:<path>` identity for a caller's literal saved-workflow path. */
|
|
1666
|
+
function canonicalRequestedSavedIdentity(path) {
|
|
1667
|
+
const canonicalPath = canonicalSavedWorkflowPath(path);
|
|
1668
|
+
// A bare basename is an alias selector, not the canonical saved path the
|
|
1669
|
+
// command fence must bind. It may still resolve a legacy pin, but must never
|
|
1670
|
+
// authorize replacing its existing UUID stamp.
|
|
1671
|
+
return canonicalPath && canonicalPath.includes("/") ? `wf:${canonicalPath}` : null;
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Canonical `wf:<path>` identity, but only for a complete corroborating record.
|
|
1675
|
+
* Command-fence replacement is stricter than pin routing: a same-path record
|
|
1676
|
+
* with a missing/malformed route may be partial or replayed, and must not let a
|
|
1677
|
+
* new UUID replace the existing fail-closed stamp.
|
|
1678
|
+
*/
|
|
1679
|
+
function canonicalSavedRecordIdentity(value) {
|
|
1680
|
+
if (!value || typeof value !== "object")
|
|
1681
|
+
return null;
|
|
1682
|
+
const record = value;
|
|
1683
|
+
const path = canonicalSavedWorkflowPath(record.path);
|
|
1684
|
+
const routing = canonicalSavedWorkflowRoutingIdentity(record.routing_key);
|
|
1685
|
+
// Both fields must be present and corroborate. A mixed, partial, or replayed
|
|
1686
|
+
// snapshot is not a safe source of a command-fence replacement.
|
|
1687
|
+
if (!path || !routing || routing !== `wf:${path}`)
|
|
1688
|
+
return null;
|
|
1689
|
+
return routing;
|
|
1690
|
+
}
|
|
1691
|
+
function canonicalSavedWorkflowRoutingIdentity(value) {
|
|
1692
|
+
if (typeof value !== "string" || !value.startsWith("wf:"))
|
|
1693
|
+
return null;
|
|
1694
|
+
const path = canonicalSavedWorkflowPath(value.slice(3));
|
|
1695
|
+
return path ? `wf:${path}` : null;
|
|
1696
|
+
}
|
|
1379
1697
|
/** Stable-identity match (positive only) — used to prefer the active record among matches. */
|
|
1380
1698
|
function recMatchesActive(rec, activeObj) {
|
|
1381
1699
|
return identityVerdict(rec, activeObj) === true;
|
|
@@ -1429,10 +1747,20 @@ export async function resolvePinTarget(ctx, path, filename) {
|
|
|
1429
1747
|
if (resolved) {
|
|
1430
1748
|
// Canonicalize to the stable key so routing survives rename/reconnect.
|
|
1431
1749
|
const rec = resolved.record;
|
|
1750
|
+
// `resolveOpenWorkflow()` establishes whether rec and top-level active are
|
|
1751
|
+
// exact two-field peers. That is still insufficient to replace a command
|
|
1752
|
+
// fence when the caller supplied a basename/key/routing alias: retain the
|
|
1753
|
+
// legacy pin resolution, but adopt its UUID only for the caller's own exact
|
|
1754
|
+
// canonical saved path.
|
|
1755
|
+
const callerIdentity = canonicalRequestedSavedIdentity(path);
|
|
1756
|
+
const workflowUuid = callerIdentity && callerIdentity === canonicalSavedRecordIdentity(rec)
|
|
1757
|
+
? resolved.workflowUuid
|
|
1758
|
+
: undefined;
|
|
1432
1759
|
return {
|
|
1433
1760
|
ok: true,
|
|
1434
1761
|
pinPath: rec.key ?? rec.path ?? path,
|
|
1435
1762
|
pinFilename: filename ?? rec.filename ?? rec.path,
|
|
1763
|
+
workflowUuid,
|
|
1436
1764
|
};
|
|
1437
1765
|
}
|
|
1438
1766
|
// Indeterminate list — stay lenient (older/partial panel).
|
|
@@ -1445,6 +1773,8 @@ export const __openWorkflowTestHooks = {
|
|
|
1445
1773
|
},
|
|
1446
1774
|
isAckTimeout,
|
|
1447
1775
|
activeMatchesTarget,
|
|
1776
|
+
activeMatchesOpenRefreshTarget,
|
|
1777
|
+
activeRecordMatchesExactSavedIdentity,
|
|
1448
1778
|
resolveOpenWorkflow,
|
|
1449
1779
|
};
|
|
1450
1780
|
const slotRef = z.union([z.string(), z.number().int().min(0)]);
|
|
@@ -1734,6 +2064,7 @@ async function readWorkflowFromPath(rawPath) {
|
|
|
1734
2064
|
// (the panel executors already read pos/bounds as [x, y] / [x, y, w, h] arrays).
|
|
1735
2065
|
const xy = () => z.array(z.number()).min(2).max(2).describe("[x, y] (two numbers).");
|
|
1736
2066
|
const rect = () => z.array(z.number()).min(4).max(4).describe("[x, y, width, height] (four numbers).");
|
|
2067
|
+
const nodeSize = () => z.array(z.number().positive()).min(2).max(2).describe("[width, height] (two positive numbers).");
|
|
1737
2068
|
/** Build a tab-bound execution context shared by both transports. */
|
|
1738
2069
|
export function makePanelToolCtx(bridge, tabId, workflowTargets) {
|
|
1739
2070
|
// The routing tab id is held on the returned ctx object (NOT captured by
|
|
@@ -1861,12 +2192,65 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
|
|
|
1861
2192
|
await sleep(Math.min(intervalMs, left));
|
|
1862
2193
|
}
|
|
1863
2194
|
};
|
|
2195
|
+
const panelConnectionIdentity = () => typeof bridge.tabConnectionIdentity === "function"
|
|
2196
|
+
? bridge.tabConnectionIdentity(ctx.tabId)
|
|
2197
|
+
: undefined;
|
|
2198
|
+
// A restart report must NEVER count the panel socket that existed before the
|
|
2199
|
+
// reboot command. Unlike awaitReachable(), which intentionally returns at once
|
|
2200
|
+
// for a healthy binding, this waits for a strictly newer hello generation. The
|
|
2201
|
+
// fresh hello can keep the same tab/socket id or arrive under a new one; in the
|
|
2202
|
+
// latter case only rebind after the old target is gone, preserving strict
|
|
2203
|
+
// multi-tab routing and never guessing away from a still-live pre-restart tab.
|
|
2204
|
+
const awaitPostRestartReachable = async (before, budgetMs) => {
|
|
2205
|
+
// The actual UiBridge exposes a tab-session binding. Preserve historical
|
|
2206
|
+
// lightweight/mock-context behavior only when that capability does not exist
|
|
2207
|
+
// at all; a real bridge missing the pre-dispatch identity fails closed.
|
|
2208
|
+
if (typeof bridge.tabConnectionIdentity !== "function")
|
|
2209
|
+
return awaitReachable(budgetMs);
|
|
2210
|
+
if (before == null)
|
|
2211
|
+
return false;
|
|
2212
|
+
const timing = reconnectWaitTiming();
|
|
2213
|
+
const budget = Math.max(0, budgetMs != null ? Math.min(budgetMs, timing.budgetMs) : timing.budgetMs);
|
|
2214
|
+
const deadline = Date.now() + budget;
|
|
2215
|
+
const isOriginalTabReconnected = () => {
|
|
2216
|
+
const current = panelConnectionIdentity();
|
|
2217
|
+
return (current != null &&
|
|
2218
|
+
current.generation > before.generation &&
|
|
2219
|
+
current.tabSessionId === before.tabSessionId);
|
|
2220
|
+
};
|
|
2221
|
+
for (;;) {
|
|
2222
|
+
if (isOriginalTabReconnected())
|
|
2223
|
+
return true;
|
|
2224
|
+
// A new tab id cannot resolve through the retired binding. Once that binding is
|
|
2225
|
+
// actually gone, the existing conservative rebind can follow the sole new tab.
|
|
2226
|
+
if (!bridge.canReach(ctx.tabId)) {
|
|
2227
|
+
const interactive = interactiveTabIds() ?? [];
|
|
2228
|
+
if (interactive.length > 0)
|
|
2229
|
+
ensureReachable();
|
|
2230
|
+
if (isOriginalTabReconnected())
|
|
2231
|
+
return true;
|
|
2232
|
+
}
|
|
2233
|
+
const left = deadline - Date.now();
|
|
2234
|
+
if (left <= 0)
|
|
2235
|
+
return false;
|
|
2236
|
+
await sleep(Math.min(Math.max(1, timing.intervalMs), left));
|
|
2237
|
+
}
|
|
2238
|
+
};
|
|
1864
2239
|
const sendRouted = async (cmd, timeoutMs, onDispatchedRid) => {
|
|
1865
2240
|
const target = workflowTargets?.get(ctx.tabId);
|
|
1866
2241
|
const routed = target ? withWorkflowTarget(cmd, target) : cmd;
|
|
1867
2242
|
return bridge.send(routed, { tabId: ctx.tabId, timeoutMs, onDispatchedRid });
|
|
1868
2243
|
};
|
|
1869
2244
|
const call = async (cmd, timeoutMs, onDispatchedRid) => {
|
|
2245
|
+
// #694: capture the rid of THIS call's dispatched attempt so an OUTCOME-UNKNOWN
|
|
2246
|
+
// mutating failure can name it as the caller's explicit retry token (see the
|
|
2247
|
+
// catch below). The bridge fires the observer post-write (per attempt); chain
|
|
2248
|
+
// to any caller-supplied observer (workflow_open's receipt correlation).
|
|
2249
|
+
let dispatchedRid;
|
|
2250
|
+
const observeRid = (rid) => {
|
|
2251
|
+
dispatchedRid = rid;
|
|
2252
|
+
onDispatchedRid?.(rid);
|
|
2253
|
+
};
|
|
1870
2254
|
try {
|
|
1871
2255
|
// #436: a MUTATING graph edit must not fire into the "Connected: none"
|
|
1872
2256
|
// window a ComfyUI restart/reload opens. A read survives that window (it is
|
|
@@ -1884,7 +2268,7 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
|
|
|
1884
2268
|
await awaitReachable();
|
|
1885
2269
|
}
|
|
1886
2270
|
ensureReachable();
|
|
1887
|
-
return ok(await sendRouted(cmd, timeoutMs,
|
|
2271
|
+
return ok(await sendRouted(cmd, timeoutMs, observeRid));
|
|
1888
2272
|
}
|
|
1889
2273
|
catch (err) {
|
|
1890
2274
|
// Post-reconnect retry-once: a reboot/free_vram/reconnect can drop the tab's
|
|
@@ -1896,7 +2280,7 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
|
|
|
1896
2280
|
try {
|
|
1897
2281
|
await sleep(retrySettleMs());
|
|
1898
2282
|
ensureReachable(); // rebinds a current-mode session onto the reconnected tab
|
|
1899
|
-
return ok(await sendRouted(cmd, timeoutMs,
|
|
2283
|
+
return ok(await sendRouted(cmd, timeoutMs, observeRid));
|
|
1900
2284
|
}
|
|
1901
2285
|
catch (err2) {
|
|
1902
2286
|
// The retry also failed — surface an actionable reconnecting status rather
|
|
@@ -1937,6 +2321,26 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
|
|
|
1937
2321
|
`moment, or rebind with panel_set_workflow_target({mode:"current"}) to follow the ` +
|
|
1938
2322
|
`tab that's live now. (${err instanceof Error ? err.message : String(err)})`);
|
|
1939
2323
|
}
|
|
2324
|
+
// #694 — EXPLICIT caller retry identity. A MUTATING command whose outcome is
|
|
2325
|
+
// UNKNOWN — a post-write reply timeout or a mid-command disconnect (the only
|
|
2326
|
+
// two dispatched:true rejections the bridge mints) — may already have been
|
|
2327
|
+
// applied by the panel, so a blind retry can double-apply. Name the dispatched
|
|
2328
|
+
// attempt's rid as the caller's retry token: re-issuing identical args plus
|
|
2329
|
+
// retry_of:"<rid>" lets the panel recognize and dedupe that exact mutation.
|
|
2330
|
+
// Pre-write refusals (dispatched:false, handled above) mint NO token — nothing
|
|
2331
|
+
// was sent, so there is nothing to dedupe. Minting is gated BOTH ways: the
|
|
2332
|
+
// bridge classifies the command as mutating (requiresWorkflowStampEnforcement)
|
|
2333
|
+
// AND the command is one the retry map admits (RETRY_TOKEN_CMDS) — a
|
|
2334
|
+
// read/view-only command outside BRIDGE_READONLY_CMDS (find_nodes, canvas,
|
|
2335
|
+
// screenshot, list_subgraphs) can satisfy the first without the second, and
|
|
2336
|
+
// it must never mint a token (a ledger answer for a read is a STALE outcome).
|
|
2337
|
+
if (dispatchedRid &&
|
|
2338
|
+
requiresWorkflowStampEnforcement(cmd) &&
|
|
2339
|
+
RETRY_TOKEN_CMDS.has(typeof cmd.cmd === "string" ? cmd.cmd : "") &&
|
|
2340
|
+
(dispatchOutcomeOf(err) === true || isReplyTimeoutTagged(err))) {
|
|
2341
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
2342
|
+
return fail(`${cause}\n\nTo retry this exact mutation, re-issue identical args plus retry_of:"${dispatchedRid}"; otherwise call normally.`);
|
|
2343
|
+
}
|
|
1940
2344
|
return fail(err);
|
|
1941
2345
|
}
|
|
1942
2346
|
};
|
|
@@ -2042,6 +2446,9 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
|
|
|
2042
2446
|
ctx.rebindToActiveTab = rebindToActiveTab;
|
|
2043
2447
|
ctx.ensureReachable = ensureReachable;
|
|
2044
2448
|
ctx.awaitReachable = awaitReachable;
|
|
2449
|
+
ctx.panelConnectionIdentity = panelConnectionIdentity;
|
|
2450
|
+
ctx.awaitPostRestartReachable = awaitPostRestartReachable;
|
|
2451
|
+
ctx.tabCanMutateGraph = () => bridge.tabCanMutateGraph(ctx.tabId);
|
|
2045
2452
|
return ctx;
|
|
2046
2453
|
}
|
|
2047
2454
|
/**
|
|
@@ -2251,8 +2658,16 @@ allowStateFallback = false) {
|
|
|
2251
2658
|
/* fall through to the actionable error below */
|
|
2252
2659
|
}
|
|
2253
2660
|
}
|
|
2254
|
-
|
|
2255
|
-
|
|
2661
|
+
// #721: only blame an old panel when the error actually IS an
|
|
2662
|
+
// unsupported-command rejection. Anything else (e.g. the panel's graph
|
|
2663
|
+
// desync guard, whose remedy is rebinding/opening the workflow) already
|
|
2664
|
+
// carries its own remedy in the message — appending the version hint
|
|
2665
|
+
// there misdirects the agent to pack/path/graph instead.
|
|
2666
|
+
if (isPanelCmdUnsupportedError(err, "graph_serialize")) {
|
|
2667
|
+
throw new Error(`Couldn't capture the live canvas (${msg}). ` +
|
|
2668
|
+
`An older panel version may not support graph_serialize — pass pack, path, or graph instead.`);
|
|
2669
|
+
}
|
|
2670
|
+
throw new Error(`Couldn't capture the live canvas (${msg}).`);
|
|
2256
2671
|
}
|
|
2257
2672
|
const wf = reply?.workflow;
|
|
2258
2673
|
if (!wf || typeof wf !== "object") {
|
|
@@ -2517,6 +2932,127 @@ export const __panelAskTestHooks = {
|
|
|
2517
2932
|
askSurfaceError,
|
|
2518
2933
|
isReplyTimeoutError,
|
|
2519
2934
|
};
|
|
2935
|
+
const PANEL_EDIT_NODE_FIELDS = ["pos", "size", "title", "preset", "color", "bgcolor", "shape", "collapsed", "pinned", "mode"];
|
|
2936
|
+
const NODE_COLOR_HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
2937
|
+
/** Cross-field rules that a flat ZodRawShape cannot express. Keep these at the
|
|
2938
|
+
* MCP boundary as well as in the panel executor: malformed direct tool calls
|
|
2939
|
+
* must never become a no-op or an ambiguous graph edit. */
|
|
2940
|
+
function validatePanelEditNodeArgs(args) {
|
|
2941
|
+
const hasNodeId = args.node_id !== undefined;
|
|
2942
|
+
const hasNodeIds = args.node_ids !== undefined;
|
|
2943
|
+
if (hasNodeId === hasNodeIds)
|
|
2944
|
+
return "panel_edit_node requires exactly one of node_id or node_ids.";
|
|
2945
|
+
if (hasNodeIds && (!Array.isArray(args.node_ids) || args.node_ids.length === 0))
|
|
2946
|
+
return "panel_edit_node node_ids must be a non-empty array.";
|
|
2947
|
+
if (!PANEL_EDIT_NODE_FIELDS.some((field) => args[field] !== undefined))
|
|
2948
|
+
return "panel_edit_node requires at least one editable field.";
|
|
2949
|
+
if (args.preset !== undefined && (args.color !== undefined || args.bgcolor !== undefined)) {
|
|
2950
|
+
return "panel_edit_node preset cannot be combined with color or bgcolor.";
|
|
2951
|
+
}
|
|
2952
|
+
return null;
|
|
2953
|
+
}
|
|
2954
|
+
/**
|
|
2955
|
+
* #694 — `retry_of`: an EXPLICIT caller retry identity for MUTATING panel
|
|
2956
|
+
* commands. When a mutating call fails OUTCOME-UNKNOWN (a post-write reply
|
|
2957
|
+
* timeout or a mid-command disconnect), the error text names the dispatched
|
|
2958
|
+
* attempt's rid and tells the caller to re-issue identical args plus
|
|
2959
|
+
* retry_of:"<rid>"; the panel dedupes the retried mutation on that token so the
|
|
2960
|
+
* retry can never double-apply. The token is OPAQUE caller data to the bridge —
|
|
2961
|
+
* forwarded to the wire UNTOUCHED (contrast workflow_uuid, which is bridge-owned
|
|
2962
|
+
* and always overwritten). Optional everywhere; never required.
|
|
2963
|
+
*/
|
|
2964
|
+
const RETRY_OF_ARG = {
|
|
2965
|
+
retry_of: z
|
|
2966
|
+
.string()
|
|
2967
|
+
.optional()
|
|
2968
|
+
.describe("Retry token (#694): pass the retry_of rid from a previous outcome-unknown failure of this identical call to retry that exact mutation; omit otherwise."),
|
|
2969
|
+
};
|
|
2970
|
+
/**
|
|
2971
|
+
* #694 — the MUTATING panel tools that accept retry_of, keyed to the bridge
|
|
2972
|
+
* command each dispatches. Mirrors the #694 mutation surface EXACTLY: every
|
|
2973
|
+
* graph_* command NOT in BRIDGE_READONLY_CMDS (isMutatingGraphCommand — the
|
|
2974
|
+
* bridge's own fail-closed classification, which also arms the tight default
|
|
2975
|
+
* timeout and the dispatched:true mid-command outcome) plus the four workflow
|
|
2976
|
+
* mutators (workflow_save / workflow_save_as / workflow_rename / workflow_close
|
|
2977
|
+
* — the requiresWorkflowStampEnforcement set). Navigation/creation
|
|
2978
|
+
* (workflow_open / workflow_new), BRIDGE_READONLY_CMDS reads, and the tools whose
|
|
2979
|
+
* descriptions declare them view/read-only (panel_find_nodes, panel_canvas,
|
|
2980
|
+
* panel_screenshot, panel_list_subgraphs) are excluded: a read must never mint
|
|
2981
|
+
* or carry a retry token — its retry could be answered from the ledger with a
|
|
2982
|
+
* STALE outcome (codex gate). A few state-changing commands that sit OUTSIDE
|
|
2983
|
+
* BRIDGE_READONLY_CMDS but are reads in spirit (graph_select_nodes,
|
|
2984
|
+
* graph_enter/exit_subgraph, graph_copy_nodes) accept the token: they change UI
|
|
2985
|
+
* state idempotently, so a deduped retry is a no-op. EXPLICIT MAP, mirroring the
|
|
2986
|
+
* RETRY_SAFE_CMDS / MUTATING_GRAPH_EDIT_CMDS maintenance model — keep in sync
|
|
2987
|
+
* when mutating tools are added. Exported for the #694 surface-integrity test.
|
|
2988
|
+
*/
|
|
2989
|
+
export const RETRY_TOKEN_CMD_BY_TOOL = {
|
|
2990
|
+
panel_add_node: "graph_add_node",
|
|
2991
|
+
panel_edit_node: "graph_edit_node",
|
|
2992
|
+
panel_remove_node: "graph_remove_node",
|
|
2993
|
+
panel_clear: "graph_clear",
|
|
2994
|
+
panel_flatten_workflow: "graph_load",
|
|
2995
|
+
panel_load_workflow: "graph_load",
|
|
2996
|
+
panel_connect: "graph_connect",
|
|
2997
|
+
panel_disconnect: "graph_disconnect",
|
|
2998
|
+
panel_set_widget: "graph_set_widget",
|
|
2999
|
+
panel_set_property: "graph_set_node_property",
|
|
3000
|
+
panel_move_node: "graph_move_node",
|
|
3001
|
+
panel_resize_node: "graph_resize_node",
|
|
3002
|
+
panel_auto_layout: "graph_auto_layout",
|
|
3003
|
+
panel_run: "graph_run",
|
|
3004
|
+
panel_save_workflow: "workflow_save", // workflow_save_as when `name` is given
|
|
3005
|
+
panel_rename_workflow: "workflow_rename",
|
|
3006
|
+
panel_close_workflow: "workflow_close",
|
|
3007
|
+
panel_select_nodes: "graph_select_nodes",
|
|
3008
|
+
panel_create_subgraph: "graph_create_subgraph",
|
|
3009
|
+
panel_subgraph_group: "graph_subgraph_group",
|
|
3010
|
+
panel_copy_nodes: "graph_copy_nodes",
|
|
3011
|
+
panel_paste_nodes: "graph_paste_nodes",
|
|
3012
|
+
panel_save_subgraph: "graph_save_subgraph",
|
|
3013
|
+
panel_add_subgraph: "graph_add_subgraph",
|
|
3014
|
+
panel_create_group: "graph_create_group",
|
|
3015
|
+
panel_move_group: "graph_move_group",
|
|
3016
|
+
panel_edit_group: "graph_edit_group",
|
|
3017
|
+
panel_remove_group: "graph_remove_group",
|
|
3018
|
+
panel_set_node_title: "graph_set_title",
|
|
3019
|
+
panel_set_node_collapsed: "graph_set_node_collapsed",
|
|
3020
|
+
panel_set_node_mode: "graph_set_node_mode",
|
|
3021
|
+
panel_set_node_color: "graph_set_node_color",
|
|
3022
|
+
panel_enter_subgraph: "graph_enter_subgraph",
|
|
3023
|
+
panel_exit_subgraph: "graph_exit_subgraph",
|
|
3024
|
+
panel_move_rail: "graph_move_rail",
|
|
3025
|
+
panel_promote_widget: "graph_promote_widget",
|
|
3026
|
+
panel_expose_subgraph_output: "graph_expose_subgraph_output",
|
|
3027
|
+
panel_expose_subgraph_input: "graph_expose_subgraph_input",
|
|
3028
|
+
panel_unpack_subgraph: "graph_unpack_subgraph",
|
|
3029
|
+
panel_update_node: "graph_update_node",
|
|
3030
|
+
};
|
|
3031
|
+
/** #694 — the bridge commands the retry map admits, for the mint gate: a
|
|
3032
|
+
* dispatched timeout/drop only mints a retry token when the command is in
|
|
3033
|
+
* this set (bridge classification alone would include read/view-only
|
|
3034
|
+
* commands that sit outside BRIDGE_READONLY_CMDS). */
|
|
3035
|
+
export const RETRY_TOKEN_CMDS = new Set(Object.values(RETRY_TOKEN_CMD_BY_TOOL).concat(["workflow_save_as"]));
|
|
3036
|
+
/** #694 — augment one MUTATING tool def: accept retry_of and attach it, UNTOUCHED,
|
|
3037
|
+
* to every mutating command the handler dispatches (per-command gated so a read
|
|
3038
|
+
* probe inside the same handler — e.g. panel_flatten_workflow's live-canvas
|
|
3039
|
+
* graph_serialize — stays clean). `call` is overridden on a prototype-delegating
|
|
3040
|
+
* wrapper (Object.create(ctx)) so LIVE properties — e.g. ctx.tabId rebinding —
|
|
3041
|
+
* keep reading through to the real ctx; only `call` shadows. */
|
|
3042
|
+
function withRetryToken(d) {
|
|
3043
|
+
return {
|
|
3044
|
+
...d,
|
|
3045
|
+
schema: { ...d.schema, ...RETRY_OF_ARG },
|
|
3046
|
+
handler: (args, ctx) => {
|
|
3047
|
+
const retryOf = typeof args.retry_of === "string" && args.retry_of !== "" ? args.retry_of : undefined;
|
|
3048
|
+
if (!retryOf)
|
|
3049
|
+
return d.handler(args, ctx);
|
|
3050
|
+
const wrapped = Object.create(ctx);
|
|
3051
|
+
wrapped.call = (cmd, timeoutMs, onDispatchedRid) => ctx.call(requiresWorkflowStampEnforcement(cmd) ? { ...cmd, retry_of: retryOf } : cmd, timeoutMs, onDispatchedRid);
|
|
3052
|
+
return d.handler(args, wrapped);
|
|
3053
|
+
},
|
|
3054
|
+
};
|
|
3055
|
+
}
|
|
2520
3056
|
/**
|
|
2521
3057
|
* The SINGLE source of truth for the panel_* tool surface. Both transports
|
|
2522
3058
|
* register these exact definitions, so the Claude (in-process) and Codex (HTTP)
|
|
@@ -2525,7 +3061,7 @@ export const __panelAskTestHooks = {
|
|
|
2525
3061
|
export function buildPanelToolDefs() {
|
|
2526
3062
|
// Local helper so each def reads like the original `tool(...)` call.
|
|
2527
3063
|
const def = (name, description, schema, handler) => ({ name, description, schema, handler });
|
|
2528
|
-
|
|
3064
|
+
const defs = [
|
|
2529
3065
|
def("panel_query_graph", "FILTER or TRAVERSE a SUBSET of the live canvas, for when you ALREADY KNOW what you're looking for. NOT for 'show me the canvas' or any whole-graph overview — call panel_graph_outline FIRST for that. NOT query_workflow (that queries a saved file or JSON you provide, not the live canvas). Filters, traverses, projects and aggregates over the workflow the user is CURRENTLY VIEWING without dumping the whole graph (replaces the old panel_get_graph full-JSON dump; output is TOKEN-BOUNDED with an explicit truncation marker, so a big graph can never flood your context). Combine: `types` (node type contains any), `title` (contains), `where` widget predicates ANDed ('cfg>7', 'steps<=20', 'sampler_name=euler', 'text~sunset' — ops = != >= <= > < ~contains), `ids` (exact nodes — THE way to read ONE node's exact slot/widget detail: {ids:[42], fields:'detail'}), `upstream_of`/`downstream_of` + `depth` (dependency traversal: upstream = what FEEDS that node, downstream = what CONSUMES it; seed at depth 0), `fields` ('compact' one line per node [default], 'ids', 'detail' = the full node summary with slots + connections + mode), `group_by:'type'` (counts only), `limit` (default 40). detail rows include each node's MODE — a 'bypass' node is skipped and a 'mute' node kills everything downstream, so check modes on the path you care about before running (fix with panel_set_node_mode). Every result also carries `groups` (id, title, member node_ids — groups are geometric, trust this list) and, when viewing a SUBGRAPH (after panel_enter_subgraph), `rails` (boundary rail ids/slots). Typical flow: panel_graph_outline to orient → panel_query_graph to pinpoint/inspect → edit. Read-only.", {
|
|
2530
3066
|
types: z.array(z.string()).optional().describe("Node type contains ANY of these (case-insensitive)."),
|
|
2531
3067
|
title: z.string().optional().describe("Node title contains this."),
|
|
@@ -2639,7 +3175,7 @@ export function buildPanelToolDefs() {
|
|
|
2639
3175
|
mode: args.mode,
|
|
2640
3176
|
limit: args.limit,
|
|
2641
3177
|
})),
|
|
2642
|
-
def("panel_add_node", "Add a node to the user's OPEN ComfyUI graph by class_type (e.g. 'KSampler', 'CheckpointLoaderSimple'). The user sees it appear live; Ctrl+Z undoes it. Returns the created node's id, slots, and default widget values.", {
|
|
3178
|
+
def("panel_add_node", "Add a node to the user's OPEN ComfyUI graph by class_type (e.g. 'KSampler', 'CheckpointLoaderSimple'). The user sees it appear live; Ctrl+Z undoes it. Returns the created node's id, slots, and default widget values. Frontend-only virtual types are addable too: 'Note' and 'MarkdownNote' — the supported way to ANNOTATE a workflow with on-canvas instructions (add the node, then put the text in its 'text' widget via panel_set_widget) — plus 'Reroute' and 'PrimitiveNode'. These are LiteGraph-native and never appear in the backend node registry, so they legitimately bypass the backend class_type check.", {
|
|
2643
3179
|
class_type: z.string().describe("Exact ComfyUI node class_type to create."),
|
|
2644
3180
|
pos: xy()
|
|
2645
3181
|
.optional()
|
|
@@ -2749,6 +3285,11 @@ export function buildPanelToolDefs() {
|
|
|
2749
3285
|
return ok(`${summary}\n\n${JSON.stringify(graph)}`);
|
|
2750
3286
|
}
|
|
2751
3287
|
const loaded = await ctx.call({ cmd: "graph_load", graph: graph }, 30000);
|
|
3288
|
+
// ctx.call returns an error ToolResult for an outcome-unknown graph_load
|
|
3289
|
+
// (rather than throwing). Preserve it verbatim: wrapping it as a successful
|
|
3290
|
+
// "Loaded" result would fabricate success and hide its retry_of token.
|
|
3291
|
+
if (loaded.isError)
|
|
3292
|
+
return loaded;
|
|
2752
3293
|
const loadText = loaded.content?.[0]?.text ?? "";
|
|
2753
3294
|
return ok(`${summary}\nLoaded onto the canvas (one undo restores the original). ${loadText.slice(0, 120)}`);
|
|
2754
3295
|
}),
|
|
@@ -2896,18 +3437,46 @@ export function buildPanelToolDefs() {
|
|
|
2896
3437
|
.union([z.string(), z.number(), z.boolean(), z.null()])
|
|
2897
3438
|
.describe("New property value (string/number/boolean/null). For the rgthree Fast Groups Bypasser, matchTitle is a title substring/regex filter."),
|
|
2898
3439
|
}, async (args, ctx) => ctx.call({ cmd: "graph_set_node_property", node_id: args.node_id, name: args.name, value: args.value })),
|
|
2899
|
-
def("
|
|
2900
|
-
node_id: z.number().int().describe("
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
3440
|
+
def("panel_edit_node", "Atomically edit one node, or apply the same edit to several nodes. Pass exactly one of node_id or node_ids, plus at least one field. In one Ctrl+Z step you can move (pos), resize (size — including Note/MarkdownNote), retitle, recolor, change shape, collapse, pin, or set execution mode. Widget values, LiteGraph properties, links, and slot order stay on their dedicated tools. For a multi-node call, position/size/title/mode apply the same value to every target. Color fields accept #RGB, #RGBA, #RRGGBB, or #RRGGBBAA; null clears a color. Bypassing a subgraph retains panel_set_node_mode's unsafe-boundary guard; force:true is required to override it. Undoable with Ctrl+Z.", {
|
|
3441
|
+
node_id: z.number().int().optional().describe("One node id from panel_graph_outline / panel_query_graph. Provide this OR node_ids, not both."),
|
|
3442
|
+
node_ids: z.array(z.number().int()).min(1).optional().describe("Several node ids that receive the same presentation edit. Provide this OR node_id, not both."),
|
|
3443
|
+
pos: xy().optional().describe("New canvas [x, y]."),
|
|
3444
|
+
size: nodeSize().optional().describe("New [width, height] in canvas px. Uses the node's setSize so DOM-widget nodes reflow and minimum sizes are honored."),
|
|
3445
|
+
title: z.string().optional().describe("New header title."),
|
|
3446
|
+
preset: z.enum(["red", "brown", "green", "blue", "pale_blue", "cyan", "purple", "yellow", "black"]).optional().describe("Named LiteGraph palette color (sets both title and body). Cannot be combined with color/bgcolor."),
|
|
3447
|
+
color: z.string().regex(NODE_COLOR_HEX).nullable().optional().describe("Title-bar color hex, or null to clear."),
|
|
3448
|
+
bgcolor: z.string().regex(NODE_COLOR_HEX).nullable().optional().describe("Body color hex, or null to clear."),
|
|
3449
|
+
shape: z.enum(["default", "box", "round", "card"]).optional().describe("Node outline shape; default restores the theme default."),
|
|
3450
|
+
collapsed: z.boolean().optional().describe("true collapses to a title chip; false expands."),
|
|
3451
|
+
pinned: z.boolean().optional().describe("Whether LiteGraph marks this node pinned for presentation/layout."),
|
|
3452
|
+
mode: z.enum(["active", "bypass", "mute"]).optional().describe("Execution mode. Bypass/mute change what renders; inspect the graph first."),
|
|
3453
|
+
force: z.boolean().optional().describe("Required only to bypass a subgraph whose positional I/O boundary mapping is unsafe."),
|
|
3454
|
+
}, async (args, ctx) => {
|
|
3455
|
+
const error = validatePanelEditNodeArgs(args);
|
|
3456
|
+
if (error)
|
|
3457
|
+
return fail(error);
|
|
3458
|
+
return ctx.call({
|
|
3459
|
+
cmd: "graph_edit_node",
|
|
3460
|
+
node_id: args.node_id,
|
|
3461
|
+
node_ids: args.node_ids,
|
|
3462
|
+
pos: args.pos,
|
|
3463
|
+
size: args.size,
|
|
3464
|
+
title: args.title,
|
|
3465
|
+
preset: args.preset,
|
|
3466
|
+
color: args.color,
|
|
3467
|
+
bgcolor: args.bgcolor,
|
|
3468
|
+
shape: args.shape,
|
|
3469
|
+
collapsed: args.collapsed,
|
|
3470
|
+
pinned: args.pinned,
|
|
3471
|
+
mode: args.mode,
|
|
3472
|
+
force: args.force,
|
|
3473
|
+
});
|
|
3474
|
+
}),
|
|
3475
|
+
// Keep legacy bridge commands behind compatibility tool names. graph_edit_node
|
|
3476
|
+
// is newer than several installed panels, while current panels adapt these
|
|
3477
|
+
// commands into the same atomic implementation.
|
|
3478
|
+
def("panel_move_node", "Compatibility wrapper for panel_edit_node(pos).", { node_id: z.number().int(), pos: xy() }, async (args, ctx) => ctx.call({ cmd: "graph_move_node", node_id: args.node_id, pos: args.pos })),
|
|
3479
|
+
def("panel_resize_node", "Compatibility wrapper for panel_edit_node(size).", { node_id: z.number().int(), size: nodeSize() }, async (args, ctx) => ctx.call({ cmd: "graph_resize_node", node_id: args.node_id, size: args.size })),
|
|
2911
3480
|
def("panel_auto_layout", "Automatically arrange the user's open graph (or a subset of nodes) into a clean left-to-right / top-to-bottom / grid layout based on the real link topology. Group boxes move with their members and are re-fit. Use dry_run:true to preview proposed positions without touching the canvas. Undoable (one Ctrl+Z).", {
|
|
2912
3481
|
node_ids: z
|
|
2913
3482
|
.array(z.number().int())
|
|
@@ -2991,7 +3560,7 @@ export function buildPanelToolDefs() {
|
|
|
2991
3560
|
QueueMonitor.markSelfQueued(queuedId);
|
|
2992
3561
|
// Append anti-poll guidance: the agent should go idle after queuing so the
|
|
2993
3562
|
// executed event auto-injects the output image, rather than busy-polling.
|
|
2994
|
-
const note = "\n\n[IMPORTANT] You will be notified automatically with the output image(s)/video when the render finishes — do NOT poll
|
|
3563
|
+
const note = "\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.";
|
|
2995
3564
|
// Backpressure note. A backlog is only alarming when it's a job we did NOT
|
|
2996
3565
|
// queue (possibly foreign/stuck). Deliberately batching renders — a sweep,
|
|
2997
3566
|
// a multi-variant comparison — is a NORMAL workflow, so a queue made of our
|
|
@@ -3007,14 +3576,14 @@ export function buildPanelToolDefs() {
|
|
|
3007
3576
|
warn =
|
|
3008
3577
|
`\n\n[QUEUE] Queued behind your own in-flight render(s) (1 running${pendingTxt}). ` +
|
|
3009
3578
|
`This is normal when batching a sweep or comparison — they drain in order and nothing is stuck; ` +
|
|
3010
|
-
`each result is delivered to you as it finishes. To drop a single pending item, use
|
|
3011
|
-
`Only use
|
|
3579
|
+
`each result is delivered to you as it finishes. To drop a single pending item, use queue (action:"cancel_queued"). ` +
|
|
3580
|
+
`Only use queue (action:"cancel") with clear_pending:true if a render is ACTUALLY wedged — it kills the running job AND your entire queue.`;
|
|
3012
3581
|
}
|
|
3013
3582
|
else {
|
|
3014
3583
|
warn =
|
|
3015
3584
|
`\n\n[QUEUE] A render is already running${pre.runningPromptId ? ` (prompt ${pre.runningPromptId})` : ""}${pendingTxt}, ` +
|
|
3016
|
-
`and the queue includes work this session didn't queue — your run is queued behind it. Inspect with
|
|
3017
|
-
`If the running job is genuinely stuck,
|
|
3585
|
+
`and the queue includes work this session didn't queue — your run is queued behind it. Inspect with queue (action:"list") before acting. ` +
|
|
3586
|
+
`If the running job is genuinely stuck, queue (action:"cancel") with clear_pending:true interrupts it AND drops pending, then escalate to restart_comfyui if it reports the job wedged.`;
|
|
3018
3587
|
}
|
|
3019
3588
|
}
|
|
3020
3589
|
if (res.content?.[0]?.type === "text") {
|
|
@@ -3680,18 +4249,27 @@ export function buildPanelToolDefs() {
|
|
|
3680
4249
|
// so both entry points validate identically.
|
|
3681
4250
|
let pinPath = path;
|
|
3682
4251
|
let pinFilename = filename;
|
|
4252
|
+
let pinnedWorkflowUuid;
|
|
3683
4253
|
if (mode === "pinned" && path) {
|
|
3684
4254
|
const res = await resolvePinTarget(ctx, path, filename);
|
|
3685
4255
|
if (!res.ok)
|
|
3686
4256
|
return fail(res.error);
|
|
3687
4257
|
pinPath = res.pinPath;
|
|
3688
4258
|
pinFilename = res.pinFilename;
|
|
4259
|
+
pinnedWorkflowUuid = res.workflowUuid;
|
|
3689
4260
|
}
|
|
3690
4261
|
const target = ctx.workflowTarget.set(ctx.tabId, {
|
|
3691
4262
|
mode,
|
|
3692
4263
|
path: pinPath,
|
|
3693
4264
|
filename: pinFilename,
|
|
3694
4265
|
});
|
|
4266
|
+
// #716 — a successful, positively-active re-pin is an explicit recovery
|
|
4267
|
+
// boundary after reconnect/open. Its UUID came from the same fresh
|
|
4268
|
+
// workflow_list result that validated the pin. Missing, malformed, or
|
|
4269
|
+
// indeterminate values leave the old command stamp intact (fail closed).
|
|
4270
|
+
if (mode === "pinned" && pinnedWorkflowUuid) {
|
|
4271
|
+
refreshWorkflowUuid(ctx, { workflow_uuid: pinnedWorkflowUuid });
|
|
4272
|
+
}
|
|
3695
4273
|
ctx.bridge.push({ type: "workflow_target", target }, ctx.tabId);
|
|
3696
4274
|
const hint = target.mode === "pinned"
|
|
3697
4275
|
? `Pinned to "${target.filename ?? target.path}". Graph tools will target that workflow without switching the user's view.`
|
|
@@ -3783,14 +4361,8 @@ export function buildPanelToolDefs() {
|
|
|
3783
4361
|
bounds: args.bounds,
|
|
3784
4362
|
}, 15000)),
|
|
3785
4363
|
def("panel_remove_group", "Remove a group box from the user's open graph. The nodes inside the group are NOT deleted — only the box. Undoable.", { group_id: z.number().int().describe("Group id from panel_query_graph's groups[] / panel_create_group.") }, async (args, ctx) => ctx.call({ cmd: "graph_remove_group", group_id: args.group_id }, 15000)),
|
|
3786
|
-
def("panel_set_node_title", "
|
|
3787
|
-
|
|
3788
|
-
title: z.string().describe("New title text."),
|
|
3789
|
-
}, async (args, ctx) => ctx.call({ cmd: "graph_set_title", node_id: args.node_id, title: args.title }, 15000)),
|
|
3790
|
-
def("panel_set_node_collapsed", "Collapse (minimize) or expand a node on the user's open graph. Collapsed nodes shrink to just their title bar — handy for tidying loaders or rarely-touched nodes. Undoable.", {
|
|
3791
|
-
node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
|
|
3792
|
-
collapsed: z.boolean().optional().describe("true = collapse/minimize (default), false = expand."),
|
|
3793
|
-
}, async (args, ctx) => ctx.call({ cmd: "graph_set_node_collapsed", node_id: args.node_id, collapsed: args.collapsed })),
|
|
4364
|
+
def("panel_set_node_title", "Compatibility wrapper for panel_edit_node(title).", { node_id: z.number().int(), title: z.string() }, async (args, ctx) => ctx.call({ cmd: "graph_set_title", node_id: args.node_id, title: args.title }, 15000)),
|
|
4365
|
+
def("panel_set_node_collapsed", "Compatibility wrapper for panel_edit_node(collapsed).", { node_id: z.number().int(), collapsed: z.boolean().optional() }, async (args, ctx) => ctx.call({ cmd: "graph_set_node_collapsed", node_id: args.node_id, collapsed: args.collapsed ?? true })),
|
|
3794
4366
|
def("panel_set_node_mode", "Set a node's EXECUTION MODE on the user's open graph — active, bypass, or mute — and return { node_id, mode, previous_mode }. This is how you turn a node ON or OFF without deleting it. Modes:\n" +
|
|
3795
4367
|
"• 'active' — normal: the node executes.\n" +
|
|
3796
4368
|
"• 'bypass' — the node is SKIPPED and PASSES ITS INPUT THROUGH to its output (downstream still runs, just as if this node weren't there). Use to disable a single processing node (an upscaler, a LoRA, a detailer) while keeping the pipeline connected.\n" +
|
|
@@ -3805,21 +4377,12 @@ export function buildPanelToolDefs() {
|
|
|
3805
4377
|
.optional()
|
|
3806
4378
|
.describe("Override the unsafe-bypass guard on a subgraph node (proceed with a positional boundary forward even when input/output types don't line up by index). Omit for normal safe behaviour."),
|
|
3807
4379
|
}, async (args, ctx) => ctx.call({ cmd: "graph_set_node_mode", node_id: args.node_id, mode: args.mode, force: args.force })),
|
|
3808
|
-
def("panel_set_node_color", "
|
|
3809
|
-
node_id: z.number().int()
|
|
3810
|
-
preset: z
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
color: z.string().nullable().optional().describe("Title-bar color hex, or null to clear. Ignored if preset given."),
|
|
3815
|
-
bgcolor: z.string().nullable().optional().describe("Body color hex, or null to clear. Ignored if preset given."),
|
|
3816
|
-
}, async (args, ctx) => ctx.call({
|
|
3817
|
-
cmd: "graph_set_node_color",
|
|
3818
|
-
node_id: args.node_id,
|
|
3819
|
-
preset: args.preset,
|
|
3820
|
-
color: args.color,
|
|
3821
|
-
bgcolor: args.bgcolor,
|
|
3822
|
-
})),
|
|
4380
|
+
def("panel_set_node_color", "Legacy color compatibility wrapper. Unlike panel_edit_node, color and bgcolor accept any CSS color string; when preset is supplied it wins over explicit colors, preserving the historical bridge behavior.", {
|
|
4381
|
+
node_id: z.number().int(),
|
|
4382
|
+
preset: z.enum(["red", "brown", "green", "blue", "pale_blue", "cyan", "purple", "yellow", "black"]).nullable().optional(),
|
|
4383
|
+
color: z.string().nullable().optional(),
|
|
4384
|
+
bgcolor: z.string().nullable().optional(),
|
|
4385
|
+
}, async (args, ctx) => ctx.call({ cmd: "graph_set_node_color", node_id: args.node_id, preset: args.preset, color: args.color, bgcolor: args.bgcolor })),
|
|
3823
4386
|
def("panel_screenshot", "SCREENSHOT the canvas to a PNG IMAGE — pixels, for when the question is VISUAL: overlaps, alignment, rails, colors, group bands. To READ what is on the canvas as text (ids, types, widget values, wiring) use panel_graph_outline instead; an image cannot be searched or quoted. Renders the workflow the user is currently viewing (root graph, or the open subgraph): frames the whole graph (nodes + groups), captures, then restores the user's view. Use it to verify a layout you just built instead of reasoning from coordinates alone.", { padding: z.number().optional().describe("Margin around the graph in px (default 60).") }, async (args, ctx) => {
|
|
3824
4387
|
try {
|
|
3825
4388
|
ctx.ensureReachable?.();
|
|
@@ -3827,7 +4390,11 @@ export function buildPanelToolDefs() {
|
|
|
3827
4390
|
// screenshots the PINNED workflow (via injected workflow_path), not just
|
|
3828
4391
|
// whatever tab is visible (codex — graph_* must carry the pin).
|
|
3829
4392
|
const target = ctx.workflowTarget?.get(ctx.tabId);
|
|
3830
|
-
const cmd = withWorkflowTarget(
|
|
4393
|
+
const cmd = withWorkflowTarget(
|
|
4394
|
+
// #694: panel_screenshot is view-only — OUTSIDE the retry map — so it
|
|
4395
|
+
// never carries the token (a ledger answer for a read is a STALE
|
|
4396
|
+
// outcome), and the withRetryToken wrapper can't reach this direct send.
|
|
4397
|
+
{ cmd: "graph_screenshot", padding: args.padding }, target ?? { mode: "current" });
|
|
3831
4398
|
const res = (await ctx.bridge.send(cmd, {
|
|
3832
4399
|
tabId: ctx.tabId,
|
|
3833
4400
|
}));
|
|
@@ -3908,7 +4475,7 @@ export function buildPanelToolDefs() {
|
|
|
3908
4475
|
return ctx.call({ cmd: "graph_update_node", id: args.id, version: args.version, channel: args.channel, mode: args.mode }, 30000);
|
|
3909
4476
|
}),
|
|
3910
4477
|
def("panel_node_queue_status", "Check the built-in Manager's install/update queue status (to see if a queued install finished). Read-only.", {}, async (_args, ctx) => ctx.call({ cmd: "nodes_queue_status" }, 20000)),
|
|
3911
|
-
def("panel_restart_comfyui", "Restart the user's ComfyUI server via the built-in Manager — needed to load newly installed/updated custom nodes. CALL THIS DIRECTLY when a restart is needed: it pops a confirm card and only restarts on a yes (don't ask separately first). ComfyUI and this agent go down briefly, then the panel auto-reconnects and you resume. ⚠️ BUSY GUARD: a restart ABORTS any in-progress or queued generation — if ComfyUI is generating, this tool REFUSES and tells you (it does NOT restart). When that happens, tell the user a render is running and WAIT for it (poll panel_node_queue_status), or pass force:true ONLY if the user explicitly confirms they want to kill the running generation. Best practice: before restarting after an install, check the queue is idle first. Only call when a restart is actually needed.", { force: z.boolean().optional() }, async ({ force }, ctx) => {
|
|
4478
|
+
def("panel_restart_comfyui", "Restart the user's ComfyUI server via the built-in Manager — needed to load newly installed/updated custom nodes. CALL THIS DIRECTLY when a restart is needed: it pops a confirm card and only restarts on a yes (don't ask separately first). ComfyUI and this agent go down briefly, then the panel auto-reconnects and you resume. ⚠️ BUSY GUARD: a restart ABORTS any in-progress or queued generation — if ComfyUI is generating, this tool REFUSES and tells you (it does NOT restart). When that happens, tell the user a render is running and WAIT for it (poll panel_node_queue_status), or pass force:true ONLY if the user explicitly confirms they want to kill the running generation. Best practice: before restarting after an install, check the queue is idle first. Only call when a restart is actually needed. On an externally-managed install whose relaunch can't be proven from here (e.g. Pinokio), the restart is REFUSED before anything is stopped — restart from the launcher that owns the server instead.", { force: z.boolean().optional() }, async ({ force }, ctx) => {
|
|
3912
4479
|
// Whole-handler budget (#536): confirm + dispatch + readiness — INCLUDING
|
|
3913
4480
|
// the legacy path's UNPREEMPTIBLE synchronous execSync blocks — must ALL finish
|
|
3914
4481
|
// under the outer ~300s tools/call limit. 255s + the legacy admission rule below
|
|
@@ -3944,6 +4511,124 @@ export function buildPanelToolDefs() {
|
|
|
3944
4511
|
"panel card.");
|
|
3945
4512
|
}
|
|
3946
4513
|
if (decision !== "yes") {
|
|
4514
|
+
// #742: NEVER claim "not restarted" while the server is actually DOWN —
|
|
4515
|
+
// and NEVER declare a loss from ONE probe (codex gate): a genuinely
|
|
4516
|
+
// restarting server is refused during its normal down window, and a
|
|
4517
|
+
// transport/card error mapping to "no" is exactly how a tab dying
|
|
4518
|
+
// during a healthy prior reboot lands here. So recheck over a short,
|
|
4519
|
+
// bounded window and report DOWN only when the endpoint is STILL
|
|
4520
|
+
// refused at the end of it; a recovery inside the window is reported
|
|
4521
|
+
// as such (bound instance) or keeps the plain cancel line (unbound).
|
|
4522
|
+
// CAUSATION ("a restart took it down") is named ONLY when the probed
|
|
4523
|
+
// target is PROVABLY the same boot instance the restart would have
|
|
4524
|
+
// stopped — the same binding the refuse-safe preflight uses; an
|
|
4525
|
+
// unbound configured endpoint gets a generic unreachable report that
|
|
4526
|
+
// never claims "we stopped it".
|
|
4527
|
+
const declineBootBase = captureRebootHealthBase(ctx);
|
|
4528
|
+
const boundToRestartTarget = declineBootBase != null && sameHttpBase(getComfyUIBaseUrl(), declineBootBase);
|
|
4529
|
+
const declineProbeBase = boundToRestartTarget
|
|
4530
|
+
? declineBootBase
|
|
4531
|
+
: getComfyUIBaseUrl();
|
|
4532
|
+
// r15: capture the session's held dispatch TOKEN before the probe
|
|
4533
|
+
// awaits — the exoneration clear (r13) and the recovery claim (r14)
|
|
4534
|
+
// must key on the token VALIDATED HERE, never whichever token is
|
|
4535
|
+
// current after the awaits: a concurrent accepted restart stamping a
|
|
4536
|
+
// fresh token mid-probe keeps its record (clear-if-same below), so a
|
|
4537
|
+
// later persistent DOWN can still attribute to it.
|
|
4538
|
+
const declineHeldToken = sessionRestartDispatchToken(ctx);
|
|
4539
|
+
const declineTiming = declineProbeTimingOverride ?? {
|
|
4540
|
+
windowMs: DECLINE_PROBE_WINDOW_MS,
|
|
4541
|
+
intervalMs: DECLINE_PROBE_INTERVAL_MS,
|
|
4542
|
+
probeTimeoutMs: DECLINE_PROBE_TIMEOUT_MS,
|
|
4543
|
+
};
|
|
4544
|
+
const outcome = await probeDeclineRecovery(declineProbeBase, declineTiming.windowMs, declineTiming.intervalMs, declineTiming.probeTimeoutMs, overallDeadline);
|
|
4545
|
+
// The VALIDATED token's record (null when it was superseded by a
|
|
4546
|
+
// newer stamp during the probe — the conservative outcome: neither
|
|
4547
|
+
// claim nor clear keys on a record this probe didn't validate).
|
|
4548
|
+
const declineHeldRecord = declineHeldToken
|
|
4549
|
+
? getRestartDispatchRecord(declineHeldToken)
|
|
4550
|
+
: null;
|
|
4551
|
+
// r13: a HEALTHY/RECOVERED observation exonerates any dispatch THIS
|
|
4552
|
+
// session has on record — the restart explained itself, so its token
|
|
4553
|
+
// must never ground causation for a LATER, independent failure.
|
|
4554
|
+
// Base-matched: a healthy observation only exonerates the instance
|
|
4555
|
+
// it was taken on (an unbound healthy endpoint says nothing about a
|
|
4556
|
+
// boot-instance record). The 10-minute causation window stays as the
|
|
4557
|
+
// backstop for records never observed back.
|
|
4558
|
+
// r14: the record is captured BEFORE the clear — the recovery CLAIM
|
|
4559
|
+
// below ("a restart initiated earlier appears to have completed")
|
|
4560
|
+
// requires the token to have been present at this moment.
|
|
4561
|
+
// r15: CLEAR-IF-SAME — the clear fires only when the session still
|
|
4562
|
+
// holds the SAME token validated before the probe; a newer dispatch
|
|
4563
|
+
// stamped mid-probe keeps its record.
|
|
4564
|
+
if (outcome.status === "healthy" || outcome.status === "recovered") {
|
|
4565
|
+
if (declineHeldToken != null &&
|
|
4566
|
+
declineHeldRecord != null &&
|
|
4567
|
+
(declineHeldRecord.base == null ||
|
|
4568
|
+
sameHttpBase(declineHeldRecord.base, declineProbeBase))) {
|
|
4569
|
+
clearSessionRestartDispatchIfSame(ctx, declineHeldToken);
|
|
4570
|
+
}
|
|
4571
|
+
}
|
|
4572
|
+
if (outcome.status === "down") {
|
|
4573
|
+
const secs = Math.max(1, Math.round(outcome.waited_ms / 1000));
|
|
4574
|
+
if (boundToRestartTarget) {
|
|
4575
|
+
// r4: causation may be named ONLY against a RECORDED restart
|
|
4576
|
+
// dispatch — recent enough to plausibly be the cause, and
|
|
4577
|
+
// targeting THIS instance. r5: the record must be one THIS
|
|
4578
|
+
// SESSION dispatched (holds the token of) — another session's
|
|
4579
|
+
// failed restart never grounds causation here. An unrelated
|
|
4580
|
+
// crash / manual stop (no record, a stale record, another
|
|
4581
|
+
// instance's record, or another session's record) gets the
|
|
4582
|
+
// causation-free report.
|
|
4583
|
+
const dispatch = sessionRestartDispatch(ctx);
|
|
4584
|
+
const causative = dispatch != null &&
|
|
4585
|
+
Date.now() - dispatch.at <= RESTART_DISPATCH_CAUSATION_WINDOW_MS &&
|
|
4586
|
+
(dispatch.base == null || sameHttpBase(dispatch.base, declineBootBase));
|
|
4587
|
+
if (causative) {
|
|
4588
|
+
return ok("⚠️ ComfyUI is DOWN — it was STOPPED and did not come back (still " +
|
|
4589
|
+
`unreachable after a ${secs}s recheck window), so do NOT treat this as ` +
|
|
4590
|
+
"\"nothing happened\". The restart just declined was NOT what stopped " +
|
|
4591
|
+
"it (nothing was dispatched after the decline), but a restart initiated " +
|
|
4592
|
+
"earlier already took the server down and it never returned. Start " +
|
|
4593
|
+
"ComfyUI manually from whatever launches it (e.g. Pinokio, the Desktop " +
|
|
4594
|
+
"app, or your terminal), then reload the panel tab so it reconnects. " +
|
|
4595
|
+
"restart_comfyui can attempt the relaunch for you when the install's " +
|
|
4596
|
+
"launch path is resolvable.");
|
|
4597
|
+
}
|
|
4598
|
+
return ok("⚠️ ComfyUI is DOWN — still unreachable after " +
|
|
4599
|
+
`a ${secs}s recheck window — and no restart was dispatched through me ` +
|
|
4600
|
+
"that would explain it (the restart just declined was NOT dispatched, " +
|
|
4601
|
+
"and none is on record recently for this instance). Something else " +
|
|
4602
|
+
"stopped it (a crash, a manual stop, or its launcher). Start ComfyUI " +
|
|
4603
|
+
"manually from whatever launches it (e.g. Pinokio, the Desktop app, or " +
|
|
4604
|
+
"your terminal), then reload the panel tab so it reconnects.");
|
|
4605
|
+
}
|
|
4606
|
+
return ok("⚠️ ComfyUI appears to be DOWN — the configured endpoint was still " +
|
|
4607
|
+
`unreachable after a ${secs}s recheck window. The restart just declined ` +
|
|
4608
|
+
"was NOT dispatched, and I can't confirm from here whether the server " +
|
|
4609
|
+
"was actually stopped — this endpoint isn't provably the instance the " +
|
|
4610
|
+
"restart would have cycled. Check ComfyUI on its host and start it " +
|
|
4611
|
+
"manually if it is down, then reload the panel tab so it reconnects.");
|
|
4612
|
+
}
|
|
4613
|
+
if (outcome.status === "recovered" && boundToRestartTarget) {
|
|
4614
|
+
// r14: the recovery CLAIM ("a restart initiated earlier appears to
|
|
4615
|
+
// have completed") passes the SAME causation gate as the DOWN
|
|
4616
|
+
// report — a session-held, bound-confirmed record, recent, and
|
|
4617
|
+
// base-matched, captured BEFORE the r13 exoneration clear (which
|
|
4618
|
+
// fires either way). Without it, the down→healthy cycle alone
|
|
4619
|
+
// proves nothing about what caused the down: report the recovery
|
|
4620
|
+
// causation-free.
|
|
4621
|
+
const recoveryCausative = declineHeldRecord != null &&
|
|
4622
|
+
Date.now() - declineHeldRecord.at <= RESTART_DISPATCH_CAUSATION_WINDOW_MS &&
|
|
4623
|
+
(declineHeldRecord.base == null ||
|
|
4624
|
+
sameHttpBase(declineHeldRecord.base, declineBootBase));
|
|
4625
|
+
return ok(recoveryCausative
|
|
4626
|
+
? "Cancelled — no new restart was dispatched. Note: ComfyUI was briefly " +
|
|
4627
|
+
"unreachable but is healthy again — a restart initiated earlier " +
|
|
4628
|
+
"appears to have completed."
|
|
4629
|
+
: "Cancelled — no new restart was dispatched. ComfyUI was briefly " +
|
|
4630
|
+
"unreachable but is healthy again.");
|
|
4631
|
+
}
|
|
3947
4632
|
return ok("Cancelled — ComfyUI was not restarted.");
|
|
3948
4633
|
}
|
|
3949
4634
|
// Heal an orphaned session onto the live tab FIRST, then bind the reboot dispatch
|
|
@@ -3952,7 +4637,105 @@ export function buildPanelToolDefs() {
|
|
|
3952
4637
|
// server-authorized + immutable, bound to the exact host FAMILY the reboot goes
|
|
3953
4638
|
// to (null unless the bound tab provably fronts our boot instance).
|
|
3954
4639
|
ctx.ensureReachable?.();
|
|
4640
|
+
// #742 REFUSE-SAFE PREFLIGHT: a Manager reboot stops ComfyUI OUT-OF-BAND —
|
|
4641
|
+
// it never goes through our validated kill+relaunch — so before dispatching
|
|
4642
|
+
// anything, the stop must be provable survivable (#368/#370: losing a restart
|
|
4643
|
+
// is cheap, losing the server is not). On a Pinokio-style install (externally
|
|
4644
|
+
// supervised; no main.py/interpreter resolvable from here) a plain Manager
|
|
4645
|
+
// restart kills the process and the supervisor does NOT re-launch it — the
|
|
4646
|
+
// exact #742 lost-server. When the reboot would target OUR local boot
|
|
4647
|
+
// instance (the same instance binding the legacy fallback uses), prove a
|
|
4648
|
+
// relaunch is possible FIRST and refuse BEFORE any stop when it isn't. Only
|
|
4649
|
+
// the PROVEN-dangerous shape refuses (a reachable local non-Desktop process
|
|
4650
|
+
// with an unbuildable/unvalidatable relaunch); every other shape — Desktop
|
|
4651
|
+
// (Electron-supervised, #400), unverifiable, or remote — proceeds exactly as
|
|
4652
|
+
// before. The binding for this DECISION is captured pre-await; nothing
|
|
4653
|
+
// downstream may reuse it (r7).
|
|
4654
|
+
const preflightHealthBase = captureRebootHealthBase(ctx);
|
|
4655
|
+
if (preflightHealthBase != null && sameHttpBase(getComfyUIBaseUrl(), preflightHealthBase)) {
|
|
4656
|
+
// Snapshot the target GENERATION at the decision (r11): a final-state
|
|
4657
|
+
// base comparison (A vs A) cannot detect an intervening A→B→A
|
|
4658
|
+
// retarget, so stability is judged by the monotonic epoch bumped on
|
|
4659
|
+
// EVERY retarget — any mutation, including a round trip back to the
|
|
4660
|
+
// same base, is caught.
|
|
4661
|
+
const preflightTargetGeneration = getComfyuiTargetGeneration();
|
|
4662
|
+
const preflight = await (localRestartPreflightOverride ?? preflightLocalRestart)();
|
|
4663
|
+
// r8/r9/r10: the preflight AWAIT makes the pre-decision captures
|
|
4664
|
+
// STALE — and the preflight itself reads MUTABLE config (target URL,
|
|
4665
|
+
// port, COMFYUI_PATH) throughout, so a config retarget during the
|
|
4666
|
+
// await can re-point the whole assessment at a DIFFERENT install
|
|
4667
|
+
// while the tab still fronts the original one. Re-heal and
|
|
4668
|
+
// re-capture BEFORE trusting the result either way. The guarantee
|
|
4669
|
+
// that must hold: a stop/reboot is only ever sent when the preflight
|
|
4670
|
+
// validated THE instance the dispatch will cycle.
|
|
4671
|
+
ctx.ensureReachable?.();
|
|
4672
|
+
const postPreflightHealthBase = captureRebootHealthBase(ctx);
|
|
4673
|
+
const tabFrontsSameInstance = postPreflightHealthBase != null &&
|
|
4674
|
+
sameHttpBase(preflightHealthBase, postPreflightHealthBase);
|
|
4675
|
+
const configStable = getComfyuiTargetGeneration() === preflightTargetGeneration;
|
|
4676
|
+
if (!configStable && tabFrontsSameInstance) {
|
|
4677
|
+
// r10: the target config moved MID-CHECK, so the preflight result
|
|
4678
|
+
// — pass OR fail — cannot vouch for the tab-fronted instance (a
|
|
4679
|
+
// PASS may have validated a different, safe install; it must never
|
|
4680
|
+
// bless a stop of this one). Its relaunch is UNPROVEN → refuse; an
|
|
4681
|
+
// instance with an unproven relaunch is never sent a stop.
|
|
4682
|
+
return ok({
|
|
4683
|
+
rebooting: false,
|
|
4684
|
+
ready: false,
|
|
4685
|
+
confirmed_cycle: false,
|
|
4686
|
+
refused: true,
|
|
4687
|
+
note: "Refusing to restart ComfyUI: the ComfyUI target configuration changed " +
|
|
4688
|
+
"while the restart safety check was running, so the check cannot vouch " +
|
|
4689
|
+
"for a safe relaunch of the instance this panel fronts. A stop is never " +
|
|
4690
|
+
"sent to an instance whose relaunch is unproven — ComfyUI was NOT " +
|
|
4691
|
+
"stopped (it is still running). Let the target settle, then retry " +
|
|
4692
|
+
"panel_restart_comfyui.",
|
|
4693
|
+
});
|
|
4694
|
+
}
|
|
4695
|
+
if (!preflight.ok && tabFrontsSameInstance) {
|
|
4696
|
+
// r9: the danger proof follows the INSTANCE the tab fronts, NOT the
|
|
4697
|
+
// mutable runtime config — a config-only retarget mid-await must
|
|
4698
|
+
// not wash out the proof that the tab-fronted boot instance is
|
|
4699
|
+
// unrelaunchable. The tab STILL fronts that same instance, so the
|
|
4700
|
+
// proof is still valid and the refusal stands: an instance proven
|
|
4701
|
+
// unrelaunchable is NEVER sent a stop/reboot, regardless of
|
|
4702
|
+
// config/tab shuffling mid-flight. Only a genuinely different,
|
|
4703
|
+
// unconfirmable target falls through to the honest-unconfirmed
|
|
4704
|
+
// dispatch (nothing was ever proved dangerous for it — and nothing
|
|
4705
|
+
// was ever stopped, the preflight never stops anything).
|
|
4706
|
+
return ok({
|
|
4707
|
+
rebooting: false,
|
|
4708
|
+
ready: false,
|
|
4709
|
+
confirmed_cycle: false,
|
|
4710
|
+
refused: true,
|
|
4711
|
+
note: `Refusing to restart ComfyUI: ${preflight.reason} This looks like an ` +
|
|
4712
|
+
"externally-managed install (e.g. Pinokio): a restart from here would STOP " +
|
|
4713
|
+
"ComfyUI and nothing would bring it back automatically, so it was refused " +
|
|
4714
|
+
"BEFORE anything was stopped — ComfyUI is still running. Restart it from the " +
|
|
4715
|
+
"launcher that owns it (e.g. Pinokio's own controls), or point COMFYUI_PATH " +
|
|
4716
|
+
"at the live install so a relaunch can be proven and use restart_comfyui.",
|
|
4717
|
+
});
|
|
4718
|
+
}
|
|
4719
|
+
// Otherwise: a PASS with a stable config (proven safe for THE
|
|
4720
|
+
// tab-fronted instance — proceed), or the tab now fronts a genuinely
|
|
4721
|
+
// different, unconfirmable target (nothing provable about it — the
|
|
4722
|
+
// dispatch path below treats that honestly, r6/r7). Nothing was
|
|
4723
|
+
// stopped in any case — the preflight never stops anything.
|
|
4724
|
+
}
|
|
4725
|
+
// r7: the preflight AWAIT sits between the binding capture and the dispatch,
|
|
4726
|
+
// breaking the no-await invariant above — a tab/connection rebind during
|
|
4727
|
+
// that await would make every pre-await capture stale (the dispatch would
|
|
4728
|
+
// go to an unconfirmable/different target while the causation stamp, the
|
|
4729
|
+
// recovery observer, and the legacy-fallback gate all read the STALE bound
|
|
4730
|
+
// base). Re-heal and re-capture the tab id, panel identity, and health base
|
|
4731
|
+
// AT THE DISPATCH POINT; everything below uses ONLY these fresh captures.
|
|
4732
|
+
ctx.ensureReachable?.();
|
|
3955
4733
|
const boundTabId = ctx.tabId;
|
|
4734
|
+
// Snapshot the exact browser-tab registration that is about to receive the
|
|
4735
|
+
// reboot. A post-restart success must observe a strictly newer hello from
|
|
4736
|
+
// this SAME browser tab; a different tab can reuse the same saved-workflow
|
|
4737
|
+
// routing id while the original is still reconnecting.
|
|
4738
|
+
const preRestartPanelIdentity = ctx.panelConnectionIdentity?.();
|
|
3956
4739
|
const healthBase = captureRebootHealthBase(ctx);
|
|
3957
4740
|
const timing = getPanelRebootTiming();
|
|
3958
4741
|
const dispatchTimeout = Math.max(1, Math.min(15000, overallDeadline - Date.now()));
|
|
@@ -4115,6 +4898,18 @@ export function buildPanelToolDefs() {
|
|
|
4115
4898
|
" — restart ComfyUI on the host, then reconnect.");
|
|
4116
4899
|
}
|
|
4117
4900
|
clearTimeout(restartTimer);
|
|
4901
|
+
// #742 r5/r6: the managed restart stopped the process — record the
|
|
4902
|
+
// dispatch with THIS session holding the token, stamped with the
|
|
4903
|
+
// BOUND-CONFIRMED base (this fallback only runs when the instance
|
|
4904
|
+
// binding held, so healthBase is non-null here). restartComfyUI
|
|
4905
|
+
// also stamped its own process-wide record, which never grounds
|
|
4906
|
+
// causation. Only a PROVEN stop is recorded; a refusal/timeout
|
|
4907
|
+
// (restart undefined, or stopped!==true) records nothing. The
|
|
4908
|
+
// token is kept so the recovery clear below is CLEAR-IF-SAME (r15).
|
|
4909
|
+
let legacyDispatchToken;
|
|
4910
|
+
if (restart?.stopped === true) {
|
|
4911
|
+
legacyDispatchToken = stampSessionRestartDispatch(ctx, healthBase);
|
|
4912
|
+
}
|
|
4118
4913
|
// DEFINITIVE no-restart: a spawn failure, OR restartComfyUI refused before
|
|
4119
4914
|
// stopping anything (no process found / unsafe relaunch → stopped:false &&
|
|
4120
4915
|
// started:false). The process was NOT cycled, so the still-healthy endpoint is
|
|
@@ -4130,21 +4925,55 @@ export function buildPanelToolDefs() {
|
|
|
4130
4925
|
// Otherwise (the process WAS stopped/started, or restartComfyUI's own readiness
|
|
4131
4926
|
// poll merely expired — neither terminal) DEFER to OUR OWN observed DOWN→UP.
|
|
4132
4927
|
const recovery = await proofPromise;
|
|
4928
|
+
// #742 r4/r5/r15: the managed restart was observed back — clear THIS
|
|
4929
|
+
// session's record, CLEAR-IF-SAME: only when the session still holds
|
|
4930
|
+
// the token THIS restart stamped (a concurrent dispatch's newer
|
|
4931
|
+
// record survives). restartComfyUI also clears its own process-wide
|
|
4932
|
+
// record on success; this covers only-observer-saw-it recoveries.
|
|
4933
|
+
if (recovery.ready && legacyDispatchToken != null) {
|
|
4934
|
+
clearSessionRestartDispatchIfSame(ctx, legacyDispatchToken);
|
|
4935
|
+
}
|
|
4133
4936
|
const observed = recovery.via === "observed-cycle";
|
|
4937
|
+
// The legacy Manager path restarts ComfyUI out-of-band too. Server recovery alone
|
|
4938
|
+
// is not graph-tool readiness: wait for the browser tab to reconnect, then verify
|
|
4939
|
+
// the same workflow-stamp capability the bridge requires before it dispatches a
|
|
4940
|
+
// mutation. Without this, updating the panel pack followed by a legacy restart can
|
|
4941
|
+
// falsely report ready while the browser is still running stale panel JS (#709).
|
|
4942
|
+
const tabBack = recovery.ready
|
|
4943
|
+
? ctx.awaitPostRestartReachable
|
|
4944
|
+
? await ctx.awaitPostRestartReachable(preRestartPanelIdentity, Math.max(0, overallDeadline - Date.now()))
|
|
4945
|
+
: ctx.awaitReachable
|
|
4946
|
+
? await ctx.awaitReachable(Math.max(0, overallDeadline - Date.now()))
|
|
4947
|
+
: true
|
|
4948
|
+
: false;
|
|
4949
|
+
const graphToolsReady = tabBack && (ctx.tabCanMutateGraph ? ctx.tabCanMutateGraph() : true);
|
|
4134
4950
|
return ok({
|
|
4135
4951
|
rebooting: true,
|
|
4136
|
-
ready:
|
|
4952
|
+
ready: graphToolsReady,
|
|
4953
|
+
graph_tools_ready: graphToolsReady,
|
|
4954
|
+
server_ready: recovery.ready,
|
|
4955
|
+
panel_tab_reconnected: tabBack,
|
|
4137
4956
|
confirmed_cycle: observed, // true = we directly observed the down→up cycle
|
|
4138
4957
|
recovered_ms: recovery.waited_ms,
|
|
4139
4958
|
probes: recovery.attempts,
|
|
4140
4959
|
saw_down: recovery.sawDown,
|
|
4141
4960
|
via: recovery.ready ? recovery.via : undefined,
|
|
4142
|
-
note:
|
|
4143
|
-
"
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4961
|
+
note: recovery.ready && !graphToolsReady
|
|
4962
|
+
? "ComfyUI-Manager (legacy 3.x) had no reboot endpoint; the headless managed restart " +
|
|
4963
|
+
`came back healthy in ${(recovery.waited_ms / 1000).toFixed(1)}s, but ` +
|
|
4964
|
+
(!tabBack
|
|
4965
|
+
? "the panel tab has NOT reconnected yet (ready:false). Wait a moment then retry, or " +
|
|
4966
|
+
'rebind with panel_set_workflow_target({mode:"current"}) before issuing graph tools.'
|
|
4967
|
+
: "the panel tab reconnected but cannot safely run graph mutations (ready:false), usually " +
|
|
4968
|
+
"because it is still running a stale panel bundle. Hard-refresh the ComfyUI browser tab " +
|
|
4969
|
+
"(Ctrl+Shift+R) before issuing graph tools; if that does not restore it, update the panel " +
|
|
4970
|
+
"and open/reload a saved workflow with a stable identity.")
|
|
4971
|
+
: "ComfyUI-Manager (legacy 3.x) had no reboot endpoint; ran the headless managed " +
|
|
4972
|
+
"restart (kill + relaunch) " +
|
|
4973
|
+
(recovery.ready
|
|
4974
|
+
? `and it came back healthy in ${(recovery.waited_ms / 1000).toFixed(1)}s` +
|
|
4975
|
+
(observed ? " (observed it go down then come back)." : " (cycle not directly observed).")
|
|
4976
|
+
: `but it did NOT become healthy within ${Math.round(recovery.waited_ms / 1000)}s — verify with health_check / panel_node_queue_status before assuming it restarted.`),
|
|
4148
4977
|
});
|
|
4149
4978
|
}
|
|
4150
4979
|
// Genuine refusal (busy guard / security / no eligible fallback) — return
|
|
@@ -4158,6 +4987,25 @@ export function buildPanelToolDefs() {
|
|
|
4158
4987
|
resetClient();
|
|
4159
4988
|
resetObjectInfoCache();
|
|
4160
4989
|
resetManagerApiCache("panel Manager reboot");
|
|
4990
|
+
// #742 r4/r5/r6: record the ACTUAL dispatch (acceptance proven — a refusal
|
|
4991
|
+
// never reaches here). ONLY a BOUND-CONFIRMED target (the same binding
|
|
4992
|
+
// the r1 causation scoping and the refuse-safe preflight use) may stamp a
|
|
4993
|
+
// causation-capable record — held on THIS session with the BOUND base at
|
|
4994
|
+
// stamp time, never the mutable configured one. An unbound/unconfirmable
|
|
4995
|
+
// target (r6) stamps only the shared PROCESS-WIDE slot, which never
|
|
4996
|
+
// grounds causation: the dispatch can't be proven to have hit the
|
|
4997
|
+
// instance a later decline would probe, and a session that rebinds to
|
|
4998
|
+
// the boot tab afterward can't claim it either (a re-targeted session
|
|
4999
|
+
// can't prove the earlier dispatch hit its current instance — no claim
|
|
5000
|
+
// is the truthful answer). It is cleared below if observed back —
|
|
5001
|
+
// CLEAR-IF-SAME on the token stamped here (r15).
|
|
5002
|
+
let acceptedDispatchToken;
|
|
5003
|
+
if (healthBase != null && sameHttpBase(getComfyUIBaseUrl(), healthBase)) {
|
|
5004
|
+
acceptedDispatchToken = stampSessionRestartDispatch(ctx, healthBase);
|
|
5005
|
+
}
|
|
5006
|
+
else {
|
|
5007
|
+
recordRestartDispatch(getComfyUIBaseUrl(), PROCESS_WIDE_RESTART_DISPATCH_TOKEN);
|
|
5008
|
+
}
|
|
4161
5009
|
// Observe recovery. There is exactly ONE sound proof that THIS ComfyUI instance
|
|
4162
5010
|
// actually cycled: a directly OBSERVED down→up on the server-authorized, immutable,
|
|
4163
5011
|
// family-bound boot endpoint (observeRecovery). We do NOT fabricate a second proof
|
|
@@ -4192,6 +5040,12 @@ export function buildPanelToolDefs() {
|
|
|
4192
5040
|
// down→up, which the observer has been (and continues) watching for.
|
|
4193
5041
|
gate.deadline = Math.min(Date.now() + timing.budgetMs, overallDeadline);
|
|
4194
5042
|
const recovery = await recoveryPromise;
|
|
5043
|
+
// #742 r4/r5/r15: the dispatched restart was observed back — clear the
|
|
5044
|
+
// record, CLEAR-IF-SAME: only when the session still holds the token
|
|
5045
|
+
// THIS dispatch stamped (a concurrent dispatch's newer record survives).
|
|
5046
|
+
if (recovery.ready && acceptedDispatchToken != null) {
|
|
5047
|
+
clearSessionRestartDispatchIfSame(ctx, acceptedDispatchToken);
|
|
5048
|
+
}
|
|
4195
5049
|
if (!recovery.ready) {
|
|
4196
5050
|
const waited = Math.round(recovery.waited_ms / 1000);
|
|
4197
5051
|
return ok({
|
|
@@ -4206,21 +5060,32 @@ export function buildPanelToolDefs() {
|
|
|
4206
5060
|
: `The reboot command was sent but I could NOT confirm ComfyUI actually cycled within ${waited}s (it never went down — the panel may have merely disconnected/inferred a reboot without one). Verify with health_check / panel_node_queue_status; do NOT assume it restarted.`,
|
|
4207
5061
|
});
|
|
4208
5062
|
}
|
|
4209
|
-
// #400: ComfyUI is healthy, but the panel's browser tab re-registers its own
|
|
4210
|
-
// socket a moment later.
|
|
5063
|
+
// #400/#709: ComfyUI is healthy, but the panel's browser tab re-registers its own
|
|
5064
|
+
// socket a moment later. The old socket can still be reachable after dispatch, so
|
|
5065
|
+
// the generation waiter below requires a fresh hello before reporting readiness.
|
|
4211
5066
|
// hits "no connected tab … Connected: none" and the agent is told to hand-rebind.
|
|
4212
5067
|
// Wait (bounded, clamped to THIS handler's deadline) for the tab to reconnect and
|
|
4213
5068
|
// rebind this session onto it. `ready` reflects GRAPH-TOOL readiness (a bound tab),
|
|
4214
5069
|
// NOT just server health — a caller keying off `ready` must not be led into the
|
|
4215
5070
|
// Connected:none window (codex). `server_ready` carries the certified cycle either
|
|
4216
|
-
// way.
|
|
4217
|
-
// the historical
|
|
4218
|
-
const tabBack = ctx.
|
|
4219
|
-
? await ctx.
|
|
4220
|
-
:
|
|
5071
|
+
// way. A production PanelToolCtx supplies the generation waiter; an explicitly
|
|
5072
|
+
// lightweight context retains the historical reachability-only test contract.
|
|
5073
|
+
const tabBack = ctx.awaitPostRestartReachable
|
|
5074
|
+
? await ctx.awaitPostRestartReachable(preRestartPanelIdentity, Math.max(0, overallDeadline - Date.now()))
|
|
5075
|
+
: ctx.awaitReachable
|
|
5076
|
+
? await ctx.awaitReachable(Math.max(0, overallDeadline - Date.now()))
|
|
5077
|
+
: true;
|
|
5078
|
+
// A recovered socket does NOT mean graph mutations are usable: an already-open
|
|
5079
|
+
// browser tab can reconnect after ComfyUI restarts while still serving stale panel
|
|
5080
|
+
// JS, which lacks the #570 workflow-stamp fence. Report the same capability the
|
|
5081
|
+
// bridge enforces before dispatch so this success path never fabricates readiness.
|
|
5082
|
+
// Old/lightweight contexts have no capability accessor, so preserve their historical
|
|
5083
|
+
// contract rather than claiming a production tab passed a check it never ran.
|
|
5084
|
+
const graphToolsReady = tabBack && (ctx.tabCanMutateGraph ? ctx.tabCanMutateGraph() : true);
|
|
4221
5085
|
return ok({
|
|
4222
5086
|
rebooting: true,
|
|
4223
|
-
ready:
|
|
5087
|
+
ready: graphToolsReady, // graph tools require a bound AND workflow-stamp-capable tab
|
|
5088
|
+
graph_tools_ready: graphToolsReady,
|
|
4224
5089
|
server_ready: true, // ComfyUI itself cycled and is healthy
|
|
4225
5090
|
confirmed_cycle: true, // we directly observed the down→up cycle on the boot endpoint
|
|
4226
5091
|
recovered_ms: recovery.waited_ms,
|
|
@@ -4228,15 +5093,21 @@ export function buildPanelToolDefs() {
|
|
|
4228
5093
|
saw_down: recovery.sawDown,
|
|
4229
5094
|
via: recovery.via,
|
|
4230
5095
|
panel_tab_reconnected: tabBack,
|
|
4231
|
-
note:
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
5096
|
+
note: (tabBack && !graphToolsReady
|
|
5097
|
+
? `ComfyUI restart accepted and it is healthy again in ${(recovery.waited_ms / 1000).toFixed(1)}s` +
|
|
5098
|
+
" (observed it go down then come back); the panel tab reconnected but cannot safely run " +
|
|
5099
|
+
"graph mutations (ready:false), usually because it is still running a stale panel bundle. " +
|
|
5100
|
+
"Hard-refresh the ComfyUI browser tab (Ctrl+Shift+R) before issuing graph tools; if that " +
|
|
5101
|
+
"does not restore it, update the panel and open/reload a saved workflow with a stable identity"
|
|
5102
|
+
: `ComfyUI restart accepted and it is healthy again in ${(recovery.waited_ms / 1000).toFixed(1)}s` +
|
|
5103
|
+
" (observed it go down then come back)" +
|
|
5104
|
+
(dropped ? "; connection dropped as expected while it went down" : "") +
|
|
5105
|
+
(tabBack
|
|
5106
|
+
? "; the panel tab reconnected — graph tools are ready."
|
|
5107
|
+
: "; ComfyUI is back but the panel tab has NOT reconnected yet (ready:false) — " +
|
|
5108
|
+
'wait a moment then retry, or rebind with panel_set_workflow_target({mode:"current"}) ' +
|
|
5109
|
+
"before issuing graph tools.") +
|
|
5110
|
+
"."),
|
|
4240
5111
|
});
|
|
4241
5112
|
}),
|
|
4242
5113
|
def("panel_free_vram", "Unload all loaded models and free VRAM (ComfyUI /free). Use to unwedge a stuck/OOM ComfyUI when a cancel didn't free memory — before retrying or, last resort, restarting (panel_restart_comfyui). Does NOT restart ComfyUI; it just drops resident models and frees cached memory.", {}, async (_args, ctx) => ctx.call({ cmd: "free_vram" }, 15000)),
|
|
@@ -4366,6 +5237,9 @@ export function buildPanelToolDefs() {
|
|
|
4366
5237
|
return ctx.call({ cmd: "ui_update", card_id: args.card_id, spec: v.spec }, 15000);
|
|
4367
5238
|
}),
|
|
4368
5239
|
];
|
|
5240
|
+
// #694: every MUTATING panel tool (RETRY_TOKEN_CMD_BY_TOOL) accepts the explicit
|
|
5241
|
+
// retry token in its schema and forwards it, untouched, on its command frames.
|
|
5242
|
+
return defs.map((d) => (d.name in RETRY_TOKEN_CMD_BY_TOOL ? withRetryToken(d) : d));
|
|
4369
5243
|
}
|
|
4370
5244
|
/**
|
|
4371
5245
|
* Build the per-tab live-graph MCP server for the Claude (in-process Agent SDK)
|