Files
robertvoy-ComfyUI-Distributed/web/workerSettings.js
T
Robert Wojciechowski 6b94242110 feat: add Runpod serverless image Collector pools (#96)
* feat: add Runpod serverless image collector pools

* fix: prevent worker settings clipping and remove preview copy

* fix: initialize worker settings before mounting the form

* fix: validate complete serverless requests against 10 MB limit

* fix: simplify serverless cards and keep status dots circular

* fix: match serverless endpoint box to master card

* fix: preserve serverless capacity and recovery across job failures

Retain durable reservations until provider termination or explicit recovery, protect unresolved pools and credentials, and add scoped reconciliation. Consolidate lifecycle and secret policies and split prompt/media stages with regression coverage.

* fix(serverless): disable Clear saved API key while the worker is enabled

The checkbox read "Clear saved API key (disable pool first)", but the
panel never defines "pool" and saving with it ticked on an enabled worker
was rejected by the backend.

- Disable the checkbox while the worker is enabled, with a tooltip saying
  to turn the worker off first. Also disable it when no key is saved.
- Use "worker" instead of "pool" in user-facing serverless errors, the
  serverless guide and the README entry.

* feat(serverless): replace Concurrent jobs with a single Images per run setting

- Remove the separate Concurrent jobs field; rename Jobs per run to
  Images per run (1-16) with a tooltip explaining that Runpod's max
  workers decides how many GPUs run them.
- Backend uses a fixed per-worker limit of 16 outstanding jobs, so all
  images of a run are submitted at once. Unresolved jobs still count
  against it. The limit is constant, so it never has to change while
  jobs are unresolved.
- Legacy 'concurrency' in saved configs and older browser tabs is
  accepted and ignored/dropped; it no longer triggers the busy guard.
- Revert the enable toggle and show the reason when the backend rejects it.
- Trim a pasted api.runpod.ai/v2/<id> URL to the endpoint ID; plainer
  endpoint-ID error otherwise.
- Confirm before a type change deletes a saved Runpod API key.
- Update docs and tests.
2026-10-13 15:19:00 -07:00

534 lines
20 KiB
JavaScript

import { renderSidebarContent } from './sidebarRenderer.js';
import { generateUUID } from './constants.js';
import { parseHostInput } from './urlUtils.js';
import { toggleWorkerExpanded } from './workerLifecycle.js';
const WORKERS_CHANGED_EVENT = "distributed:workers-changed";
function emitWorkersChanged(extension) {
if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") {
return;
}
window.dispatchEvent(new CustomEvent(WORKERS_CHANGED_EVENT, {
detail: { workers: extension.config?.workers || [] },
}));
}
export function isRemoteWorker(extension, worker) {
const workerType = String(worker?.type || "").toLowerCase();
// Explicit type always wins over host heuristics.
if (workerType === "cloud" || workerType === "remote" || workerType === "serverless") {
return true;
}
if (workerType === "local") {
return false;
}
// Otherwise check by host (backward compatibility)
const parsed = parseHostInput(worker?.host || window.location.hostname);
const host = String(parsed.host || window.location.hostname || "").toLowerCase();
const currentHost = String(parseHostInput(window.location.hostname).host || window.location.hostname || "").toLowerCase();
const localHosts = new Set(["", "localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]);
return !(localHosts.has(host) || host === currentHost);
}
export function isCloudWorker(extension, worker) {
return worker.type === "cloud";
}
// Mirrors the backend rule in utils/serverless_config.py.
const RUNPOD_ENDPOINT_ID_RE = /^[A-Za-z0-9_-]{1,200}$/;
/**
* Accept a pasted Runpod API URL (https://api.runpod.ai/v2/<id>/...) and keep
* only the endpoint ID; anything else is returned trimmed for validation.
*/
export function normalizeRunpodEndpointId(value) {
const text = String(value || "").trim();
const match = text.match(/^(?:https?:\/\/)?api\.runpod\.ai\/v2\/([A-Za-z0-9_-]{1,200})(?:[/?#].*)?$/i);
return match ? match[1] : text;
}
function showServerlessValidationError(extension, detail) {
extension.app.extensionManager.toast.add({
severity: "error",
summary: "Validation Error",
detail,
life: 3000,
});
}
async function saveServerlessWorkerSettings(extension, worker, workerId, name) {
const endpointId = normalizeRunpodEndpointId(document.getElementById(`endpoint-id-${workerId}`).value);
const apiKey = document.getElementById(`api-key-${workerId}`).value || "";
const clearApiKey = Boolean(document.getElementById(`clear-api-key-${workerId}`).checked);
const jobsPerRun = Number(document.getElementById(`jobs-per-run-${workerId}`).value);
const timeoutSeconds = Number(document.getElementById(`timeout-seconds-${workerId}`).value);
if (!name.trim()) {
showServerlessValidationError(extension, "Worker name is required");
return;
}
if (!endpointId) {
showServerlessValidationError(extension, "Endpoint ID is required for Runpod serverless workers");
return;
}
if (!RUNPOD_ENDPOINT_ID_RE.test(endpointId)) {
showServerlessValidationError(
extension,
"Enter just the Runpod endpoint ID (for example abc123xyz), not a URL. It is shown on the endpoint's page in the Runpod console.",
);
return;
}
if (!Number.isInteger(jobsPerRun) || jobsPerRun < 1 || jobsPerRun > 16) {
showServerlessValidationError(extension, "Images per run must be a whole number from 1 to 16");
return;
}
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 30 || timeoutSeconds > 86400) {
showServerlessValidationError(extension, "Timeout must be an integer between 30 and 86400 seconds");
return;
}
const updateData = {
name: name.trim(),
type: "serverless",
provider: "runpod",
endpoint_id: endpointId,
...(apiKey.length > 0 && !clearApiKey ? { api_key: apiKey } : {}),
...(clearApiKey ? { clear_api_key: true } : {}),
jobs_per_run: jobsPerRun,
timeout_seconds: timeoutSeconds,
// Never enable a serverless worker as a side effect of saving credentials.
enabled: worker.enabled,
};
try {
await extension.api.updateWorker(workerId, updateData);
document.getElementById(`api-key-${workerId}`).value = "";
worker.name = name.trim();
worker.type = "serverless";
worker.provider = "runpod";
worker.endpoint_id = endpointId;
worker.jobs_per_run = jobsPerRun;
// Replaced by a fixed backend limit; drop any value loaded from an older config.
delete worker.concurrency;
worker.timeout_seconds = timeoutSeconds;
worker.api_key_configured = clearApiKey
? false
: apiKey.length > 0 || Boolean(worker.api_key_configured);
// The credential is write-only: never copy it into extension config/state.
delete worker.api_key;
delete worker.host;
delete worker.port;
delete worker.cuda_device;
delete worker.extra_args;
extension.state.updateWorker(workerId, { enabled: worker.enabled });
emitWorkersChanged(extension);
extension.app.extensionManager.toast.add({
severity: "success",
summary: "Settings Saved",
detail: `Worker ${worker.name} settings updated`,
life: 3000,
});
extension.state.setWorkerExpanded(workerId, false);
if (extension.panelElement) {
renderSidebarContent(extension, extension.panelElement);
}
} catch (error) {
extension.app.extensionManager.toast.add({
severity: "error",
summary: "Save Failed",
detail: error.message,
life: 5000,
});
}
}
export async function testServerlessWorker(extension, workerId) {
const button = document.getElementById(`test-serverless-${workerId}`);
const originalText = button?.textContent;
if (button) {
button.disabled = true;
button.textContent = "Testing...";
}
try {
// This endpoint only checks the saved endpoint configuration; it does not enqueue inference.
const result = await extension.api.testServerlessWorker(workerId);
extension.app.extensionManager.toast.add({
severity: result?.ok ? "success" : "error",
summary: result?.ok ? "Runpod Endpoint Ready" : "Runpod Endpoint Test Failed",
detail: result?.message || (result?.ok ? "Saved endpoint is reachable" : "Endpoint test failed"),
life: 4000,
});
return result;
} catch (error) {
extension.app.extensionManager.toast.add({
severity: "error",
summary: "Runpod Endpoint Test Failed",
detail: error.message,
life: 5000,
});
return null;
} finally {
if (button) {
button.disabled = false;
button.textContent = originalText;
}
}
}
export async function saveWorkerSettings(extension, workerId) {
const worker = extension.config.workers.find((w) => w.id === workerId);
if (!worker) {
return;
}
// Get form values
const workerType = document.getElementById(`worker-type-${workerId}`).value;
if (workerType === "serverless") {
await saveServerlessWorkerSettings(extension, worker, workerId, document.getElementById(`name-${workerId}`).value);
return;
}
const name = document.getElementById(`name-${workerId}`).value;
if (worker.type === "serverless" && worker.api_key_configured) {
// The backend deletes the stored key when a worker stops being serverless.
const proceed = confirm(
`Change "${worker.name}" from Serverless (Runpod) to another type?\n\n` +
"Its saved Runpod API key will be deleted. You would need to enter it again to switch back."
);
if (!proceed) return;
}
const isRemote = workerType === "remote" || workerType === "cloud";
const isCloud = workerType === "cloud";
const rawHost = isRemote ? document.getElementById(`host-${workerId}`).value : window.location.hostname;
const parsedHost = isRemote ? parseHostInput(rawHost) : { host: window.location.hostname, port: null };
const host = isRemote ? parsedHost.host : window.location.hostname;
const hostTrimmed = (host || "").trim();
let port = parseInt(document.getElementById(`port-${workerId}`).value);
const cudaDevice = isRemote ? undefined : parseInt(document.getElementById(`cuda-${workerId}`).value);
const extraArgs = isRemote ? undefined : document.getElementById(`args-${workerId}`).value;
if (isRemote && Number.isFinite(parsedHost.port)) {
port = parsedHost.port;
}
// Validate
if (!name.trim()) {
extension.app.extensionManager.toast.add({
severity: "error",
summary: "Validation Error",
detail: "Worker name is required",
life: 3000,
});
return;
}
if ((workerType === "remote" || workerType === "cloud") && !hostTrimmed) {
extension.app.extensionManager.toast.add({
severity: "error",
summary: "Validation Error",
detail: "Host is required for remote workers",
life: 3000,
});
return;
}
if (!isCloud && (isNaN(port) || port < 1 || port > 65535)) {
extension.app.extensionManager.toast.add({
severity: "error",
summary: "Validation Error",
detail: "Port must be between 1 and 65535",
life: 3000,
});
return;
}
// Check for port conflicts
// Remote workers can reuse ports, but local workers cannot share ports with each other or master
if (!isRemote) {
// Check if port conflicts with master
const masterPort = parseInt(window.location.port) || (window.location.protocol === "https:" ? 443 : 80);
if (port === masterPort) {
extension.app.extensionManager.toast.add({
severity: "error",
summary: "Port Conflict",
detail: `Port ${port} is already in use by the master server`,
life: 3000,
});
return;
}
// Check if port conflicts with other local workers
const localPortConflict = extension.config.workers.some(
(w) => w.id !== workerId && w.port === port && !w.host // local workers have no host or host is null
);
if (localPortConflict) {
extension.app.extensionManager.toast.add({
severity: "error",
summary: "Port Conflict",
detail: `Port ${port} is already in use by another local worker`,
life: 3000,
});
return;
}
} else {
// For remote workers, only check conflicts with other workers on the same host
const sameHostConflict = extension.config.workers.some((w) => w.id !== workerId && w.port === port && w.host === hostTrimmed);
if (sameHostConflict) {
extension.app.extensionManager.toast.add({
severity: "error",
summary: "Port Conflict",
detail: `Port ${port} is already in use by another worker on ${host}`,
life: 3000,
});
return;
}
}
const wasUnconfiguredRemote =
(worker.type === "remote" || worker.type === "cloud") &&
(!String(worker.host || "").trim()) &&
!worker.enabled;
const nextEnabled = isRemote && hostTrimmed && wasUnconfiguredRemote ? true : worker.enabled;
try {
await extension.api.updateWorker(workerId, {
name: name.trim(),
type: workerType,
host: isRemote ? hostTrimmed : null,
port,
cuda_device: isRemote ? null : cudaDevice,
extra_args: isRemote ? null : extraArgs ? extraArgs.trim() : "",
enabled: nextEnabled,
});
// Update local config
worker.name = name.trim();
worker.type = workerType;
if (isRemote) {
worker.host = hostTrimmed;
delete worker.cuda_device;
delete worker.extra_args;
} else {
delete worker.host;
worker.cuda_device = cudaDevice;
worker.extra_args = extraArgs ? extraArgs.trim() : "";
}
worker.port = port;
worker.enabled = nextEnabled;
// Sync to state
extension.state.updateWorker(workerId, { enabled: nextEnabled });
emitWorkersChanged(extension);
extension.app.extensionManager.toast.add({
severity: "success",
summary: "Settings Saved",
detail: nextEnabled && wasUnconfiguredRemote
? `Worker ${name} configured and enabled`
: `Worker ${name} settings updated`,
life: 3000,
});
// Keep post-save card height consistent with the default collapsed layout.
extension.state.setWorkerExpanded(workerId, false);
// Refresh the UI
if (extension.panelElement) {
renderSidebarContent(extension, extension.panelElement);
}
} catch (error) {
extension.app.extensionManager.toast.add({
severity: "error",
summary: "Save Failed",
detail: error.message,
life: 5000,
});
}
}
export function cancelWorkerSettings(extension, workerId) {
// Collapse the settings panel
toggleWorkerExpanded(extension, workerId);
// Reset form values to original
const worker = extension.config.workers.find((w) => w.id === workerId);
if (worker) {
document.getElementById(`name-${workerId}`).value = worker.name;
document.getElementById(`host-${workerId}`).value = worker.host || "";
document.getElementById(`port-${workerId}`).value = worker.port;
document.getElementById(`cuda-${workerId}`).value = worker.cuda_device || 0;
document.getElementById(`args-${workerId}`).value = worker.extra_args || "";
const endpointInput = document.getElementById(`endpoint-id-${workerId}`);
if (endpointInput) endpointInput.value = worker.endpoint_id || "";
const apiKeyInput = document.getElementById(`api-key-${workerId}`);
if (apiKeyInput) {
apiKeyInput.value = "";
apiKeyInput.disabled = false;
}
const clearApiKeyInput = document.getElementById(`clear-api-key-${workerId}`);
if (clearApiKeyInput) clearApiKeyInput.checked = false;
const jobsPerRunInput = document.getElementById(`jobs-per-run-${workerId}`);
if (jobsPerRunInput) jobsPerRunInput.value = worker.jobs_per_run ?? 1;
const timeoutInput = document.getElementById(`timeout-seconds-${workerId}`);
if (timeoutInput) timeoutInput.value = worker.timeout_seconds ?? 1800;
// Reset remote checkbox
const remoteCheckbox = document.getElementById(`remote-${workerId}`);
if (remoteCheckbox) {
remoteCheckbox.checked = isRemoteWorker(extension, worker);
}
}
}
export async function deleteWorker(extension, workerId) {
const worker = extension.config.workers.find((w) => w.id === workerId);
if (!worker) {
return;
}
// Confirm deletion
if (!confirm(`Are you sure you want to delete worker "${worker.name}"?`)) {
return;
}
try {
await extension.api.deleteWorker(workerId);
// Remove from local config
const index = extension.config.workers.findIndex((w) => w.id === workerId);
if (index !== -1) {
extension.config.workers.splice(index, 1);
}
emitWorkersChanged(extension);
extension.app.extensionManager.toast.add({
severity: "success",
summary: "Worker Deleted",
detail: `Worker ${worker.name} has been removed`,
life: 3000,
});
// Refresh the UI
if (extension.panelElement) {
renderSidebarContent(extension, extension.panelElement);
}
} catch (error) {
extension.app.extensionManager.toast.add({
severity: "error",
summary: "Delete Failed",
detail: error.message,
life: 5000,
});
}
}
export async function addNewWorker(extension) {
const toInt = (value) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : null;
};
const totalCudaDevices = toInt(extension.cudaDeviceCount);
const masterCudaDevice = toInt(extension.masterCudaDevice ?? extension.config?.master?.cuda_device);
const localWorkers = (extension.config?.workers || []).filter((w) => !isRemoteWorker(extension, w));
let selectedCudaDevice = extension.config.workers.length;
let fallbackToRemote = false;
if (totalCudaDevices !== null && totalCudaDevices > 0) {
const usedCudaDevices = new Set();
for (const worker of localWorkers) {
const cudaIdx = toInt(worker.cuda_device);
if (cudaIdx !== null) {
usedCudaDevices.add(cudaIdx);
}
}
if (masterCudaDevice !== null) {
usedCudaDevices.add(masterCudaDevice);
}
const availableCudaDevices = [];
for (let i = 0; i < totalCudaDevices; i++) {
if (!usedCudaDevices.has(i)) {
availableCudaDevices.push(i);
}
}
if (availableCudaDevices.length === 0) {
fallbackToRemote = true;
selectedCudaDevice = null;
} else {
selectedCudaDevice = availableCudaDevices[0];
}
}
// Generate new worker ID using UUID (fallback for non-secure contexts)
const newId = generateUUID();
// Find next available port
const usedPorts = extension.config.workers.map((w) => w.port);
let nextPort = 8189;
while (usedPorts.includes(nextPort)) {
nextPort++;
}
// Create new worker object
const newWorker = {
id: newId,
name: `Worker ${extension.config.workers.length + 1}`,
port: nextPort,
type: fallbackToRemote ? "remote" : "local",
host: fallbackToRemote ? "" : null,
cuda_device: selectedCudaDevice,
enabled: fallbackToRemote ? false : true, // Remote fallback starts disabled until configured
extra_args: "",
};
// Add to config
extension.config.workers.push(newWorker);
// Save immediately
try {
await extension.api.updateWorker(newId, {
name: newWorker.name,
port: newWorker.port,
cuda_device: newWorker.cuda_device,
extra_args: newWorker.extra_args,
enabled: newWorker.enabled,
host: newWorker.host,
type: newWorker.type,
});
// Sync to state
extension.state.updateWorker(newId, { enabled: newWorker.enabled });
emitWorkersChanged(extension);
extension.app.extensionManager.toast.add({
severity: fallbackToRemote ? "warn" : "success",
summary: fallbackToRemote ? "Remote Worker Added" : "Worker Added",
detail: fallbackToRemote
? `No local GPU available, so a disabled remote worker was added on port ${nextPort}. Configure host and enable it.`
: `New worker created on port ${nextPort}`,
life: fallbackToRemote ? 5000 : 3000,
});
// Refresh UI and expand the new worker
extension.state.setWorkerExpanded(newId, true);
if (extension.panelElement) {
renderSidebarContent(extension, extension.panelElement);
}
} catch (error) {
extension.app.extensionManager.toast.add({
severity: "error",
summary: "Failed to Add Worker",
detail: error.message,
life: 5000,
});
}
}