comfyui-mcp 0.48.21 → 0.48.22
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 +2 -2
- package/dist/comfyui/client.js +46 -6
- package/dist/comfyui/client.js.map +1 -1
- package/dist/config.js +15 -0
- package/dist/config.js.map +1 -1
- package/dist/orchestrator/error-text.js +29 -1
- package/dist/orchestrator/error-text.js.map +1 -1
- package/dist/orchestrator/error-text.test.js +28 -0
- package/dist/orchestrator/error-text.test.js.map +1 -1
- package/dist/orchestrator/index.js +151 -50
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/panel-agent.js +21 -4
- package/dist/orchestrator/panel-agent.js.map +1 -1
- package/dist/orchestrator/panel-tools.js +1098 -168
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/orchestrator/ready-banner.js +70 -0
- package/dist/orchestrator/ready-banner.js.map +1 -0
- package/dist/services/api-nodes.js +5 -7
- package/dist/services/api-nodes.js.map +1 -1
- package/dist/services/download-cache.js +126 -18
- package/dist/services/download-cache.js.map +1 -1
- package/dist/services/download-jobs.js +463 -14
- package/dist/services/download-jobs.js.map +1 -1
- package/dist/services/download-progress.js +247 -2
- package/dist/services/download-progress.js.map +1 -1
- package/dist/services/env-capabilities.js +80 -9
- package/dist/services/env-capabilities.js.map +1 -1
- package/dist/services/generate-audio.js +16 -0
- package/dist/services/generate-audio.js.map +1 -1
- package/dist/services/local-models-fallback.js +7 -3
- package/dist/services/local-models-fallback.js.map +1 -1
- package/dist/services/manifest.js +180 -65
- package/dist/services/manifest.js.map +1 -1
- package/dist/services/model-resolver.js +267 -11
- package/dist/services/model-resolver.js.map +1 -1
- package/dist/services/node-management.js +38 -27
- package/dist/services/node-management.js.map +1 -1
- package/dist/services/output-dir.js +33 -3
- package/dist/services/output-dir.js.map +1 -1
- package/dist/services/panel-secrets.js +19 -7
- package/dist/services/panel-secrets.js.map +1 -1
- package/dist/services/queue-monitor.js +45 -2
- package/dist/services/queue-monitor.js.map +1 -1
- package/dist/services/queue-monitor.stall.test.js +78 -0
- package/dist/services/queue-monitor.stall.test.js.map +1 -0
- package/dist/services/secure-bridge.js +84 -0
- package/dist/services/secure-bridge.js.map +1 -1
- package/dist/services/storage/azure-blob.js +8 -3
- package/dist/services/storage/azure-blob.js.map +1 -1
- package/dist/services/storage/index.js +3 -3
- package/dist/services/storage/index.js.map +1 -1
- package/dist/services/storage/s3.js +9 -3
- package/dist/services/storage/s3.js.map +1 -1
- package/dist/services/ui-bridge.js +294 -24
- package/dist/services/ui-bridge.js.map +1 -1
- package/dist/services/workflow-converter.js +767 -72
- package/dist/services/workflow-converter.js.map +1 -1
- package/dist/services/workflow-lock.js +127 -27
- package/dist/services/workflow-lock.js.map +1 -1
- package/dist/services/workspace-env.js +48 -0
- package/dist/services/workspace-env.js.map +1 -1
- package/dist/tools/generate-audio.js +44 -0
- package/dist/tools/generate-audio.js.map +1 -1
- package/dist/tools/model-extras.js +24 -4
- package/dist/tools/model-extras.js.map +1 -1
- package/dist/tools/model-management.js +124 -14
- package/dist/tools/model-management.js.map +1 -1
- package/dist/tools/report-issue.js +220 -66
- package/dist/tools/report-issue.js.map +1 -1
- package/dist/tools/report-issue.test.js +223 -83
- package/dist/tools/report-issue.test.js.map +1 -1
- package/dist/tools/skills-access.js +7 -2
- package/dist/tools/skills-access.js.map +1 -1
- package/dist/tools/template-schema.js +5 -5
- package/dist/tools/template-schema.js.map +1 -1
- package/dist/tools/workflow-compose.js +18 -3
- package/dist/tools/workflow-compose.js.map +1 -1
- package/package.json +1 -1
- package/packs/anima/install-runpod.sh +0 -1
- package/packs/anima/install-windows.bat +0 -1
- package/packs/anima/manifest.yaml +0 -1
- package/packs/anima-img2img/install-runpod.sh +0 -1
- package/packs/anima-img2img/install-windows.bat +0 -1
- package/packs/anima-img2img/manifest.yaml +0 -1
- package/packs/anima-inpaint/install-runpod.sh +0 -1
- package/packs/anima-inpaint/install-windows.bat +0 -1
- package/packs/anima-inpaint/manifest.yaml +0 -1
- package/packs/anima-txt2img/install-runpod.sh +0 -1
- package/packs/anima-txt2img/install-windows.bat +0 -1
- package/packs/anima-txt2img/manifest.yaml +0 -1
- package/packs/artokun-flow/install-runpod.sh +0 -1
- package/packs/artokun-flow/install-windows.bat +0 -1
- package/packs/artokun-flow/manifest.yaml +0 -1
- package/packs/ernie/install-runpod.sh +0 -1
- package/packs/ernie/install-windows.bat +0 -1
- package/packs/ernie/manifest.yaml +0 -1
- package/packs/ernie-combo/install-runpod.sh +0 -1
- package/packs/ernie-combo/install-windows.bat +0 -1
- package/packs/ernie-combo/manifest.yaml +0 -1
- package/packs/ernie-img2img/install-runpod.sh +0 -1
- package/packs/ernie-img2img/install-windows.bat +0 -1
- package/packs/ernie-img2img/manifest.yaml +0 -1
- package/packs/ernie-txt2img/install-runpod.sh +0 -1
- package/packs/ernie-txt2img/install-windows.bat +0 -1
- package/packs/ernie-txt2img/manifest.yaml +0 -1
- package/packs/ideogram/install-runpod.sh +0 -1
- package/packs/ideogram/install-windows.bat +0 -1
- package/packs/ideogram/manifest.yaml +0 -1
- package/packs/ideogram-img2img/install-runpod.sh +0 -1
- package/packs/ideogram-img2img/install-windows.bat +0 -1
- package/packs/ideogram-img2img/manifest.yaml +0 -1
- package/packs/ideogram-txt2img/install-runpod.sh +0 -1
- package/packs/ideogram-txt2img/install-windows.bat +0 -1
- package/packs/ideogram-txt2img/manifest.yaml +0 -1
- package/packs/ltx-2.3/install-runpod.sh +0 -1
- package/packs/ltx-2.3/install-windows.bat +0 -1
- package/packs/ltx-2.3/manifest.yaml +0 -1
- package/packs/ltx-2.3-extender/install-runpod.sh +0 -1
- package/packs/ltx-2.3-extender/install-windows.bat +0 -1
- package/packs/ltx-2.3-extender/manifest.yaml +0 -1
- package/packs/ltx-2.3-extender-no-audio/install-runpod.sh +0 -1
- package/packs/ltx-2.3-extender-no-audio/install-windows.bat +0 -1
- package/packs/ltx-2.3-extender-no-audio/manifest.yaml +0 -1
- package/packs/ltx-2.3-flf/install-runpod.sh +0 -1
- package/packs/ltx-2.3-flf/install-windows.bat +0 -1
- package/packs/ltx-2.3-flf/manifest.yaml +0 -1
- package/packs/ltx-2.3-img2vid/install-runpod.sh +0 -1
- package/packs/ltx-2.3-img2vid/install-windows.bat +0 -1
- package/packs/ltx-2.3-img2vid/manifest.yaml +3 -4
- package/packs/ltx-2.3-txt2vid/install-runpod.sh +0 -1
- package/packs/ltx-2.3-txt2vid/install-windows.bat +0 -1
- package/packs/ltx-2.3-txt2vid/manifest.yaml +3 -4
- package/packs/ltx-2.3-xy-plot/install-runpod.sh +0 -1
- package/packs/ltx-2.3-xy-plot/install-windows.bat +0 -1
- package/packs/ltx-2.3-xy-plot/manifest.yaml +0 -1
- package/packs/ltx23-distill-3stage/install-runpod.sh +0 -1
- package/packs/ltx23-distill-3stage/install-windows.bat +0 -1
- package/packs/ltx23-distill-3stage/manifest.yaml +0 -1
- package/packs/qwen-image/install-runpod.sh +0 -1
- package/packs/qwen-image/install-windows.bat +0 -1
- package/packs/qwen-image/manifest.yaml +0 -1
- package/packs/qwen-image-edit/install-runpod.sh +0 -1
- package/packs/qwen-image-edit/install-windows.bat +0 -1
- package/packs/qwen-image-edit/manifest.yaml +0 -1
- package/packs/qwen-image-edit-edit/install-runpod.sh +0 -1
- package/packs/qwen-image-edit-edit/install-windows.bat +0 -1
- package/packs/qwen-image-edit-edit/manifest.yaml +0 -1
- package/packs/wan-animate/install-runpod.sh +0 -1
- package/packs/wan-animate/install-windows.bat +0 -1
- package/packs/wan-animate/manifest.yaml +0 -1
- package/packs/wan-animate-character/install-runpod.sh +0 -1
- package/packs/wan-animate-character/install-windows.bat +0 -1
- package/packs/wan-animate-character/manifest.yaml +0 -1
- package/packs/wan-animate-ofm/install-runpod.sh +0 -1
- package/packs/wan-animate-ofm/install-windows.bat +0 -1
- package/packs/wan-animate-ofm/manifest.yaml +0 -1
- package/packs/wan-longer-videos/install-runpod.sh +0 -1
- package/packs/wan-longer-videos/install-windows.bat +0 -1
- package/packs/wan-longer-videos/manifest.yaml +0 -1
- package/packs/wan-longer-videos-i2v/install-runpod.sh +0 -1
- package/packs/wan-longer-videos-i2v/install-windows.bat +0 -1
- package/packs/wan-longer-videos-i2v/manifest.yaml +0 -1
- package/packs/wan-longer-videos-i2v-96gb/install-runpod.sh +0 -1
- package/packs/wan-longer-videos-i2v-96gb/install-windows.bat +0 -1
- package/packs/wan-longer-videos-i2v-96gb/manifest.yaml +0 -1
- package/packs/wan-longer-videos-t2v/install-runpod.sh +0 -1
- package/packs/wan-longer-videos-t2v/install-windows.bat +0 -1
- package/packs/wan-longer-videos-t2v/manifest.yaml +0 -1
- package/packs/wan-longer-videos-t2v-96gb/install-runpod.sh +0 -1
- package/packs/wan-longer-videos-t2v-96gb/install-windows.bat +0 -1
- package/packs/wan-longer-videos-t2v-96gb/manifest.yaml +0 -1
- package/packs/wan-longer-videos-v2v/install-runpod.sh +0 -1
- package/packs/wan-longer-videos-v2v/install-windows.bat +0 -1
- package/packs/wan-longer-videos-v2v/manifest.yaml +0 -1
- package/packs/wan-longer-videos-v2v-96gb/install-runpod.sh +0 -1
- package/packs/wan-longer-videos-v2v-96gb/install-windows.bat +0 -1
- package/packs/wan-longer-videos-v2v-96gb/manifest.yaml +0 -1
- package/packs/wan-multitalk/install-runpod.sh +0 -1
- package/packs/wan-multitalk/install-windows.bat +0 -1
- package/packs/wan-multitalk/manifest.yaml +0 -1
- package/packs/wan-pusa-extend/install-runpod.sh +0 -1
- package/packs/wan-pusa-extend/install-windows.bat +0 -1
- package/packs/wan-pusa-extend/manifest.yaml +0 -1
- package/packs/wan-transparent/install-runpod.sh +0 -1
- package/packs/wan-transparent/install-windows.bat +0 -1
- package/packs/wan-transparent/manifest.yaml +0 -1
- package/packs/wan-transparent-img2vid/install-runpod.sh +0 -1
- package/packs/wan-transparent-img2vid/install-windows.bat +0 -1
- package/packs/wan-transparent-img2vid/manifest.yaml +0 -1
- package/packs/wan-transparent-img2vid-96gb/install-runpod.sh +0 -1
- package/packs/wan-transparent-img2vid-96gb/install-windows.bat +0 -1
- package/packs/wan-transparent-img2vid-96gb/manifest.yaml +0 -1
- package/packs/z-image-base/install-runpod.sh +0 -1
- package/packs/z-image-base/install-windows.bat +0 -1
- package/packs/z-image-base/manifest.yaml +0 -1
- package/packs/z-image-base-combo/install-runpod.sh +0 -1
- package/packs/z-image-base-combo/install-windows.bat +0 -1
- package/packs/z-image-base-combo/manifest.yaml +0 -1
- package/packs/z-image-base-controlnet/install-runpod.sh +0 -1
- package/packs/z-image-base-controlnet/install-windows.bat +0 -1
- package/packs/z-image-base-controlnet/manifest.yaml +0 -1
- package/packs/z-image-base-img2img/install-runpod.sh +0 -1
- package/packs/z-image-base-img2img/install-windows.bat +0 -1
- package/packs/z-image-base-img2img/manifest.yaml +0 -1
- package/packs/z-image-base-inpaint/install-runpod.sh +0 -1
- package/packs/z-image-base-inpaint/install-windows.bat +0 -1
- package/packs/z-image-base-inpaint/manifest.yaml +0 -1
- package/packs/z-image-base-txt2img/install-runpod.sh +0 -1
- package/packs/z-image-base-txt2img/install-windows.bat +0 -1
- package/packs/z-image-base-txt2img/manifest.yaml +0 -1
- package/packs/z-image-turbo/install-runpod.sh +0 -1
- package/packs/z-image-turbo/install-windows.bat +0 -1
- package/packs/z-image-turbo/manifest.yaml +0 -1
- package/packs/z-image-turbo-combo/install-runpod.sh +0 -1
- package/packs/z-image-turbo-combo/install-windows.bat +0 -1
- package/packs/z-image-turbo-combo/manifest.yaml +0 -1
- package/packs/z-image-turbo-controlnet/install-runpod.sh +0 -1
- package/packs/z-image-turbo-controlnet/install-windows.bat +0 -1
- package/packs/z-image-turbo-controlnet/manifest.yaml +0 -1
- package/packs/z-image-turbo-detail-daemon/install-runpod.sh +0 -1
- package/packs/z-image-turbo-detail-daemon/install-windows.bat +0 -1
- package/packs/z-image-turbo-detail-daemon/manifest.yaml +0 -1
- package/packs/z-image-turbo-img2img/install-runpod.sh +0 -1
- package/packs/z-image-turbo-img2img/install-windows.bat +0 -1
- package/packs/z-image-turbo-img2img/manifest.yaml +0 -1
- package/packs/z-image-turbo-inpainting/install-runpod.sh +0 -1
- package/packs/z-image-turbo-inpainting/install-windows.bat +0 -1
- package/packs/z-image-turbo-inpainting/manifest.yaml +0 -1
- package/packs/z-image-turbo-txt2img/install-runpod.sh +0 -1
- package/packs/z-image-turbo-txt2img/install-windows.bat +0 -1
- package/packs/z-image-turbo-txt2img/manifest.yaml +0 -1
- package/packs/z-image-xy-plot/install-runpod.sh +0 -1
- package/packs/z-image-xy-plot/install-windows.bat +0 -1
- package/packs/z-image-xy-plot/manifest.yaml +0 -1
- package/scripts/test-packs.sh +8 -3
|
@@ -23,22 +23,24 @@
|
|
|
23
23
|
// gating for panel_clear/panel_restart_comfyui) is IDENTICAL across providers,
|
|
24
24
|
// so parity is automatic — neither path reimplements a tool.
|
|
25
25
|
import { z } from "zod";
|
|
26
|
+
import { randomUUID } from "node:crypto";
|
|
26
27
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
27
28
|
import { extname, isAbsolute, join, resolve } from "node:path";
|
|
28
29
|
import { fileURLToPath } from "node:url";
|
|
29
30
|
import { comfyuiFetch } from "../comfyui/fetch.js";
|
|
30
31
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
31
32
|
import { parse as parseYaml } from "yaml";
|
|
33
|
+
import { dispatchOutcomeOf } from "../services/ui-bridge.js";
|
|
32
34
|
import { withWorkflowTarget, } from "../services/workflow-target-store.js";
|
|
33
35
|
import { addUserMcpServer, readUserMcpServers, removeUserMcpServer, setUserMcpServerSecret, } from "../services/user-mcp-config.js";
|
|
34
36
|
import { setComfyuiSecret, setAgentSecret, isAllowedAgentSecretKey } from "../services/panel-secrets.js";
|
|
35
37
|
import { flattenUiWorkflow } from "../services/flatten-workflow.js";
|
|
36
38
|
import { getNsfwConsent, setNsfwConsent } from "../services/panel-settings.js";
|
|
37
39
|
import { QueueMonitor } from "../services/queue-monitor.js";
|
|
38
|
-
import { getObjectInfo, backfillObjectInfo, resetClient, resetObjectInfoCache, } from "../comfyui/client.js";
|
|
40
|
+
import { getClient, getObjectInfo, backfillObjectInfo, resetClient, resetObjectInfoCache, } from "../comfyui/client.js";
|
|
39
41
|
import { convertUiToApi, collectNodeTypes } from "../services/workflow-converter.js";
|
|
40
42
|
import { restartComfyUI } from "../services/process-control.js";
|
|
41
|
-
import { isRemoteMode } from "../config.js";
|
|
43
|
+
import { isRemoteMode, isCloudMode, getBootLocalComfyUIBaseUrl, getComfyUIBaseUrl, } from "../config.js";
|
|
42
44
|
import { sliceWorkflow } from "../services/workflow-slicer.js";
|
|
43
45
|
import { validateA2UISpecServer } from "../services/a2ui-spec.js";
|
|
44
46
|
/** Treat these as an affirmative answer to the adult-content consent card. */
|
|
@@ -81,29 +83,31 @@ export function rebootConfirmed(res) {
|
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
/**
|
|
84
|
-
* True when a comfy_reboot ToolResult is
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* mid-command
|
|
89
|
-
*
|
|
90
|
-
*
|
|
86
|
+
* True when a comfy_reboot ToolResult is the bridge's canonical POST-WRITE mid-command
|
|
87
|
+
* drop — the command was ACTUALLY WRITTEN to the panel socket and the connection then
|
|
88
|
+
* died before a reply (the reboot handler exits the instant it accepts, so the socket
|
|
89
|
+
* dies mid-flight). The bridge emits this for a MUTATING command as
|
|
90
|
+
* "disconnected mid-command … OUTCOME UNKNOWN" (ui-bridge.ts handleMidCommandDisconnect).
|
|
91
|
+
*
|
|
92
|
+
* Deliberately NOT matched (coordinator P0): a raw PRE-WRITE `sock.send()` failure
|
|
93
|
+
* (ECONNRESET / socket hang up / EPIPE / "was NOT dispatched") — that means the command
|
|
94
|
+
* was NEVER written, so NOTHING was dispatched. Treating a pre-write send failure as an
|
|
95
|
+
* accepted/ambiguous "dropped reboot" would let readiness certify a cycle that was never
|
|
96
|
+
* even requested. Also NOT matched: pre-dispatch "is not open" / "did not reply within N
|
|
97
|
+
* ms" (a live-but-frozen tab) / idempotent-read grace expiry — those return verbatim.
|
|
98
|
+
* A genuine refusal comes back as a NON-error `rebooting:false` (rebootConfirmed handles).
|
|
91
99
|
*/
|
|
92
100
|
export function rebootDropped(res) {
|
|
93
101
|
if (!res?.isError)
|
|
94
102
|
return false;
|
|
95
103
|
const text = res?.content?.find((c) => c.type === "text")?.text ?? "";
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// idempotent-read "genuinely gone" grace expiry. Treating those as a reboot
|
|
104
|
-
// would risk a FALSE success (claiming a restart that never fired) once an
|
|
105
|
-
// unrelated tab reconnection makes readiness pass. Those return verbatim.
|
|
106
|
-
return /disconnected mid-command|OUTCOME UNKNOWN|ECONNRESET|socket hang up|premature close|other side closed|ECONNABORTED|EPIPE/i.test(text);
|
|
104
|
+
// The AUTHORITATIVE signal is the bridge's TYPED dispatch flag (dispatchOutcomeOf),
|
|
105
|
+
// checked by the caller BEFORE this. This text match is a defense-in-depth fallback:
|
|
106
|
+
// the pre-write wrapper ("the command was NOT dispatched") must WIN even if its quoted
|
|
107
|
+
// detail contains a post-write phrase, so a pre-write send failure is never a "drop".
|
|
108
|
+
if (/NOT dispatched/i.test(text))
|
|
109
|
+
return false;
|
|
110
|
+
return /disconnected mid-command|OUTCOME UNKNOWN/i.test(text);
|
|
107
111
|
}
|
|
108
112
|
/**
|
|
109
113
|
* True when a comfy_reboot ToolResult is a NON-error, NON-fired refusal whose
|
|
@@ -130,6 +134,12 @@ export function rebootNoEndpoint(res) {
|
|
|
130
134
|
return /reboot endpoint|reboot route|was NOT restarted|no reachable .*reboot/i.test(text);
|
|
131
135
|
}
|
|
132
136
|
let panelRebootTimingOverride = null;
|
|
137
|
+
// The whole readiness wait (settle + poll budget) MUST finish comfortably below the
|
|
138
|
+
// client's outer ~300s tools/call timeout, so a FAILING wait always returns a clean
|
|
139
|
+
// ready:false in time instead of being killed as a bare 300s timeout — even if the
|
|
140
|
+
// COMFYUI_PANEL_REBOOT_* env overrides are set absurdly high (coordinator codex P2).
|
|
141
|
+
const MAX_REBOOT_SETTLE_MS = 10_000; // 10s
|
|
142
|
+
const MAX_REBOOT_BUDGET_MS = 240_000; // 240s → settle+budget ≤ 250s < 300s outer
|
|
133
143
|
function parsePositiveNumberEnv(name, fallback) {
|
|
134
144
|
const raw = process.env[name];
|
|
135
145
|
if (raw == null || raw === "")
|
|
@@ -137,16 +147,23 @@ function parsePositiveNumberEnv(name, fallback) {
|
|
|
137
147
|
const n = Number(raw);
|
|
138
148
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
139
149
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
150
|
+
/** Reboot-readiness timing from env, with each value HARD-CAPPED so no override can
|
|
151
|
+
* push the total wait past the outer tools/call budget (coordinator codex P2).
|
|
152
|
+
* The probe interval defaults to a TIGHT 500ms: the observer runs CONCURRENTLY with
|
|
153
|
+
* the reboot dispatch and must catch a BRIEF down window (a fast restart can be down
|
|
154
|
+
* for well under 2s), needing >=2 down probes inside it (coordinator HIGH). settleMs
|
|
155
|
+
* is retained only for the env cap; the observer no longer settles before probing. */
|
|
156
|
+
function computeRebootTimingFromEnv() {
|
|
143
157
|
return {
|
|
144
|
-
settleMs: Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_REBOOT_SETTLE_S", 3) * 1000),
|
|
145
|
-
budgetMs: Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_REBOOT_BUDGET_S", 120) * 1000),
|
|
146
|
-
intervalMs: Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_REBOOT_INTERVAL_S", 2) * 1000),
|
|
147
|
-
probeTimeoutMs: Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_REBOOT_PROBE_S",
|
|
158
|
+
settleMs: Math.min(MAX_REBOOT_SETTLE_MS, Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_REBOOT_SETTLE_S", 3) * 1000)),
|
|
159
|
+
budgetMs: Math.min(MAX_REBOOT_BUDGET_MS, Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_REBOOT_BUDGET_S", 120) * 1000)),
|
|
160
|
+
intervalMs: Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_REBOOT_INTERVAL_S", 0.2) * 1000),
|
|
161
|
+
probeTimeoutMs: Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_REBOOT_PROBE_S", 2) * 1000),
|
|
148
162
|
};
|
|
149
163
|
}
|
|
164
|
+
function getPanelRebootTiming() {
|
|
165
|
+
return panelRebootTimingOverride ?? computeRebootTimingFromEnv();
|
|
166
|
+
}
|
|
150
167
|
/** The default generous readiness budget, in seconds — reported to callers. */
|
|
151
168
|
export function panelRebootBudgetSeconds() {
|
|
152
169
|
return Math.round(getPanelRebootTiming().budgetMs / 1000);
|
|
@@ -156,12 +173,31 @@ export const __panelToolsTestHooks = {
|
|
|
156
173
|
setPanelRebootTiming(timing) {
|
|
157
174
|
panelRebootTimingOverride = timing;
|
|
158
175
|
},
|
|
176
|
+
/** Inject a fake boot-endpoint probe so readiness tests drive the real proof loop
|
|
177
|
+
* without real HTTP. Returns a ProbeStatus, or a boolean (true→healthy/false→down)
|
|
178
|
+
* so DOWN→UP can be scripted with plain booleans. null restores the live probe. */
|
|
179
|
+
setHealthProbe(fn) {
|
|
180
|
+
healthProbeOverride = fn;
|
|
181
|
+
},
|
|
182
|
+
looksLikeSystemStats,
|
|
183
|
+
probeComfyHealth,
|
|
184
|
+
probeComfyEndpoint,
|
|
185
|
+
captureRebootHealthBase,
|
|
186
|
+
sameHttpOrigin,
|
|
187
|
+
sameHttpBase,
|
|
188
|
+
isLoopbackOrigin,
|
|
189
|
+
loopbackProbeUrl,
|
|
190
|
+
/** Compute reboot timing from env WITH the P2 hard caps (bypasses any override). */
|
|
191
|
+
computeRebootTimingFromEnv,
|
|
159
192
|
/** Zero out the post-drop retry settle so retry-once tests don't sleep. */
|
|
160
193
|
setRetrySettleMs(ms) {
|
|
161
194
|
retrySettleMsOverride = ms;
|
|
162
195
|
},
|
|
163
196
|
isRetrySafeCmd,
|
|
164
197
|
isTransientReconnectError,
|
|
198
|
+
// #384 live-canvas capture fallback (defined later in the module).
|
|
199
|
+
reconstructUiFromState: (reply) => reconstructUiFromState(reply),
|
|
200
|
+
resolveWorkflowInput: (args, ctx, allowStateFallback = true) => resolveWorkflowInput(args, ctx, allowStateFallback),
|
|
165
201
|
};
|
|
166
202
|
function sleep(ms) {
|
|
167
203
|
return new Promise((r) => setTimeout(r, ms));
|
|
@@ -221,43 +257,379 @@ function retrySettleMs() {
|
|
|
221
257
|
return retrySettleMsOverride;
|
|
222
258
|
return Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_RETRY_SETTLE_S", 0.4) * 1000);
|
|
223
259
|
}
|
|
260
|
+
/** True when a decoded /system_stats body has the recognizable ComfyUI shape (a
|
|
261
|
+
* `system` object and/or a `devices` array) — the same fields health_check /
|
|
262
|
+
* get_environment read. A bare 2xx from a reverse-proxy login page, an SPA
|
|
263
|
+
* catch-all, or a proxy error page is NOT ComfyUI and must NOT certify recovery
|
|
264
|
+
* (codex #509 P1). */
|
|
265
|
+
function looksLikeSystemStats(body) {
|
|
266
|
+
if (!body || typeof body !== "object")
|
|
267
|
+
return false;
|
|
268
|
+
const b = body;
|
|
269
|
+
const hasSystem = b.system != null && typeof b.system === "object";
|
|
270
|
+
const hasDevices = Array.isArray(b.devices);
|
|
271
|
+
return hasSystem || hasDevices;
|
|
272
|
+
}
|
|
273
|
+
/** The CONCRETE loopback FAMILY of a hostname, or null when it isn't an unambiguous
|
|
274
|
+
* loopback literal. IPv4 loopback (127.0.0.1 / the 0.0.0.0 wildcard) → "127.0.0.1";
|
|
275
|
+
* IPv6 loopback (::1 / the :: wildcard) → "::1". The families are kept DISTINCT so a
|
|
276
|
+
* v4 tab and a v6 instance at the same port are NOT wrongly matched (coordinator
|
|
277
|
+
* finding 4: v6 A on [::1]:8188 + v4 B on 127.0.0.1:8188 are DIFFERENT instances).
|
|
278
|
+
*
|
|
279
|
+
* `localhost` returns null ON PURPOSE (coordinator P0): a URL preserves the literal
|
|
280
|
+
* "localhost" and does NOT reveal whether the browser actually reached 127.0.0.1 or
|
|
281
|
+
* ::1 — so PINNING it to a family we can't verify could send the auth-bearing probe to
|
|
282
|
+
* a DIFFERENT-family instance than the reboot went to (v6 A rebooted, v4 B probed →
|
|
283
|
+
* false cert + auth leak). We therefore refuse the ambiguity: a `localhost` boot/tab
|
|
284
|
+
* origin is NOT directly-probeable and routes to the honest dispatched-unconfirmed
|
|
285
|
+
* result instead of the direct-probe certification path. */
|
|
286
|
+
function loopbackFamily(host) {
|
|
287
|
+
const h = host.toLowerCase().replace(/^\[|\]$/g, "");
|
|
288
|
+
if (h === "127.0.0.1" || h === "0.0.0.0")
|
|
289
|
+
return "127.0.0.1";
|
|
290
|
+
if (h === "::1" || h === "::" || h === "0000:0000:0000:0000:0000:0000:0000:0000")
|
|
291
|
+
return "::1";
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
/** True when a hostname is loopback-equivalent (either family, incl. the wildcard
|
|
295
|
+
* binds 0.0.0.0/:: which are reachable on loopback). */
|
|
296
|
+
function isLoopbackHostName(host) {
|
|
297
|
+
return loopbackFamily(host) !== null;
|
|
298
|
+
}
|
|
299
|
+
/** The scheme://host:port origin of a URL (default ports made explicit), or null if
|
|
300
|
+
* unparseable. Loopback hosts canonicalize to their FAMILY loopback (v4 → 127.0.0.1,
|
|
301
|
+
* v6 → ::1) — so localhost/127.0.0.1/0.0.0.0 compare equal, and ::1/:: compare equal,
|
|
302
|
+
* but a v4 host and a v6 host DIFFER (they may be different instances). Ports differ. */
|
|
303
|
+
function httpOriginOf(rawUrl) {
|
|
304
|
+
try {
|
|
305
|
+
const u = new URL(rawUrl);
|
|
306
|
+
const port = u.port || (u.protocol === "https:" ? "443" : "80");
|
|
307
|
+
const host = u.hostname.toLowerCase();
|
|
308
|
+
const canonHost = loopbackFamily(host) ?? host;
|
|
309
|
+
return `${u.protocol}//${canonHost}:${port}`;
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/** Rewrite a CONCRETE loopback-literal base URL to one that is actually CONNECTABLE and
|
|
316
|
+
* that AGREES with loopbackFamily's identity canonicalization — so the probe (and the
|
|
317
|
+
* auth headers it carries) can never hit a DIFFERENT-family instance than the one
|
|
318
|
+
* identity matched (coordinator P1). Every IPv4-family loopback literal (127.0.0.1 /
|
|
319
|
+
* 0.0.0.0) → the literal 127.0.0.1; every IPv6-family loopback literal (::1 / ::) → the
|
|
320
|
+
* bracketed literal [::1]. A DNS-ambiguous `localhost` has no concrete family and is
|
|
321
|
+
* left UNCHANGED (callers gate it out via loopbackFamily before probing). Non-loopback
|
|
322
|
+
* hosts are returned unchanged. */
|
|
323
|
+
function loopbackProbeUrl(rawUrl) {
|
|
324
|
+
try {
|
|
325
|
+
const u = new URL(rawUrl);
|
|
326
|
+
const fam = loopbackFamily(u.hostname);
|
|
327
|
+
if (fam === "127.0.0.1")
|
|
328
|
+
u.hostname = "127.0.0.1";
|
|
329
|
+
else if (fam === "::1")
|
|
330
|
+
u.hostname = "[::1]";
|
|
331
|
+
return u.toString().replace(/\/+$/, "");
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return rawUrl;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** True when two URLs share the exact same scheme + host + port (path ignored).
|
|
338
|
+
* Used ONLY for the redirect host-escape check — a same-host redirect isn't a
|
|
339
|
+
* host escape. Instance IDENTITY uses sameHttpBase (path-aware) instead. */
|
|
340
|
+
function sameHttpOrigin(a, b) {
|
|
341
|
+
const oa = a ? httpOriginOf(a) : null;
|
|
342
|
+
const ob = b ? httpOriginOf(b) : null;
|
|
343
|
+
return oa != null && oa === ob;
|
|
344
|
+
}
|
|
345
|
+
/** The canonical scheme://host:port/path form of a URL (loopback host normalized,
|
|
346
|
+
* trailing slashes stripped, path case-sensitive), or null if unparseable. Two
|
|
347
|
+
* ComfyUI instances reverse-proxied under the SAME host:port but DIFFERENT path
|
|
348
|
+
* prefixes (/a vs /b) are DISTINCT — so instance identity must include the path. */
|
|
349
|
+
function canonicalHttpBase(rawUrl) {
|
|
350
|
+
const origin = httpOriginOf(rawUrl);
|
|
351
|
+
if (origin == null)
|
|
352
|
+
return null;
|
|
353
|
+
try {
|
|
354
|
+
const path = new URL(rawUrl).pathname.replace(/\/+$/, "");
|
|
355
|
+
return `${origin}${path}`;
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/** True when two URLs identify the SAME instance: same scheme+host+port AND the
|
|
362
|
+
* same path prefix (a reverse-proxied mount point is part of its identity). */
|
|
363
|
+
function sameHttpBase(a, b) {
|
|
364
|
+
const ca = a ? canonicalHttpBase(a) : null;
|
|
365
|
+
const cb = b ? canonicalHttpBase(b) : null;
|
|
366
|
+
return ca != null && ca === cb;
|
|
367
|
+
}
|
|
368
|
+
/** True when a URL's host is loopback-EQUIVALENT (incl. the wildcard binds 0.0.0.0/::,
|
|
369
|
+
* which are reachable on loopback) — the only hosts the orchestrator can reach on its
|
|
370
|
+
* OWN machine to health-probe (the #509 local case). */
|
|
371
|
+
function isLoopbackOrigin(rawUrl) {
|
|
372
|
+
try {
|
|
373
|
+
return isLoopbackHostName(new URL(rawUrl).hostname);
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/** Connection error codes that DEFINITIVELY mean the endpoint's PORT is not accepting —
|
|
380
|
+
* the listener is gone (a restarting process closed it). This is the ONLY connection
|
|
381
|
+
* failure that proves a process-down for the cycle proof, and for a LOOPBACK probe the
|
|
382
|
+
* ONLY sound one: ECONNREFUSED = the host actively refused the connection because nothing
|
|
383
|
+
* is listening on that port. Everything else is (correctly) NOT a listener-down:
|
|
384
|
+
* - ECONNRESET / EPIPE / EPROTO / ETIMEDOUT / "socket hang up" — a still-LISTENING server
|
|
385
|
+
* can reset a connection or transiently fail TLS without going down;
|
|
386
|
+
* - ENETUNREACH / EHOSTUNREACH / ENETDOWN / EHOSTDOWN — a local network/routing failure;
|
|
387
|
+
* the ComfyUI process can still be listening while the stack is momentarily unavailable
|
|
388
|
+
* (codex High);
|
|
389
|
+
* - ENOTFOUND / EAI_AGAIN — DNS (inapplicable to a loopback literal), not a listener-down.
|
|
390
|
+
* A genuine restart CLOSES the loopback port, so repeated polling observes ECONNREFUSED
|
|
391
|
+
* during the down window; the ambiguous codes above stay "unknown" so a transient glitch
|
|
392
|
+
* + a later 200 can never fake a restart cycle. */
|
|
393
|
+
const PORT_NOT_LISTENING_CODES = new Set([
|
|
394
|
+
"ECONNREFUSED", // host refused — nothing listening on the port (the restarting-process signal)
|
|
395
|
+
]);
|
|
396
|
+
/** Extract a connection error's OS code (undici wraps the real error under `.cause`). */
|
|
397
|
+
function connErrorCode(err) {
|
|
398
|
+
const e = err;
|
|
399
|
+
if (typeof e?.code === "string")
|
|
400
|
+
return e.code;
|
|
401
|
+
if (typeof e?.cause?.code === "string")
|
|
402
|
+
return e.cause.code;
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Probe the boot endpoint and CLASSIFY it. Because the down→up transition is the SOLE
|
|
407
|
+
* proof a process actually CYCLED, "down" must mean the endpoint STOPPED SERVING at the
|
|
408
|
+
* CONNECTION level — the port isn't accepting (a restarting process closes its listener).
|
|
409
|
+
* The boot endpoint in the certify path is a DIRECT loopback ComfyUI (no reverse proxy —
|
|
410
|
+
* captureRebootHealthBase probes 127.0.0.1/[::1] directly), so:
|
|
411
|
+
* - "down" = a CONNECTION failure whose code DEFINITIVELY means the port isn't accepting
|
|
412
|
+
* (ECONNREFUSED — the process is not listening, a genuine restart). Ambiguous
|
|
413
|
+
* mid-connection errors (ECONNRESET / EPIPE / EPROTO / hang up) and network/DNS
|
|
414
|
+
* reachability failures (ENETUNREACH / EHOSTUNREACH / ENOTFOUND …) do NOT count — the
|
|
415
|
+
* server can still be listening — so they are "unknown". ECONNREFUSED is the ONLY
|
|
416
|
+
* signal that proves a cycle.
|
|
417
|
+
* - "healthy" = a same-origin 2xx carrying a real /system_stats body.
|
|
418
|
+
* - "unknown" = the server RESPONDED (so its HTTP listener is UP — NOT a process-down),
|
|
419
|
+
* just not as ComfyUI-up-and-serving-stats: ANY 5xx (a transient 500 is an app error,
|
|
420
|
+
* NOT a restart — codex false-success fix), a 3xx (redirect:"manual", so a login/SPA
|
|
421
|
+
* redirect can't certify and no auth is sent onward), a 4xx (401/403/404/429), a
|
|
422
|
+
* wrong-origin URL, or a 2xx with a non-ComfyUI / malformed body; AND our own request
|
|
423
|
+
* TIMEOUT (the port accepted the connection but was slow to answer → listening, not
|
|
424
|
+
* down). "unknown" is NOT a down and NEVER contributes to the cycle proof — so a
|
|
425
|
+
* transient 5xx / slow response can never masquerade as a restart.
|
|
426
|
+
* Never throws.
|
|
427
|
+
*/
|
|
428
|
+
async function probeComfyEndpoint(base, timeoutMs) {
|
|
429
|
+
if (!base)
|
|
430
|
+
return "unknown";
|
|
431
|
+
const url = `${base}/system_stats`;
|
|
432
|
+
const controller = new AbortController();
|
|
433
|
+
const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
|
|
434
|
+
timer.unref?.();
|
|
435
|
+
try {
|
|
436
|
+
const res = await comfyuiFetch(url, { signal: controller.signal, redirect: "manual" });
|
|
437
|
+
const status = res.status;
|
|
438
|
+
// A 5xx means the HTTP server ANSWERED — its listener is UP — so it is NOT proof the
|
|
439
|
+
// process went down; treat it as "unknown", never "down" (a transient 500 must not
|
|
440
|
+
// fake a restart cycle). Same for 3xx/4xx.
|
|
441
|
+
if (status < 200 || status >= 300)
|
|
442
|
+
return "unknown"; // 3xx/4xx/5xx = responded, not stats
|
|
443
|
+
if (res.url && !sameHttpOrigin(res.url, url))
|
|
444
|
+
return "unknown"; // wrong origin
|
|
445
|
+
let body;
|
|
446
|
+
try {
|
|
447
|
+
body = await res.json();
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
return "unknown"; // 2xx but not JSON — up, but not a /system_stats we trust
|
|
451
|
+
}
|
|
452
|
+
return looksLikeSystemStats(body) ? "healthy" : "unknown";
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
// OUR abort = a TIMEOUT: the port accepted the connection but was slow to answer, so
|
|
456
|
+
// its listener is UP (not a process-down) → "unknown", never part of a cycle proof (a
|
|
457
|
+
// transiently-slow no-op server must not fake a restart).
|
|
458
|
+
if (controller.signal.aborted)
|
|
459
|
+
return "unknown";
|
|
460
|
+
// A connection failure is "down" ONLY when its code DEFINITIVELY means the port isn't
|
|
461
|
+
// accepting (ECONNREFUSED &c). An AMBIGUOUS mid-connection error (ECONNRESET / EPIPE /
|
|
462
|
+
// EPROTO / hang up) can come from a STILL-listening server, so it is "unknown" — never
|
|
463
|
+
// a down that a later 200 could turn into a phantom cycle (codex High).
|
|
464
|
+
const code = connErrorCode(err);
|
|
465
|
+
return code != null && PORT_NOT_LISTENING_CODES.has(code) ? "down" : "unknown";
|
|
466
|
+
}
|
|
467
|
+
finally {
|
|
468
|
+
clearTimeout(timer);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/** Boolean healthy? wrapper over probeComfyEndpoint (redirect-safe). */
|
|
472
|
+
async function probeComfyHealth(base, timeoutMs) {
|
|
473
|
+
return (await probeComfyEndpoint(base, timeoutMs)) === "healthy";
|
|
474
|
+
}
|
|
475
|
+
/** Coerce a health-probe override's boolean (true→healthy / false→down) or an explicit
|
|
476
|
+
* ProbeStatus, so tests can script recovery sequences with plain booleans. */
|
|
477
|
+
function normalizeProbe(v) {
|
|
478
|
+
if (v === true)
|
|
479
|
+
return "healthy";
|
|
480
|
+
if (v === false)
|
|
481
|
+
return "down";
|
|
482
|
+
return v;
|
|
483
|
+
}
|
|
224
484
|
/**
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
485
|
+
* The FIXED ComfyUI base URL to health-probe during a reboot readiness wait, or
|
|
486
|
+
* null when we must fall back to the panel round-trip (as before #509). Captured by
|
|
487
|
+
* the handler BEFORE it dispatches comfy_reboot and held for the whole wait.
|
|
488
|
+
*
|
|
489
|
+
* SECURITY (coordinator codex P1): the probe TARGET is the orchestrator's own
|
|
490
|
+
* PROCESS-START local ComfyUI endpoint (getBootLocalComfyUIBaseUrl) — captured at
|
|
491
|
+
* boot and IMMUTABLE. It is deliberately NOT getComfyUIBaseUrl(): that reflects the
|
|
492
|
+
* mutable runtime config a panel `hello` can retarget (applyComfyuiUrl →
|
|
493
|
+
* setComfyuiTarget), so a client could steer it. And it is NEVER the client-advertised
|
|
494
|
+
* `hello.comfyui_url` (spoofable; comfyuiFetch would leak the configured ComfyUI auth
|
|
495
|
+
* headers to an attacker-chosen origin). The tab origin is used ONLY as a gate, and the
|
|
496
|
+
* gate reads the SERVER-OBSERVED handshake Origin (tabServerOrigin) — which the browser
|
|
497
|
+
* sets and blocks page JS from forging — NOT the spoofable hello.comfyui_url: we
|
|
498
|
+
* self-probe our OWN boot endpoint solely when the rebooted tab PROVABLY (by its
|
|
499
|
+
* handshake) fronts THAT SAME instance, so a socket that merely CLAIMS the boot URL can't
|
|
500
|
+
* ride an unrelated boot-instance cycle to a false certification (codex High). Null
|
|
501
|
+
* (→ honest dispatched-unconfirmed) when:
|
|
502
|
+
* - cloud OR remote mode; or
|
|
503
|
+
* - the orchestrator didn't boot against a LOCAL loopback ComfyUI; or
|
|
504
|
+
* - the tab isn't SERVER-TRUSTED-local (tabIsLocal — arrived on the token-less
|
|
505
|
+
* loopback primary listener; relay/tunnel/LAN/pairing → false); or
|
|
506
|
+
* - the tab's HANDSHAKE origin is absent, ambiguous (`localhost`), or does NOT match our
|
|
507
|
+
* boot endpoint by scheme+host+port (concrete-family loopback-canonicalized) — i.e. it
|
|
508
|
+
* drives a DIFFERENT instance / family, or one we can't verify; AND, because a handshake
|
|
509
|
+
* Origin carries NO path, a boot target mounted under a basePath fails path-aware
|
|
510
|
+
* identity and is (soundly) fail-closed to dispatched-unconfirmed.
|
|
231
511
|
*/
|
|
232
|
-
|
|
233
|
-
if (
|
|
234
|
-
|
|
512
|
+
function captureRebootHealthBase(ctx) {
|
|
513
|
+
if (isCloudMode() || isRemoteMode())
|
|
514
|
+
return null;
|
|
515
|
+
const bootBase = getBootLocalComfyUIBaseUrl(); // server-authorized, hello-immutable
|
|
516
|
+
if (!bootBase || !isLoopbackOrigin(bootBase))
|
|
517
|
+
return null;
|
|
518
|
+
const base = bootBase.replace(/\/+$/, "");
|
|
519
|
+
// Server-trusted provenance: the tab arrived on the token-less loopback listener.
|
|
520
|
+
if (ctx.bridge?.tabIsLocal?.(ctx.tabId) !== true)
|
|
521
|
+
return null;
|
|
522
|
+
// And the rebooted tab must provably front THAT SAME boot instance. Use the SERVER-
|
|
523
|
+
// OBSERVED handshake Origin (tabServerOrigin) — the browser sets it on the WS upgrade
|
|
524
|
+
// and blocks page JS from forging it — NOT the spoofable client hello.comfyui_url
|
|
525
|
+
// (tabOrigin): a non-Comfy socket on the host could otherwise CLAIM the boot URL, ack
|
|
526
|
+
// comfy_reboot without rebooting, and ride an unrelated boot-instance cycle to a false
|
|
527
|
+
// ready:true (codex High). A handshake Origin proves only scheme+host+port (it carries
|
|
528
|
+
// NO path), so we compare it path-AWARE (sameHttpBase) against the boot base: when the
|
|
529
|
+
// boot target is mounted under a basePath (e.g. …:8188/comfy) the pathless Origin cannot
|
|
530
|
+
// prove the tab fronts THAT mount vs another instance at the same host:port, so we FAIL
|
|
531
|
+
// CLOSED to the honest dispatched-unconfirmed result rather than certify unsoundly (codex
|
|
532
|
+
// P1). The common pathless boot base matches an equal Origin and certifies. Loopback
|
|
533
|
+
// identity canonicalizes only CONCRETE literals by family (127.0.0.1 ≡ a 0.0.0.0 bind;
|
|
534
|
+
// ::1 ≡ a :: bind) — a DNS-ambiguous `localhost` on EITHER side yields no family
|
|
535
|
+
// (loopbackFamily → null), so it never matches a concrete literal and this returns null
|
|
536
|
+
// (coordinator P0). A different instance / family / path / absent Origin → null too.
|
|
537
|
+
const origin = ctx.bridge?.tabServerOrigin?.(ctx.tabId);
|
|
538
|
+
if (!sameHttpBase(origin, base))
|
|
539
|
+
return null;
|
|
540
|
+
// Return a CONNECTABLE probe URL bound to the SAME concrete family identity matched
|
|
541
|
+
// above: a wildcard-bound (0.0.0.0/::) local ComfyUI is reachable on loopback, so probe
|
|
542
|
+
// the family literal at that port (127.0.0.1 / [::1]). The probe (and the auth headers
|
|
543
|
+
// it carries) can therefore never cross to a different-family instance.
|
|
544
|
+
return loopbackProbeUrl(base);
|
|
545
|
+
}
|
|
546
|
+
let healthProbeOverride = null;
|
|
547
|
+
/**
|
|
548
|
+
* Observe the boot endpoint's recovery AFTER a reboot was dispatched, and certify ONLY on
|
|
549
|
+
* an OBSERVED DOWN→UP cycle. Acceptance (dispatch confirmed/dropped) is the guard against
|
|
550
|
+
* a NO-OP, but we deliberately do NOT certify a lone healthy endpoint after a settle:
|
|
551
|
+
* the panel emits rebooting:true even when it merely INFERS a reboot from a dropped fetch
|
|
552
|
+
* (its comfy_reboot handler's catch branch), so a confirmed ack is NOT a guarantee that a
|
|
553
|
+
* real Manager reboot was accepted — treat it like the ambiguous DROP and require the
|
|
554
|
+
* endpoint to actually go DOWN then come back (coordinator: panel invariant unverifiable).
|
|
555
|
+
* - ANY single "down" (an ECONNREFUSED — the port stopped listening) marks it going down;
|
|
556
|
+
* the next "healthy" → observed-cycle.
|
|
557
|
+
* - Never healthy after an observed down, OR never a down at all → couldn't-confirm.
|
|
558
|
+
*
|
|
559
|
+
* CONCURRENT mode (a `gate` is supplied): the caller starts this BEFORE awaiting the full
|
|
560
|
+
* dispatch, so probes are already sampling the endpoint DURING the ack/drop/timeout window
|
|
561
|
+
* — catching a FAST reboot whose down→up completes before the ack returns (the #509 fast-
|
|
562
|
+
* reboot false-timeout). PROBE-FIRST-THEN-SLEEP: the observer AWAITS the post-write
|
|
563
|
+
* notification (gate.waitDispatched — no timer poll, so no leading window in which a
|
|
564
|
+
* sub-millisecond cycle could be missed), then takes its FIRST probe IMMEDIATELY at the
|
|
565
|
+
* post-write dispatch instant (no leading interval sleep), sleeping intervalMs only BETWEEN
|
|
566
|
+
* subsequent probes. COUNTING stays post-write: a sample
|
|
567
|
+
* marks the cycle only if taken at/after gate.dispatchedAt, so a pre-dispatch down never
|
|
568
|
+
* contributes. gate.deadline is the live deadline (tightened to ack-completion + budget so a
|
|
569
|
+
* slow ack doesn't eat it); gate.cancelled aborts.
|
|
570
|
+
* LEGACY mode (no gate): started AFTER the restart's synchronous work; probe immediately
|
|
571
|
+
* against the fixed `deadline`.
|
|
572
|
+
*/
|
|
573
|
+
async function observeRecovery(timing, deadline, opts) {
|
|
235
574
|
const start = Date.now();
|
|
236
|
-
|
|
237
|
-
// NOT budgetMs/intervalMs iterations that each also burn a probe timeout + a
|
|
238
|
-
// sleep (which would overrun the advertised bound several-fold). Each probe and
|
|
239
|
-
// each inter-probe sleep is capped by the time remaining.
|
|
240
|
-
const deadline = start + timing.budgetMs;
|
|
241
|
-
// Clamp interval to a floor so a 0/tiny env value can't hot-loop unbounded —
|
|
242
|
-
// UNLESS a test override is active (tests inject small deterministic values).
|
|
575
|
+
const gate = opts.gate;
|
|
243
576
|
const intervalMs = panelRebootTimingOverride
|
|
244
577
|
? Math.max(1, timing.intervalMs)
|
|
245
|
-
: Math.max(
|
|
578
|
+
: Math.max(50, timing.intervalMs);
|
|
579
|
+
const probe = healthProbeOverride ?? probeComfyEndpoint;
|
|
580
|
+
const currentDeadline = () => gate?.deadline ?? deadline;
|
|
581
|
+
let sawDown = false;
|
|
246
582
|
let attempts = 0;
|
|
247
583
|
for (;;) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
584
|
+
if (gate?.cancelled)
|
|
585
|
+
break;
|
|
586
|
+
if (currentDeadline() - Date.now() <= 0)
|
|
587
|
+
break;
|
|
588
|
+
if (gate && !gate.dispatched) {
|
|
589
|
+
// CONCURRENT mode, not yet dispatched: AWAIT the post-write NOTIFICATION (resolves the
|
|
590
|
+
// microtask after the socket write) WITHOUT probing — no timer poll, so there is NO
|
|
591
|
+
// leading window in which a sub-millisecond down→up could be missed (codex). The very
|
|
592
|
+
// first probe then fires the instant the command is dispatched (probe-first).
|
|
593
|
+
await gate.waitDispatched;
|
|
594
|
+
if (gate.cancelled)
|
|
595
|
+
break;
|
|
596
|
+
// Fall through and probe immediately (a fast non-accepted outcome that resolves before
|
|
597
|
+
// this observer wakes will already have set gate.cancelled above; otherwise a single
|
|
598
|
+
// read of our OWN boot endpoint during the sub-ack window is the accepted benign
|
|
599
|
+
// residual — see the handler's INHERENT TRADEOFF note — and is discarded on refusal).
|
|
600
|
+
}
|
|
601
|
+
// PROBE NOW (no leading interval sleep) — the first sample lands at the post-write
|
|
602
|
+
// dispatch instant so a sub-interval down→up is caught (coordinator: probe-first).
|
|
603
|
+
const sampleAt = Date.now();
|
|
251
604
|
attempts++;
|
|
252
|
-
|
|
253
|
-
|
|
605
|
+
const t = Math.max(1, Math.min(timing.probeTimeoutMs, currentDeadline() - Date.now()));
|
|
606
|
+
let status = "unknown";
|
|
607
|
+
try {
|
|
608
|
+
status = normalizeProbe(await probe(opts.healthBase, t));
|
|
254
609
|
}
|
|
255
|
-
|
|
610
|
+
catch {
|
|
611
|
+
status = "unknown";
|
|
612
|
+
}
|
|
613
|
+
if (gate?.cancelled)
|
|
614
|
+
break;
|
|
615
|
+
// COUNTING gate: a sample contributes to the cycle only if taken at/after the post-write
|
|
616
|
+
// dispatched instant (defensive — the observer also defers its first probe to dispatch).
|
|
617
|
+
if (gate == null || sampleAt >= gate.dispatchedAt) {
|
|
618
|
+
if (status === "down") {
|
|
619
|
+
sawDown = true;
|
|
620
|
+
}
|
|
621
|
+
else if (status === "healthy" && sawDown) {
|
|
622
|
+
return { ready: true, waited_ms: Date.now() - start, attempts, via: "observed-cycle", sawDown };
|
|
623
|
+
}
|
|
624
|
+
// "healthy" without a prior down, and "unknown", are ignored — keep looking.
|
|
625
|
+
}
|
|
626
|
+
// Sleep BETWEEN probes (both modes).
|
|
627
|
+
const left = currentDeadline() - Date.now();
|
|
256
628
|
if (left <= 0)
|
|
257
629
|
break;
|
|
258
630
|
await sleep(Math.min(intervalMs, left));
|
|
259
631
|
}
|
|
260
|
-
return { ready: false, waited_ms: Date.now() - start, attempts };
|
|
632
|
+
return { ready: false, waited_ms: Date.now() - start, attempts, sawDown };
|
|
261
633
|
}
|
|
262
634
|
// ---- workflow_open verify-after-timeout (#215/#319/#496) --------------------
|
|
263
635
|
// `panel_open_workflow` forwards `workflow_open` over the UI bridge and waits for
|
|
@@ -267,7 +639,7 @@ async function waitForPanelReady(ctx, timing) {
|
|
|
267
639
|
// genuinely happened (the executor ran; the ack just didn't make it back in the
|
|
268
640
|
// window). Reporting that as a failure is a FALSE FAILURE: a follow-up
|
|
269
641
|
// `workflow_list` shows the target IS the active tab, and it invites unsafe
|
|
270
|
-
// retries. Mirroring the reboot-readiness pattern (
|
|
642
|
+
// retries. Mirroring the reboot-readiness pattern (observeRecovery / #497), on
|
|
271
643
|
// an ack-timeout we do NOT immediately fail — we VERIFY the AUTHORITATIVE active
|
|
272
644
|
// workflow by polling `workflow_list` (a fresh bridge round-trip, never a stale
|
|
273
645
|
// cache) and return SUCCESS with a `recovered` note if the target became active,
|
|
@@ -671,51 +1043,116 @@ function comfyWorkflowsDirs() {
|
|
|
671
1043
|
join(base, "user", "workflows"),
|
|
672
1044
|
];
|
|
673
1045
|
}
|
|
674
|
-
/**
|
|
675
|
-
*
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
1046
|
+
/** Validate a parsed value is a UI/litegraph workflow (a top-level `nodes`
|
|
1047
|
+
* array), throwing a source-labelled error otherwise. */
|
|
1048
|
+
function assertUiWorkflow(parsed, sourceLabel) {
|
|
1049
|
+
if (!parsed || typeof parsed !== "object") {
|
|
1050
|
+
throw new Error(`${sourceLabel} did not parse to a workflow object.`);
|
|
1051
|
+
}
|
|
1052
|
+
if (!Array.isArray(parsed.nodes)) {
|
|
1053
|
+
throw new Error(`${sourceLabel} is not a UI workflow (missing a top-level \`nodes\` array). ` +
|
|
1054
|
+
`Provide a UI/litegraph workflow JSON, not API/prompt format.`);
|
|
1055
|
+
}
|
|
1056
|
+
return parsed;
|
|
1057
|
+
}
|
|
1058
|
+
/** Read + parse a UI workflow JSON by path. Resolves an ABSOLUTE path off the
|
|
1059
|
+
* orchestrator's disk, or a RELATIVE name authoritatively through the CONNECTED
|
|
1060
|
+
* ComfyUI's userdata API — which resolves under the server's RUNTIME
|
|
1061
|
+
* `--user-directory` (custom or default), so the RIGHT file always wins and a
|
|
1062
|
+
* stale same-named file under the guessed default dir can never shadow it
|
|
1063
|
+
* (#202). Only when the server can't serve the name (404 / unreachable) does it
|
|
1064
|
+
* fall back to the orchestrator's guessed local workflows dirs, so a
|
|
1065
|
+
* disk-staged file still opens. Guards: must be .json and must parse to a UI
|
|
1066
|
+
* workflow (a top-level `nodes` array). Fails loudly (never loads the wrong
|
|
1067
|
+
* file) when the name resolves nowhere. */
|
|
1068
|
+
async function readWorkflowFromPath(rawPath) {
|
|
679
1069
|
const p = (rawPath ?? "").trim();
|
|
680
1070
|
if (!p)
|
|
681
1071
|
throw new Error("Provide a non-empty `path` to a workflow .json file.");
|
|
682
1072
|
if (!/\.json$/i.test(p)) {
|
|
683
1073
|
throw new Error(`"${p}" is not a .json file — pass the path to a ComfyUI workflow JSON.`);
|
|
684
1074
|
}
|
|
685
|
-
|
|
686
|
-
|
|
1075
|
+
const readLocal = (resolved) => {
|
|
1076
|
+
let parsed;
|
|
1077
|
+
try {
|
|
1078
|
+
parsed = JSON.parse(readFileSync(resolved, "utf8"));
|
|
1079
|
+
}
|
|
1080
|
+
catch (err) {
|
|
1081
|
+
throw new Error(`"${resolved}" is not valid JSON: ${err.message}`);
|
|
1082
|
+
}
|
|
1083
|
+
return assertUiWorkflow(parsed, `"${resolved}"`);
|
|
1084
|
+
};
|
|
1085
|
+
// ABSOLUTE path → the orchestrator's own disk, unchanged.
|
|
687
1086
|
if (isAbsolute(p)) {
|
|
688
|
-
|
|
1087
|
+
const resolved = resolve(p);
|
|
1088
|
+
if (existsSync(resolved) && statSync(resolved).isFile())
|
|
1089
|
+
return readLocal(resolved);
|
|
1090
|
+
throw new Error(`No workflow file at "${p}". Looked under ${resolved}. ` +
|
|
1091
|
+
`Pass an absolute path, or a name relative to the ComfyUI workflows folder.`);
|
|
689
1092
|
}
|
|
690
|
-
|
|
691
|
-
// Relative to each ComfyUI workflows dir (the common case — a just-staged file).
|
|
692
|
-
for (const dir of comfyWorkflowsDirs())
|
|
693
|
-
candidates.push(resolve(dir, p));
|
|
694
|
-
// Also relative to the orchestrator's CWD as a last resort.
|
|
695
|
-
candidates.push(resolve(process.cwd(), p));
|
|
696
|
-
}
|
|
697
|
-
const resolved = candidates.find((c) => existsSync(c) && statSync(c).isFile());
|
|
698
|
-
if (!resolved) {
|
|
699
|
-
const where = isAbsolute(p)
|
|
700
|
-
? candidates[0]
|
|
701
|
-
: `the ComfyUI workflows dir (${comfyWorkflowsDirs().join(" or ") || "COMFYUI_PATH not set"}) or an absolute path`;
|
|
702
|
-
throw new Error(`No workflow file at "${p}". Looked under ${where}. Pass an absolute path, or a name relative to the ComfyUI workflows folder.`);
|
|
703
|
-
}
|
|
704
|
-
let parsed;
|
|
1093
|
+
let outcome;
|
|
705
1094
|
try {
|
|
706
|
-
|
|
1095
|
+
const client = getClient();
|
|
1096
|
+
const encoded = encodeURIComponent(`workflows/${p.replace(/^[\\/]+/, "")}`);
|
|
1097
|
+
const res = await client.fetchApi(`/api/userdata/${encoded}`);
|
|
1098
|
+
if (res.ok) {
|
|
1099
|
+
// Read the body as TEXT and classify HERE so a malformed 2xx surfaces its
|
|
1100
|
+
// OWN error (no fallback), while ComfyUI's "200 + EMPTY body = file does
|
|
1101
|
+
// not exist" convention (some builds; see parseWorkflowLock) is treated as
|
|
1102
|
+
// an ABSENCE that DOES allow the local fallback — not a malformed error.
|
|
1103
|
+
const body = (await res.text()).trim();
|
|
1104
|
+
if (body === "") {
|
|
1105
|
+
outcome = { kind: "absent", detail: "was not in the ComfyUI userdata library (empty 200 response)" };
|
|
1106
|
+
}
|
|
1107
|
+
else {
|
|
1108
|
+
try {
|
|
1109
|
+
outcome = { kind: "found", parsed: JSON.parse(body) };
|
|
1110
|
+
}
|
|
1111
|
+
catch (err) {
|
|
1112
|
+
outcome = { kind: "malformed", detail: err instanceof Error ? err.message : String(err) };
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
else if (res.status === 404) {
|
|
1117
|
+
outcome = { kind: "absent", detail: "was not in the ComfyUI userdata library (HTTP 404)" };
|
|
1118
|
+
}
|
|
1119
|
+
else {
|
|
1120
|
+
outcome = { kind: "refused", detail: `ComfyUI userdata library returned HTTP ${res.status}` };
|
|
1121
|
+
}
|
|
707
1122
|
}
|
|
708
1123
|
catch (err) {
|
|
709
|
-
|
|
1124
|
+
outcome = {
|
|
1125
|
+
kind: "unreachable",
|
|
1126
|
+
detail: `ComfyUI userdata library was unreachable (${err instanceof Error ? err.message : String(err)})`,
|
|
1127
|
+
};
|
|
710
1128
|
}
|
|
711
|
-
if (
|
|
712
|
-
|
|
1129
|
+
if (outcome.kind === "found") {
|
|
1130
|
+
// A found-but-non-UI file must surface its own honest error, not silence.
|
|
1131
|
+
return assertUiWorkflow(outcome.parsed, `The workflow "${p}" from the ComfyUI userdata library`);
|
|
713
1132
|
}
|
|
714
|
-
if (
|
|
715
|
-
throw new Error(`"${
|
|
716
|
-
`Provide a UI/litegraph workflow JSON, not API/prompt format.`);
|
|
1133
|
+
if (outcome.kind === "malformed") {
|
|
1134
|
+
throw new Error(`The workflow "${p}" in the ComfyUI userdata library is not valid JSON: ${outcome.detail}`);
|
|
717
1135
|
}
|
|
718
|
-
|
|
1136
|
+
if (outcome.kind === "refused") {
|
|
1137
|
+
// Server is reachable but did not serve the file — do NOT fall back to a
|
|
1138
|
+
// possibly-stale local file; report the status honestly.
|
|
1139
|
+
throw new Error(`Could not read "${p}" from the connected ComfyUI: ${outcome.detail}. ` +
|
|
1140
|
+
`Pass an absolute path, or a name shown by panel_list_workflows.`);
|
|
1141
|
+
}
|
|
1142
|
+
// outcome.kind is "absent" (404) or "unreachable" — fall back to the
|
|
1143
|
+
// orchestrator's guessed local workflows dirs (best-effort; only meaningful on
|
|
1144
|
+
// a same-machine ComfyUI whose user-dir matches the default layout, or a file
|
|
1145
|
+
// staged straight to disk).
|
|
1146
|
+
const localCandidates = [
|
|
1147
|
+
...comfyWorkflowsDirs().map((dir) => resolve(dir, p)),
|
|
1148
|
+
resolve(process.cwd(), p), // orchestrator CWD as a last local resort
|
|
1149
|
+
];
|
|
1150
|
+
const local = localCandidates.find((c) => existsSync(c) && statSync(c).isFile());
|
|
1151
|
+
if (local)
|
|
1152
|
+
return readLocal(local);
|
|
1153
|
+
throw new Error(`No workflow file at "${p}". It ${outcome.detail}, and it is not under the orchestrator's workflows ` +
|
|
1154
|
+
`dir (${comfyWorkflowsDirs().join(" or ") || "COMFYUI_PATH not set"}). ` +
|
|
1155
|
+
`Pass an absolute path, or a name shown by panel_list_workflows.`);
|
|
719
1156
|
}
|
|
720
1157
|
// IMPORTANT (Codex parity): use `z.array(z.number())` — NOT `z.tuple([...])` — for
|
|
721
1158
|
// fixed-length coordinate vectors. zod's `.tuple()` emits JSON-Schema draft-04
|
|
@@ -829,22 +1266,53 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
|
|
|
829
1266
|
// (We gate inside the tool because the SDK's canUseTool is bypassed under
|
|
830
1267
|
// bypassPermissions, which the panel agent runs in; the Codex HTTP path runs
|
|
831
1268
|
// approvalPolicy "never", so the same in-tool gate is the only safeguard.)
|
|
832
|
-
const confirm = async (question, header) => {
|
|
1269
|
+
const confirm = async (question, header, timeoutMs) => {
|
|
1270
|
+
// #360: the enclosing MCP `tools/call` is killed at ~300s. A hardcoded 300s
|
|
1271
|
+
// card wait had ZERO margin below that budget — so an unanswered confirm blew
|
|
1272
|
+
// the whole tool call (a transport timeout) instead of returning cleanly, and
|
|
1273
|
+
// any late answer was lost. CLAMP the card deadline under the budget (the same
|
|
1274
|
+
// getAskTiming() ceiling panel_ask uses for #486) and, on a reply-timeout,
|
|
1275
|
+
// poll the bridge's late-reply buffer for a bounded grace so a slow-but-valid
|
|
1276
|
+
// yes/no is still HONORED. A genuine no-answer returns "timeout" (reported
|
|
1277
|
+
// honestly by the caller), never a silent decline.
|
|
1278
|
+
const base = getAskTiming();
|
|
1279
|
+
// A caller may pass a tighter WHOLE-confirm budget (#536: panel_restart bounds
|
|
1280
|
+
// confirm+dispatch+readiness under the outer limit). Treat timeoutMs as the HARD
|
|
1281
|
+
// ceiling on deadline+grace so we never overrun the caller's budget, while still
|
|
1282
|
+
// never exceeding the ask clamp. Absent → the full clamp (deadline+grace).
|
|
1283
|
+
const total = typeof timeoutMs === "number"
|
|
1284
|
+
? Math.max(1, Math.min(timeoutMs, base.deadlineMs + base.graceMs))
|
|
1285
|
+
: base.deadlineMs + base.graceMs;
|
|
1286
|
+
const deadlineMs = Math.max(1, Math.min(base.deadlineMs, total));
|
|
1287
|
+
const graceMs = Math.max(0, total - deadlineMs);
|
|
1288
|
+
const timing = { deadlineMs, graceMs, pollMs: base.pollMs };
|
|
1289
|
+
const askId = randomUUID();
|
|
833
1290
|
try {
|
|
834
1291
|
ensureReachable();
|
|
835
1292
|
const reply = await bridge.send({
|
|
836
1293
|
cmd: "ask_user",
|
|
1294
|
+
ask_id: askId,
|
|
837
1295
|
question,
|
|
838
1296
|
header,
|
|
839
1297
|
options: [
|
|
840
1298
|
{ label: "Yes, go ahead", description: "" },
|
|
841
1299
|
{ label: "No, cancel", description: "" },
|
|
842
1300
|
],
|
|
843
|
-
}, { tabId: ctx.tabId, timeoutMs:
|
|
844
|
-
return isAffirmative(reply);
|
|
1301
|
+
}, { tabId: ctx.tabId, timeoutMs: timing.deadlineMs });
|
|
1302
|
+
return isAffirmative(reply) ? "yes" : "no";
|
|
845
1303
|
}
|
|
846
|
-
catch {
|
|
847
|
-
|
|
1304
|
+
catch (err) {
|
|
1305
|
+
// Only a card-reply TIMEOUT is recoverable/honest-as-timeout: poll the late
|
|
1306
|
+
// buffer, then report "timeout" if still unanswered. Any other error (no
|
|
1307
|
+
// panel, transport failure) → "no" so the destructive op is SKIPPED, exactly
|
|
1308
|
+
// as the previous catch-all did.
|
|
1309
|
+
if (isReplyTimeoutError(err)) {
|
|
1310
|
+
const late = await pollLateAskReply(bridge, askId, timing);
|
|
1311
|
+
if (late !== undefined)
|
|
1312
|
+
return isAffirmative(late) ? "yes" : "no";
|
|
1313
|
+
return "timeout";
|
|
1314
|
+
}
|
|
1315
|
+
return "no";
|
|
848
1316
|
}
|
|
849
1317
|
};
|
|
850
1318
|
// EXPLICIT self-heal — see PanelToolCtx.rebindToActiveTab. Only rebinds when
|
|
@@ -880,11 +1348,90 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
|
|
|
880
1348
|
* what I have open" is the common ask, and requiring a save-to-disk round trip
|
|
881
1349
|
* first derailed real sessions (deleted placeholder files, 404 tabs).
|
|
882
1350
|
*/
|
|
883
|
-
|
|
1351
|
+
/**
|
|
1352
|
+
* Rebuild a UI-format workflow ({ nodes, links }) from the panel's back-compat
|
|
1353
|
+
* `graph_get_state` reply (the #384 fallback). Each summarized node carries its
|
|
1354
|
+
* widget values keyed BY NAME (`widgets`) and its inputs' upstream source
|
|
1355
|
+
* (`connected_from`), so we materialize:
|
|
1356
|
+
* - nodes with `widgets_values` as the name→value OBJECT — convertUiToApi maps
|
|
1357
|
+
* those by name, which also sidesteps the positional widget-order pitfalls,
|
|
1358
|
+
* - a synthetic links array + per-input `link` ids from `connected_from`.
|
|
1359
|
+
* Returns null when the reply has no usable nodes.
|
|
1360
|
+
*/
|
|
1361
|
+
function reconstructUiFromState(reply) {
|
|
1362
|
+
const r = reply;
|
|
1363
|
+
const nodesIn = r?.nodes;
|
|
1364
|
+
if (!Array.isArray(nodesIn) || nodesIn.length === 0)
|
|
1365
|
+
return null;
|
|
1366
|
+
// graph_get_state caps at MAX_STATE_NODES (100) and flags the overflow. A
|
|
1367
|
+
// truncated capture would silently yield an INCOMPLETE executable graph, so
|
|
1368
|
+
// refuse it — the caller then surfaces the actionable "pass pack/path/graph"
|
|
1369
|
+
// error rather than stripping a partial workflow.
|
|
1370
|
+
if (r?.truncated === true)
|
|
1371
|
+
return null;
|
|
1372
|
+
if (typeof r?.node_count === "number" && r.node_count > nodesIn.length)
|
|
1373
|
+
return null;
|
|
1374
|
+
const uiNodes = nodesIn.map((raw) => {
|
|
1375
|
+
const n = raw;
|
|
1376
|
+
const mode = n.mode === "mute" ? 2 : n.mode === "bypass" ? 4 : 0;
|
|
1377
|
+
return {
|
|
1378
|
+
id: n.id,
|
|
1379
|
+
type: n.type,
|
|
1380
|
+
mode,
|
|
1381
|
+
pos: [0, 0],
|
|
1382
|
+
inputs: (n.inputs ?? []).map((inp) => ({
|
|
1383
|
+
name: inp.name,
|
|
1384
|
+
type: inp.type ?? "*",
|
|
1385
|
+
link: null,
|
|
1386
|
+
})),
|
|
1387
|
+
outputs: (n.outputs ?? []).map((o) => ({
|
|
1388
|
+
name: o.name,
|
|
1389
|
+
type: o.type ?? "*",
|
|
1390
|
+
links: [],
|
|
1391
|
+
})),
|
|
1392
|
+
widgets_values: n.widgets && typeof n.widgets === "object"
|
|
1393
|
+
? n.widgets
|
|
1394
|
+
: [],
|
|
1395
|
+
properties: {},
|
|
1396
|
+
...(n.title ? { title: n.title } : {}),
|
|
1397
|
+
};
|
|
1398
|
+
});
|
|
1399
|
+
const byId = new Map(uiNodes.map((n) => [n.id, n]));
|
|
1400
|
+
const links = [];
|
|
1401
|
+
let linkId = 0;
|
|
1402
|
+
nodesIn.forEach((raw, idx) => {
|
|
1403
|
+
const inputs = raw.inputs ?? [];
|
|
1404
|
+
const tgt = uiNodes[idx];
|
|
1405
|
+
inputs.forEach((inp, slot) => {
|
|
1406
|
+
const from = inp.connected_from;
|
|
1407
|
+
if (!from || from.node_id == null || !byId.has(from.node_id))
|
|
1408
|
+
return;
|
|
1409
|
+
const id = ++linkId;
|
|
1410
|
+
tgt.inputs[slot].link = id;
|
|
1411
|
+
const srcNode = byId.get(from.node_id);
|
|
1412
|
+
const srcSlot = from.output_slot ?? 0;
|
|
1413
|
+
while (srcNode.outputs.length <= srcSlot) {
|
|
1414
|
+
srcNode.outputs.push({ name: `out_${srcNode.outputs.length}`, type: "*", links: [] });
|
|
1415
|
+
}
|
|
1416
|
+
srcNode.outputs[srcSlot].links.push(id);
|
|
1417
|
+
links.push([id, from.node_id, srcSlot, tgt.id, slot, inp.type ?? "*"]);
|
|
1418
|
+
});
|
|
1419
|
+
});
|
|
1420
|
+
return { nodes: uiNodes, links };
|
|
1421
|
+
}
|
|
1422
|
+
async function resolveWorkflowInput(args, ctx,
|
|
1423
|
+
// The live-canvas graph_get_state fallback (#384) is LOSSY: it reconstructs
|
|
1424
|
+
// only nodes/links/widgets (name-keyed) — no layout, groups, properties, or
|
|
1425
|
+
// subgraph definitions. That's fine for panel_strip_workflow (API/prompt output
|
|
1426
|
+
// for inspection/execution), but panel_flatten_workflow LOADS its result back
|
|
1427
|
+
// ONTO the canvas and panel_slice_workflow needs groups to find its seeds, so
|
|
1428
|
+
// they must NOT take this fallback — they keep the actionable "update your
|
|
1429
|
+
// panel" error instead. Only strip opts in.
|
|
1430
|
+
allowStateFallback = false) {
|
|
884
1431
|
if (args.pack)
|
|
885
1432
|
return readPackWorkflow(args.pack);
|
|
886
1433
|
if (args.path)
|
|
887
|
-
return readWorkflowFromPath(args.path);
|
|
1434
|
+
return await readWorkflowFromPath(args.path);
|
|
888
1435
|
if (args.graph != null) {
|
|
889
1436
|
return (typeof args.graph === "string"
|
|
890
1437
|
? JSON.parse(args.graph)
|
|
@@ -907,7 +1454,31 @@ async function resolveWorkflowInput(args, ctx) {
|
|
|
907
1454
|
});
|
|
908
1455
|
}
|
|
909
1456
|
catch (err) {
|
|
910
|
-
|
|
1457
|
+
// #384: a panel too old to register graph_serialize (added at 0.11.4) still
|
|
1458
|
+
// answers the back-compat `graph_get_state`. On an "Unknown command" rejection
|
|
1459
|
+
// ONLY (a genuine transport/timeout error must surface as-is), fall back to it
|
|
1460
|
+
// and reconstruct the graph so "strip the live canvas" works without a
|
|
1461
|
+
// save-to-disk round trip.
|
|
1462
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1463
|
+
if (allowStateFallback && /unknown command/i.test(msg)) {
|
|
1464
|
+
try {
|
|
1465
|
+
const target = ctx.workflowTarget?.get(ctx.tabId);
|
|
1466
|
+
const stateCmd = target
|
|
1467
|
+
? withWorkflowTarget({ cmd: "graph_get_state" }, target)
|
|
1468
|
+
: { cmd: "graph_get_state" };
|
|
1469
|
+
const stateReply = await ctx.bridge.send(stateCmd, {
|
|
1470
|
+
tabId: ctx.tabId,
|
|
1471
|
+
timeoutMs: 30000,
|
|
1472
|
+
});
|
|
1473
|
+
const rebuilt = reconstructUiFromState(stateReply);
|
|
1474
|
+
if (rebuilt)
|
|
1475
|
+
return rebuilt;
|
|
1476
|
+
}
|
|
1477
|
+
catch {
|
|
1478
|
+
/* fall through to the actionable error below */
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
throw new Error(`Couldn't capture the live canvas (${msg}). ` +
|
|
911
1482
|
`An older panel version may not support graph_serialize — pass pack, path, or graph instead.`);
|
|
912
1483
|
}
|
|
913
1484
|
const wf = reply?.workflow;
|
|
@@ -916,6 +1487,133 @@ async function resolveWorkflowInput(args, ctx) {
|
|
|
916
1487
|
}
|
|
917
1488
|
return wf;
|
|
918
1489
|
}
|
|
1490
|
+
let askTimingOverride = null;
|
|
1491
|
+
// The enclosing MCP `tools/call` is killed at ~300s. The card deadline PLUS the
|
|
1492
|
+
// late-answer grace poll must finish UNDER that, or a slow-but-valid pick is lost
|
|
1493
|
+
// to the framework before we can honor it (#486). This is the HARD ceiling on the
|
|
1494
|
+
// total ask budget — applied even when env overrides ask for more, so a
|
|
1495
|
+
// misconfigured COMFYUI_PANEL_ASK_DEADLINE_S/GRACE_S can never recreate #486.
|
|
1496
|
+
const ASK_TOTAL_BUDGET_CAP_MS = 285_000;
|
|
1497
|
+
function getAskTiming() {
|
|
1498
|
+
if (askTimingOverride)
|
|
1499
|
+
return askTimingOverride;
|
|
1500
|
+
const pollMs = Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_ASK_POLL_S", 0.5) * 1000);
|
|
1501
|
+
// Defaults keep deadline + grace comfortably under the budget (240 + up to 45 =
|
|
1502
|
+
// 285s). Env overrides are HARD-clamped: the deadline is capped first (leaving at
|
|
1503
|
+
// least a 1s slice), then the grace gets only whatever budget remains, so
|
|
1504
|
+
// deadline + grace is guaranteed ≤ ASK_TOTAL_BUDGET_CAP_MS regardless of input.
|
|
1505
|
+
let deadlineMs = Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_ASK_DEADLINE_S", 240) * 1000);
|
|
1506
|
+
let graceMs = Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_ASK_GRACE_S", 45) * 1000);
|
|
1507
|
+
deadlineMs = Math.min(deadlineMs, ASK_TOTAL_BUDGET_CAP_MS - 1000);
|
|
1508
|
+
graceMs = Math.min(graceMs, Math.max(0, ASK_TOTAL_BUDGET_CAP_MS - deadlineMs));
|
|
1509
|
+
return { deadlineMs, graceMs, pollMs };
|
|
1510
|
+
}
|
|
1511
|
+
/** True when an error is the bridge's reply-TIMEOUT for a card (the tab never
|
|
1512
|
+
* replied within the window), NOT a genuine transport/command error. Only a
|
|
1513
|
+
* timeout warrants polling the late-reply buffer for a slow-but-valid answer. */
|
|
1514
|
+
function isReplyTimeoutError(err) {
|
|
1515
|
+
const msg = err instanceof Error ? err.message : String(err ?? "");
|
|
1516
|
+
return /did not reply to .* within \d+\s*ms|backgrounded or frozen/i.test(msg);
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Actionable error string when THIS session has no interactive surface able to
|
|
1520
|
+
* render an ask card (so the ask would block with no way to answer), or `null`
|
|
1521
|
+
* when a card can render. Uses the bridge's `isHeadless` on the tab the ask would
|
|
1522
|
+
* target (the current tab if reachable, else the resolved active tab). Defensive:
|
|
1523
|
+
* an unknown/lightweight bridge, or an ambiguous active-tab resolution, returns
|
|
1524
|
+
* null so the normal send path surfaces its own clear error instead.
|
|
1525
|
+
*/
|
|
1526
|
+
function askSurfaceError(ctx) {
|
|
1527
|
+
const b = ctx.bridge;
|
|
1528
|
+
if (typeof b.isHeadless !== "function")
|
|
1529
|
+
return null; // lightweight/unknown bridge
|
|
1530
|
+
let targetId = ctx.tabId;
|
|
1531
|
+
if (typeof b.canReach === "function" && !b.canReach(targetId)) {
|
|
1532
|
+
if (typeof b.resolveActiveTabId !== "function")
|
|
1533
|
+
return null;
|
|
1534
|
+
try {
|
|
1535
|
+
targetId = b.resolveActiveTabId();
|
|
1536
|
+
}
|
|
1537
|
+
catch {
|
|
1538
|
+
return null; // no single active tab — let the send path report it clearly
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
if (!b.isHeadless(targetId))
|
|
1542
|
+
return null;
|
|
1543
|
+
return ("No interactive panel surface can render a choice card in this session — the " +
|
|
1544
|
+
"connected client is canvas-less (a mobile mirror, a remote/headless viewer, or " +
|
|
1545
|
+
"an exec/headless run), so panel_ask can't be answered here and would block. Ask " +
|
|
1546
|
+
"the user directly in plain chat text, or invoke panel_ask from an interactive " +
|
|
1547
|
+
"ComfyUI browser tab (not nested inside an exec/headless call).");
|
|
1548
|
+
}
|
|
1549
|
+
/** Poll the bridge's late-reply buffer for a validated ask answer that arrived
|
|
1550
|
+
* after the card-reply timeout, up to the grace budget. undefined if none. */
|
|
1551
|
+
async function pollLateAskReply(bridge, askId, timing) {
|
|
1552
|
+
const take = bridge
|
|
1553
|
+
.takeLateAskReply;
|
|
1554
|
+
if (typeof take !== "function")
|
|
1555
|
+
return undefined;
|
|
1556
|
+
const deadline = Date.now() + timing.graceMs;
|
|
1557
|
+
for (;;) {
|
|
1558
|
+
const late = take.call(bridge, askId);
|
|
1559
|
+
if (late !== undefined)
|
|
1560
|
+
return late;
|
|
1561
|
+
const left = deadline - Date.now();
|
|
1562
|
+
if (left <= 0)
|
|
1563
|
+
return undefined;
|
|
1564
|
+
await sleep(Math.max(1, Math.min(timing.pollMs, left)));
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Run a panel_ask: render the choice card and return the user's pick. Clamps the
|
|
1569
|
+
* card deadline under the MCP tools/call budget and, on a reply-timeout, honors a
|
|
1570
|
+
* late-but-valid answer from the bridge's late-reply buffer before failing (#486).
|
|
1571
|
+
* Sent DIRECTLY over the bridge (like the confirm/consent cards) so a stable
|
|
1572
|
+
* `ask_id` can key the late-reply buffer.
|
|
1573
|
+
*/
|
|
1574
|
+
async function askUserWithGrace(ctx, ask) {
|
|
1575
|
+
const timing = getAskTiming();
|
|
1576
|
+
const askId = randomUUID();
|
|
1577
|
+
const cmd = {
|
|
1578
|
+
cmd: "ask_user",
|
|
1579
|
+
ask_id: askId,
|
|
1580
|
+
question: ask.question,
|
|
1581
|
+
options: ask.options,
|
|
1582
|
+
header: ask.header,
|
|
1583
|
+
multi_select: ask.multi_select,
|
|
1584
|
+
};
|
|
1585
|
+
try {
|
|
1586
|
+
ctx.ensureReachable?.();
|
|
1587
|
+
const reply = await ctx.bridge.send(cmd, {
|
|
1588
|
+
tabId: ctx.tabId,
|
|
1589
|
+
timeoutMs: timing.deadlineMs,
|
|
1590
|
+
});
|
|
1591
|
+
return ok(reply);
|
|
1592
|
+
}
|
|
1593
|
+
catch (err) {
|
|
1594
|
+
if (isReplyTimeoutError(err)) {
|
|
1595
|
+
const late = await pollLateAskReply(ctx.bridge, askId, timing);
|
|
1596
|
+
if (late !== undefined)
|
|
1597
|
+
return ok(late);
|
|
1598
|
+
return fail("The question card was not answered in time (or no interactive panel surface " +
|
|
1599
|
+
"rendered it — e.g. an exec/headless run), so nothing was selected. If you " +
|
|
1600
|
+
"still need the decision, ask the user directly in plain chat text, or " +
|
|
1601
|
+
"re-invoke panel_ask from an interactive ComfyUI tab.");
|
|
1602
|
+
}
|
|
1603
|
+
return fail(err);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
export const __panelAskTestHooks = {
|
|
1607
|
+
/** Inject fast ask timing so tests don't wait the real deadline/grace. */
|
|
1608
|
+
setAskTiming(timing) {
|
|
1609
|
+
askTimingOverride = timing;
|
|
1610
|
+
},
|
|
1611
|
+
/** The env-derived (hard-clamped) ask timing, for the budget-cap test. */
|
|
1612
|
+
getAskTiming,
|
|
1613
|
+
ASK_TOTAL_BUDGET_CAP_MS,
|
|
1614
|
+
askSurfaceError,
|
|
1615
|
+
isReplyTimeoutError,
|
|
1616
|
+
};
|
|
919
1617
|
/**
|
|
920
1618
|
* The SINGLE source of truth for the panel_* tool surface. Both transports
|
|
921
1619
|
* register these exact definitions, so the Claude (in-process) and Codex (HTTP)
|
|
@@ -1047,7 +1745,12 @@ export function buildPanelToolDefs() {
|
|
|
1047
1745
|
}, async (args, ctx) => ctx.call({ cmd: "graph_add_node", class_type: args.class_type, pos: args.pos, title: args.title })),
|
|
1048
1746
|
def("panel_remove_node", "Remove a node (and its connections) from the user's open graph by id. Undoable with Ctrl+Z.", { node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph.") }, async (args, ctx) => ctx.call({ cmd: "graph_remove_node", node_id: args.node_id })),
|
|
1049
1747
|
def("panel_clear", "Remove EVERY node from the user's open graph — only for an explicit 'clear/reset the canvas'. Just CALL THIS DIRECTLY when they ask to clear: the tool itself pops a confirm card and only wipes on a yes (don't ask separately first). The wipe is a single Ctrl+Z undo. NEVER use this for a 'new workflow' — that's panel_new_workflow (a new tab, leaves this graph intact).", {}, async (_args, ctx) => {
|
|
1050
|
-
|
|
1748
|
+
const decision = await ctx.confirm("Clear the canvas? This removes every node from the open workflow. (One Ctrl+Z undoes it.)", "Clear canvas");
|
|
1749
|
+
if (decision === "timeout") {
|
|
1750
|
+
return ok("Timed out waiting for your confirmation, so I left the canvas as-is. " +
|
|
1751
|
+
"Tell me to clear it again when you're ready.");
|
|
1752
|
+
}
|
|
1753
|
+
if (decision !== "yes") {
|
|
1051
1754
|
return ok("Cancelled — the canvas was left as-is.");
|
|
1052
1755
|
}
|
|
1053
1756
|
return ctx.call({ cmd: "graph_clear" });
|
|
@@ -1074,7 +1777,9 @@ export function buildPanelToolDefs() {
|
|
|
1074
1777
|
.optional()
|
|
1075
1778
|
.describe("Inline UI workflow (object or JSON string) to strip instead of a pack/path."),
|
|
1076
1779
|
}, async (args, ctx) => {
|
|
1077
|
-
|
|
1780
|
+
// strip opts into the lossy live-canvas fallback (#384) — its API/prompt
|
|
1781
|
+
// output is for inspection/execution, never reloaded onto the canvas.
|
|
1782
|
+
const raw = await resolveWorkflowInput(args, ctx, true);
|
|
1078
1783
|
const ui = raw;
|
|
1079
1784
|
const bulk = await getObjectInfo();
|
|
1080
1785
|
const objectInfo = await backfillObjectInfo(bulk, collectNodeTypes(ui));
|
|
@@ -1195,9 +1900,10 @@ export function buildPanelToolDefs() {
|
|
|
1195
1900
|
data = readPackWorkflow(args.pack);
|
|
1196
1901
|
}
|
|
1197
1902
|
else if (args.path) {
|
|
1198
|
-
// Read an arbitrary workflow JSON
|
|
1199
|
-
//
|
|
1200
|
-
|
|
1903
|
+
// Read an arbitrary workflow JSON server-side — a local disk path, or
|
|
1904
|
+
// (for a relative name under a custom --user-directory) the connected
|
|
1905
|
+
// ComfyUI's userdata API — keeping the big JSON out of chat (#202).
|
|
1906
|
+
data = await readWorkflowFromPath(args.path);
|
|
1201
1907
|
}
|
|
1202
1908
|
else if (args.graph != null) {
|
|
1203
1909
|
data = typeof args.graph === "string" ? JSON.parse(args.graph) : args.graph;
|
|
@@ -1247,13 +1953,27 @@ export function buildPanelToolDefs() {
|
|
|
1247
1953
|
node_id: z.number().int().describe("Node id whose input to disconnect."),
|
|
1248
1954
|
input: slotRef.optional().describe("Input slot name or index (default 0)."),
|
|
1249
1955
|
}, async (args, ctx) => ctx.call({ cmd: "graph_disconnect", node_id: args.node_id, input: args.input })),
|
|
1250
|
-
def("panel_set_widget", "Set a widget value on a node in the user's open graph (steps, cfg, seed, ckpt_name, text prompts, …). Returns the previous and new value. Undoable with Ctrl+Z.", {
|
|
1956
|
+
def("panel_set_widget", "Set a widget value on a node in the user's open graph (steps, cfg, seed, ckpt_name, text prompts, …). Returns the previous and new value. Undoable with Ctrl+Z. To CLEAR a text widget to an empty string, pass `clear: true` (some MCP clients drop an empty-string `value` from the serialized payload, so `value: \"\"` may not arrive — `clear: true` always works).", {
|
|
1251
1957
|
node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
|
|
1252
1958
|
widget: z.string().describe("Widget name (e.g. 'steps', 'cfg', 'text')."),
|
|
1253
1959
|
value: z
|
|
1254
1960
|
.union([z.string(), z.number(), z.boolean()])
|
|
1255
|
-
.
|
|
1256
|
-
|
|
1961
|
+
.optional()
|
|
1962
|
+
.describe("New value. Must match the widget's expected type. Optional only when `clear: true` is set (which forces an empty string)."),
|
|
1963
|
+
clear: z
|
|
1964
|
+
.boolean()
|
|
1965
|
+
.optional()
|
|
1966
|
+
.describe("Set true to clear the widget to an empty string (\"\"). Escape hatch for when a client cannot carry an empty-string `value` through tool-arg JSON. Overrides `value`."),
|
|
1967
|
+
}, async (args, ctx) => {
|
|
1968
|
+
// Distinguish "value present but empty" from "value absent" by key
|
|
1969
|
+
// presence, NOT a truthiness check — an empty string is a legitimate
|
|
1970
|
+
// value. `clear: true` is the transport-independent way to set "".
|
|
1971
|
+
const value = args.clear === true ? "" : args.value;
|
|
1972
|
+
if (value === undefined) {
|
|
1973
|
+
return fail("panel_set_widget needs a `value`. To set an empty string, pass `clear: true` (some clients drop an empty-string `value`).");
|
|
1974
|
+
}
|
|
1975
|
+
return ctx.call({ cmd: "graph_set_widget", node_id: args.node_id, widget: args.widget, value });
|
|
1976
|
+
}),
|
|
1257
1977
|
def("panel_move_node", "Move a node to a new canvas position [x, y] in the user's open graph. Undoable.", {
|
|
1258
1978
|
node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
|
|
1259
1979
|
pos: xy().describe("New canvas [x, y] (two numbers)."),
|
|
@@ -1474,7 +2194,15 @@ export function buildPanelToolDefs() {
|
|
|
1474
2194
|
if (args.target_kind !== "env") {
|
|
1475
2195
|
return ok("The built-in comfyui server takes secrets as env vars — use target_kind 'env' (e.g. key 'CIVITAI_API_TOKEN').");
|
|
1476
2196
|
}
|
|
1477
|
-
setComfyuiSecret(args.key, `${args.value_prefix ?? ""}${secret}
|
|
2197
|
+
setComfyuiSecret(args.key, `${args.value_prefix ?? ""}${secret}`, {
|
|
2198
|
+
// This save ANSWERS an outstanding agent secret request — mark it so
|
|
2199
|
+
// the orchestrator injects the "retry the action" nudge, and carry
|
|
2200
|
+
// the requesting tab so ONLY that tab's agent is nudged (never a
|
|
2201
|
+
// broadcast to unrelated tabs). A Settings-panel slot save omits
|
|
2202
|
+
// both and never nudges (#164).
|
|
2203
|
+
requested: true,
|
|
2204
|
+
tabId: ctx.tabId,
|
|
2205
|
+
});
|
|
1478
2206
|
// Redacted ack ONLY — the secret never enters the agent's context. The
|
|
1479
2207
|
// respawn is deferred to this turn's end, so this is accurate.
|
|
1480
2208
|
return ok(`🔒 Token saved for the built-in comfyui tools (env "${args.key}"). It's being applied now — the comfyui tools respawn with it as soon as this turn ends, then I'll retry. No reload needed.`);
|
|
@@ -1558,7 +2286,12 @@ export function buildPanelToolDefs() {
|
|
|
1558
2286
|
.describe("Step state (default 'pending'). Mark the one you're on 'active'."),
|
|
1559
2287
|
}))
|
|
1560
2288
|
.describe("The full ordered checklist (replaces the current one). Empty array clears the tray."),
|
|
1561
|
-
},
|
|
2289
|
+
},
|
|
2290
|
+
// #322: a 5s ack deadline false-timed-out a responsive session whose tab was
|
|
2291
|
+
// momentarily backgrounded. set_todo is a non-destructive, idempotent full-
|
|
2292
|
+
// replace UI write (already in RETRY_SAFE_CMDS), so give it the same sane 15s
|
|
2293
|
+
// bound as the other UI-state writes (workflow_save) instead of a tight 5s.
|
|
2294
|
+
async (args, ctx) => ctx.call({ cmd: "set_todo", items: args.items }, 15000)),
|
|
1562
2295
|
def("panel_open_civitai", "Open the in-panel CivitAI browser for the user, pre-seeded with a search term and suggested filters, so they can VISUALLY browse and pick a model / LoRA / checkpoint / workflow / image. When the user asks about — or you're recommending — specific CivitAI models/LoRAs/checkpoints (e.g. 'what's a good relight LoRA?'), PREFER opening this docked browser and highlighting your picks over a text-only answer: it docks beside the chat (dock defaults true) so chat and results stay visible together, and it lets the user SEE the actual cards instead of reading a table. Typical show-don't-tell flow: panel_open_civitai (docked) → panel_civitai_search to refine → panel_civitai_results to READ the metadata + URLs → panel_civitai_highlight the one(s) you recommend, with a brief text summary of why. Set a helpful query + filters matched to their goal (including the browsing level). Their selection comes back to you as a normal chat message — UNLESS the panel is muted, in which case they download it directly themselves. Prefer this over guessing a specific model or asking them to paste a URL.", {
|
|
1563
2296
|
query: z
|
|
1564
2297
|
.string()
|
|
@@ -1737,15 +2470,22 @@ export function buildPanelToolDefs() {
|
|
|
1737
2470
|
.describe("The choices (at least 2). An 'Other' free-text field is added automatically."),
|
|
1738
2471
|
header: z.string().optional().describe("Very short label/chip for the card (e.g. 'Sampler')."),
|
|
1739
2472
|
multi_select: z.boolean().optional().describe("Allow selecting multiple options (default false)."),
|
|
1740
|
-
}, async (args, ctx) =>
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
2473
|
+
}, async (args, ctx) => {
|
|
2474
|
+
// #300: fail FAST with an actionable error when there is no interactive
|
|
2475
|
+
// surface to render the card (a canvas-less/headless client, or an exec/
|
|
2476
|
+
// headless run), rather than blocking with no way to answer.
|
|
2477
|
+
const surfaceErr = askSurfaceError(ctx);
|
|
2478
|
+
if (surfaceErr)
|
|
2479
|
+
return fail(surfaceErr);
|
|
2480
|
+
// #486: clamp the card deadline under the MCP tools/call budget and honor a
|
|
2481
|
+
// late-but-valid answer via the bridge's late-reply buffer.
|
|
2482
|
+
return askUserWithGrace(ctx, {
|
|
2483
|
+
question: args.question,
|
|
2484
|
+
options: args.options,
|
|
2485
|
+
header: args.header,
|
|
2486
|
+
multi_select: args.multi_select,
|
|
2487
|
+
});
|
|
2488
|
+
}),
|
|
1749
2489
|
def("panel_save_workflow", "Save the user's open workflow PROGRAMMATICALLY — no Save/Rename dialog ever pops. A never-saved workflow is auto-named and persisted; pass `name` to give it (or rename it to) a specific name. Use this freely (e.g. after building a graph) — it won't interrupt the user.", { name: z.string().optional().describe("Name to save/rename to (no .json needed). Omit to save in place / auto-name an unsaved workflow.") }, async (args, ctx) => args.name
|
|
1750
2490
|
? ctx.call({ cmd: "workflow_save_as", name: args.name }, 15000)
|
|
1751
2491
|
: ctx.call({ cmd: "workflow_save" }, 15000)),
|
|
@@ -2010,100 +2750,290 @@ export function buildPanelToolDefs() {
|
|
|
2010
2750
|
}, async (args, ctx) => ctx.call({ cmd: "graph_update_node", id: args.id, version: args.version, channel: args.channel, mode: args.mode }, 30000)),
|
|
2011
2751
|
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)),
|
|
2012
2752
|
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) => {
|
|
2013
|
-
|
|
2753
|
+
// Whole-handler budget (#536): confirm + dispatch + readiness — INCLUDING
|
|
2754
|
+
// the legacy path's UNPREEMPTIBLE synchronous execSync blocks — must ALL finish
|
|
2755
|
+
// under the outer ~300s tools/call limit. 255s + the legacy admission rule below
|
|
2756
|
+
// (kill+relaunch starts only with >=130s left, its ~40s of sync work FRONT-LOADED)
|
|
2757
|
+
// means the handler PROVABLY returns well under 300s. The confirm wait is bound
|
|
2758
|
+
// to the remaining budget (its deadline+grace can't overrun it — see confirm).
|
|
2759
|
+
const OVERALL_MAX_MS = 255_000;
|
|
2760
|
+
const overallDeadline = Date.now() + OVERALL_MAX_MS;
|
|
2761
|
+
const decision = await ctx.confirm("Restart ComfyUI now? It (and this agent) will go down briefly, then reconnect and resume automatically.", "Restart ComfyUI", Math.max(1, overallDeadline - Date.now()));
|
|
2762
|
+
if (decision === "timeout") {
|
|
2763
|
+
return ok("Timed out waiting for your confirmation, so I did NOT restart ComfyUI. " +
|
|
2764
|
+
"Tell me to restart it and I'll go ahead.");
|
|
2765
|
+
}
|
|
2766
|
+
if (decision !== "yes") {
|
|
2014
2767
|
return ok("Cancelled — ComfyUI was not restarted.");
|
|
2015
2768
|
}
|
|
2016
|
-
|
|
2769
|
+
// Heal an orphaned session onto the live tab FIRST, then bind the reboot dispatch
|
|
2770
|
+
// to that ONE tab id (no await between capture and dispatch, so JS run-to-
|
|
2771
|
+
// completion prevents any rebind in between). The boot-endpoint probe target is
|
|
2772
|
+
// server-authorized + immutable, bound to the exact host FAMILY the reboot goes
|
|
2773
|
+
// to (null unless the bound tab provably fronts our boot instance).
|
|
2774
|
+
ctx.ensureReachable?.();
|
|
2775
|
+
const boundTabId = ctx.tabId;
|
|
2776
|
+
const healthBase = captureRebootHealthBase(ctx);
|
|
2777
|
+
const timing = getPanelRebootTiming();
|
|
2778
|
+
const dispatchTimeout = Math.max(1, Math.min(15000, overallDeadline - Date.now()));
|
|
2779
|
+
// CONCURRENT OBSERVATION (coordinator): start probing the fixed boot endpoint NOW,
|
|
2780
|
+
// in parallel with the dispatch, so a FAST reboot whose down→up completes entirely
|
|
2781
|
+
// inside the ack/drop/timeout window is still captured (the reopened #509 fast-reboot
|
|
2782
|
+
// false-timeout). COUNTING stays post-write via the gate: the observer neither probes
|
|
2783
|
+
// nor counts until gate.dispatched flips (the instant AFTER the socket write), so a
|
|
2784
|
+
// pre-dispatch down never contributes. gate.deadline starts at the whole-handler cap
|
|
2785
|
+
// (probing spans the ack window) and is tightened to ack-completion + budget below.
|
|
2786
|
+
//
|
|
2787
|
+
// INHERENT TRADEOFF (coordinator, verified: no early-accept signal exists — the bridge
|
|
2788
|
+
// resolves send() only with the single rid-correlated {rebooting} reply, so accept vs
|
|
2789
|
+
// REFUSE is known only IN that reply). To catch a fast reboot we MUST probe DURING the
|
|
2790
|
+
// ack window, i.e. before we know accept/refuse. The residual is BENIGN and bounded:
|
|
2791
|
+
// • the probe targets ONLY the orchestrator's OWN immutable, server-authorized boot
|
|
2792
|
+
// ComfyUI (captureRebootHealthBase → getBootLocalComfyUIBaseUrl) with the correct
|
|
2793
|
+
// configured auth — never a client-advertised, cross-family, or wrong instance, so
|
|
2794
|
+
// it is NOT an auth leak or a wrong-instance probe (handshake-Origin gated above);
|
|
2795
|
+
// • a genuinely REFUSED reboot does NOT restart ComfyUI, so no REAL ECONNREFUSED→
|
|
2796
|
+
// healthy cycle occurs to certify; and even a CONTRIVED one is explicitly discarded
|
|
2797
|
+
// (the refusal branch below returns the refusal verbatim and never reads the
|
|
2798
|
+
// observer — a refusal can NEVER certify).
|
|
2799
|
+
// Eliminating even this harmless own-endpoint read would require probing only AFTER the
|
|
2800
|
+
// reply, which reopens the #509 fast-reboot false-timeout — an unacceptable regression.
|
|
2801
|
+
let signalDispatched;
|
|
2802
|
+
const gate = {
|
|
2803
|
+
dispatched: false,
|
|
2804
|
+
dispatchedAt: Number.POSITIVE_INFINITY,
|
|
2805
|
+
cancelled: false,
|
|
2806
|
+
deadline: overallDeadline,
|
|
2807
|
+
waitDispatched: new Promise((r) => {
|
|
2808
|
+
signalDispatched = r;
|
|
2809
|
+
}),
|
|
2810
|
+
};
|
|
2811
|
+
const recoveryPromise = healthBase != null
|
|
2812
|
+
? observeRecovery(timing, gate.deadline, { healthBase, gate })
|
|
2813
|
+
: null;
|
|
2814
|
+
// The AUTHORITATIVE, TYPED dispatch outcome from the bridge rejection (if any):
|
|
2815
|
+
// false = a PRE-write send failure (nothing transmitted), true = a POST-write
|
|
2816
|
+
// mid-command OUTCOME-UNKNOWN drop / reply-timeout. Captured from the RAW error —
|
|
2817
|
+
// text can't defeat it — so a pre-write failure whose detail happens to quote
|
|
2818
|
+
// "OUTCOME UNKNOWN" is still categorically NOT-dispatched (coordinator P1).
|
|
2819
|
+
let res;
|
|
2820
|
+
let dispatchOutcome;
|
|
2821
|
+
// ctx.bridge.send()'s Promise executor writes to the socket SYNCHRONOUSLY, so by the
|
|
2822
|
+
// time it returns the promise the command has been written (or synchronously pre-write
|
|
2823
|
+
// failed). Open the counting gate right here — this is the POST-write instant — then
|
|
2824
|
+
// await the ack. Probing (already running) begins the moment this flips.
|
|
2825
|
+
const sendPromise = ctx.bridge.send({ cmd: "comfy_reboot", force: force === true }, { tabId: boundTabId, timeoutMs: dispatchTimeout });
|
|
2826
|
+
gate.dispatched = true;
|
|
2827
|
+
gate.dispatchedAt = Date.now();
|
|
2828
|
+
// Wake the observer's FIRST probe IMMEDIATELY (microtask — no timer window) now that
|
|
2829
|
+
// the command has been written. Resolved on EVERY path (accept / drop / refuse /
|
|
2830
|
+
// pre-write failure), so the observer never hangs on gate.waitDispatched.
|
|
2831
|
+
signalDispatched();
|
|
2832
|
+
try {
|
|
2833
|
+
res = ok(await sendPromise);
|
|
2834
|
+
}
|
|
2835
|
+
catch (err) {
|
|
2836
|
+
res = fail(err);
|
|
2837
|
+
dispatchOutcome = dispatchOutcomeOf(err);
|
|
2838
|
+
}
|
|
2839
|
+
// A PRE-write send failure means nothing was transmitted — the reboot never happened,
|
|
2840
|
+
// so NOTHING may certify: abort the concurrent observer immediately (coordinator P1).
|
|
2841
|
+
if (dispatchOutcome === false)
|
|
2842
|
+
gate.cancelled = true;
|
|
2017
2843
|
// Classify the reboot dispatch:
|
|
2018
2844
|
// - CONFIRMED (rebooting:true): the panel acked before it went down.
|
|
2019
|
-
// - EXPECTED DROP: the reboot handler exits the instant it accepts the
|
|
2020
|
-
//
|
|
2021
|
-
//
|
|
2022
|
-
//
|
|
2023
|
-
//
|
|
2024
|
-
//
|
|
2025
|
-
// back as a NON-error ToolResult with `rebooting:false` — the server is
|
|
2026
|
-
// still up and was NOT restarted; return it verbatim and touch nothing.
|
|
2845
|
+
// - EXPECTED DROP: the reboot handler exits the instant it accepts the request,
|
|
2846
|
+
// so ComfyUI (and the tab it serves) goes down before it can ack — a bridge
|
|
2847
|
+
// mid-command "OUTCOME UNKNOWN"/disconnect. That drop IS the accept + went-down
|
|
2848
|
+
// signal (#493, panel #222/#263/#266/#306/#307).
|
|
2849
|
+
// - REFUSAL: a busy-guard / Manager-forbidden / no-endpoint refusal — the server
|
|
2850
|
+
// is still up and was NOT restarted; return it verbatim and touch nothing.
|
|
2027
2851
|
const fired = rebootConfirmed(res);
|
|
2028
|
-
|
|
2852
|
+
// A pre-write send failure (typed dispatchOutcome === false) is categorically NOT an
|
|
2853
|
+
// accepted drop — never enter the probing path for a command that never left. The
|
|
2854
|
+
// text check (rebootDropped) is a defense-in-depth fallback for older bridges that
|
|
2855
|
+
// don't carry the typed flag.
|
|
2856
|
+
const dropped = !fired && dispatchOutcome !== false && (dispatchOutcome === true || rebootDropped(res));
|
|
2029
2857
|
if (!fired && !dropped) {
|
|
2030
|
-
//
|
|
2031
|
-
//
|
|
2032
|
-
//
|
|
2033
|
-
//
|
|
2034
|
-
// (
|
|
2035
|
-
//
|
|
2036
|
-
//
|
|
2037
|
-
|
|
2038
|
-
|
|
2858
|
+
// NOT accepted (e.g. a rebooting:false busy-guard/security REFUSAL). BELT-AND-
|
|
2859
|
+
// SUSPENDERS (coordinator): EXPLICITLY DISCARD any cycle the concurrent observer may
|
|
2860
|
+
// have sampled during the sub-ack window — a refusal must NEVER certify. We cancel
|
|
2861
|
+
// the observer and, crucially, never read recoveryPromise on this path: whatever it
|
|
2862
|
+
// resolved to (even a contrived ready:true) is dropped, and we return the refusal
|
|
2863
|
+
// verbatim. (The legacy no-endpoint fallback below starts its OWN fresh observation
|
|
2864
|
+
// after the restart's synchronous work; it does not reuse this observer.)
|
|
2865
|
+
gate.cancelled = true;
|
|
2866
|
+
void recoveryPromise; // discarded — a refused reboot can never yield ready:true
|
|
2867
|
+
// If the SOLE reason is NO Manager reboot endpoint (legacy Manager
|
|
2868
|
+
// 3.x — #425, panel #253/#266) AND the target is a LOCAL, process-controllable
|
|
2869
|
+
// ComfyUI, fall back to the headless managed restart (kill + relaunch). A
|
|
2870
|
+
// busy-guard / security refusal is NOT eligible (rebootNoEndpoint excludes them).
|
|
2871
|
+
if (!isRemoteMode() &&
|
|
2872
|
+
rebootNoEndpoint(res) &&
|
|
2873
|
+
// INSTANCE BINDING: restartComfyUI() acts on the orchestrator's GLOBAL config
|
|
2874
|
+
// target (a hello can retarget it). Only run it when the bound tab provably
|
|
2875
|
+
// fronts our OWN boot instance AND that boot instance is the CURRENT global
|
|
2876
|
+
// target — so the relaunch cycles the SAME instance this tab rebooted.
|
|
2877
|
+
healthBase != null &&
|
|
2878
|
+
sameHttpBase(getComfyUIBaseUrl(), healthBase)) {
|
|
2879
|
+
// The managed kill+relaunch does UNPREEMPTIBLE synchronous execSync work — PID
|
|
2880
|
+
// discovery (~5+8s) + termination (~10s) + first port-free lookup (~13s) ≈ 40s
|
|
2881
|
+
// worst case (Windows) — that a Promise.race CANNOT interrupt, and it BLOCKS the
|
|
2882
|
+
// observer during that window. Admit it ONLY with enough budget for that sync
|
|
2883
|
+
// work AND a full cold-start observation AFTER it, and give the observer a
|
|
2884
|
+
// deadline that spans BOTH (coordinator P1: the proof deadline must start after,
|
|
2885
|
+
// not before, the restart's synchronous work — otherwise a genuine cold start
|
|
2886
|
+
// that finishes at sync+coldStart false-times-out).
|
|
2887
|
+
const LEGACY_SYNC_WORST_CASE_MS = 40_000; // execSync PID lookup + kill + port-free
|
|
2888
|
+
const LEGACY_COLD_START_OBS_MS = 100_000; // cold-start observation AFTER the sync
|
|
2889
|
+
const LEGACY_RESTART_MIN_BUDGET_MS = LEGACY_SYNC_WORST_CASE_MS + LEGACY_COLD_START_OBS_MS;
|
|
2890
|
+
if (overallDeadline - Date.now() < LEGACY_RESTART_MIN_BUDGET_MS) {
|
|
2891
|
+
return ok({
|
|
2892
|
+
rebooting: false,
|
|
2893
|
+
ready: false,
|
|
2894
|
+
confirmed_cycle: false,
|
|
2895
|
+
note: "The built-in Manager exposed no reboot endpoint (legacy Manager 3.x), and " +
|
|
2896
|
+
"there isn't enough remaining time to safely run the headless managed restart " +
|
|
2897
|
+
"(kill + relaunch). ComfyUI was NOT restarted — retry panel_restart_comfyui " +
|
|
2898
|
+
"(a fresh call gets the full budget).",
|
|
2899
|
+
});
|
|
2900
|
+
}
|
|
2901
|
+
// A managed kill+relaunch restarts ComfyUI out-of-band, so drop the memoized
|
|
2902
|
+
// caches. The observer watches the boot endpoint itself with a deadline spanning
|
|
2903
|
+
// the ~40s blocking sync + a full cold-start window, and certifies ONLY on an
|
|
2904
|
+
// OBSERVED down→up — a never-restarted healthy endpoint (a Desktop first-healthy
|
|
2905
|
+
// Manager-reboot / preflight no-op) is honestly couldn't-confirm (coordinator P1).
|
|
2906
|
+
resetClient();
|
|
2907
|
+
resetObjectInfoCache();
|
|
2908
|
+
// The observation window spans the ~40s blocking sync + a full cold-start
|
|
2909
|
+
// window. (Under a test timing override, use the injected budget instead so the
|
|
2910
|
+
// never-certify cases don't wait the real ~140s.)
|
|
2911
|
+
const legacyProofWindow = panelRebootTimingOverride
|
|
2912
|
+
? timing.settleMs + timing.budgetMs
|
|
2913
|
+
: LEGACY_RESTART_MIN_BUDGET_MS;
|
|
2914
|
+
const proofDeadline = Math.min(Date.now() + legacyProofWindow, overallDeadline);
|
|
2915
|
+
const proofPromise = observeRecovery(timing, proofDeadline, { healthBase });
|
|
2916
|
+
const restartBudget = Math.max(1, overallDeadline - Date.now());
|
|
2039
2917
|
let restart;
|
|
2918
|
+
let restartTimer;
|
|
2040
2919
|
try {
|
|
2041
|
-
restart = await
|
|
2920
|
+
restart = await Promise.race([
|
|
2921
|
+
restartComfyUI(),
|
|
2922
|
+
new Promise((resolve) => {
|
|
2923
|
+
restartTimer = setTimeout(() => resolve(undefined), restartBudget);
|
|
2924
|
+
restartTimer.unref?.();
|
|
2925
|
+
}),
|
|
2926
|
+
]);
|
|
2042
2927
|
}
|
|
2043
2928
|
catch (err) {
|
|
2929
|
+
clearTimeout(restartTimer);
|
|
2930
|
+
void proofPromise.catch(() => { }); // self-terminates at proofDeadline
|
|
2044
2931
|
return fail("The built-in Manager exposed no reboot endpoint (legacy Manager 3.x), " +
|
|
2045
2932
|
"and the headless managed restart also failed: " +
|
|
2046
2933
|
(err instanceof Error ? err.message : String(err)) +
|
|
2047
2934
|
" — restart ComfyUI on the host, then reconnect.");
|
|
2048
2935
|
}
|
|
2049
|
-
|
|
2936
|
+
clearTimeout(restartTimer);
|
|
2937
|
+
// DEFINITIVE no-restart: a spawn failure, OR restartComfyUI refused before
|
|
2938
|
+
// stopping anything (no process found / unsafe relaunch → stopped:false &&
|
|
2939
|
+
// started:false). The process was NOT cycled, so the still-healthy endpoint is
|
|
2940
|
+
// the OLD one — fail clearly rather than certify a no-op (coordinator P1).
|
|
2941
|
+
if (restart?.spawn_error ||
|
|
2942
|
+
(restart != null && restart.stopped !== true && restart.started !== true)) {
|
|
2943
|
+
void proofPromise.catch(() => { });
|
|
2050
2944
|
return fail("The built-in Manager exposed no reboot endpoint (legacy Manager 3.x). " +
|
|
2051
|
-
"Tried the headless managed restart (kill + relaunch)
|
|
2052
|
-
`
|
|
2945
|
+
"Tried the headless managed restart (kill + relaunch), but it did not restart " +
|
|
2946
|
+
`ComfyUI: ${restart?.message ?? "unknown error"} ` +
|
|
2053
2947
|
"Restart ComfyUI on the host, then reconnect.");
|
|
2054
2948
|
}
|
|
2055
|
-
//
|
|
2056
|
-
//
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
resetObjectInfoCache();
|
|
2060
|
-
const timing = getPanelRebootTiming();
|
|
2061
|
-
const recovery = await waitForPanelReady(ctx, timing);
|
|
2949
|
+
// Otherwise (the process WAS stopped/started, or restartComfyUI's own readiness
|
|
2950
|
+
// poll merely expired — neither terminal) DEFER to OUR OWN observed DOWN→UP.
|
|
2951
|
+
const recovery = await proofPromise;
|
|
2952
|
+
const observed = recovery.via === "observed-cycle";
|
|
2062
2953
|
return ok({
|
|
2063
2954
|
rebooting: true,
|
|
2064
2955
|
ready: recovery.ready,
|
|
2956
|
+
confirmed_cycle: observed, // true = we directly observed the down→up cycle
|
|
2065
2957
|
recovered_ms: recovery.waited_ms,
|
|
2066
2958
|
probes: recovery.attempts,
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2959
|
+
saw_down: recovery.sawDown,
|
|
2960
|
+
via: recovery.ready ? recovery.via : undefined,
|
|
2961
|
+
note: "ComfyUI-Manager (legacy 3.x) had no reboot endpoint; ran the headless managed " +
|
|
2962
|
+
"restart (kill + relaunch) " +
|
|
2963
|
+
(recovery.ready
|
|
2964
|
+
? `and it came back healthy in ${(recovery.waited_ms / 1000).toFixed(1)}s` +
|
|
2965
|
+
(observed ? " (observed it go down then come back)." : " (cycle not directly observed).")
|
|
2966
|
+
: `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.`),
|
|
2072
2967
|
});
|
|
2073
2968
|
}
|
|
2074
|
-
// Genuine refusal (
|
|
2075
|
-
//
|
|
2076
|
-
// (codex WS-3 finding #2).
|
|
2969
|
+
// Genuine refusal (busy guard / security / no eligible fallback) — return
|
|
2970
|
+
// verbatim; do NOT reset caches (that would close the shared client mid-render).
|
|
2077
2971
|
return res;
|
|
2078
2972
|
}
|
|
2079
|
-
// A
|
|
2080
|
-
//
|
|
2081
|
-
// memoized /object_info survive the restart and go stale: get_node_info
|
|
2082
|
-
// then returns pre-restart schemas, model dropdowns, and required/optional
|
|
2083
|
-
// placement (#353/#378/#394), and newly installed nodes stay invisible
|
|
2084
|
-
// (#357). The reboot is the triggering event — drop both caches so the
|
|
2085
|
-
// next call refetches against the fresh server.
|
|
2973
|
+
// ACCEPTED. A reboot restarts ComfyUI out-of-band, so the orchestrator's cached WS
|
|
2974
|
+
// client + /object_info go stale (#353/#357/#378/#394) — drop both caches.
|
|
2086
2975
|
resetClient();
|
|
2087
2976
|
resetObjectInfoCache();
|
|
2088
|
-
//
|
|
2089
|
-
//
|
|
2090
|
-
//
|
|
2091
|
-
//
|
|
2092
|
-
//
|
|
2093
|
-
//
|
|
2094
|
-
//
|
|
2095
|
-
|
|
2096
|
-
|
|
2977
|
+
// Observe recovery. There is exactly ONE sound proof that THIS ComfyUI instance
|
|
2978
|
+
// actually cycled: a directly OBSERVED down→up on the server-authorized, immutable,
|
|
2979
|
+
// family-bound boot endpoint (observeRecovery). We do NOT fabricate a second proof
|
|
2980
|
+
// from a weaker proxy. In particular a panel tab disconnecting→reconnecting proves
|
|
2981
|
+
// only that a panel↔orchestrator socket churned — NOT that the (possibly remote)
|
|
2982
|
+
// ComfyUI cycled; `tab_id` is client-supplied and a different same-kind socket can
|
|
2983
|
+
// take that id over with a fresh nonce, so a tab reconnect can never certify a
|
|
2984
|
+
// same-instance restart (codex gate). So when there is NO probeable boot endpoint
|
|
2985
|
+
// (remote / cloud / older / untrusted-locality panel), we HONESTLY report the reboot
|
|
2986
|
+
// as dispatched-and-accepted but NOT server-confirmable — a non-error result that
|
|
2987
|
+
// tells the caller to verify, NOT the #509 false-TIMEOUT *error* (the real #509 local
|
|
2988
|
+
// case is a probeable boot endpoint and is certified by observeRecovery below).
|
|
2989
|
+
if (healthBase == null) {
|
|
2990
|
+
// No probeable boot endpoint — the concurrent observer was never started.
|
|
2991
|
+
return ok({
|
|
2992
|
+
rebooting: true,
|
|
2993
|
+
ready: false,
|
|
2994
|
+
confirmed_cycle: false,
|
|
2995
|
+
dispatched: true,
|
|
2996
|
+
note: "ComfyUI restart was dispatched and accepted; it is restarting out-of-band. " +
|
|
2997
|
+
"There is no local boot endpoint I can safely probe from here, so I can't " +
|
|
2998
|
+
"confirm it finished coming back — a panel reconnect wouldn't prove this " +
|
|
2999
|
+
"instance actually cycled. Check health_check / panel_node_queue_status in a " +
|
|
3000
|
+
"few seconds to confirm it's back.",
|
|
3001
|
+
});
|
|
3002
|
+
}
|
|
3003
|
+
// The concurrent observer has been probing since dispatch (catching a fast down→up
|
|
3004
|
+
// inside the ack window). Now measure the readiness budget from ACK COMPLETION — so a
|
|
3005
|
+
// slow ack doesn't eat it — by tightening the live deadline, then await the verdict.
|
|
3006
|
+
// Both fired and dropped are AMBIGUOUS (the panel emits rebooting:true even when it
|
|
3007
|
+
// only INFERS a reboot from a dropped fetch), so certification requires an OBSERVED
|
|
3008
|
+
// down→up, which the observer has been (and continues) watching for.
|
|
3009
|
+
gate.deadline = Math.min(Date.now() + timing.budgetMs, overallDeadline);
|
|
3010
|
+
const recovery = await recoveryPromise;
|
|
2097
3011
|
if (!recovery.ready) {
|
|
2098
|
-
|
|
2099
|
-
|
|
3012
|
+
const waited = Math.round(recovery.waited_ms / 1000);
|
|
3013
|
+
return ok({
|
|
3014
|
+
rebooting: true,
|
|
3015
|
+
ready: false,
|
|
3016
|
+
confirmed_cycle: false,
|
|
3017
|
+
recovered_ms: recovery.waited_ms,
|
|
3018
|
+
probes: recovery.attempts,
|
|
3019
|
+
saw_down: recovery.sawDown,
|
|
3020
|
+
note: recovery.sawDown
|
|
3021
|
+
? `Reboot was dispatched and ComfyUI went down, but it has not become healthy within ${waited}s — it may still be starting or the restart failed. Verify with health_check / panel_node_queue_status before retrying; do NOT assume it is back.`
|
|
3022
|
+
: `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.`,
|
|
3023
|
+
});
|
|
2100
3024
|
}
|
|
2101
3025
|
return ok({
|
|
2102
3026
|
rebooting: true,
|
|
2103
3027
|
ready: true,
|
|
3028
|
+
confirmed_cycle: true, // we directly observed the down→up cycle on the boot endpoint
|
|
2104
3029
|
recovered_ms: recovery.waited_ms,
|
|
2105
3030
|
probes: recovery.attempts,
|
|
2106
|
-
|
|
3031
|
+
saw_down: recovery.sawDown,
|
|
3032
|
+
via: recovery.via,
|
|
3033
|
+
note: `ComfyUI restart accepted and it is healthy again in ${(recovery.waited_ms / 1000).toFixed(1)}s` +
|
|
3034
|
+
" (observed it go down then come back)" +
|
|
3035
|
+
(dropped ? "; connection dropped as expected while it went down" : "") +
|
|
3036
|
+
".",
|
|
2107
3037
|
});
|
|
2108
3038
|
}),
|
|
2109
3039
|
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)),
|