Files
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

688 lines
25 KiB
JavaScript

import { TIMEOUTS, STATUS_COLORS } from './constants.js';
import { buildWorkerUrl, normalizeWorkerUrl } from './urlUtils.js';
import { isRemoteWorker } from './workerSettings.js';
import { applyProbeResultToWorkerDot } from './workerUtils.js';
export { normalizeWorkerUrl };
let _statusCheckRunning = false;
function setStatusDotClass(dot, statusClass) {
if (!dot) {
return;
}
const classes = [
"worker-status--online",
"worker-status--offline",
"worker-status--unknown",
"worker-status--processing",
];
dot.classList.remove(...classes);
if (statusClass) {
dot.classList.add(statusClass);
}
}
function setButtonClass(button, className) {
if (!button) {
return;
}
button.classList.remove("btn--stop", "btn--launch", "btn--log", "btn--working", "btn--success", "btn--error");
if (className) {
button.classList.add(className);
}
}
function setButtonVisibility(button, visible) {
if (!button) {
return;
}
button.classList.toggle("is-hidden", !visible);
button.style.display = visible ? "" : "none";
}
export function formatServerlessStatus(status) {
if (!status) return "Checking status…";
if (!status.ok) return "Unavailable";
const jobs = (Array.isArray(status.jobs) ? status.jobs : [])
.map((job) => String(job?.status || "").trim().toUpperCase());
// Prefer current work over old ledger entries, and avoid raw provider enums.
const phases = [
["Downloading result", ["DOWNLOADING"]],
["Cancelling", ["CANCEL_REQUESTED"]],
["Running", ["RUNNING", "PROCESSING", "IN_PROGRESS"]],
["Starting", ["STARTING"]],
["Submitting", ["SUBMITTING"]],
["Queued", ["WAITING", "IN_QUEUE", "QUEUED", "PENDING"]],
];
for (const [label, states] of phases) {
const count = jobs.filter((state) => states.includes(state)).length;
if (count) return count > 1 ? `${label} · ${count} jobs` : label;
}
// Health counts queued AND running jobs, not just the waiting queue.
const remaining = Number(status.queueRemaining);
if (Number.isFinite(remaining) && remaining > 0) {
return `Active · ${remaining} ${remaining === 1 ? "job" : "jobs"}`;
}
if (jobs[0] === "FAILED") return "Last job failed";
if (jobs[0] === "TIMED_OUT") return "Last job timed out";
return "Ready";
}
function updateServerlessStatusMessage(workerId, status) {
const messageElement = document.getElementById(`serverless-status-${workerId}`);
if (!messageElement) return;
messageElement.textContent = formatServerlessStatus(status);
messageElement.title = status?.message || "";
}
export async function checkAllWorkerStatuses(extension) {
if (_statusCheckRunning || !extension.panelElement) {
return;
}
_statusCheckRunning = true;
let nextInterval = 5000;
try {
// Create a fresh AbortController for this poll cycle.
extension.statusCheckAbortController = new AbortController();
await checkMasterStatus(extension);
if (extension.config?.workers) {
await Promise.all(
extension.config.workers.map(async (worker) => {
if (worker.enabled || extension.state.isWorkerLaunching(worker.id)) {
await checkWorkerStatus(extension, worker);
}
})
);
}
let isActive = extension.state.getMasterStatus() === "processing";
extension.config?.workers?.forEach((worker) => {
const workerState = extension.state.getWorker(worker.id);
if (workerState.launching || workerState.status?.processing) {
isActive = true;
}
});
nextInterval = isActive ? 1000 : 5000;
} finally {
_statusCheckRunning = false;
if (extension.panelElement) {
extension.statusCheckTimeout = setTimeout(() => checkAllWorkerStatuses(extension), nextInterval);
}
}
}
export async function checkMasterStatus(extension) {
try {
const signal = extension.statusCheckAbortController?.signal || null;
const probeResult = await extension.api.probeWorker(
window.location.origin,
TIMEOUTS.STATUS_CHECK,
signal,
);
if (!probeResult.ok) {
throw new Error(`HTTP ${probeResult.status}`);
}
const queueRemaining = probeResult.queueRemaining || 0;
const isProcessing = queueRemaining > 0;
// Update master status in state
extension.state.setMasterStatus(isProcessing ? "processing" : "online");
// Update master status dot
const statusDot = document.getElementById("master-status");
if (statusDot) {
if (!extension.isMasterParticipating()) {
if (isProcessing) {
setStatusDotClass(statusDot, "worker-status--processing");
statusDot.title = `Orchestrating (${queueRemaining} in queue)`;
} else {
setStatusDotClass(statusDot, "worker-status--unknown");
statusDot.title = "Master orchestrator only";
}
} else if (isProcessing) {
setStatusDotClass(statusDot, "worker-status--processing");
statusDot.title = `Processing (${queueRemaining} in queue)`;
} else {
setStatusDotClass(statusDot, "worker-status--online");
statusDot.title = "Online";
}
}
} catch (error) {
if (error?.name === "AbortError") {
return;
}
// Master is always online (we're running on it), so keep it green
const statusDot = document.getElementById("master-status");
if (statusDot) {
setStatusDotClass(
statusDot,
extension.isMasterParticipating() ? "worker-status--online" : "worker-status--unknown"
);
statusDot.title = extension.isMasterParticipating() ? "Online" : "Master orchestrator only";
}
}
}
// Helper to build worker URL
export function getWorkerUrl(extension, worker, endpoint = "") {
if (worker?.type === "serverless") {
return null;
}
return buildWorkerUrl(worker, endpoint, window.location);
}
export async function checkWorkerStatus(extension, worker) {
// Assume caller ensured enabled; proceed with check
if (worker.type === "serverless") {
try {
const result = await extension.api.getServerlessStatus(worker.id);
const queueRemaining = Number(result?.queueRemaining);
const normalizedQueue = Number.isFinite(queueRemaining) ? Math.max(0, queueRemaining) : 0;
const normalizedStatus = {
ok: Boolean(result?.ok),
queueRemaining: normalizedQueue,
message: typeof result?.message === "string" ? result.message : "",
jobs: (Array.isArray(result?.jobs) ? result.jobs : []).map((job) => ({
job_id: String(job?.job_id || ""),
status: String(job?.status || ""),
worker_id: String(job?.worker_id || ""),
execution_id: String(job?.execution_id || ""),
})),
};
const hasActiveJobs = normalizedStatus.jobs.some((job) =>
/^(waiting|submitting|in_queue|queued|pending|running|processing|starting|in_progress|downloading|cancel_requested)$/i.test(job.status.trim())
);
const status = {
online: normalizedStatus.ok,
processing: normalizedQueue > 0 || hasActiveJobs,
queueCount: normalizedQueue,
};
extension.state.updateWorker(worker.id, { serverlessStatus: normalizedStatus });
extension.state.setWorkerStatus(worker.id, status);
updateServerlessStatusMessage(worker.id, normalizedStatus);
const updatedInPlace = extension.updateWorkerCard?.(worker.id, status);
if (!updatedInPlace) {
updateWorkerControls(extension, worker.id);
}
} catch (error) {
if (error?.name === "AbortError") return;
const failedStatus = { ok: false, queueRemaining: 0, message: error?.message || "Runpod status unavailable", jobs: [] };
extension.state.updateWorker(worker.id, { serverlessStatus: failedStatus });
extension.state.setWorkerStatus(worker.id, { online: false, processing: false, queueCount: 0 });
updateServerlessStatusMessage(worker.id, failedStatus);
const updatedInPlace = extension.updateWorkerCard?.(worker.id, extension.state.getWorkerStatus(worker.id));
if (!updatedInPlace) {
updateWorkerControls(extension, worker.id);
}
extension.log(`Serverless worker ${worker.id} status check failed: ${error.message}`, "debug");
}
return;
}
const workerUrl = getWorkerUrl(extension, worker);
try {
const signal = extension.statusCheckAbortController?.signal || null;
const probeResult = await extension.api.probeWorker(
workerUrl,
TIMEOUTS.STATUS_CHECK,
signal,
);
if (!probeResult.ok) {
throw new Error(`HTTP ${probeResult.status}`);
}
const queueRemaining = probeResult.queueRemaining || 0;
const isProcessing = queueRemaining > 0;
// Update status
extension.state.setWorkerStatus(worker.id, {
online: true,
processing: isProcessing,
queueCount: queueRemaining,
});
// Update status dot based on probe result
applyProbeResultToWorkerDot(worker.id, probeResult);
// Clear launching state since worker is now online
if (extension.state.isWorkerLaunching(worker.id)) {
extension.state.setWorkerLaunching(worker.id, false);
clearLaunchingFlag(extension, worker.id);
}
} catch (error) {
// Don't process aborted requests
if (error.name === "AbortError") {
return;
}
// Worker is offline or unreachable
extension.state.setWorkerStatus(worker.id, {
online: false,
processing: false,
queueCount: 0,
});
// Check if worker is launching
if (extension.state.isWorkerLaunching(worker.id)) {
extension.ui.updateStatusDot(worker.id, STATUS_COLORS.PROCESSING_YELLOW, "Launching...", true);
} else if (worker.enabled) {
// Only update to red if not currently launching AND still enabled.
applyProbeResultToWorkerDot(worker.id, { ok: false });
}
// If disabled, don't update the dot (leave it gray)
extension.log(`Worker ${worker.id} status check failed: ${error.message}`, "debug");
}
// Update control buttons based on new status
const updatedInPlace = extension.updateWorkerCard?.(worker.id, extension.state.getWorkerStatus(worker.id));
if (!updatedInPlace) {
updateWorkerControls(extension, worker.id);
}
}
export async function launchWorker(extension, workerId) {
const worker = extension.config.workers.find((w) => w.id === workerId);
if (!worker || worker.type === "serverless") {
return;
}
// If worker is disabled, enable it first
if (!worker.enabled) {
await extension.updateWorkerEnabled(workerId, true);
// Update the checkbox UI
const checkbox = document.getElementById(`gpu-${workerId}`);
if (checkbox) {
checkbox.checked = true;
}
}
// Re-query button AFTER updateWorkerEnabled (which may re-render sidebar)
const launchBtn = document.querySelector(`#controls-${workerId} button`);
extension.ui.updateStatusDot(workerId, STATUS_COLORS.PROCESSING_YELLOW, "Launching...", true);
extension.state.setWorkerLaunching(workerId, true);
// Allow 90 seconds for worker to launch (model loading can take time)
setTimeout(() => {
extension.state.setWorkerLaunching(workerId, false);
}, TIMEOUTS.LAUNCH);
if (!launchBtn) {
return;
}
try {
// Disable button immediately
launchBtn.disabled = true;
const result = await extension.api.launchWorker(workerId);
if (result) {
extension.log(`Launched ${worker.name} (PID: ${result.pid})`, "info");
if (result.log_file) {
extension.log(`Log file: ${result.log_file}`, "debug");
}
extension.state.setWorkerManaged(workerId, {
pid: result.pid,
log_file: result.log_file,
started_at: Date.now(),
});
// Update controls immediately to hide launch button and show stop/log buttons
updateWorkerControls(extension, workerId);
setTimeout(() => checkWorkerStatus(extension, worker), TIMEOUTS.STATUS_CHECK);
}
} catch (error) {
// Check if worker was already running
if (error.message && error.message.includes("already running")) {
extension.log(`Worker ${worker.name} is already running`, "info");
updateWorkerControls(extension, workerId);
setTimeout(() => checkWorkerStatus(extension, worker), TIMEOUTS.STATUS_CHECK_DELAY);
} else {
extension.log(`Error launching worker: ${error.message || error}`, "error");
// Re-enable button on error
if (launchBtn) {
launchBtn.disabled = false;
}
}
}
}
export async function stopWorker(extension, workerId) {
const worker = extension.config.workers.find((w) => w.id === workerId);
if (!worker || worker.type === "serverless") {
return;
}
const stopBtn = document.querySelectorAll(`#controls-${workerId} button`)[1];
// Provide immediate feedback
if (stopBtn) {
stopBtn.disabled = true;
stopBtn.textContent = "Stopping...";
setButtonClass(stopBtn, "btn--working");
}
try {
const result = await extension.api.stopWorker(workerId);
if (result) {
extension.log(`Stopped worker: ${result.message}`, "info");
extension.state.setWorkerManaged(workerId, null);
// Immediately update status to offline
extension.ui.updateStatusDot(workerId, STATUS_COLORS.OFFLINE_RED, "Offline");
extension.state.setWorkerStatus(workerId, { online: false });
// Flash success feedback
if (stopBtn) {
setButtonClass(stopBtn, "btn--success");
stopBtn.textContent = "Stopped!";
setTimeout(() => {
updateWorkerControls(extension, workerId);
}, TIMEOUTS.FLASH_SHORT);
}
// Verify status after a short delay
setTimeout(() => checkWorkerStatus(extension, worker), TIMEOUTS.STATUS_CHECK);
} else {
extension.log(`Failed to stop worker: ${result.message}`, "error");
// Flash error feedback
if (stopBtn) {
setButtonClass(stopBtn, "btn--error");
stopBtn.textContent = result.message.includes("already stopped") ? "Not Running" : "Failed";
// If already stopped, update status immediately
if (result.message.includes("already stopped")) {
extension.ui.updateStatusDot(workerId, STATUS_COLORS.OFFLINE_RED, "Offline");
extension.state.setWorkerStatus(workerId, { online: false });
}
setTimeout(() => {
updateWorkerControls(extension, workerId);
}, TIMEOUTS.FLASH_MEDIUM);
}
}
} catch (error) {
extension.log(`Error stopping worker: ${error}`, "error");
// Reset button on error
if (stopBtn) {
setButtonClass(stopBtn, "btn--error");
stopBtn.textContent = "Error";
setTimeout(() => {
updateWorkerControls(extension, workerId);
}, TIMEOUTS.FLASH_MEDIUM);
}
}
}
export async function clearLaunchingFlag(extension, workerId) {
try {
await extension.api.clearLaunchingFlag(workerId);
extension.log(`Cleared launching flag for worker ${workerId}`, "debug");
} catch (error) {
extension.log(`Error clearing launching flag: ${error.message || error}`, "error");
}
}
export async function loadManagedWorkers(extension) {
try {
const result = await extension.api.getManagedWorkers();
const serverlessWorkerIds = new Set(
(extension.config?.workers || []).filter((worker) => worker.type === "serverless").map((worker) => String(worker.id))
);
// Managed-worker lifecycle state is ComfyUI-process-only; serverless is provider-managed.
for (const [workerId, info] of Object.entries(result.managed_workers)) {
if (serverlessWorkerIds.has(String(workerId))) continue;
extension.state.setWorkerManaged(workerId, info);
// If worker is marked as launching, add to launchingWorkers set
if (info.launching) {
extension.state.setWorkerLaunching(workerId, true);
extension.log(`Worker ${workerId} is in launching state`, "debug");
}
}
// Update UI for all workers
if (extension.config?.workers) {
extension.config.workers.forEach((w) => updateWorkerControls(extension, w.id));
}
} catch (error) {
extension.log(`Error loading managed workers: ${error}`, "error");
}
}
export function updateWorkerControls(extension, workerId) {
const controlsDiv = document.getElementById(`controls-${workerId}`);
if (!controlsDiv) {
return;
}
const worker = extension.config.workers.find((w) => w.id === workerId);
if (!worker) {
return;
}
// Update button states - buttons are now inside a wrapper div
const launchBtn = document.getElementById(`launch-${workerId}`);
const stopBtn = document.getElementById(`stop-${workerId}`);
const logBtn = document.getElementById(`log-${workerId}`);
if (worker.type === "serverless") {
setButtonVisibility(launchBtn, false);
setButtonVisibility(stopBtn, false);
setButtonVisibility(logBtn, false);
return;
}
if (isRemoteWorker(extension, worker)) {
setButtonVisibility(launchBtn, false);
setButtonVisibility(stopBtn, false);
if (logBtn) {
setButtonVisibility(logBtn, true);
logBtn.disabled = false;
logBtn.textContent = "View Log";
setButtonClass(logBtn, "btn--log");
}
return;
}
// Ensure we check for string ID
const managedInfo = extension.state.getWorker(workerId).managed;
const status = extension.state.getWorkerStatus(workerId);
// Show log button immediately if we have log file info (even if worker is still starting)
if (logBtn) {
const showLog = Boolean(managedInfo?.log_file);
setButtonVisibility(logBtn, showLog);
if (showLog) {
setButtonClass(logBtn, "btn--log");
}
}
if (status?.online || managedInfo) {
// Worker is running or we just launched it
setButtonVisibility(launchBtn, false);
if (managedInfo) {
// Only show stop button if we manage this worker
setButtonVisibility(stopBtn, true);
stopBtn.disabled = false;
stopBtn.textContent = "Stop";
setButtonClass(stopBtn, "btn--stop");
} else {
// Hide stop button for workers launched outside UI
setButtonVisibility(stopBtn, false);
}
} else {
// Worker is not running
setButtonVisibility(launchBtn, true);
launchBtn.disabled = false;
launchBtn.textContent = "Launch";
setButtonClass(launchBtn, "btn--launch");
setButtonVisibility(stopBtn, false);
}
}
export async function viewWorkerLog(extension, workerId, isRemote = false) {
const worker = extension.config.workers.find((w) => w.id === workerId);
if (worker?.type === "serverless") {
return;
}
const isRemoteLog = isRemote || (worker ? isRemoteWorker(extension, worker) : false);
const managedInfo = extension.state.getWorker(workerId).managed;
if (!isRemoteLog && !managedInfo?.log_file) {
return;
}
const logBtn = document.getElementById(`log-${workerId}`);
// Provide immediate feedback
if (logBtn) {
logBtn.disabled = true;
logBtn.textContent = "Loading...";
setButtonClass(logBtn, "btn--working");
}
try {
const fetchLog = isRemoteLog
? async () => extension.api.getRemoteWorkerLog(workerId, 300)
: async () => extension.api.getWorkerLog(workerId, 1000);
const data = await fetchLog();
// Create modal dialog
extension.ui.showLogModal(extension, workerId, data, fetchLog);
// Restore button
if (logBtn) {
logBtn.disabled = false;
logBtn.textContent = "View Log";
setButtonClass(logBtn, "btn--log");
}
} catch (error) {
extension.log("Error viewing log: " + error.message, "error");
extension.app.extensionManager.toast.add({
severity: "error",
summary: "Error",
detail: `Failed to load log: ${error.message}`,
life: 5000,
});
// Flash error and restore button
if (logBtn) {
setButtonClass(logBtn, "btn--error");
logBtn.textContent = "Error";
setTimeout(() => {
logBtn.disabled = false;
logBtn.textContent = "View Log";
setButtonClass(logBtn, "btn--log");
}, TIMEOUTS.FLASH_LONG);
}
}
}
export async function refreshLog(extension, workerId, silent = false) {
const logContent = document.getElementById("distributed-log-content");
if (!logContent) {
return;
}
try {
const worker = extension.config.workers.find((w) => w.id === workerId);
if (worker?.type === "serverless") return;
const isRemoteLog = worker ? isRemoteWorker(extension, worker) : false;
const data = isRemoteLog
? await extension.api.getRemoteWorkerLog(workerId, 300)
: await extension.api.getWorkerLog(workerId, 1000);
// Update content
const shouldAutoScroll = logContent.scrollTop + logContent.clientHeight >= logContent.scrollHeight - 50;
logContent.textContent = data.content;
// Auto-scroll if was at bottom
if (shouldAutoScroll) {
logContent.scrollTop = logContent.scrollHeight;
}
// Only show toast if not in silent mode (manual refresh)
if (!silent) {
extension.app.extensionManager.toast.add({
severity: "success",
summary: "Log Refreshed",
detail: "Log content updated",
life: 2000,
});
}
} catch (error) {
// Only show error toast if not in silent mode
if (!silent) {
extension.app.extensionManager.toast.add({
severity: "error",
summary: "Refresh Failed",
detail: error.message,
life: 3000,
});
}
}
}
export function startLogAutoRefresh(extension, workerId) {
// Stop any existing auto-refresh
stopLogAutoRefresh(extension);
// Refresh every 2 seconds
extension.logAutoRefreshInterval = setInterval(() => {
refreshLog(extension, workerId, true); // silent mode
}, TIMEOUTS.LOG_REFRESH);
}
export function stopLogAutoRefresh(extension) {
if (extension.logAutoRefreshInterval) {
clearInterval(extension.logAutoRefreshInterval);
extension.logAutoRefreshInterval = null;
}
}
export function toggleWorkerExpanded(extension, workerId) {
const gpuDiv = document.querySelector(`[data-worker-id="${workerId}"]`);
const settingsDiv = gpuDiv?.querySelector(`#settings-${workerId}`) || document.getElementById(`settings-${workerId}`);
const settingsArrow = gpuDiv?.querySelector(".settings-arrow");
if (!settingsDiv) {
return;
}
if (extension.state.isWorkerExpanded(workerId)) {
extension.state.setWorkerExpanded(workerId, false);
settingsDiv.classList.remove("expanded");
settingsDiv.style.padding = "0 12px";
settingsDiv.style.marginTop = "0";
settingsDiv.style.marginBottom = "0";
if (settingsArrow) {
settingsArrow.classList.remove("settings-arrow--expanded");
}
} else {
extension.state.setWorkerExpanded(workerId, true);
settingsDiv.classList.add("expanded");
settingsDiv.style.padding = "12px";
settingsDiv.style.marginTop = "8px";
settingsDiv.style.marginBottom = "8px";
if (settingsArrow) {
settingsArrow.classList.add("settings-arrow--expanded");
}
}
}