Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/images-source-recovery.mjs
T
Vito 4040007264 fix(workspace): keep open pages valid across a restart and recover a changed source (#430) (#434)
The gallery root scope and the Images listing cursor were keyed with a
per-process random key, so every ComfyUI restart made an open tab's
scopes answer 409 source-changed (blank Tag image preview, a Generate
that did nothing) and its cursors fail Load more.

- utils/workspace_keys.py: one random secret in workspace-scope.key
  beside the database (owner-only, never config.json or the database),
  two derived keys; per-process fallback with a warning. Move library
  copies it.
- Cursor v2 binds the listing scope in its MAC instead of sending an
  unkeyed hash of the root paths; an unverifiable cursor answers 400
  cursor-stale and Images offers Refresh instead of a dead Retry.
- POST /prompt_manager/images/rescope maps a stale target to its
  current root only when the held scope was issued for it.
- Images finds a renumbered file again from the inspector, the viewer
  and the Tag image dialog (the dialog stays open, draft kept); nothing
  else is kept by an old root_index:path key.
- Tag image dialog: a state that blocks Save shows its reason above the
  actions with Refresh, disabled actions look disabled, and an empty
  preview says "Preview unavailable" (D147).
2026-10-28 23:06:36 +00:00

97 lines
5.0 KiB
JavaScript

/**
* Finding a file again after its source changed (#430).
*
* A listed file is addressed as `{root_index, root_scope, path}`. When the sources are renumbered every per-file
* action on it answers 409 `source-changed`. `rescopeImage` asks the server (POST /prompt_manager/images/rescope)
* for the file's current listing descriptor: the server maps the target only when its scope was issued for one of
* the current roots, so a removed or replaced source (or a changed key) answers null and the page offers Refresh.
*
* `createSourceRecovery` is the page's single flight: one rescope per held `(key, scope)` pair, its result shared
* by every caller (the inspector, the viewer and the Tag image dialog may all ask for the same file), and a pair
* already tried answers its first result, so a refusal can never loop. `apply(image, fresh, started)` is the page's
* part (refresh the collection, move the selection); `started` is the page generation when the rescope began, so a
* page whose filters changed meanwhile can leave itself alone. `reset()` (a manual Refresh) forgets every pair.
*/
import { imageKey } from './images-state.mjs';
export const RESCOPE_URL = '/prompt_manager/images/rescope';
// A page holds a handful of stale files at once; older pairs are forgotten first.
const MEMO_LIMIT = 64;
const hex64 = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
function validTarget(value) {
return value !== null && typeof value === 'object' && Number.isSafeInteger(value.root_index) && value.root_index >= 0 &&
hex64(value.root_scope) && typeof value.path === 'string' && value.path.length > 0;
}
/** The fresh descriptor of `image` (same path, current root and scope), else null. */
export async function rescopeImage(api, image, {signal} = {}) {
if (!validTarget(image)) return null;
let value;
try {
value = await api.post(RESCOPE_URL, {target: {root_index: image.root_index, root_scope: image.root_scope, path: image.path}},
{signal});
} catch (error) {
// 409 (the source is gone) or 404 (the file is gone): final, the caller offers Refresh. Anything else (no
// answer, a 5xx) is rethrown so the pair may be asked again.
if (error?.status === 404 || error?.status === 409) return null;
throw error;
}
const fresh = value?.success === true ? value.image : null;
if (!validTarget(fresh) || fresh.path !== image.path) return null;
return Object.freeze({...fresh});
}
export function createSourceRecovery({api, signal, apply, generation = () => 0}) {
// An embedded gallery's own page adapter may have no `post`: its rescope then simply finds nothing.
if (typeof apply !== 'function') throw new TypeError('createSourceRecovery needs apply');
const memo = new Map();
function recover(image) {
if (!validTarget(image)) return Promise.resolve(null);
const pair = `${imageKey(image)}|${image.root_scope}`;
if (memo.has(pair)) return memo.get(pair);
const started = generation();
const pending = rescopeImage(api, image, {signal})
.then(fresh => (fresh && !signal?.aborted ? apply(image, fresh, started) : null))
.then(result => result ?? null, () => {
if (memo.get(pair) === pending) memo.delete(pair);
return null;
});
memo.set(pair, pending);
if (memo.size > MEMO_LIMIT) memo.delete(memo.keys().next().value);
return pending;
}
return {recover, reset: () => memo.clear()};
}
/**
* The Images page's `apply` (#430). Keys are `root_index:path`, so after a renumbering nothing from the old list is
* kept by key (a checked or selected key could name a same-named file in another source): the viewer closes, the
* checks clear, a Source filter on the old index follows the file, and only the file itself stays selected while the
* collection lists again. An unchanged numbering re-reads the loaded range in place. `page` holds the page's hooks.
*/
export function recoveredSource(page) {
return async (image, fresh, started) => {
// The held scope still names its root: nothing is stale (a media error had another cause).
if (fresh.root_index === image.root_index && fresh.root_scope === image.root_scope) return null;
if (!page.current(started)) return fresh;
const selected = page.selected(image);
if (fresh.root_index === image.root_index) {
if (selected) page.inspect(fresh);
await page.stream.refresh().catch(() => {});
return fresh;
}
page.closeViewer();
page.clearChecks();
const {filters} = page;
if (filters.root === String(image.root_index)) filters.root = String(fresh.root_index);
else if (filters.root !== 'all') { filters.root = 'all'; filters.subfolder = ''; }
if (selected) page.inspect(fresh);
else page.dropSelection();
page.updateScope();
page.stream.reset(filters, {load:page.listsNow()});
page.rediscover();
return fresh;
};
}