Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/activity-store.mjs
T
Vito 9ab25f9aaa feat(system): Maintenance UI (System S7) (#322)
* fix(gallery): clear only Prompt Manager cache entries (#222)

The legacy gallery's Clear cache deleted every localStorage key starting
pm_ or promptManager_ (the workspace theme and the dismissed update notice
among them) and every CacheStorage cache whose name held gallery or
thumbnail, which on ComfyUI's shared origin can belong to another tool.

web/js/cache-ownership.js names what a clear may remove from an inventory
of the real writers: every key Prompt Manager writes is a preference and no
page opens CacheStorage or IndexedDB, so nothing is removed and the toast
says so. A test fails when a new storage writer is not classified. The
server thumbnail clear stays its own request; a refused or unreachable
server changes nothing and is reported, and refused browser storage is
reported as such.

* feat(system): run reviewed maintenance natively

System › Maintenance is native: the concept's notice and its Database and
Images & cache surfaces replace the hand-off.

- Integrity & relationship checks runs the health check; the report's
  Review missing links reviews a fresh Missing-file links plan.
- Review plan shows the server's read-only plan (D100): scope, protected,
  backup policy and media lines, the listed records, how long the review
  holds and the backup box; Apply reviewed operation sends only its token
  and opens the job in Activity. busy names the running job, plan-used links
  the job that applied it, an expired or replaced review is reviewed again,
  and a lost answer says Outcome not confirmed and points to Activity.
- Find duplicates starts the search; its view offers Review groups (D101):
  ten groups at a time, Keep radios per copy, each keeper sent as
  {group_id, root_index, path}; the dialog says History keeps the removed
  paths in its run records.
- Thumbnail cache opens the thumbnail dialog; Metadata cache is disabled
  with its reason while nothing is owned (D102, Q5).
- Activity descriptors for maintenance, duplicates and duplicate_cleanup.
- The legacy duplicates dialog's View Full names its source (?root=).

* test(system): compare Maintenance with the concept

system-maintenance-concept.spec compares System › Maintenance, the plan
dialog of each operation and the duplicates dialog with the concept in both
themes and both viewports, mapped only by D100–D102, and proves a mutated
row, label, danger mark or token is noticed. The duplicates dialog's Review
duplicate cleanup is the concept's plain button.

* test(system): Maintenance no longer hands off; raise coverage floors

The System shell test expected Maintenance's D94 hand-off; the section is
native now. coverage:update sets the new modules' floors from the
measured run (cache-ownership 94, maintenance-review 94,
system-maintenance 92 lines) and raises the floors other files passed.

* test(admin): close the preview worker after the last job in teardown

A scan publishes its last event, which ends the stream the test reads,
before it hands its missing previews to the automatic worker. That
hand-off can run after tearDownAsync closed the worker, so it starts a new
one whose .part files were still open when the final drain checked for
open handles (py3.10 on ubuntu, test_scan_reads_metadata_on_several_threads).
The drain now closes the worker for good once every job has ended.

* fix(system): address the S7 final review

- I1: an apply answer is a refusal only when our server sent it (a known
  code, or a 4xx whose body says success:false); a proxy's 5xx, an
  unreadable 2xx or a dropped connection says Outcome not confirmed and
  reads Activity again. ApiError gains 'refused'; the server's own
  pre-start failure is coded 'apply-failed'.
- M1: the lost-response test lets the apply run, then drops its answer.
- M2: a failed apply records its code in the job's scope; Activity offers
  Review again for an overtaken plan (Maintenance for a duplicate cleanup
  whose sources changed).
- M3: a failed duplicate cleanup toasts the server's reason.
- M4: a review button holds while its preview is read (one preview per
  double press).
- M5: the storage guard also catches bracket and property assignment and
  CacheStorage/IndexedDB opens; why the Toolkit build is skipped is
  written down.
- M6: a refused storage listing says so on the Metadata cache row.
- M7, M8: D100 text corrected; workflow_storage has its title.
- M9: a busy start names the job the server named.

* test: follow #319 in the Maintenance tests

- The thumbnail dialog has only Scope since #319.
- The admin teardown refuses new preview workers on its own API instead
  of closing with final=True, which since #319 also shuts the process-wide
  encoders that later test modules use.

* fix(system): keep the server's search-again line on a duplicate review

A duplicate cleanup review the server refuses with 409 sources-changed
showed the generic "could not be reviewed" message; it now shows the
server's "The sources changed since the search. Search for duplicates
again."

* fix(system): a plan with nothing to do offers only Close

A reviewed maintenance plan whose scope is 0 records ("No duplicate
prompt text") offered Apply reviewed operation, which started an empty
job. It now shows its rows, "There is nothing to apply; nothing was
changed." and a Close button.

* fix(system): wrap long copy paths in the duplicates review

A Keep label is the copy's source and path, and dataset folders often have
long names with no space to break at, so the review scrolled sideways.
The label now wraps anywhere inside its compare item (D101).
2026-10-25 05:51:57 -07:00

298 lines
13 KiB
JavaScript

/**
* The Activity store (#249 Activity-1): this server session's jobs, for the top-bar button and pill and the Activity
* page. One `GET /prompt_manager/jobs` read, then one SSE stream (`/jobs/<id>/events`) per running job.
*
* - A dropped stream re-reads the list after a backoff that doubles from 1 s and is capped at 30 s, so a stopped
* server never sees a retry storm. A successful stream message resets the backoff.
* - While the tab is visible the list is read again every 15 s, so a job started elsewhere (another tab, the legacy
* admin page) appears without a reload.
* - Jobs live only in server memory. A running job the server no longer lists (ComfyUI restarted), or one whose
* server cannot be read at all, is shown as ended (`lost`), so no pill stays stuck.
* - Toasts announce what happened; Activity is where progress lives. "Scan started." follows this tab's own start;
* the finish toast follows any job this store saw running when it ends.
*/
import { showWorkspaceToast } from './toast.mjs';
import { KINDS, kindOf, finishToast, startToast } from './activity-kinds.mjs';
export { addedOf, taggedOf, finishToast, startToast, pillParts, outcomeLine } from './activity-kinds.mjs';
export const JOBS_URL = '/prompt_manager/jobs';
export const BACKOFF_FIRST_MS = 1000;
export const BACKOFF_MAX_MS = 30000;
export const POLL_MS = 15000;
export const LOST_AFTER_FAILURES = 2;
export const LOST_DETAIL = 'This job ended when ComfyUI restarted.';
const SERVER_STATES = new Set(['running', 'finished', 'cancelled', 'failed']);
const FINAL_STATES = new Set(['finished', 'cancelled', 'failed']);
const JOB_ID = /^[0-9a-f]{32}$/;
/**
* Each job kind's copy and view live in one table (activity-kinds.mjs, D111): the scan's (D56), image tagging's (D89),
* thumbnails' and the generic one. The names below are that table's, kept for this store's importers.
*/
export const JOB_VIEWS = KINDS;
/** The view of a job `kind`; an unknown kind gets the generic one. */
export const jobView = kindOf;
/** The wait before the `attempt`-th re-read (0-based): 1 s, 2 s, 4 s … never more than 30 s. */
export function backoffDelay(attempt) {
const step = Number.isSafeInteger(attempt) && attempt > 0 ? Math.min(attempt, 16) : 0;
return Math.min(BACKOFF_MAX_MS, BACKOFF_FIRST_MS * 2 ** step);
}
const count = value => Number.isSafeInteger(value) && value >= 0 ? value : 0;
const time = value => typeof value === 'number' && Number.isFinite(value) ? value : null;
const plainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
/** A job summary from the API, or null when it is not one (unknown state, bad id). */
export function parseJob(raw) {
if (!plainObject(raw) || typeof raw.id !== 'string' || !JOB_ID.test(raw.id) || !SERVER_STATES.has(raw.state)) return null;
const counters = {};
if (plainObject(raw.counters)) for (const [key, value] of Object.entries(raw.counters)) counters[key] = count(value);
// What the job's detail lists per outcome, and its log length: a view re-reads the detail only when they change.
const listed = {};
if (plainObject(raw.listed)) for (const [key, value] of Object.entries(raw.listed)) listed[key] = count(value);
const progress = typeof raw.progress === 'number' && Number.isFinite(raw.progress) ? Math.round(raw.progress) : 0;
return {
id: raw.id,
kind: typeof raw.kind === 'string' ? raw.kind : '',
title: typeof raw.title === 'string' && raw.title.trim() ? raw.title : 'Background job',
state: raw.state,
started_at: time(raw.started_at),
finished_at: time(raw.finished_at),
progress: Math.max(0, Math.min(100, progress)),
status_line: typeof raw.status_line === 'string' ? raw.status_line : '',
processed: count(raw.processed),
total: count(raw.total),
counters,
scope: plainObject(raw.scope) ? { ...raw.scope } : {},
listed,
log_length: count(raw.log_length),
// A held legacy-page job cannot be cancelled; an older server sends no key (cancellable then).
cancellable: raw.cancellable !== false,
};
}
function defaultConnect(url) {
const resolve = globalThis.ApiClient?.resolveUrl ?? (value => value);
return new EventSource(resolve(url));
}
/**
* @param {{api: {get: Function, post: Function}, scope: {signal: AbortSignal, cleanup: Function},
* connect?: (url: string) => {onmessage: Function|null, onerror: Function|null, close: Function},
* notify?: (message: string) => void, timers?: {setTimeout: Function, clearTimeout: Function},
* pollMs?: number, isVisible?: () => boolean, lifecycle?: EventTarget}} options
*
* `lifecycle` (the window) ends the store's work on `pagehide`: the periodic and backoff reads stop, the streams close
* and a read in flight is aborted, so nothing answers into a page that is going away (a closed tab, a test teardown).
* A `pageshow` from the back-forward cache reads the list again.
*/
export function createActivityStore({ api, scope, connect = defaultConnect, notify = showWorkspaceToast,
timers = globalThis, pollMs = POLL_MS, isVisible = () => globalThis.document?.visibilityState !== 'hidden',
lifecycle = globalThis.window ?? null } = {}) {
let jobs = [];
let status = 'idle';
let attempt = 0;
// Failed list reads in a row: one (a proxy's 502, a dropped poll) is not yet a restart.
let failures = 0;
let retryTimer = null;
let pollTimer = null;
let reads = 0;
let disposed = false;
let suspended = false;
let reading = null;
const streams = new Map();
const listeners = new Set();
const emit = () => { for (const listener of [...listeners]) listener(store); };
const find = id => jobs.find(job => job.id === id);
function announce(previous, next) {
if (previous?.state === 'running' && FINAL_STATES.has(next.state)) notify(finishToast(next));
}
function closeStream(id) {
const source = streams.get(id);
if (!source) return;
streams.delete(id);
source.onmessage = null;
source.onerror = null;
source.close();
}
function follow() {
for (const id of [...streams.keys()]) if (find(id)?.state !== 'running') closeStream(id);
for (const job of jobs) if (job.state === 'running' && !streams.has(job.id)) openStream(job.id);
}
function openStream(id) {
const source = connect(`${JOBS_URL}/${id}/events`);
streams.set(id, source);
source.onmessage = event => {
let job;
try { job = parseJob(JSON.parse(event.data)); } catch { job = null; }
if (!job || job.id !== id) return;
attempt = 0;
upsert(job);
};
source.onerror = () => {
closeStream(id);
if (find(id)?.state === 'running') scheduleResync({ drop: true });
};
}
/** A job never runs again once it has ended, whatever an older read in flight still says. */
const settled = (previous, next) => FINAL_STATES.has(previous?.state) && next.state === 'running' ? previous : next;
function upsert(job) {
const previous = find(job.id);
job = settled(previous, job);
jobs = previous ? jobs.map(item => item.id === job.id ? job : item) : [job, ...jobs];
announce(previous, job);
follow();
emit();
}
/**
* Replace the jobs with the server's list. A running job it no longer has ended with the server and stays as
* ended; a finished job it no longer lists was pruned there and is dropped here too.
*/
function merge(list) {
const next = list.map(parseJob).filter(Boolean).map(job => settled(find(job.id), job));
const ids = new Set(next.map(job => job.id));
const carried = jobs.filter(job => !ids.has(job.id) && (job.state === 'running' || job.state === 'lost'))
.map(job => ({ ...job, state: 'lost' }));
for (const job of next) announce(find(job.id), job);
jobs = [...next, ...carried];
follow();
}
function markLost() {
jobs = jobs.map(job => job.state === 'running' ? { ...job, state: 'lost' } : job);
follow();
}
/** Re-read after the backoff; `drop` marks a re-read for a dropped stream, which keeps the backoff growing. */
function scheduleResync({ drop = false } = {}) {
if (disposed || suspended || retryTimer !== null) return;
const delay = backoffDelay(attempt);
attempt += 1;
retryTimer = timers.setTimeout(() => {
retryTimer = null;
load({ drop });
}, delay);
}
/** The next periodic read; a hidden tab skips its turn. */
function schedulePoll() {
if (disposed || suspended || pollMs <= 0) return;
if (pollTimer !== null) timers.clearTimeout(pollTimer);
pollTimer = timers.setTimeout(() => {
pollTimer = null;
if (isVisible()) load();
else schedulePoll();
}, pollMs);
}
/** Read this session's jobs. A failed read marks running jobs ended and tries again after the backoff. */
async function load({ drop = false } = {}) {
if (disposed || suspended) return;
const read = ++reads;
// Only the newest read applies, so an older one still in flight is aborted.
reading?.abort();
const controller = new AbortController();
reading = controller;
try {
const result = await api.get(JOBS_URL, { signal: controller.signal, cache: 'no-store' });
// An older read answering late is dropped.
if (disposed || suspended || read !== reads) return;
if (!Array.isArray(result?.jobs)) throw new TypeError('Invalid job list');
merge(result.jobs);
status = 'ready';
// A good read ends the backoff (the server is back, or nothing dropped), except for a stream that keeps
// dropping while the list reads fine: that waits longer each time, and a received message resets it.
if (failures > 0 || !drop) attempt = 0;
failures = 0;
schedulePoll();
} catch (error) {
if (disposed || suspended || scope.signal?.aborted || controller.signal.aborted || read !== reads) return;
failures += 1;
if (failures >= LOST_AFTER_FAILURES) markLost();
status = 'error';
scheduleResync();
}
emit();
}
const store = {
get status() { return status; },
jobs: () => [...jobs],
running: () => jobs.filter(job => job.state === 'running'),
find,
load,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
/** Start a job of `kind`: {job}, or {busy, message} naming the running job that holds its slot. */
async start(kind, body = {}) {
try {
const result = await api.post(`${JOBS_URL}/${encodeURIComponent(kind)}`, body, { signal: scope.signal });
const job = parseJob(result?.job);
if (!job) throw new TypeError('Invalid job');
upsert(job);
notify(startToast(job));
return { job };
} catch (error) {
if (error?.status !== 409 || error?.code !== 'busy') throw error;
await load();
// The job the server named holds the slot; read jobs run side by side, so the first running one may not.
return { busy: error.running ?? store.running()[0] ?? null, message: error.message };
}
},
/**
* Follow a job another route started (its summary from that route's answer, e.g. the health check's
* `POST /system/health`): it is listed and its stream opened at once, with no read and no start toast.
*/
track(raw) {
const job = parseJob(raw);
if (!job) throw new TypeError('Invalid job');
upsert(job);
return job;
},
/** Ask a running job to stop at its next file boundary; false once it has ended. */
async cancel(id) {
const result = await api.post(`${JOBS_URL}/${encodeURIComponent(id)}/cancel`, {}, { signal: scope.signal });
return result?.cancellation_requested === true;
},
};
/** Stop every timer, stream and read in flight. */
function halt() {
if (retryTimer !== null) timers.clearTimeout(retryTimer);
if (pollTimer !== null) timers.clearTimeout(pollTimer);
retryTimer = null;
pollTimer = null;
reading?.abort();
reading = null;
for (const id of [...streams.keys()]) closeStream(id);
}
const onPageHide = () => { suspended = true; halt(); };
const onPageShow = event => {
if (!suspended || !event.persisted) return;
suspended = false;
load();
};
lifecycle?.addEventListener('pagehide', onPageHide);
lifecycle?.addEventListener('pageshow', onPageShow);
scope.cleanup(() => {
disposed = true;
halt();
lifecycle?.removeEventListener('pagehide', onPageHide);
lifecycle?.removeEventListener('pageshow', onPageShow);
listeners.clear();
});
return store;
}