From f6e5f1547141c7cfd678ef80c140b44ee524eb75 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 21:42:45 -0600 Subject: [PATCH 01/10] feat(gallery): restore PBR map generation and image storage administration Add a Generate PBR Maps image action that queues one pbr_maps utility graph into the auto-add board, watches its settlement and refreshes the gallery when it completes. Add an admin-only Image storage editor to the Server settings: folder layout for new images, polled move/recovery status, and confirmed Start Move and Recover actions backed by a Gallery image-storage data module. Extend the mock backend with runtime-config layout, image move routes and enqueue_batch, and align the Gallery and configuration docs with the restored UI. --- .../06.Gallery/gallery-reference.mdx | 3 +- .../06.Gallery/image-storage-maintenance.mdx | 6 +- .../docs/configuration/01.invokeai-yaml.mdx | 2 +- .../frontend/webv2/public/locales/en.json | 50 ++++ .../scripts/mock-backend-fixtures.test.mjs | 156 ++++++++++- .../frontend/webv2/scripts/mock-backend.mjs | 198 ++++++++++++- .../gallery/data/imageStorage.test.ts | 139 +++++++++ .../src/features/gallery/data/imageStorage.ts | 175 ++++++++++++ .../webv2/src/features/gallery/queries.ts | 20 ++ .../queue/runtime/utilityGraph.test.ts | 112 +++++++- .../features/queue/runtime/utilityGraph.ts | 138 +++++++++ .../webv2/src/features/queue/utility.ts | 3 + .../ImageContextMenu.browser.test.tsx | 20 ++ .../image-actions/ImageContextMenu.tsx | 8 + .../image-actions/pbrMapsGraph.test.ts | 30 ++ .../workbench/image-actions/pbrMapsGraph.ts | 30 ++ .../useImageActions.browser.test.tsx | 118 ++++++++ .../image-actions/useImageActions.ts | 54 ++++ .../settings/CustomSettingsEditors.tsx | 4 + .../ImageStorageSettings.browser.test.tsx | 265 ++++++++++++++++++ .../settings/ImageStorageSettings.tsx | 264 +++++++++++++++++ .../settings/applicationContributions.ts | 1 + 22 files changed, 1785 insertions(+), 11 deletions(-) create mode 100644 invokeai/frontend/webv2/src/features/gallery/data/imageStorage.test.ts create mode 100644 invokeai/frontend/webv2/src/features/gallery/data/imageStorage.ts create mode 100644 invokeai/frontend/webv2/src/workbench/image-actions/pbrMapsGraph.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/image-actions/pbrMapsGraph.ts create mode 100644 invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.browser.test.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.tsx diff --git a/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx b/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx index 8d954d225e8..b73b05e9195 100644 --- a/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx +++ b/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx @@ -62,7 +62,7 @@ On a multi-user installation, only a board's owner or an administrator can delet Right-click an item, or several selected items, for its menu. -The context menu for an image, with quick-action icons at the top followed by Load Workflow, Recall Metadata, Add to Current Canvas, New Project from Image, Send to Upscale, Send to Video, Use as Reference Image, Use as Prompt Template, Select for Compare, Find in Gallery, Change Board and Delete Image. +The context menu for an image, with quick-action icons at the top followed by Load Workflow, Recall Metadata, Add to Current Canvas, New Project from Image, Send to Upscale, Send to Video, Use as Reference Image, Use as Prompt Template, Select for Compare, Generate PBR Maps, Find in Gallery, Change Board and Delete Image. For an image, the menu offers: @@ -71,6 +71,7 @@ For an image, the menu offers: * **Add to Current Canvas** — add the image to the open project's canvas as a new layer of the kind you choose. * **New Project from Image** — start a new project whose canvas holds the image, sized to fit it. * **Send to Upscale**, **Send to Video**, **Use as Reference Image**, **Use as Prompt Template** and **Select for Compare** — use the image elsewhere in InvokeAI. +* **Generate PBR Maps** — queue normal, roughness and displacement maps of the image. They are saved to the board the Gallery is auto-adding to (Uncategorized when there is none), and a notice reports when they are ready. The first run downloads the PBR models. * **Change Board** and **Delete Image**. A video's menu has the equivalent actions for video, including **Extend in Video**, **Use as Reference Video** and **Use as Conditioning Clip**. With several items selected, the menu offers the actions that apply to all of them, such as **Star All**, **Download Selection**, **Change Board** and **Delete Selection**; for several images it also leads with **Add to Current Canvas** and **New Project from Images**. diff --git a/docs/src/content/docs/Users Guide/06.Gallery/image-storage-maintenance.mdx b/docs/src/content/docs/Users Guide/06.Gallery/image-storage-maintenance.mdx index 7f5ca0e97cb..81a81596df4 100644 --- a/docs/src/content/docs/Users Guide/06.Gallery/image-storage-maintenance.mdx +++ b/docs/src/content/docs/Users Guide/06.Gallery/image-storage-maintenance.mdx @@ -9,9 +9,9 @@ This operation changes where image files and thumbnails are stored on disk. It d ## Access -Image storage maintenance is available from the in-application Settings panel. +Open **Settings → Server → Image storage**. The **Folder layout for new images** select changes `image_subfolder_strategy` immediately for images saved from then on. Below it, **Move existing images** shows how many images are stored outside the current layout, the state of the last move job, and the **Start Move** and **Recover** buttons; each asks for confirmation before it starts. While a job runs, the status refreshes every few seconds and the controls are unavailable until it finishes. -Administrators can start image moves, force recovery, and inspect the current move status. If multi-user mode is disabled, the single local user has the same access. Non-admin users in multi-user mode cannot run or inspect image move status. +Administrators can start image moves, force recovery, and inspect the current move status. If multi-user mode is disabled, the single local user has the same access. Non-admin users in multi-user mode do not see the Server section and cannot run or inspect image move status. ## Maintenance Mode @@ -25,7 +25,7 @@ The move process is crash-recoverable. InvokeAI records each move job in its dat If InvokeAI stops during a move, recovery resumes incomplete jobs. If recovery finds an ambiguous filesystem state, such as both the old and new full-size image files existing or neither file existing, it halts that item for manual repair instead of blindly updating the database. Other items in the same job are still finalized, and the maintenance lock is released when the job is recorded as failed. -Recovery finalizes jobs that were already recorded before the stop. Images that had not yet been planned into a move job are not moved by recovery itself. The Settings panel reports how many images still need moving; run **Start Move** again to continue. +Recovery finalizes jobs that were already recorded before the stop. Images that had not yet been planned into a move job are not moved by recovery itself. The Image storage setting reports how many images still need moving; run **Start Move** again to continue. While a job is interrupted, **Start Move** stays unavailable and the status names the job that **Recover** will finish. Missing intermediate image files are treated as already cleaned up and do not halt the move. Missing non-intermediate image files, unreadable images, and truncated images are recorded as failed items. When the full-size file is still at its original path, recovery leaves it there so it can be repaired or removed safely. If a previous filesystem move already placed the full-size file at its destination, recovery keeps that destination and reconciles the database before recording the item as failed. After recovery releases the lock, back up the failed file and either replace it with a valid image or delete its image record from the gallery, then run **Start Move** again if images still need moving. Do not delete an active journal row or edit `images.image_subfolder` while maintenance is active. diff --git a/docs/src/content/docs/configuration/01.invokeai-yaml.mdx b/docs/src/content/docs/configuration/01.invokeai-yaml.mdx index 28f3514631a..2c1dfa11865 100644 --- a/docs/src/content/docs/configuration/01.invokeai-yaml.mdx +++ b/docs/src/content/docs/configuration/01.invokeai-yaml.mdx @@ -197,7 +197,7 @@ Background indexing of new images already waits until no generation is running, #### Image Subfolder Strategy -By default, generated images are stored in a single flat directory under `outputs/images/`. The `image_subfolder_strategy` setting lets you organize newly-created images into subfolders automatically. You can edit this setting in `invokeai.yaml` or, as an admin user, in the Settings panel. +By default, generated images are stored in a single flat directory under `outputs/images/`. The `image_subfolder_strategy` setting lets you organize newly-created images into subfolders automatically. You can edit this setting in `invokeai.yaml` or, as an admin user, under **Settings → Server → Image storage**. ```yaml image_subfolder_strategy: flat # default value diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 728a9fad1f5..fa17af49da5 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -1841,6 +1841,45 @@ "failed": "Database VACUUM failed.", "requestFailed": "The database could not be vacuumed." }, + "imageStorage": { + "adminOnly": "Only an administrator can change where images are stored.", + "layoutLabel": "Folder layout for new images", + "layoutHelp": "Applies to images saved from now on. Existing images stay where they are until you run Start Move.", + "layoutLoadFailed": "The storage layout could not be loaded.", + "layoutSaveFailed": "The storage layout could not be saved.", + "layouts": { + "flat": "Flat (one folder)", + "date": "By date (YYYY/MM/DD)", + "type": "By image category", + "hash": "By name prefix (hashed)", + "unknown": "Unknown layout ({{layout}})" + }, + "maintenanceHeading": "Move existing images", + "maintenanceDescription": "Moves every image and thumbnail into the current layout. Names, boards and metadata are unchanged. While the move runs, the gallery and the queue are paused.", + "statusLoadFailed": "The image move status could not be loaded.", + "statusIdle_zero": "All images are stored in the current layout.", + "statusIdle_one": "{{count}} image is stored outside the current layout.", + "statusIdle_other": "{{count}} images are stored outside the current layout.", + "statusMoving": "Moving images into the current layout…", + "statusRecovering": "Recovering an interrupted image move…", + "statusNeedsRecovery": "Image move job {{jobId}} was interrupted. Run Recover to finish it before starting another move.", + "latestJob": "Last job {{jobId}}: {{state}}", + "jobStates": { + "planned": "planned", + "moving": "moving", + "moved": "moved", + "committed": "completed", + "error": "failed" + }, + "startMove": "Start Move", + "startConfirmTitle": "Move all images into the current layout?", + "startConfirmBody": "InvokeAI pauses image reads, uploads, deletions and generation for everyone on this server until the move finishes. It cannot start while queue work is active.", + "startFailed": "The image move could not be started.", + "recover": "Recover", + "recoverConfirmTitle": "Recover the image move journal?", + "recoverConfirmBody": "Recovery finishes moves that were recorded before the server stopped. Items in an ambiguous state are left for manual repair and reported here.", + "recoverFailed": "Image move recovery could not be started." + }, "galleryMaintenance": { "heading": "Gallery maintenance", "removeMissing": "Remove records for missing images", @@ -1973,6 +2012,10 @@ "label": "Generation devices", "description": "Server-wide accelerator selection. Changes require a server restart." }, + "imageStorage": { + "label": "Image storage", + "description": "Where this server stores image files on disk, and the maintenance job that moves existing images into that layout." + }, "workspaceActions": { "label": "Data & workspace", "description": "Reset the layout, manage intermediates, or permanently delete saved projects and settings. Administrators can also run database maintenance." @@ -4105,6 +4148,13 @@ "loadedLabel": "Loaded “{{name}}” from image", "missing": "This image has no workflow embedded." }, + "pbrMaps": { + "completed": "PBR maps are ready", + "completedMessage": "Normal, roughness and displacement maps for {{name}} were added to the gallery.", + "failed": "Could not generate PBR maps", + "queued": "Generating PBR maps", + "queuedMessage": "Normal, roughness and displacement maps for {{name}} are queued." + }, "delete": { "failure_one": "Could not delete {{count}} item.", "failure_other": "Could not delete {{count}} items.", diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs index ded68687021..1772f8a83c0 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs @@ -200,7 +200,10 @@ test('the HTTP reset contract selects profiles explicitly and restores the start 100 ); assert.equal(projects.length, 40); - assert.deepEqual(runtimeConfig, { config: { generation_devices: 'auto' }, set_fields: [] }); + assert.deepEqual(runtimeConfig, { + config: { generation_devices: 'auto', image_subfolder_strategy: 'flat' }, + set_fields: [], + }); assert.deepEqual(await readJson('/__reset', { method: 'POST' }), { counts: MOCK_BACKEND_PROFILE_COUNTS.empty, @@ -938,3 +941,154 @@ test('workflow thumbnails round-trip their bytes and refresh the listed URL on e assert.equal(await listedUrl(), null); }); }); + +test('image storage: the layout is patched alone and the move count follows it', async () => { + await withRepresentativeBackend(async (backend) => { + assert.deepEqual(await getJson(backend, '/api/v1/image_moves/status'), { + active_job_id: null, + is_running: false, + last_error: null, + latest_job: null, + needs_move_count: 0, + operation: null, + }); + + const patched = await getJson(backend, '/api/v1/app/runtime_config', { + body: JSON.stringify({ image_subfolder_strategy: 'date' }), + headers: { 'content-type': 'application/json' }, + method: 'PATCH', + }); + + assert.deepEqual(patched, { + config: { generation_devices: 'auto', image_subfolder_strategy: 'date' }, + set_fields: ['image_subfolder_strategy'], + }); + assert.equal((await getJson(backend, '/api/v1/image_moves/status')).needs_move_count, 1_000); + + const invalid = await fetch(`${backend.origin}/api/v1/app/runtime_config`, { + body: JSON.stringify({ image_subfolder_strategy: 'by_user' }), + headers: { 'content-type': 'application/json' }, + method: 'PATCH', + }); + + assert.equal(invalid.status, 422); + }); +}); + +test('image storage: a move runs across status polls, commits, and refuses to overlap', async () => { + await withRepresentativeBackend(async (backend) => { + await getJson(backend, '/api/v1/app/runtime_config', { + body: JSON.stringify({ image_subfolder_strategy: 'hash' }), + headers: { 'content-type': 'application/json' }, + method: 'PATCH', + }); + + const accepted = await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' }); + + assert.equal(accepted.status, 202); + assert.deepEqual(await accepted.json(), { + active_job_id: 1, + is_running: true, + last_error: null, + latest_job: { error_message: null, id: 1, state: 'moving' }, + needs_move_count: 1_000, + operation: 'move_all', + }); + + const overlapping = await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' }); + + assert.equal(overlapping.status, 409); + + const inProgress = await getJson(backend, '/api/v1/image_moves/status'); + + assert.equal(inProgress.is_running, true); + assert.equal(inProgress.operation, 'move_all'); + + assert.deepEqual(await getJson(backend, '/api/v1/image_moves/status'), { + active_job_id: null, + is_running: false, + last_error: null, + latest_job: { error_message: null, id: 1, state: 'committed' }, + needs_move_count: 0, + operation: null, + }); + }); +}); + +test('image storage: faults seed an interrupted job for recovery, a busy queue, and a non-admin caller', async () => { + await withRepresentativeBackend(async (backend) => { + await getJson(backend, '/__faults?imageMoves=needs-recovery', { method: 'POST' }); + + const interrupted = await getJson(backend, '/api/v1/image_moves/status'); + + assert.equal(interrupted.active_job_id, 1); + assert.equal(interrupted.is_running, false); + assert.equal(interrupted.last_error, 'Server stopped during an image move'); + assert.equal((await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' })).status, 409); + + const recovering = await fetch(`${backend.origin}/api/v1/image_moves/recover`, { method: 'POST' }); + + assert.equal(recovering.status, 202); + assert.equal((await recovering.json()).operation, 'recovery'); + await getJson(backend, '/api/v1/image_moves/status'); + + const recovered = await getJson(backend, '/api/v1/image_moves/status'); + + assert.equal(recovered.active_job_id, null); + assert.equal(recovered.is_running, false); + assert.equal(recovered.last_error, null); + assert.equal(recovered.latest_job.state, 'committed'); + + await getJson(backend, '/__faults?imageMoves=queue-active', { method: 'POST' }); + + const refused = await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' }); + + assert.equal(refused.status, 409); + assert.equal((await fetch(`${backend.origin}/api/v1/image_moves/recover`, { method: 'POST' })).status, 202); + + await getJson(backend, '/__faults?intermediatesCaller=user', { method: 'POST' }); + + assert.equal((await fetch(`${backend.origin}/api/v1/image_moves/status`)).status, 403); + assert.equal( + ( + await fetch(`${backend.origin}/api/v1/app/runtime_config`, { + body: JSON.stringify({ image_subfolder_strategy: 'date' }), + headers: { 'content-type': 'application/json' }, + method: 'PATCH', + }) + ).status, + 403 + ); + + await getJson(backend, '/__reset', { method: 'POST' }); + + assert.equal((await getJson(backend, '/api/v1/image_moves/status')).active_job_id, null); + }); +}); + +test('enqueue_batch accepts a utility graph as pending queue items with its origin', async () => { + await withRepresentativeBackend(async (backend) => { + const before = (await getJson(backend, '/api/v1/queue/default/item_ids')).total_count; + const response = await fetch(`${backend.origin}/api/v1/queue/default/enqueue_batch`, { + body: JSON.stringify({ + batch: { graph: { edges: [], id: 'pbr', nodes: {} }, origin: 'utility:pbr-1', runs: 1 }, + prepend: false, + }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }); + + assert.equal(response.status, 201); + + const enqueued = await response.json(); + + assert.equal(enqueued.enqueued, 1); + assert.equal(enqueued.item_ids.length, 1); + + const item = await getJson(backend, `/api/v1/queue/default/i/${String(enqueued.item_ids[0])}`); + + assert.equal(item.origin, 'utility:pbr-1'); + assert.equal(item.status, 'pending'); + assert.equal((await getJson(backend, '/api/v1/queue/default/item_ids')).total_count, before + 1); + }); +}); diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs index 74836005032..11cc45c1122 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs @@ -48,12 +48,84 @@ const ARCHITECTURE_CAPABILITIES = JSON.parse( /** * POST /__faults sets capabilities to error, empty, or ok, and `intermediatesCaller` to admin or user, until POST * /__reset; independent of workload profile. `user` also turns on multi-user auth with a non-admin session: sign in - * with any email and password (or reload a page that already holds the token) to reach the non-admin UI. + * with any email and password (or reload a page that already holds the token) to reach the non-admin UI. It also + * makes the admin-only image storage routes answer 403. `imageMoves` seeds the image move journal: `idle` (the + * default), `needs-recovery` (an interrupted job that only Recover can finish) or `queue-active` (Start Move is + * refused with 409 while recovery still works). */ const CAPABILITY_FAULTS = new Set(['ok', 'error', 'empty']); const INTERMEDIATES_CALLERS = new Set(['admin', 'user']); +const IMAGE_MOVE_FAULTS = new Set(['idle', 'needs-recovery', 'queue-active']); +const IMAGE_STORAGE_LAYOUTS = new Set(['flat', 'date', 'type', 'hash']); +/** Status reads a running move or recovery takes to finish, so polling clients observe it in progress. */ +const IMAGE_MOVE_TICKS = 2; -const createFaults = () => ({ capabilities: 'ok', intermediatesCaller: 'admin' }); +const createFaults = () => ({ capabilities: 'ok', imageMoves: 'idle', intermediatesCaller: 'admin' }); + +/** + * Every fixture image is stored flat, so the move count is the whole library whenever the configured layout + * differs from the one the last committed move left the files in. + */ +const createImageStorage = () => ({ + layout: 'flat', + storedLayout: 'flat', + job: null, + lastError: null, + nextJobId: 1, + running: null, +}); + +const imageMoveStatus = (state) => { + const storage = state.imageStorage; + + return { + active_job_id: storage.job !== null && storage.job.state !== 'committed' ? storage.job.id : null, + is_running: storage.running !== null, + last_error: storage.lastError, + latest_job: storage.job === null ? null : { ...storage.job }, + needs_move_count: storage.layout === storage.storedLayout ? 0 : state.images.size, + operation: storage.running?.operation ?? null, + }; +}; + +const seedInterruptedImageMove = (state) => { + const storage = state.imageStorage; + + storage.job = { error_message: null, id: storage.nextJobId, state: 'moving' }; + storage.nextJobId += 1; + storage.lastError = 'Server stopped during an image move'; +}; + +const startImageMoveOperation = (state, operation) => { + const storage = state.imageStorage; + + if (operation === 'move_all') { + storage.job = { error_message: null, id: storage.nextJobId, state: 'moving' }; + storage.nextJobId += 1; + } + storage.lastError = null; + storage.running = { operation, ticks: IMAGE_MOVE_TICKS }; +}; + +/** Each status read advances a running operation; it commits on the last tick. */ +const tickImageMove = (state) => { + const storage = state.imageStorage; + + if (storage.running === null) { + return; + } + storage.running.ticks -= 1; + if (storage.running.ticks > 0) { + return; + } + if (storage.running.operation === 'move_all') { + storage.storedLayout = storage.layout; + } + if (storage.job !== null) { + storage.job = { ...storage.job, state: 'committed' }; + } + storage.running = null; +}; const MOCK_USER_ID = 'fixture-user'; @@ -123,6 +195,7 @@ const createState = (profile) => { ], ]), images: new Map(fixture.images.map((image) => [image.image_name, clone(image)])), + imageStorage: createImageStorage(), models: new Map(fixture.models.map((model) => [model.key, clone(model)])), mutationClock: 0, nextBoardNumber: fixture.boards.length + 1, @@ -796,6 +869,7 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { const body = await readJsonBody(request); const requested = url.searchParams.get('capabilities') ?? body.capabilities; const caller = url.searchParams.get('intermediatesCaller') ?? body.intermediatesCaller; + const imageMoves = url.searchParams.get('imageMoves') ?? body.imageMoves; if (requested !== undefined && requested !== null) { if (!CAPABILITY_FAULTS.has(requested)) { @@ -812,6 +886,18 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { faults.intermediatesCaller = caller; } + + if (imageMoves !== undefined && imageMoves !== null) { + if (!IMAGE_MOVE_FAULTS.has(imageMoves)) { + return json(400, { detail: `Unknown image moves fault: ${String(imageMoves)}` }); + } + + faults.imageMoves = imageMoves; + state.imageStorage = createImageStorage(); + if (imageMoves === 'needs-recovery') { + seedInterruptedImageMove(state); + } + } } return json(200, { ok: true, faults: { ...faults } }); @@ -907,8 +993,67 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { return json(200, [{ device: 'cpu', name: 'CPU' }]); } - if (method === 'GET' && path === '/api/v1/app/runtime_config') { - return json(200, { config: { generation_devices: 'auto' }, set_fields: [] }); + if (path === '/api/v1/app/runtime_config') { + const runtimeConfig = () => ({ + config: { generation_devices: 'auto', image_subfolder_strategy: state.imageStorage.layout }, + set_fields: state.imageStorage.layout === 'flat' ? [] : ['image_subfolder_strategy'], + }); + + if (method === 'GET') { + return json(200, runtimeConfig()); + } + if (method === 'PATCH') { + if (faults.intermediatesCaller === 'user') { + return json(403, { detail: 'Admin privileges required' }); + } + + const body = await readJsonBody(request); + + if ('image_subfolder_strategy' in body) { + if (!IMAGE_STORAGE_LAYOUTS.has(body.image_subfolder_strategy)) { + return json(422, { detail: 'image_subfolder_strategy must be one of flat, date, type, hash' }); + } + + state.imageStorage.layout = body.image_subfolder_strategy; + } + + return json(200, runtimeConfig()); + } + } + + if (path.startsWith('/api/v1/image_moves/')) { + if (faults.intermediatesCaller === 'user') { + return json(403, { detail: 'Admin privileges required' }); + } + + const storage = state.imageStorage; + + if (method === 'GET' && path === '/api/v1/image_moves/status') { + tickImageMove(state); + return json(200, imageMoveStatus(state)); + } + if (method === 'POST' && path === '/api/v1/image_moves/start') { + if (storage.running !== null) { + return json(409, { detail: 'An image move job is already running' }); + } + if (imageMoveStatus(state).active_job_id !== null) { + return json(409, { detail: 'An image move job is already active' }); + } + if (faults.imageMoves === 'queue-active') { + return json(409, { detail: 'Cannot move images while the queue has active work' }); + } + + startImageMoveOperation(state, 'move_all'); + return json(202, imageMoveStatus(state)); + } + if (method === 'POST' && path === '/api/v1/image_moves/recover') { + if (storage.running !== null) { + return json(409, { detail: 'An image move job is already running' }); + } + + startImageMoveOperation(state, 'recovery'); + return json(202, imageMoveStatus(state)); + } } if (path.startsWith('/api/v1/app/external_providers')) { @@ -1148,6 +1293,51 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { const queueMatch = /^\/api\/v1\/queue\/[^/]+\/(.+)$/.exec(path); if (queueMatch) { const action = queueMatch[1]; + + if (action === 'enqueue_batch' && method === 'POST') { + const { batch = {} } = await readJsonBody(request); + const runs = Number.isInteger(batch.runs) && batch.runs > 0 ? batch.runs : 1; + const now = timestamp(state); + const batchId = `mock-batch-${String(state.mutationClock)}`; + const itemIds = []; + + // Items stay pending: the mock has no processor and no socket, so nothing ever completes them. + for (let run = 0; run < runs; run += 1) { + const itemId = Math.max(0, ...state.queueItems.keys()) + 1; + + itemIds.push(itemId); + state.queueItems.set(itemId, { + batch_id: batchId, + completed_at: null, + created_at: now, + destination: batch.destination ?? null, + error_message: null, + error_traceback: null, + error_type: null, + field_values: null, + item_id: itemId, + origin: batch.origin ?? null, + retried_from_item_id: null, + session: { prepared_source_mapping: {}, results: {} }, + session_id: `mock-session-${String(itemId)}`, + started_at: null, + status: 'pending', + updated_at: now, + user_display_name: 'Fixture User', + user_email: 'fixture@example.test', + user_id: MOCK_USER_ID, + }); + } + + return json(201, { + batch: { ...batch, batch_id: batchId }, + enqueued: itemIds.length, + item_ids: itemIds, + priority: 0, + queue_id: 'default', + }); + } + const scopedItems = queueItemsForScope(state, url); if (action === 'status') { diff --git a/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.test.ts new file mode 100644 index 00000000000..5468de85d3b --- /dev/null +++ b/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.test.ts @@ -0,0 +1,139 @@ +import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { QueryClient } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + apiFetchJson: vi.fn(), + invalidateGallery: vi.fn(() => Promise.resolve()), +})); + +vi.mock('@platform/transport/http', () => ({ apiFetchJson: mocks.apiFetchJson })); +vi.mock('./queryCache', () => ({ invalidateGallery: mocks.invalidateGallery })); + +import { + getImageMoveStatus, + imageMoveStatusQueryOptions, + imageStorageKeys, + imageStorageLayoutQueryOptions, + startImageMove, + updateImageStorageLayout, +} from './imageStorage'; + +const runningStatus = { + active_job_id: 3, + is_running: true, + last_error: null, + latest_job: { error_message: null, id: 3, state: 'moving' }, + needs_move_count: 12, + operation: 'move_all', +}; + +const committedStatus = { + active_job_id: null, + is_running: false, + last_error: null, + latest_job: { error_message: null, id: 3, state: 'committed' }, + needs_move_count: 0, + operation: null, +}; + +beforeEach(() => { + vi.clearAllMocks(); + accountLifecycle.activate('admin'); +}); + +describe('image storage layout', () => { + it('reads the layout from the nested runtime config and keeps unknown server values', async () => { + mocks.apiFetchJson.mockResolvedValueOnce({ config: { image_subfolder_strategy: 'date' }, set_fields: [] }); + await expect(new QueryClient().fetchQuery(imageStorageLayoutQueryOptions())).resolves.toBe('date'); + expect(mocks.apiFetchJson).toHaveBeenCalledWith('/api/v1/app/runtime_config', expect.anything()); + + mocks.apiFetchJson.mockResolvedValueOnce({ config: { image_subfolder_strategy: 'by_user' } }); + await expect(new QueryClient().fetchQuery(imageStorageLayoutQueryOptions())).resolves.toBe('by_user'); + + mocks.apiFetchJson.mockResolvedValueOnce({ config: {} }); + await expect(new QueryClient().fetchQuery(imageStorageLayoutQueryOptions())).resolves.toBe('flat'); + }); + + it('patches only the layout field, caches the answer, and refreshes the move count', async () => { + const client = new QueryClient(); + const owner = accountLifecycle.capture(); + client.setQueryData(imageStorageKeys.moveStatus(owner), { needsMoveCount: 0 }); + mocks.apiFetchJson.mockResolvedValueOnce({ config: { image_subfolder_strategy: 'hash' } }); + + await updateImageStorageLayout(client, 'hash', owner); + + expect(mocks.apiFetchJson).toHaveBeenCalledExactlyOnceWith( + '/api/v1/app/runtime_config', + expect.objectContaining({ body: JSON.stringify({ image_subfolder_strategy: 'hash' }), method: 'PATCH' }) + ); + expect(client.getQueryData(imageStorageKeys.layout(owner))).toBe('hash'); + expect(client.getQueryState(imageStorageKeys.moveStatus(owner))?.isInvalidated).toBe(true); + }); +}); + +describe('image move status', () => { + it('maps the wire status to the read model', async () => { + mocks.apiFetchJson.mockResolvedValueOnce(runningStatus); + + await expect(getImageMoveStatus()).resolves.toEqual({ + activeJobId: 3, + isRunning: true, + lastError: null, + latestJob: { errorMessage: null, id: 3, state: 'moving' }, + needsMoveCount: 12, + operation: 'move_all', + }); + }); + + it('polls while running and refreshes the gallery on the read that sees the job stop', async () => { + const client = new QueryClient(); + const owner = accountLifecycle.capture(); + const options = imageMoveStatusQueryOptions(owner); + + mocks.apiFetchJson.mockResolvedValueOnce(runningStatus); + const running = await client.fetchQuery(options); + expect(running.isRunning).toBe(true); + expect(options.refetchInterval).toBeTypeOf('function'); + const interval = options.refetchInterval as (query: { state: { data?: unknown } }) => number | false; + expect(interval({ state: { data: running } })).toBe(2_000); + expect(mocks.invalidateGallery).not.toHaveBeenCalled(); + + mocks.apiFetchJson.mockResolvedValueOnce(committedStatus); + const committed = await client.fetchQuery({ ...options, staleTime: 0 }); + expect(committed.isRunning).toBe(false); + expect(interval({ state: { data: committed } })).toBe(false); + expect(mocks.invalidateGallery).toHaveBeenCalledExactlyOnceWith(client, owner); + + // An idle read after an idle read is not a job ending. + mocks.apiFetchJson.mockResolvedValueOnce(committedStatus); + await client.fetchQuery({ ...options, staleTime: 0 }); + expect(mocks.invalidateGallery).toHaveBeenCalledTimes(1); + }); + + it('seeds the status query with the accepted job so polling starts without a second read', async () => { + const client = new QueryClient(); + const owner = accountLifecycle.capture(); + mocks.apiFetchJson.mockResolvedValueOnce(runningStatus); + + await expect(startImageMove(client, owner)).resolves.toMatchObject({ isRunning: true, operation: 'move_all' }); + + expect(mocks.apiFetchJson).toHaveBeenCalledExactlyOnceWith( + '/api/v1/image_moves/start', + expect.objectContaining({ method: 'POST' }) + ); + expect(client.getQueryData(imageStorageKeys.moveStatus(owner))).toMatchObject({ activeJobId: 3, isRunning: true }); + }); + + it('drops a response that lands after the account changed', async () => { + const client = new QueryClient(); + const owner = accountLifecycle.capture(); + mocks.apiFetchJson.mockImplementationOnce(() => { + accountLifecycle.activate('someone-else'); + return Promise.resolve(runningStatus); + }); + + await expect(startImageMove(client, owner)).rejects.toThrow(); + expect(client.getQueryData(imageStorageKeys.moveStatus(owner))).toBeUndefined(); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.ts b/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.ts new file mode 100644 index 00000000000..67581e35df4 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.ts @@ -0,0 +1,175 @@ +import type { AccountScope } from '@platform/state/accountLifecycle'; +import type { QueryClient } from '@tanstack/react-query'; + +import { assertAccountScopeCurrent, captureAccountScope } from '@platform/state/accountLifecycle'; +import { apiFetchJson } from '@platform/transport/http'; +import { queryOptions } from '@tanstack/react-query'; + +import { invalidateGallery } from './queryCache'; + +/** + * Server image storage: the subfolder layout new images are written to, and the admin-only move/recovery job + * that relocates existing files into it. Both routes are admin-only, so callers gate reads on the capability. + */ + +export const IMAGE_STORAGE_LAYOUTS = ['flat', 'date', 'type', 'hash'] as const; + +export type ImageStorageLayout = (typeof IMAGE_STORAGE_LAYOUTS)[number]; + +export const isImageStorageLayout = (value: unknown): value is ImageStorageLayout => + IMAGE_STORAGE_LAYOUTS.includes(value as ImageStorageLayout); + +export type ImageMoveOperation = 'move_all' | 'recovery'; + +export type ImageMoveJobState = 'planned' | 'moving' | 'moved' | 'committed' | 'error'; + +export interface ImageMoveJob { + id: number; + state: ImageMoveJobState; + errorMessage: string | null; +} + +export interface ImageMoveStatus { + /** Whether a move or recovery is running right now. */ + isRunning: boolean; + operation: ImageMoveOperation | null; + /** A journaled job that has not committed; with `isRunning` false it needs recovery. */ + activeJobId: number | null; + latestJob: ImageMoveJob | null; + /** The last background worker error. */ + lastError: string | null; + /** Images still stored under a layout other than the configured one. */ + needsMoveCount: number; +} + +/** The job finished, one way or the other; its outcome is what `latestJob` reports. */ +export const isTerminalImageMoveJobState = (state: ImageMoveJobState | null | undefined): boolean => + state === 'committed' || state === 'error'; + +/** `GET/PATCH /app/runtime_config` nest the settings under `config`; only the layout field is read here. */ +interface RuntimeConfigDTO { + config?: { image_subfolder_strategy?: unknown } | null; +} + +interface ImageMoveJobDTO { + id: number; + state: ImageMoveJobState; + error_message?: string | null; +} + +interface ImageMoveStatusDTO { + is_running: boolean; + operation?: ImageMoveOperation | null; + active_job_id?: number | null; + latest_job?: ImageMoveJobDTO | null; + last_error?: string | null; + needs_move_count: number; +} + +/** An unrecognised server value is kept, not coerced: the editor shows it as unknown instead of lying. */ +export const readImageStorageLayout = (dto: RuntimeConfigDTO | null): string => + typeof dto?.config?.image_subfolder_strategy === 'string' ? dto.config.image_subfolder_strategy : 'flat'; + +const toImageMoveStatus = (dto: ImageMoveStatusDTO): ImageMoveStatus => ({ + activeJobId: dto.active_job_id ?? null, + isRunning: dto.is_running, + lastError: dto.last_error ?? null, + latestJob: dto.latest_job + ? { errorMessage: dto.latest_job.error_message ?? null, id: dto.latest_job.id, state: dto.latest_job.state } + : null, + needsMoveCount: dto.needs_move_count, + operation: dto.operation ?? null, +}); + +const getAccountKey = (owner: AccountScope) => ({ accountId: owner.accountId, epoch: owner.epoch }); + +export const imageStorageKeys = { + all: ['gallery', 'image-storage'] as const, + layout: (owner: AccountScope) => [...imageStorageKeys.all, 'layout', getAccountKey(owner)] as const, + moveStatus: (owner: AccountScope) => [...imageStorageKeys.all, 'move-status', getAccountKey(owner)] as const, +}; + +const fenced = (owner: AccountScope, request: (signal: AbortSignal) => Promise, signal: AbortSignal) => + request(AbortSignal.any([signal, owner.signal])).then((result) => { + assertAccountScopeCurrent(owner); + return result; + }); + +export const getImageStorageLayout = (signal?: AbortSignal): Promise => + apiFetchJson('/api/v1/app/runtime_config', { signal }).then(readImageStorageLayout); + +export const imageStorageLayoutQueryOptions = (owner = captureAccountScope()) => + queryOptions({ + queryFn: ({ signal }) => fenced(owner, getImageStorageLayout, signal), + queryKey: imageStorageKeys.layout(owner), + staleTime: 30_000, + }); + +/** Takes effect for new images at once; existing files move only through {@link startImageMove}. */ +export const updateImageStorageLayout = async ( + client: QueryClient, + layout: ImageStorageLayout, + owner = captureAccountScope() +): Promise => { + const response = await apiFetchJson('/api/v1/app/runtime_config', { + body: JSON.stringify({ image_subfolder_strategy: layout }), + method: 'PATCH', + signal: owner.signal, + }); + + assertAccountScopeCurrent(owner); + client.setQueryData(imageStorageKeys.layout(owner), readImageStorageLayout(response)); + // The count of images outside the layout changes with it. + await client.invalidateQueries({ queryKey: imageStorageKeys.moveStatus(owner) }); +}; + +export const getImageMoveStatus = (signal?: AbortSignal): Promise => + apiFetchJson('/api/v1/image_moves/status', { signal }).then(toImageMoveStatus); + +/** + * Polls while a job runs. The read that first sees a job stop refreshes the gallery, since the move changed + * where its files live, whichever consumer happens to hold the query at the time. + */ +export const imageMoveStatusQueryOptions = (owner = captureAccountScope()) => + queryOptions({ + queryFn: async ({ client, signal }) => { + const previous = client.getQueryData(imageStorageKeys.moveStatus(owner)); + const status = await fenced(owner, getImageMoveStatus, signal); + + if (previous?.isRunning && !status.isRunning) { + void invalidateGallery(client, owner); + } + + return status; + }, + queryKey: imageStorageKeys.moveStatus(owner), + refetchInterval: (query) => (query.state.data?.isRunning ? 2_000 : false), + // A move pauses the whole server; keep following it from a background tab so its end is not missed. + refetchIntervalInBackground: true, + staleTime: 5_000, + }); + +const postImageMove = async ( + client: QueryClient, + route: 'start' | 'recover', + owner: AccountScope +): Promise => { + const status = await apiFetchJson(`/api/v1/image_moves/${route}`, { + method: 'POST', + signal: owner.signal, + }).then(toImageMoveStatus); + + assertAccountScopeCurrent(owner); + // The accepted status says the job is running, which starts the status query polling. + client.setQueryData(imageStorageKeys.moveStatus(owner), status); + + return status; +}; + +/** Moves every image into the configured layout; the server refuses (409) while queue work or a job is active. */ +export const startImageMove = (client: QueryClient, owner = captureAccountScope()): Promise => + postImageMove(client, 'start', owner); + +/** Resumes or finalizes a journaled job the server stopped in; refused (409) while a job runs. */ +export const startImageMoveRecovery = (client: QueryClient, owner = captureAccountScope()): Promise => + postImageMove(client, 'recover', owner); diff --git a/invokeai/frontend/webv2/src/features/gallery/queries.ts b/invokeai/frontend/webv2/src/features/gallery/queries.ts index 1898036d9c3..84cfc5b0d4e 100644 --- a/invokeai/frontend/webv2/src/features/gallery/queries.ts +++ b/invokeai/frontend/webv2/src/features/gallery/queries.ts @@ -44,3 +44,23 @@ export { export type { GalleryItemCachePatch } from './data/queryCache'; export { createGalleryRealtimeRuntime } from './data/realtimeRuntime'; export { galleryBoardExists } from './data/backend'; +export { + getImageMoveStatus, + getImageStorageLayout, + IMAGE_STORAGE_LAYOUTS, + imageMoveStatusQueryOptions, + imageStorageKeys, + imageStorageLayoutQueryOptions, + isImageStorageLayout, + isTerminalImageMoveJobState, + startImageMove, + startImageMoveRecovery, + updateImageStorageLayout, +} from './data/imageStorage'; +export type { + ImageMoveJob, + ImageMoveJobState, + ImageMoveOperation, + ImageMoveStatus, + ImageStorageLayout, +} from './data/imageStorage'; diff --git a/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.test.ts index f5fe5851ea4..c49b08005b2 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.test.ts @@ -12,7 +12,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { UtilityEnqueue } from './utilityGraph'; -import { DEFAULT_UTILITY_QUEUE_TIMEOUT_MS, runUtilityGraph, UtilityQueueError } from './utilityGraph'; +import { + DEFAULT_UTILITY_QUEUE_TIMEOUT_MS, + DEFAULT_UTILITY_SETTLE_TIMEOUT_MS, + runUtilityGraph, + submitUtilityGraph, + UtilityQueueError, +} from './utilityGraph'; /** A fake socket hub that records handlers per event and can emit to them, mirroring `hub.on`. */ const createFakeHub = () => { @@ -1147,3 +1153,107 @@ describe('UtilityQueueError', () => { expect(error.reason).toBe('timeout'); }); }); + +describe('submitUtilityGraph — fire, then watch settlement', () => { + const graph = { edges: [], id: 'pbr', nodes: {} }; + + it('resolves once the backend accepts the graph and settles when the item completes', async () => { + const fake = createFakeHub(); + const enqueue = vi.fn(() => Promise.resolve({ enqueued: 1, itemIds: [7] })); + + const submission = await submitUtilityGraph({ createId: () => UTIL_ID, enqueue, graph, hub: fake.hub }); + + expect(enqueue).toHaveBeenCalledExactlyOnceWith({ graph, origin: ORIGIN }); + expect(submission).toMatchObject({ itemIds: [7], origin: ORIGIN }); + // Only status changes are watched; no output image is expected. + expect(fake.handlerCount('invocation_complete')).toBe(0); + expect(fake.handlerCount('queue_item_status_changed')).toBe(1); + + fake.emit('queue_item_status_changed', statusEvent('completed', { origin: 'other-origin' })); + fake.emit('queue_item_status_changed', statusEvent('completed', { item_id: 7 })); + + await expect(submission.settled).resolves.toBeUndefined(); + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + }); + + it('settles a completion that arrives before the enqueue response', async () => { + const fake = createFakeHub(); + const deferred = createDeferred<{ enqueued: number; itemIds: number[] }>(); + const pending = submitUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => deferred.promise, + graph, + hub: fake.hub, + }); + + fake.emit('queue_item_status_changed', statusEvent('completed')); + deferred.resolve({ enqueued: 1, itemIds: [1] }); + + await expect((await pending).settled).resolves.toBeUndefined(); + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + }); + + it('rejects settlement with the backend failure message, or as canceled', async () => { + const failed = createFakeHub(); + const failedSubmission = await submitUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph, + hub: failed.hub, + }); + failed.emit('queue_item_status_changed', statusEvent('failed', { error_message: 'No model' })); + await expect(failedSubmission.settled).rejects.toMatchObject({ message: 'No model', reason: 'failed' }); + + const canceled = createFakeHub(); + const canceledSubmission = await submitUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph, + hub: canceled.hub, + }); + canceled.emit('queue_item_status_changed', statusEvent('canceled')); + await expect(canceledSubmission.settled).rejects.toMatchObject({ reason: 'canceled' }); + expect(canceled.handlerCount('queue_item_status_changed')).toBe(0); + }); + + it('rejects the submission, and detaches, when the enqueue fails or answers inconsistently', async () => { + const failing = createFakeHub(); + await expect( + submitUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => Promise.reject(new ApiError('{"detail":"queue paused"}', 409)), + graph, + hub: failing.hub, + }) + ).rejects.toMatchObject({ message: '{"detail":"queue paused"}', reason: 'enqueue' }); + expect(failing.handlerCount('queue_item_status_changed')).toBe(0); + + const inconsistent = createFakeHub(); + await expect( + submitUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => Promise.resolve({ enqueued: 0, itemIds: [] }), + graph, + hub: inconsistent.hub, + }) + ).rejects.toMatchObject({ reason: 'enqueue' }); + expect(inconsistent.handlerCount('queue_item_status_changed')).toBe(0); + }); + + it('stops watching after the settle timeout without canceling the backend item', async () => { + vi.useFakeTimers(); + const fake = createFakeHub(); + const submission = await submitUtilityGraph({ createId: () => UTIL_ID, enqueue: okEnqueue, graph, hub: fake.hub }); + const outcome = submission.settled.then( + () => 'resolved', + (error: UtilityQueueError) => error.reason + ); + + vi.advanceTimersByTime(DEFAULT_UTILITY_SETTLE_TIMEOUT_MS - 1); + expect(fake.handlerCount('queue_item_status_changed')).toBe(1); + vi.advanceTimersByTime(1); + + await expect(outcome).resolves.toBe('timeout'); + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.ts b/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.ts index 0f2d26ee0a5..ce22714e02f 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.ts @@ -189,6 +189,144 @@ export const reconcileUtilityCompletedOutput: UtilityCompletedOutputReconciler = return null; }; +/** The default time a submitted utility graph is watched for settlement; the first run may download models. */ +export const DEFAULT_UTILITY_SETTLE_TIMEOUT_MS = 30 * 60_000; + +/** Dependencies for {@link submitUtilityGraph}. */ +export interface SubmitUtilityGraphOptions { + /** The graph to enqueue; its outputs stay wherever the graph sends them (a board, intermediates). */ + graph: QueueBackendGraph; + /** The socket hub (only `on` is used). */ + hub: Pick; + /** Enqueue seam (defaults to the real utility enqueue API). */ + enqueue?: UtilityEnqueue; + /** Injectable id source (defaults to `createUuid`). */ + createId?: () => string; + /** + * Stop watching for settlement after this many ms (default {@link DEFAULT_UTILITY_SETTLE_TIMEOUT_MS}); `0` + * disables. The backend item is left running: the caller never asked for it to be canceled. + */ + settleTimeoutMs?: number; +} + +/** An accepted utility submission whose results are not returned, only awaited. */ +export interface UtilityGraphSubmission { + /** The accepted backend item ids. */ + itemIds: number[]; + /** The origin used, for diagnostics/tests. */ + origin: string; + /** + * Resolves once the backend completes the item; rejects with {@link UtilityQueueError} when it fails, is + * canceled, or stops reporting before the settle timeout. + */ + settled: Promise; +} + +/** + * Enqueue a utility graph whose results land where the graph sends them (such as a gallery board) rather than + * being returned. Resolves when the backend accepts it; `settled` tracks the item afterwards. Rejects as + * {@link UtilityQueueError} when the enqueue fails or its response is inconsistent. + */ +export const submitUtilityGraph = async (options: SubmitUtilityGraphOptions): Promise => { + const { graph, hub } = options; + const enqueue = options.enqueue ?? enqueueUtility; + const settleTimeoutMs = options.settleTimeoutMs ?? DEFAULT_UTILITY_SETTLE_TIMEOUT_MS; + let origin: string; + try { + origin = buildUtilityQueueItemOrigin((options.createId ?? createUuid)()); + } catch (cause) { + const detail = cause instanceof Error ? cause.message : String(cause); + throw new UtilityQueueError('setup', `Failed to initialize utility graph submission: ${detail}`, cause); + } + + let settleResolve!: () => void; + let settleReject!: (error: UtilityQueueError) => void; + const settled = new Promise((resolve, reject) => { + settleResolve = resolve; + settleReject = reject; + }); + let isSettled = false; + let timer: ReturnType | null = null; + let detach: (() => void) | null = null; + + const finish = (error: UtilityQueueError | null): void => { + if (isSettled) { + return; + } + isSettled = true; + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + try { + detach?.(); + } catch { + // Settlement must not depend on the listener detaching cleanly. + } + detach = null; + if (error) { + settleReject(error); + } else { + settleResolve(); + } + }; + + // Attach before enqueue so a very fast completion is not missed. + try { + detach = hub.on('queue_item_status_changed', (event: QueueItemStatusChangedEvent) => { + if (event.origin !== origin) { + return; + } + if (event.status === 'completed') { + finish(null); + } else if (event.status === 'failed') { + const errorMessage = typeof event.error_message === 'string' ? event.error_message.trim() : ''; + const errorType = typeof event.error_type === 'string' ? event.error_type.trim() : ''; + finish(new UtilityQueueError('failed', errorMessage || errorType || 'The utility graph failed.')); + } else if (event.status === 'canceled') { + finish(new UtilityQueueError('canceled', 'The utility graph was canceled.')); + } + }); + } catch (cause) { + const detail = cause instanceof Error ? cause.message : String(cause); + const error = new UtilityQueueError('setup', `Failed to attach utility graph listeners: ${detail}`, cause); + finish(error); + settled.catch(() => {}); + throw error; + } + + let result: Awaited>; + try { + result = await enqueue({ graph, origin }); + } catch (cause) { + const error = new UtilityQueueError( + 'enqueue', + cause instanceof Error ? cause.message : 'Failed to enqueue utility graph.', + cause + ); + finish(error); + // The caller never receives `settled`, so its rejection has no other handler. + settled.catch(() => {}); + throw error; + } + + const inspected = inspectUtilityEnqueueResult(result); + if (!inspected.result) { + const error = new UtilityQueueError('enqueue', 'The backend returned an inconsistent utility enqueue response.'); + finish(error); + settled.catch(() => {}); + throw error; + } + + if (!isSettled && settleTimeoutMs > 0) { + timer = setTimeout(() => { + finish(new UtilityQueueError('timeout', `The utility graph did not settle within ${String(settleTimeoutMs)}ms.`)); + }, settleTimeoutMs); + } + + return { itemIds: inspected.result.itemIds, origin, settled }; +}; + /** * Return utility output metadata without project routing; reject failures, cancellation, timeout, abort, and * enqueue errors as {@link UtilityQueueError}. diff --git a/invokeai/frontend/webv2/src/features/queue/utility.ts b/invokeai/frontend/webv2/src/features/queue/utility.ts index d39f110043a..31a00fc2c06 100644 --- a/invokeai/frontend/webv2/src/features/queue/utility.ts +++ b/invokeai/frontend/webv2/src/features/queue/utility.ts @@ -1,7 +1,10 @@ /** One-shot Queue execution interface for utility graphs such as Canvas filters. */ export { runUtilityGraph, + submitUtilityGraph, UtilityQueueError, type RunUtilityGraphOptions, + type SubmitUtilityGraphOptions, type UtilityGraphResult, + type UtilityGraphSubmission, } from './runtime/utilityGraph'; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx index 0f86bea08cd..67d80fc5522 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx @@ -58,6 +58,7 @@ const createActions = (deleteItems: ImageActions['deleteItems']): ImageActions = downloadItems: vi.fn(() => Promise.resolve()), downloadImage: vi.fn(() => Promise.resolve()), downloadImages: vi.fn(() => Promise.resolve()), + generatePbrMaps: vi.fn(() => Promise.resolve()), getImageRecallCapabilities: vi.fn(() => Promise.resolve(EMPTY_IMAGE_RECALL_CAPABILITIES)), loadImageWorkflow: vi.fn(() => Promise.resolve()), moveItemsToBoard: vi.fn(() => Promise.resolve()), @@ -287,6 +288,25 @@ describe('ImageContextMenu load workflow', () => { }); }); +describe('ImageContextMenu PBR maps', () => { + it('hands a single image to the PBR maps action', async () => { + const actions = createActions(vi.fn()); + await renderMenu(actions, [image('texture.png')]); + + await interact(() => getMenuItem('Generate PBR Maps').click()); + + expect(actions.generatePbrMaps).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ imageName: 'texture.png' }) + ); + }); + + it('is absent from bulk menus, which queue nothing per image', async () => { + await renderMenu(createActions(vi.fn()), [image('a.png'), image('b.png')]); + + expect(document.body.textContent).not.toContain('Generate PBR Maps'); + }); +}); + describe('ImageContextMenu starred state', () => { const starIconFill = (label: string): string => getComputedStyle(document.querySelector(`[aria-label="${label}"] svg`)!).fill; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx index 0df00546e56..51ab9ee3a52 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx @@ -21,6 +21,7 @@ import { ClapperboardIcon, AsteriskIcon, AudioLinesIcon, + BoxIcon, ChevronRightIcon, CopyIcon, CrosshairIcon, @@ -750,6 +751,7 @@ const SingleImageMenuItems = ({ const handleSavePromptAsTemplate = useCallback(() => void actions.savePromptAsTemplate(image), [actions, image]); const handleUseAsReferenceImage = useUseAsReferenceImageHandler(actions, image); const handleLoadWorkflow = useCallback(() => void actions.loadImageWorkflow(image), [actions, image]); + const handleGeneratePbrMaps = useCallback(() => void actions.generatePbrMaps(image), [actions, image]); const { generation, widgets } = useWorkbenchCommands(); const openWidget = useOpenWorkbenchWidget(); const handleSendToUpscale = useCallback(() => { @@ -862,6 +864,12 @@ const SingleImageMenuItems = ({ value="select-for-compare" onClick={handleSelectForCompare} /> + diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/pbrMapsGraph.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/pbrMapsGraph.test.ts new file mode 100644 index 00000000000..d29bf93652d --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/image-actions/pbrMapsGraph.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { buildPbrMapsGraph, PBR_MAPS_NODE_ID } from './pbrMapsGraph'; + +describe('buildPbrMapsGraph', () => { + it('emits one durable pbr_maps node over the image, destined for the given board', () => { + const graph = buildPbrMapsGraph({ boardId: 'board-7', imageName: 'texture.png' }); + + expect(graph.edges).toEqual([]); + expect(Object.keys(graph.nodes)).toEqual([PBR_MAPS_NODE_ID]); + expect(graph.nodes[PBR_MAPS_NODE_ID]).toEqual({ + board: { board_id: 'board-7' }, + border_mode: 'none', + id: PBR_MAPS_NODE_ID, + image: { image_name: 'texture.png' }, + is_intermediate: false, + tile_size: 512, + type: 'pbr_maps', + use_cache: false, + }); + }); + + it('leaves the maps Uncategorized when no board is auto-adding', () => { + for (const boardId of [null, 'none']) { + expect(buildPbrMapsGraph({ boardId, imageName: 'texture.png' }).nodes[PBR_MAPS_NODE_ID]).not.toHaveProperty( + 'board' + ); + } + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/pbrMapsGraph.ts b/invokeai/frontend/webv2/src/workbench/image-actions/pbrMapsGraph.ts new file mode 100644 index 00000000000..578068fe779 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/image-actions/pbrMapsGraph.ts @@ -0,0 +1,30 @@ +import type { QueueBackendGraph } from '@features/queue/contracts'; + +export const PBR_MAPS_NODE_ID = 'pbr_maps'; + +/** + * One `pbr_maps` node over a gallery image: whole-image tiles with no seam handling, saved to the board as the + * normal, roughness and displacement maps. `boardId` null or `'none'` leaves them Uncategorized. + */ +export const buildPbrMapsGraph = ({ + boardId, + imageName, +}: { + boardId: string | null; + imageName: string; +}): QueueBackendGraph => ({ + edges: [], + id: 'pbr-maps-graph', + nodes: { + [PBR_MAPS_NODE_ID]: { + id: PBR_MAPS_NODE_ID, + type: 'pbr_maps', + ...(boardId !== null && boardId !== 'none' ? { board: { board_id: boardId } } : {}), + border_mode: 'none', + image: { image_name: imageName }, + is_intermediate: false, + tile_size: 512, + use_cache: false, + }, + }, +}); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx index 3a608f51d57..72601a8ad62 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx @@ -1,4 +1,5 @@ import type { GalleryImage, GalleryItem, GalleryItemKey, GalleryItemRef } from '@features/gallery'; +import type * as QueueUtilityModule from '@features/queue/utility'; import type { CreateCanvasFromImagesResult } from '@workbench/canvas-operations/api'; import { @@ -47,6 +48,7 @@ const mocks = vi.hoisted(() => ({ Promise.resolve({ graph: null, workflow: null }) ), requestWorkflowDocumentLoad: vi.fn(), + submitUtilityGraph: vi.fn((..._args: unknown[]): Promise => Promise.reject(new Error('not submitted'))), videoMetadata: vi.fn((..._args: unknown[]) => Promise.resolve(null)), invalidateGallery: vi.fn(), invalidateGalleryItems: vi.fn(), @@ -99,6 +101,12 @@ vi.mock('@features/gallery', () => ({ toGalleryItemRef: ({ kind, name }: { kind: 'image' | 'video'; name: string }) => ({ kind, name }), })); +vi.mock('@features/queue/utility', async (importOriginal) => ({ + ...(await importOriginal()), + submitUtilityGraph: (...args: unknown[]) => mocks.submitUtilityGraph(...args), +})); +vi.mock('@platform/transport/socketHub', () => ({ socketHub: { on: () => () => {} } })); + vi.mock('@features/workflow/react', () => ({ requestWorkflowDocumentLoad: (...args: unknown[]) => mocks.requestWorkflowDocumentLoad(...args), })); @@ -590,6 +598,116 @@ describe('load workflow from image', () => { }); }); +describe('generate PBR maps', () => { + const image: GalleryImage = { + boardId: 'none', + height: 512, + imageCategory: 'general', + imageName: 'texture.png', + imageUrl: '/full/texture.png', + queuedAt: '2026-07-30T00:00:00.000Z', + sourceQueueItemId: 'backend-gallery', + starred: false, + thumbnailUrl: '/thumb/texture.png', + width: 512, + }; + const createSettlement = () => { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const settled = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { reject, resolve, settled }; + }; + + it('queues one pbr_maps graph into the project auto-add board, then refreshes the gallery when it completes', async () => { + const settlement = createSettlement(); + mocks.getProject.mockImplementation((id: string) => + makeMockProject(id, { autoAddBoardId: 'board-materials', selectedBoardId: 'board-other' }) + ); + mocks.submitUtilityGraph.mockResolvedValueOnce({ itemIds: [41], origin: 'utility:1', settled: settlement.settled }); + + await act(async () => { + await actionsRef.current?.generatePbrMaps(image); + }); + + expect(mocks.submitUtilityGraph).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + graph: expect.objectContaining({ + nodes: { + pbr_maps: expect.objectContaining({ + board: { board_id: 'board-materials' }, + image: { image_name: 'texture.png' }, + is_intermediate: false, + type: 'pbr_maps', + }), + }, + }), + }) + ); + expect(mocks.notificationsAdd).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ kind: 'success', title: 'widgets.gallery.itemActions.pbrMaps.queued' }) + ); + expect(mocks.invalidateGallery).not.toHaveBeenCalled(); + + await act(async () => { + settlement.resolve(); + await settlement.settled; + }); + + expect(mocks.invalidateGallery).toHaveBeenCalledTimes(1); + expect(mocks.notificationsAdd).toHaveBeenLastCalledWith( + expect.objectContaining({ kind: 'success', title: 'widgets.gallery.itemActions.pbrMaps.completed' }) + ); + }); + + it('leaves the maps Uncategorized while the gallery follows no board', async () => { + mocks.getProject.mockImplementation((id: string) => makeMockProject(id, { autoAddBoardId: 'none' })); + mocks.submitUtilityGraph.mockResolvedValueOnce({ + itemIds: [42], + origin: 'utility:2', + settled: new Promise(() => {}), + }); + + await act(async () => { + await actionsRef.current?.generatePbrMaps(image); + }); + + const [request] = mocks.submitUtilityGraph.mock.calls[0] as [{ graph: { nodes: Record } }]; + expect(request.graph.nodes.pbr_maps).not.toHaveProperty('board'); + }); + + it('reports an enqueue failure as an error notice and a failed run through the error channel', async () => { + mocks.submitUtilityGraph.mockRejectedValueOnce(new Error('queue paused')); + + await act(async () => { + await actionsRef.current?.generatePbrMaps(image); + }); + + expect(mocks.notificationsAdd).toHaveBeenCalledExactlyOnceWith({ + kind: 'error', + message: 'queue paused', + title: 'widgets.gallery.itemActions.pbrMaps.failed', + }); + + const settlement = createSettlement(); + mocks.submitUtilityGraph.mockResolvedValueOnce({ itemIds: [43], origin: 'utility:3', settled: settlement.settled }); + await act(async () => { + await actionsRef.current?.generatePbrMaps(image); + }); + await act(async () => { + settlement.reject(new Error('No PBR model')); + await settlement.settled.catch(() => {}); + }); + + expect(mocks.reportError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ area: 'image-actions', message: 'No PBR model', namespace: 'gallery' }) + ); + expect(mocks.invalidateGallery).not.toHaveBeenCalled(); + }); +}); + describe('partial image mutation outcomes', () => { it('moves optimistically, then rolls back and re-applies only backend-confirmed images', async () => { mocks.getItemBoardIds.mockReturnValue(new Map([['image:locked.png', 'board-0']])); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts index 7fec9fa100f..cb1135a1eb9 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts @@ -22,6 +22,7 @@ import { type GalleryVideoItem, } from '@features/gallery'; import { + getGalleryAutoAddBoardId, getGalleryBoardLabel, getGalleryDeletionSuccessor, getPersistedSelectedGalleryItemKeys, @@ -36,6 +37,7 @@ import { setPendingPromptTemplateDraft } from '@features/generation/react'; import { getArchitectureCapabilitiesSnapshot, subscribeArchitectureCapabilities } from '@features/generation/runtime'; import { getMaxReferenceImages, isVaeModelConfig, isSupportedGenerateModel } from '@features/generation/settings'; import { ensureModelsLoaded, useModelsSelector } from '@features/models'; +import { submitUtilityGraph, UtilityQueueError } from '@features/queue/utility'; import { downloadBlob } from '@platform/browser/downloadBlob'; import { useMountEffect } from '@platform/react/useMountEffect'; import { @@ -44,6 +46,7 @@ import { isAccountScopeCurrent, } from '@platform/state/accountLifecycle'; import { useExternalStoreSelector } from '@platform/state/selectors'; +import { socketHub } from '@platform/transport/socketHub'; import { useQueryClient } from '@tanstack/react-query'; import { createCanvasFromImages, @@ -87,6 +90,7 @@ import { type ImageRecallCapabilities, type ImageRecallKind, } from './imageRecall'; +import { buildPbrMapsGraph } from './pbrMapsGraph'; import { EMPTY_VIDEO_RECALL_CAPABILITIES, getVideoRecallCapabilities as deriveVideoRecallCapabilitiesFromMetadata, @@ -116,6 +120,8 @@ export interface ImageActions extends GalleryItemActions { ) => ImageRecallCapabilities; downloadImage: (image: GalleryImage) => Promise; downloadImages: (imageNames: string[]) => Promise; + /** Queues normal, roughness and displacement maps of the image into the project's auto-add board. */ + generatePbrMaps: (image: GalleryImage) => Promise; getImageRecallCapabilities: (image: GalleryImage, signal?: AbortSignal) => Promise; /** Replaces the project graph with the workflow embedded in the image and opens the editor on it. */ loadImageWorkflow: (image: GalleryImage) => Promise; @@ -881,6 +887,54 @@ export const useImageActions = ({ } }, downloadImages: (imageNames) => downloadItems(imageNames.map((name) => ({ kind: 'image', name }))), + generatePbrMaps: async (image) => { + const owner = captureAccountScope(); + const project = queries.getProject(projectId ?? queries.getSnapshot().activeProject.id); + const boardId = project ? getGalleryAutoAddBoardId(getProjectWidgetValues(project, 'gallery')) : null; + + try { + const submission = await submitUtilityGraph({ + graph: buildPbrMapsGraph({ boardId, imageName: image.imageName }), + hub: socketHub, + }); + + assertAccountScopeCurrent(owner); + recordSuccess( + t('widgets.gallery.itemActions.pbrMaps.queued'), + t('widgets.gallery.itemActions.pbrMaps.queuedMessage', { name: image.imageName }) + ); + // The utility item is not project work, so the queue runtime does not refresh the gallery for it. + void submission.settled.then( + () => { + if (!isAccountScopeCurrent(owner)) { + return; + } + + void invalidateGallery(queryClient, owner); + recordSuccess( + t('widgets.gallery.itemActions.pbrMaps.completed'), + t('widgets.gallery.itemActions.pbrMaps.completedMessage', { name: image.imageName }) + ); + }, + (error: unknown) => { + // A timeout only ends the watch; the backend item keeps running. + if (isAccountScopeCurrent(owner) && !(error instanceof UtilityQueueError && error.reason === 'timeout')) { + recordError(error); + } + } + ); + } catch (error: unknown) { + if (!isAccountScopeCurrent(owner)) { + return; + } + + notifications.add({ + kind: 'error', + message: toErrorMessage(error), + title: t('widgets.gallery.itemActions.pbrMaps.failed'), + }); + } + }, getImageRecallCapabilities: async (image, signal) => { const owner = captureAccountScope(); const requestSignal = signal ? AbortSignal.any([signal, owner.signal]) : owner.signal; diff --git a/invokeai/frontend/webv2/src/workbench/settings/CustomSettingsEditors.tsx b/invokeai/frontend/webv2/src/workbench/settings/CustomSettingsEditors.tsx index dea145b0f6a..bd03d651288 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/CustomSettingsEditors.tsx +++ b/invokeai/frontend/webv2/src/workbench/settings/CustomSettingsEditors.tsx @@ -22,6 +22,7 @@ import { clearWorkspaceData, rememberWorkspaceClearFailure } from './clearWorksp import { GenerationDevicesSettings } from './GenerationDevicesSettings'; import { HotkeysSettingsSection } from './HotkeysSettingsSection'; import { ImageMapVocabularySettings } from './ImageMapVocabularySettings'; +import { ImageStorageSettings } from './ImageStorageSettings'; import { clearWorkbenchSettings, DEFAULT_PREFERENCES, @@ -313,6 +314,9 @@ const CustomSettingField = ({ field, onReveal }: SettingFieldProps) => { case 'imageMapVocabulary': editor = ; break; + case 'imageStorage': + editor = ; + break; default: return null; } diff --git a/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.browser.test.tsx new file mode 100644 index 00000000000..8094c553706 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.browser.test.tsx @@ -0,0 +1,265 @@ +import type * as GalleryQueryCacheModule from '@features/gallery/data/queryCache'; +import type * as HttpModule from '@platform/transport/http'; + +import { ChakraProvider } from '@chakra-ui/react'; +import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { ApiError } from '@platform/transport/http'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { system } from '@theme/system'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { userEvent } from 'vitest/browser'; + +import { ImageStorageSettings } from './ImageStorageSettings'; + +const mocks = vi.hoisted(() => ({ + apiFetchJson: vi.fn<(path: string, init?: RequestInit) => Promise>(), + canManageAppConfig: true, + invalidateGallery: vi.fn((..._args: unknown[]) => Promise.resolve()), +})); + +vi.mock('@features/identity', () => ({ useCapabilities: () => ({ canManageAppConfig: mocks.canManageAppConfig }) })); +vi.mock('@platform/transport/http', async (importOriginal) => ({ + ...(await importOriginal()), + apiFetchJson: (path: string, init?: RequestInit) => mocks.apiFetchJson(path, init), +})); +vi.mock('@features/gallery/data/queryCache', async (importOriginal) => ({ + ...(await importOriginal()), + invalidateGallery: (...args: unknown[]) => mocks.invalidateGallery(...args), +})); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => [key, ...Object.values(values ?? {})].join(' '), + }), +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const STATUS_ROUTE = '/api/v1/image_moves/status'; +const CONFIG_ROUTE = '/api/v1/app/runtime_config'; + +const idleStatus = (needsMoveCount: number) => ({ + active_job_id: null, + is_running: false, + last_error: null, + latest_job: needsMoveCount === 0 ? { error_message: null, id: 2, state: 'committed' } : null, + needs_move_count: needsMoveCount, + operation: null, +}); +const runningStatus = (operation: 'move_all' | 'recovery') => ({ + active_job_id: 3, + is_running: true, + last_error: null, + latest_job: { error_message: null, id: 3, state: 'moving' }, + needs_move_count: 12, + operation, +}); +const interruptedStatus = { + active_job_id: 3, + is_running: false, + last_error: 'Server stopped during an image move', + latest_job: { error_message: null, id: 3, state: 'moving' }, + needs_move_count: 12, + operation: null, +}; + +/** Routes answer from a mutable table so a test can change what the next poll sees. */ +const routes = new Map unknown>(); +const installRoutes = (table: Record unknown>) => { + routes.clear(); + for (const [key, answer] of Object.entries(table)) { + routes.set(key, answer); + } + mocks.apiFetchJson.mockImplementation((path, init) => { + const answer = routes.get(`${init?.method ?? 'GET'} ${path}`); + + if (!answer) { + return Promise.reject(new Error(`No route for ${init?.method ?? 'GET'} ${path}`)); + } + const value = answer(); + + return value instanceof Error ? Promise.reject(value) : Promise.resolve(value); + }); +}; +const calls = (method: string, path: string) => + mocks.apiFetchJson.mock.calls.filter( + ([calledPath, init]) => calledPath === path && (init?.method ?? 'GET') === method + ); + +let host: HTMLDivElement; +let root: Root; +let queryClient: QueryClient; + +const render = async () => { + await act(() => + root.render( + + + + + + ) + ); +}; +const text = () => host.textContent ?? ''; +const button = (label: string) => + [...host.querySelectorAll('button')].find((candidate) => candidate.textContent?.trim() === label); +const dialog = () => document.querySelector('[role="alertdialog"]'); +const confirmIn = (label: string) => + [...(dialog()?.querySelectorAll('button') ?? [])].find((candidate) => + candidate.textContent?.includes(label) + ); +const trigger = () => host.querySelector('[data-scope="select"][data-part="trigger"]'); +const refetchStatus = () => act(() => queryClient.refetchQueries({ queryKey: ['gallery', 'image-storage'] })); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.canManageAppConfig = true; + accountLifecycle.activate('admin'); + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root.unmount()); + host.remove(); + queryClient.clear(); +}); + +describe('image storage settings', () => { + it('shows a read-only notice to non-admins without reading admin-only routes', async () => { + mocks.canManageAppConfig = false; + await render(); + + expect(text()).toContain('settings.imageStorage.adminOnly'); + expect(mocks.apiFetchJson).not.toHaveBeenCalled(); + }); + + it('reads the layout and move status, and patches only the layout when it changes', async () => { + let layout = 'flat'; + installRoutes({ + [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: layout }, set_fields: [] }), + [`PATCH ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: layout }, set_fields: [] }), + [`GET ${STATUS_ROUTE}`]: () => idleStatus(layout === 'flat' ? 0 : 12), + }); + await render(); + + await expect.poll(() => trigger()?.textContent).toContain('settings.imageStorage.layouts.flat'); + await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 0'); + expect(text()).toContain('settings.imageStorage.latestJob 2 settings.imageStorage.jobStates.committed'); + + await act(() => userEvent.click(trigger()!)); + await expect.poll(() => document.querySelectorAll('[role="option"]').length).toBe(4); + const dateOption = [...document.querySelectorAll('[role="option"]')].find((option) => + option.textContent?.includes('settings.imageStorage.layouts.date') + ); + layout = 'date'; + await act(() => userEvent.click(dateOption!)); + + await expect.poll(() => calls('PATCH', CONFIG_ROUTE).length).toBe(1); + expect(calls('PATCH', CONFIG_ROUTE)[0]?.[1]?.body).toBe(JSON.stringify({ image_subfolder_strategy: 'date' })); + await expect.poll(() => trigger()?.textContent).toContain('settings.imageStorage.layouts.date'); + // The count of images outside the layout is re-read after the change. + await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 12'); + }); + + it('starts a move after confirmation, polls it, and refreshes the gallery once it stops', async () => { + let status: unknown = idleStatus(12); + installRoutes({ + [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'date' } }), + [`GET ${STATUS_ROUTE}`]: () => status, + [`POST /api/v1/image_moves/start`]: () => { + status = runningStatus('move_all'); + return status; + }, + }); + await render(); + await expect.poll(() => button('settings.imageStorage.startMove')?.disabled).toBe(false); + + await act(() => userEvent.click(button('settings.imageStorage.startMove')!)); + await expect.poll(() => dialog()).not.toBeNull(); + expect(dialog()?.textContent).toContain('settings.imageStorage.startConfirmTitle'); + expect(calls('POST', '/api/v1/image_moves/start')).toHaveLength(0); + + await act(() => userEvent.click(confirmIn('settings.imageStorage.startMove')!)); + await expect.poll(() => calls('POST', '/api/v1/image_moves/start').length).toBe(1); + await expect.poll(() => text()).toContain('settings.imageStorage.statusMoving'); + expect(button('settings.imageStorage.startMove')?.disabled).toBe(true); + expect(button('settings.imageStorage.recover')?.disabled).toBe(true); + expect(trigger()?.disabled).toBe(true); + expect(mocks.invalidateGallery).not.toHaveBeenCalled(); + + status = idleStatus(0); + await refetchStatus(); + + await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 0'); + expect(mocks.invalidateGallery).toHaveBeenCalledTimes(1); + expect(button('settings.imageStorage.startMove')?.disabled).toBe(false); + expect(trigger()?.disabled).toBe(false); + }); + + it('offers recovery for an interrupted job and reports a refused start', async () => { + let status: unknown = interruptedStatus; + installRoutes({ + [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'date' } }), + [`GET ${STATUS_ROUTE}`]: () => status, + [`POST /api/v1/image_moves/recover`]: () => { + status = runningStatus('recovery'); + return status; + }, + }); + await render(); + + await expect.poll(() => text()).toContain('settings.imageStorage.statusNeedsRecovery 3'); + expect(text()).toContain('Server stopped during an image move'); + expect(button('settings.imageStorage.startMove')?.disabled).toBe(true); + expect(button('settings.imageStorage.recover')?.disabled).toBe(false); + + await act(() => userEvent.click(button('settings.imageStorage.recover')!)); + await expect.poll(() => dialog()).not.toBeNull(); + await act(() => userEvent.click(confirmIn('settings.imageStorage.recover')!)); + + await expect.poll(() => calls('POST', '/api/v1/image_moves/recover').length).toBe(1); + await expect.poll(() => text()).toContain('settings.imageStorage.statusRecovering'); + }); + + it('shows the server refusal when a move cannot start, and leaves the controls usable', async () => { + installRoutes({ + [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'date' } }), + [`GET ${STATUS_ROUTE}`]: () => idleStatus(12), + [`POST /api/v1/image_moves/start`]: () => new ApiError(JSON.stringify({ detail: 'Queue work is active' }), 409), + }); + await render(); + await expect.poll(() => button('settings.imageStorage.startMove')?.disabled).toBe(false); + + await act(() => userEvent.click(button('settings.imageStorage.startMove')!)); + await expect.poll(() => dialog()).not.toBeNull(); + await act(() => userEvent.click(confirmIn('settings.imageStorage.startMove')!)); + + await expect.poll(() => host.querySelector('[role="alert"]')?.textContent).toBe('Queue work is active'); + expect(button('settings.imageStorage.startMove')?.disabled).toBe(false); + expect(mocks.invalidateGallery).not.toHaveBeenCalled(); + }); + + it('shows a load failure for the status with a retry', async () => { + let fail = true; + installRoutes({ + [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'flat' } }), + [`GET ${STATUS_ROUTE}`]: () => + fail ? new ApiError('{"detail":"Image move service unavailable"}', 503) : idleStatus(0), + }); + await render(); + + await expect.poll(() => host.querySelector('[role="alert"]')?.textContent).toBe('Image move service unavailable'); + expect(button('settings.imageStorage.startMove')?.disabled).toBe(true); + + fail = false; + await act(() => userEvent.click(button('common.retry')!)); + + await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 0'); + expect(button('settings.imageStorage.startMove')?.disabled).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.tsx b/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.tsx new file mode 100644 index 00000000000..42aa3b1c670 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.tsx @@ -0,0 +1,264 @@ +import type { ImageMoveStatus, ImageStorageLayout } from '@features/gallery/queries'; + +import { createListCollection, HStack, Spinner, Stack, Text } from '@chakra-ui/react'; +import { + IMAGE_STORAGE_LAYOUTS, + imageMoveStatusQueryOptions, + imageStorageLayoutQueryOptions, + isImageStorageLayout, + startImageMove, + startImageMoveRecovery, + updateImageStorageLayout, +} from '@features/gallery/queries'; +import { useCapabilities } from '@features/identity'; +import { useScopedAction } from '@platform/react/useScopedAction'; +import { getApiErrorMessage } from '@platform/transport/http'; +import { Button } from '@platform/ui/Button'; +import { ConfirmDialog } from '@platform/ui/ConfirmDialog'; +import { Field } from '@platform/ui/Field'; +import { Select } from '@platform/ui/Select'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +const tKey = (key: string): string => `settings.imageStorage.${key}`; +const CONTROL_WIDTH = { base: 'full', md: '72' } as const; +const SELECT_POSITIONING = { strategy: 'fixed', sameWidth: true, hideWhenDetached: true } as const; + +type ImageMoveAction = 'start' | 'recover'; + +/** Server image storage: the folder layout for new images and the move/recovery job for existing ones. */ +export const ImageStorageSettings = () => { + const { t } = useTranslation(); + const { canManageAppConfig } = useCapabilities(); + + if (!canManageAppConfig) { + return ( + + {t(tKey('adminOnly'))} + + ); + } + + return ; +}; + +const ImageStorageEditor = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + // Both routes are admin-only; the editor only mounts for accounts that can read them. + const layoutQuery = useQuery(imageStorageLayoutQueryOptions()); + const statusQuery = useQuery(imageMoveStatusQueryOptions()); + const { isBusy: isSavingLayout, run: runLayoutSave } = useScopedAction(); + const { isBusy: isActing, run: runAction } = useScopedAction(); + const [layoutError, setLayoutError] = useState(null); + const [actionError, setActionError] = useState(null); + const [pendingAction, setPendingAction] = useState(null); + + const layout = layoutQuery.data ?? 'flat'; + const layoutCollection = useMemo(() => { + const items = IMAGE_STORAGE_LAYOUTS.map((value) => ({ label: t(tKey(`layouts.${value}`)), value })); + + // A layout this build does not know stays selectable as itself rather than silently reading as flat. + if (!isImageStorageLayout(layout)) { + items.push({ label: t(tKey('layouts.unknown'), { layout }), value: layout as ImageStorageLayout }); + } + + return createListCollection({ items }); + }, [layout, t]); + const layoutValue = useMemo(() => [layout], [layout]); + + const handleLayoutChange = useCallback( + ({ value }: { value: string[] }) => { + const next = value[0]; + + if (!isImageStorageLayout(next) || next === layout) { + return; + } + + setLayoutError(null); + void runLayoutSave( + (owner) => updateImageStorageLayout(queryClient, next, owner), + (_message, error) => setLayoutError(getApiErrorMessage(error, t(tKey('layoutSaveFailed')))) + ); + }, + [layout, queryClient, runLayoutSave, t] + ); + + const requestStart = useCallback(() => setPendingAction('start'), []); + const requestRecover = useCallback(() => setPendingAction('recover'), []); + const closeConfirmation = useCallback(() => setPendingAction(null), []); + const confirmAction = useCallback(() => { + const action = pendingAction; + + if (action === null) { + return; + } + + setActionError(null); + void runAction( + async (owner) => { + await (action === 'start' ? startImageMove(queryClient, owner) : startImageMoveRecovery(queryClient, owner)); + }, + (_message, error) => + setActionError(getApiErrorMessage(error, t(tKey(action === 'start' ? 'startFailed' : 'recoverFailed')))) + ); + }, [pendingAction, queryClient, runAction, t]); + const retryStatus = useCallback(() => void statusQuery.refetch(), [statusQuery]); + const retryLayout = useCallback(() => void layoutQuery.refetch(), [layoutQuery]); + + const status = statusQuery.data ?? null; + const isRunning = status?.isRunning ?? false; + const hasActiveJob = status !== null && status.activeJobId !== null; + const isStatusUsable = status !== null && !statusQuery.isError; + const isBusy = isRunning || isActing || !isStatusUsable; + + return ( + + + {layoutQuery.isError ? ( + + + {getApiErrorMessage(layoutQuery.error, t(tKey('layoutLoadFailed')))} + + + + ) : ( + { /> )} - + {t(tKey('maintenanceHeading'))} @@ -189,6 +197,7 @@ const ImageStorageEditor = () => { } }; -const startThumbnailRoute: BrowserCommand<[pathname: string]> = async ({ context, sessionId }, pathname) => { +/** Answers `failures` requests for the thumbnail with `status`, then serves it; the default is one 404. */ +const startThumbnailRoute: BrowserCommand< + [pathname: string, options?: { failures?: number; status?: number }] +> = async ({ context, sessionId }, pathname, { failures = 1, status: failureStatus = 404 } = {}) => { await stopThumbnailRoute(context, sessionId); const path = new URL(pathname, 'http://localhost').pathname; const pattern = `**${path}*`; @@ -65,9 +68,9 @@ const startThumbnailRoute: BrowserCommand<[pathname: string]> = async ({ context } state.urls.push(route.request().url()); - const status = state.urls.length === 1 ? 404 : 200; + const status = state.urls.length <= failures ? failureStatus : 200; state.statuses.push(status); - if (status === 404) { + if (status !== 200) { await route.fulfill({ body: 'thumbnail missing', contentType: 'text/plain', status }); } else { await route.fulfill({ From 59bf20db0b92cdf9662cf8ce78554feefb6b9747 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sun, 25 Oct 2026 00:38:04 -0600 Subject: [PATCH 05/10] fix(gallery): recover images after an image move of any length One shared probe per account replaces the capped per-tile retry: it keeps asking while the server answers 409 or is unreachable, then reloads every failed thumbnail and the Preview image once. Recovery state is keyed by URL so reused slots load plain URLs; the leaves join the route-shared startup chunk. --- .../performance/architecture-baseline.json | 4 + .../webv2/performance/browser-baseline.json | 8 + .../webv2/src/features/gallery/README.md | 12 + .../gallery/data/imageRecovery.test.ts | 290 ++++++++++++++++++ .../features/gallery/data/imageRecovery.ts | 199 ++++++++++++ .../features/gallery/data/queryCache.test.ts | 109 +------ .../src/features/gallery/data/queryCache.ts | 71 ----- .../webv2/src/features/gallery/queries.ts | 1 - .../webv2/src/features/gallery/react.test.ts | 6 +- .../webv2/src/features/gallery/react.ts | 8 +- .../ui/GalleryLoadFailures.browser.test.tsx | 104 +++++-- .../features/gallery/ui/GalleryThumbnail.tsx | 29 +- .../useGalleryImageRecovery.browser.test.tsx | 83 +++++ .../gallery/ui/useGalleryImageRecovery.ts | 45 +++ .../GalleryMaintenance.browser.test.tsx | 11 +- .../widgets/preview/PreviewFrame.tsx | 12 +- .../PreviewNavigation.browser.test.tsx | 38 ++- .../widgets/preview/PreviewWidgetView.tsx | 10 +- invokeai/frontend/webv2/vite.config.mts | 4 + .../frontend/webv2/vitest.browser.config.mts | 58 ++-- 20 files changed, 840 insertions(+), 262 deletions(-) create mode 100644 invokeai/frontend/webv2/src/features/gallery/data/imageRecovery.test.ts create mode 100644 invokeai/frontend/webv2/src/features/gallery/data/imageRecovery.ts create mode 100644 invokeai/frontend/webv2/src/features/gallery/ui/useGalleryImageRecovery.browser.test.tsx create mode 100644 invokeai/frontend/webv2/src/features/gallery/ui/useGalleryImageRecovery.ts diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index 201a7296cb6..5124acd2e97 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -135,6 +135,8 @@ "source:src/features/fonts/launchpad.tsx", "source:src/features/fonts/react.tsx", "source:src/features/fonts/runtime.ts", + "source:src/features/gallery/data/imageRecovery.ts", + "source:src/features/gallery/ui/useGalleryImageRecovery.ts", "source:src/features/identity/capabilities.ts", "source:src/features/identity/core/schemas.ts", "source:src/features/identity/core/sessionPolicy.ts", @@ -515,6 +517,7 @@ "source:src/features/gallery/core/settings.ts", "source:src/features/gallery/core/videoPaths.ts", "source:src/features/gallery/data/backend.ts", + "source:src/features/gallery/data/imageRecovery.ts", "source:src/features/gallery/data/imageUrls.ts", "source:src/features/gallery/data/queries.ts", "source:src/features/gallery/data/queryCache.ts", @@ -543,6 +546,7 @@ "source:src/features/gallery/ui/galleryStateView.ts", "source:src/features/gallery/ui/picker/GalleryPickerPopover.tsx", "source:src/features/gallery/ui/useGalleryData.ts", + "source:src/features/gallery/ui/useGalleryImageRecovery.ts", "source:src/features/gallery/ui/useGalleryUploadAction.ts", "source:src/features/gallery/ui/useGalleryUploadInput.ts", "source:src/features/gallery/utility.ts", diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 4e5834ee39f..122a5f42a1e 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -521,6 +521,7 @@ "source:src/features/gallery/core/settings.ts", "source:src/features/gallery/core/videoPaths.ts", "source:src/features/gallery/data/backend.ts", + "source:src/features/gallery/data/imageRecovery.ts", "source:src/features/gallery/data/imageUrls.ts", "source:src/features/gallery/data/videoUrls.ts", "source:src/features/gallery/launchpad.ts", @@ -528,6 +529,7 @@ "source:src/features/gallery/ui/GalleryUiContext.tsx", "source:src/features/gallery/ui/galleryStateView.ts", "source:src/features/gallery/ui/launchpad/RecentOutputs.tsx", + "source:src/features/gallery/ui/useGalleryImageRecovery.ts", "source:src/features/identity/capabilities.ts", "source:src/features/identity/core/schemas.ts", "source:src/features/identity/core/sessionPolicy.ts", @@ -912,6 +914,7 @@ "source:src/features/gallery/core/settings.ts", "source:src/features/gallery/core/videoPaths.ts", "source:src/features/gallery/data/backend.ts", + "source:src/features/gallery/data/imageRecovery.ts", "source:src/features/gallery/data/imageUrls.ts", "source:src/features/gallery/data/queries.ts", "source:src/features/gallery/data/queryCache.ts", @@ -968,6 +971,7 @@ "source:src/features/gallery/ui/useGalleryData.ts", "source:src/features/gallery/ui/useGalleryGridHotkeys.ts", "source:src/features/gallery/ui/useGalleryGridSelection.ts", + "source:src/features/gallery/ui/useGalleryImageRecovery.ts", "source:src/features/gallery/ui/useGalleryStarredStrip.ts", "source:src/features/gallery/ui/useGalleryUploadAction.ts", "source:src/features/gallery/ui/useGalleryUploadInput.ts", @@ -2012,6 +2016,7 @@ "source:src/features/gallery/core/settings.ts", "source:src/features/gallery/core/videoPaths.ts", "source:src/features/gallery/data/backend.ts", + "source:src/features/gallery/data/imageRecovery.ts", "source:src/features/gallery/data/imageUrls.ts", "source:src/features/gallery/data/queries.ts", "source:src/features/gallery/data/queryCache.ts", @@ -2068,6 +2073,7 @@ "source:src/features/gallery/ui/useGalleryData.ts", "source:src/features/gallery/ui/useGalleryGridHotkeys.ts", "source:src/features/gallery/ui/useGalleryGridSelection.ts", + "source:src/features/gallery/ui/useGalleryImageRecovery.ts", "source:src/features/gallery/ui/useGalleryStarredStrip.ts", "source:src/features/gallery/ui/useGalleryUploadAction.ts", "source:src/features/gallery/ui/useGalleryUploadInput.ts", @@ -3287,6 +3293,7 @@ "source:src/features/gallery/core/settings.ts", "source:src/features/gallery/core/videoPaths.ts", "source:src/features/gallery/data/backend.ts", + "source:src/features/gallery/data/imageRecovery.ts", "source:src/features/gallery/data/imageUrls.ts", "source:src/features/gallery/data/queries.ts", "source:src/features/gallery/data/queryCache.ts", @@ -3343,6 +3350,7 @@ "source:src/features/gallery/ui/useGalleryData.ts", "source:src/features/gallery/ui/useGalleryGridHotkeys.ts", "source:src/features/gallery/ui/useGalleryGridSelection.ts", + "source:src/features/gallery/ui/useGalleryImageRecovery.ts", "source:src/features/gallery/ui/useGalleryStarredStrip.ts", "source:src/features/gallery/ui/useGalleryUploadAction.ts", "source:src/features/gallery/ui/useGalleryUploadInput.ts", diff --git a/invokeai/frontend/webv2/src/features/gallery/README.md b/invokeai/frontend/webv2/src/features/gallery/README.md index 0cd4324bd50..4fdfc6f2541 100644 --- a/invokeai/frontend/webv2/src/features/gallery/README.md +++ b/invokeai/frontend/webv2/src/features/gallery/README.md @@ -33,3 +33,15 @@ Known limitations: ## Board destinations Where results and uploads land is decided from a cached board listing, never guessed: `canAddToCachedGalleryBoard` (`features/gallery/data/queryCache.ts`) takes a board as a destination only when a cached listing holds it with `add_media`, archived or not; a board no listing holds is not one. Submissions and uploads call `ensureGalleryBoardDestinations` first. It resolves optimistically: any cached listing of every readable board (archived included) decides at once, whatever its age; one older than a minute or invalidated is read again in the background for the next resolution; only with no such listing cached is one read awaited, and if that read fails nothing is a destination and results go to the inbox. No surface observes that listing just in case: the Gallery panel's own listing serves when it shows archived boards, and otherwise the first submission or upload reads one. A failed gallery-bound run, workflow run or upload, including one a board refused with 403 or answered 404 for, calls `invalidateGalleryBoardDestinations`: the gallery is invalidated and the listing of every readable board is read again at once, with no observer needed. A submission or upload made after that read lands decides from the new listing; one made before it lands still decides from the old listing and shares the read. The gallery upload action refuses a real board the ensured listing does not take uploads for before sending anything, and says whether the board is view-only or gone. Graph compilation applies the same predicate to explicit workflow board fields; a refused board resolves like Auto. A board the server answers 403 for exists but is unreadable: it stays named by the project and is shown as the project board meanwhile; only a 404 (deleted) reconciles the project's references. Hosts without board sharing fold every board into the caller's own through `useHostGalleryBoards`, which the panel, board menus and workflow board fields share. + +## Image storage and failed image loads + +`features/gallery/storage` is the admin-only entry for server image storage: the subfolder layout setting and the image move/recovery job (`data/imageStorage.ts`). It stays apart from `queries` so the editor's gallery reads never load that transport. When a move this client follows stops, it invalidates the gallery listings and refreshes mounted thumbnails. + +While a move runs, the server answers every thumbnail and full image read with 409, for every user, and nothing tells a non-admin tab when it ends. An image element's `onError` carries no status, so gallery thumbnails and the Preview image report their failed URL through `useGalleryImageRecovery` (`ui/useGalleryImageRecovery.ts`) to one shared probe (`data/imageRecovery.ts`), never retrying per element. The probe re-requests one failed URL at a time with `fetch`, uncached, so it can read the status: + +- 409, 502, 503, 504, or no answer at all: the server cannot serve images yet, because a move holds the files or the server is restarting mid-move. It asks again 15 s later, then every 30 s, without a cap, paused while the document is hidden and resumed as soon as it is shown. The URL stays waiting. +- 2xx with an image content type: images are served again. The recovery revision rises and every image that failed before it reloads once, with a `gallery_image_recovery` query parameter so the browser asks again. Images that loaded keep their URL. Each failure reports the revision its element's URL carried. A failure that already carried the current revision, and was not refused by the server since, failed after the server served it again: that image is broken, not waiting, and is dropped. A failure of a plain or older URL always recovers. +- Anything else (404, another refusal, a page in place of the image): that URL is dropped. Other failed URLs are probed next. + +The first probe waits 2 s, so a screen of tiles failing together costs one request. Recovery state belongs to the URL an element shows: an index-keyed slot handed another item asks for that item's plain URL. The probe lives for one account lifetime: an account change aborts its request, clears its timer and forgets its URLs. diff --git a/invokeai/frontend/webv2/src/features/gallery/data/imageRecovery.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/imageRecovery.test.ts new file mode 100644 index 00000000000..b3021e8bac7 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/gallery/data/imageRecovery.test.ts @@ -0,0 +1,290 @@ +import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getGalleryImageRecoveryRevision, + reportGalleryImageFailure, + subscribeGalleryImageRecovery, +} from './imageRecovery'; + +const SECOND = 1_000; + +class FakeDocument extends EventTarget { + visibilityState: DocumentVisibilityState = 'visible'; + + show(): void { + this.visibilityState = 'visible'; + this.dispatchEvent(new Event('visibilitychange')); + } +} + +let fakeDocument: FakeDocument; +/** What the server answers every image read with right now. */ +let serverStatus: number; +const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>(); +const requestedUrls = () => fetchMock.mock.calls.map(([url]) => url); + +const answer = (status: number, contentType = 'image/webp') => + new Response(status === 200 ? 'image' : null, { headers: { 'content-type': contentType }, status }); +const thumbnail = (index: number) => `/api/v1/images/i/image-${String(index)}.png/thumbnail`; + +describe('Gallery image recovery probe', () => { + beforeEach(() => { + vi.useFakeTimers(); + fakeDocument = new FakeDocument(); + serverStatus = 409; + fetchMock.mockReset().mockImplementation(() => Promise.resolve(answer(serverStatus))); + vi.stubGlobal('document', fakeDocument); + vi.stubGlobal('fetch', fetchMock); + // A fresh account lifetime per test: it also drops whatever probe the previous test left. + accountLifecycle.activate('image-recovery-user'); + }); + + afterEach(() => { + accountLifecycle.invalidate(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('asks about one failed image per interval for as long as a move runs, then reloads every failed image once', async () => { + const revision = getGalleryImageRecoveryRevision(); + const listener = vi.fn(); + const unsubscribe = subscribeGalleryImageRecovery(listener); + + // A screen of tiles fails at once, some of them twice. + for (let index = 0; index < 40; index += 1) { + reportGalleryImageFailure(thumbnail(index), 0); + } + reportGalleryImageFailure(thumbnail(3), 0); + + await vi.advanceTimersByTimeAsync(2 * SECOND); + expect(requestedUrls()).toEqual([thumbnail(0)]); + + // 409: asked again 15 s later, then every 30 s, for as long as the move runs. + await vi.advanceTimersByTimeAsync(15 * SECOND - 1); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + for (let probe = 3; probe <= 22; probe += 1) { + await vi.advanceTimersByTimeAsync(30 * SECOND - 1); + expect(fetchMock).toHaveBeenCalledTimes(probe - 1); + await vi.advanceTimersByTimeAsync(1); + expect(fetchMock).toHaveBeenCalledTimes(probe); + } + expect(new Set(requestedUrls())).toEqual(new Set([thumbnail(0)])); + expect(getGalleryImageRecoveryRevision()).toBe(revision); + // The probe reads the status itself, uncached, under the account's lifetime. + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ cache: 'no-store' }); + + // The move ends: the next probe sees the image served and every failed image reloads, once. + serverStatus = 200; + await vi.advanceTimersByTimeAsync(30 * SECOND); + expect(fetchMock).toHaveBeenCalledTimes(23); + expect(getGalleryImageRecoveryRevision()).toBe(revision + 1); + expect(listener).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(10 * 60 * SECOND); + expect(fetchMock).toHaveBeenCalledTimes(23); + expect(vi.getTimerCount()).toBe(0); + unsubscribe(); + }); + + it('stops probing an image the server says is missing', async () => { + const revision = getGalleryImageRecoveryRevision(); + serverStatus = 404; + reportGalleryImageFailure(thumbnail(1), 0); + + await vi.advanceTimersByTimeAsync(2 * SECOND); + expect(requestedUrls()).toEqual([thumbnail(1)]); + + await vi.advanceTimersByTimeAsync(10 * 60 * SECOND); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + expect(getGalleryImageRecoveryRevision()).toBe(revision); + }); + + it('drops a missing image and goes on to the next failed one', async () => { + fetchMock.mockImplementation((url) => Promise.resolve(answer(url === thumbnail(1) ? 404 : 409))); + reportGalleryImageFailure(thumbnail(1), 0); + reportGalleryImageFailure(thumbnail(2), 0); + + await vi.advanceTimersByTimeAsync(2 * SECOND); + await vi.advanceTimersByTimeAsync(2 * SECOND); + expect(requestedUrls()).toEqual([thumbnail(1), thumbnail(2)]); + + await vi.advanceTimersByTimeAsync(15 * SECOND); + expect(requestedUrls()).toEqual([thumbnail(1), thumbnail(2), thumbnail(2)]); + }); + + it('does not take a page served in place of the image for the image', async () => { + const revision = getGalleryImageRecoveryRevision(); + fetchMock.mockImplementation(() => Promise.resolve(answer(200, 'text/html'))); + reportGalleryImageFailure(thumbnail(1), 0); + + await vi.advanceTimersByTimeAsync(10 * 60 * SECOND); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(getGalleryImageRecoveryRevision()).toBe(revision); + }); + + it('does not reload again an image that still fails while the server serves it', async () => { + const revision = getGalleryImageRecoveryRevision(); + serverStatus = 200; + reportGalleryImageFailure(thumbnail(1), 0); + await vi.advanceTimersByTimeAsync(2 * SECOND); + expect(getGalleryImageRecoveryRevision()).toBe(revision + 1); + + // The reloaded image fails again (a broken file, say) although the server answers 200 for it. + reportGalleryImageFailure(thumbnail(1), revision + 1); + await vi.advanceTimersByTimeAsync(2 * SECOND); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(getGalleryImageRecoveryRevision()).toBe(revision + 1); + + await vi.advanceTimersByTimeAsync(10 * 60 * SECOND); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + }); + + it('recovers a reloaded image again when a later move refuses it', async () => { + const revision = getGalleryImageRecoveryRevision(); + serverStatus = 200; + reportGalleryImageFailure(thumbnail(1), 0); + await vi.advanceTimersByTimeAsync(2 * SECOND); + expect(getGalleryImageRecoveryRevision()).toBe(revision + 1); + + // A second move refuses the reloaded image; it recovers when that move ends too. + serverStatus = 409; + reportGalleryImageFailure(thumbnail(1), revision + 1); + await vi.advanceTimersByTimeAsync(2 * SECOND); + serverStatus = 200; + await vi.advanceTimersByTimeAsync(15 * SECOND); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(getGalleryImageRecoveryRevision()).toBe(revision + 2); + }); + + it('keeps waiting through a server restart mid-move, then reloads every failed image once', async () => { + const revision = getGalleryImageRecoveryRevision(); + const listener = vi.fn(); + const unsubscribe = subscribeGalleryImageRecovery(listener); + // The server goes down mid-move (connections refused), comes back unavailable, resumes the move, then serves. + const answers: (() => Promise)[] = [ + () => Promise.reject(new TypeError('Failed to fetch')), + () => Promise.reject(new TypeError('Failed to fetch')), + () => Promise.reject(new TypeError('Failed to fetch')), + () => Promise.resolve(answer(503)), + () => Promise.resolve(answer(502)), + () => Promise.resolve(answer(409)), + () => Promise.resolve(answer(200)), + ]; + fetchMock.mockImplementation(() => answers[fetchMock.mock.calls.length - 1]!()); + for (let index = 0; index < 3; index += 1) { + reportGalleryImageFailure(thumbnail(index), 0); + } + + await vi.advanceTimersByTimeAsync(10 * 60 * SECOND); + + expect(fetchMock).toHaveBeenCalledTimes(answers.length); + // No answer drained the failed images: the one that kept being asked about recovered them all. + expect(new Set(requestedUrls())).toEqual(new Set([thumbnail(0)])); + expect(getGalleryImageRecoveryRevision()).toBe(revision + 1); + expect(listener).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + unsubscribe(); + }); + + it('recovers an image that fails on its plain URL during a later short move', async () => { + const revision = getGalleryImageRecoveryRevision(); + serverStatus = 200; + reportGalleryImageFailure(thumbnail(1), 0); + await vi.advanceTimersByTimeAsync(2 * SECOND); + expect(getGalleryImageRecoveryRevision()).toBe(revision + 1); + + // A short move runs; a tile mounted during it fails on the plain URL, and the move is over by the first probe. + reportGalleryImageFailure(thumbnail(1), 0); + await vi.advanceTimersByTimeAsync(2 * SECOND); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(getGalleryImageRecoveryRevision()).toBe(revision + 2); + }); + + it('does not ask again after the account change aborts its request', async () => { + fetchMock.mockImplementation( + (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); + }) + ); + reportGalleryImageFailure(thumbnail(1), 0); + await vi.advanceTimersByTimeAsync(2 * SECOND); + expect(fetchMock).toHaveBeenCalledOnce(); + + accountLifecycle.activate('image-recovery-other-user'); + await vi.advanceTimersByTimeAsync(10 * 60 * SECOND); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('pauses while the tab is hidden and asks as soon as it is shown', async () => { + fakeDocument.visibilityState = 'hidden'; + reportGalleryImageFailure(thumbnail(1), 0); + + await vi.advanceTimersByTimeAsync(10 * 60 * SECOND); + expect(fetchMock).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + + fakeDocument.show(); + await vi.advanceTimersByTimeAsync(0); + expect(requestedUrls()).toEqual([thumbnail(1)]); + }); + + it('stops for good when the account changes, and forgets the old account’s images', async () => { + const revision = getGalleryImageRecoveryRevision(); + reportGalleryImageFailure(thumbnail(1), 0); + await vi.advanceTimersByTimeAsync(2 * SECOND); + expect(fetchMock).toHaveBeenCalledOnce(); + const firstSignal = fetchMock.mock.calls[0]?.[1]?.signal; + + accountLifecycle.activate('image-recovery-other-user'); + expect(firstSignal?.aborted).toBe(true); + serverStatus = 200; + await vi.advanceTimersByTimeAsync(10 * 60 * SECOND); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + expect(getGalleryImageRecoveryRevision()).toBe(revision); + + // A failure in the new account starts its own probe, which never asks about the old account's image. + reportGalleryImageFailure(thumbnail(2), 0); + await vi.advanceTimersByTimeAsync(2 * SECOND); + expect(requestedUrls()).toEqual([thumbnail(1), thumbnail(2)]); + expect(getGalleryImageRecoveryRevision()).toBe(revision + 1); + }); + + it('stops a probe whose request is out when the account changes', async () => { + const revision = getGalleryImageRecoveryRevision(); + let resolveAnswer!: (response: Response) => void; + fetchMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveAnswer = resolve; + }) + ); + reportGalleryImageFailure(thumbnail(1), 0); + await vi.advanceTimersByTimeAsync(2 * SECOND); + + accountLifecycle.activate('image-recovery-other-user'); + resolveAnswer(answer(200)); + await vi.advanceTimersByTimeAsync(10 * 60 * SECOND); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(getGalleryImageRecoveryRevision()).toBe(revision); + }); + + it('ignores failures reported for an account lifetime that has ended', async () => { + const staleOwner = accountLifecycle.capture(); + accountLifecycle.activate('image-recovery-other-user'); + + reportGalleryImageFailure(thumbnail(1), 0, staleOwner); + await vi.advanceTimersByTimeAsync(10 * 60 * SECOND); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/gallery/data/imageRecovery.ts b/invokeai/frontend/webv2/src/features/gallery/data/imageRecovery.ts new file mode 100644 index 00000000000..3d6c98c45c6 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/gallery/data/imageRecovery.ts @@ -0,0 +1,199 @@ +import type { AccountScope } from '@platform/state/accountLifecycle'; + +import { captureAccountScope, isAccountScopeCurrent } from '@platform/state/accountLifecycle'; + +/** Gathers the failures a screen of images reports together into one probe. */ +const GALLERY_IMAGE_PROBE_FIRST_DELAY_MS = 2_000; +/** + * The waits between probes while the server is not serving images yet (409 during a move, or down while it + * restarts); the last repeats for as long as that lasts. + */ +const GALLERY_IMAGE_PROBE_BUSY_DELAYS_MS: readonly number[] = [15_000, 30_000]; + +/** Probing for one account lifetime; dropped with it. */ +interface ImageRecoveryProbe { + /** + * Failed image URLs waiting for the server to serve images again, oldest first, each with the recovery revision + * its failed load carried (0 for the plain URL). + */ + readonly failed: Map; + /** Waiting answers since the last success, which pick the next wait. */ + busyAnswers: number; + readonly owner: AccountScope; + timer: ReturnType | null; + /** Set while a probe waits for the document to be shown. */ + visibilityListener: (() => void) | null; + /** Set while a probe request is out. */ + url: string | null; +} + +let probe: ImageRecoveryProbe | null = null; +let recoveryRevision = 0; +const recoveryListeners = new Set<() => void>(); + +/** Rises each time a probe sees the server serve a failed image again; images that failed before it reload. */ +export const getGalleryImageRecoveryRevision = (): number => recoveryRevision; + +export const subscribeGalleryImageRecovery = (listener: () => void): (() => void) => { + recoveryListeners.add(listener); + return () => recoveryListeners.delete(listener); +}; + +export const getRecoveredGalleryImageUrl = (url: string, revision: number): string => { + if (revision === 0) { + return url; + } + + const recoveredUrl = new URL(url, window.location.href); + recoveredUrl.searchParams.set('gallery_image_recovery', String(revision)); + return recoveredUrl.toString(); +}; + +const clearWait = (current: ImageRecoveryProbe): void => { + if (current.timer !== null) { + clearTimeout(current.timer); + current.timer = null; + } + if (current.visibilityListener !== null) { + document.removeEventListener('visibilitychange', current.visibilityListener); + current.visibilityListener = null; + } +}; + +const dropProbe = (current: ImageRecoveryProbe): void => { + clearWait(current); + if (probe === current) { + probe = null; + } +}; + +const schedule = (current: ImageRecoveryProbe, delayMs: number): void => { + current.timer = setTimeout(() => { + current.timer = null; + // Paused while hidden: nobody sees the images, and the probe runs as soon as the tab is shown. + if (document.visibilityState === 'hidden') { + current.visibilityListener = () => { + if (document.visibilityState !== 'hidden') { + clearWait(current); + void runProbe(current); + } + }; + document.addEventListener('visibilitychange', current.visibilityListener); + return; + } + void runProbe(current); + }, delayMs); +}; + +/** Statuses that mean the server cannot serve images yet: a move holds the files, or the server is restarting. */ +const WAITING_STATUSES = new Set([409, 502, 503, 504]); + +/** + * The probe's answer: `wait` while the server cannot serve images yet, `served` once it serves the image again, + * `other` for anything that will not change by waiting (missing, refused, or a page in place of the image). + */ +const readAnswer = async (url: string, signal: AbortSignal): Promise<'other' | 'served' | 'wait'> => { + let response: Response; + + try { + response = await fetch(url, { cache: 'no-store', signal }); + } catch { + // No answer at all: a server restarting mid-move refuses connections until it is back. An abort from the owner + // also lands here; its probe is already dropped and does not reschedule. + return 'wait'; + } + + // Only the answer matters; the image itself is read again by the elements that failed. + void response.body?.cancel().catch(() => undefined); + if (WAITING_STATUSES.has(response.status)) { + return 'wait'; + } + // A page in place of the image (a proxy's fallback or sign-in page) is not the image served again. + return response.ok && response.headers.get('content-type')?.startsWith('image/') ? 'served' : 'other'; +}; + +const runProbe = async (current: ImageRecoveryProbe): Promise => { + const [entry] = current.failed; + + if (entry === undefined) { + return; + } + + const [url, failedRevision] = entry; + + current.url = url; + const answer = await readAnswer(url, current.owner.signal); + current.url = null; + + if (probe !== current) { + return; + } + + if (answer === 'wait') { + // A move or restart can last minutes and nothing announces its end to this tab. Whatever that image's earlier + // reloads did, this failure was the server's, so the next success reloads it. + const delays = GALLERY_IMAGE_PROBE_BUSY_DELAYS_MS; + current.busyAnswers += 1; + current.failed.set(url, 0); + schedule(current, delays[Math.min(current.busyAnswers, delays.length) - 1] ?? 0); + return; + } + + // An image whose failed load already carried the current recovery revision failed after the server served it + // again: it is broken, not waiting, and another reload would not help. + const isFreshFailure = failedRevision === 0 || failedRevision < recoveryRevision; + + if (answer === 'served' && isFreshFailure) { + current.failed.clear(); + current.busyAnswers = 0; + recoveryRevision += 1; + recoveryListeners.forEach((listener) => listener()); + return; + } + + // Missing, refused for another reason, not an image, or served yet still failing after a reload: not waiting. + current.failed.delete(url); + if (current.failed.size > 0) { + schedule(current, GALLERY_IMAGE_PROBE_FIRST_DELAY_MS); + } +}; + +/** + * An image element failed to load `url`, carrying recovery `revision` (0 for the plain URL). An element cannot see + * why, and during an image move the server answers every image read with 409 for minutes. One probe per account + * lifetime asks for one failed URL at a time with a request that can see the status: while the server cannot serve + * images (409, 502-504, no connection) the probe keeps asking, paused while the tab is hidden; a success raises the + * recovery revision so every image that failed before it reloads once; any other answer drops that URL. An account + * change stops the probe and forgets its URLs. + */ +export const reportGalleryImageFailure = ( + url: string, + revision: number, + owner: AccountScope = captureAccountScope() +): void => { + if (!isAccountScopeCurrent(owner) || /^(?:blob|data):/i.test(url)) { + return; + } + + // The previous lifetime's probe dropped itself when that lifetime ended. + if (probe?.owner !== owner) { + const created: ImageRecoveryProbe = { + busyAnswers: 0, + failed: new Map(), + owner, + timer: null, + url: null, + visibilityListener: null, + }; + owner.signal.addEventListener('abort', () => dropProbe(created), { once: true }); + probe = created; + } + + // Elements sharing a URL may carry different revisions; a fresh failure among them must still recover. + const reported = probe.failed.get(url); + probe.failed.set(url, reported === undefined ? revision : Math.min(reported, revision)); + + if (probe.timer === null && probe.visibilityListener === null && probe.url === null) { + schedule(probe, GALLERY_IMAGE_PROBE_FIRST_DELAY_MS); + } +}; diff --git a/invokeai/frontend/webv2/src/features/gallery/data/queryCache.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/queryCache.test.ts index 8cc9b55116e..c065cca9ae8 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/queryCache.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/queryCache.test.ts @@ -6,7 +6,7 @@ import type { AccountScope } from '@platform/state/accountLifecycle'; import { getImageCluster, registerImageCluster } from '@features/gallery/core/semanticImageQuery'; import { accountLifecycle, captureAccountScope } from '@platform/state/accountLifecycle'; import { InfiniteQueryObserver, QueryClient, type InfiniteData } from '@tanstack/react-query'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ALL_READABLE_BOARDS_ID, @@ -24,7 +24,6 @@ import { import { addGalleryBoardToCaches, canAddToCachedGalleryBoard, - createGalleryThumbnailRetryController, ensureGalleryBoardDestinations, getCachedGalleryBoard, getGalleryItemBoardIdsFromCaches, @@ -1286,109 +1285,3 @@ describe('Gallery cache invalidation', () => { expect(client.getQueryState(oldBoardsKey)?.isInvalidated).toBe(false); }); }); - -describe('Gallery thumbnail retry', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('retries on the given backoff, ignores errors while a retry is pending, and gives up once the delays are spent', () => { - const controller = createGalleryThumbnailRetryController([3_000, 6_000, 30_000]); - const listener = vi.fn(); - controller.subscribe(listener); - - controller.onError('/thumb/a'); - vi.advanceTimersByTime(2_999); - expect(controller.getNonce()).toBe(0); - vi.advanceTimersByTime(1); - expect(controller.getNonce()).toBe(1); - expect(listener).toHaveBeenCalledTimes(1); - - // The reloaded image fails again; a second error report while the retry is pending does not add one. - controller.onError('/thumb/a'); - controller.onError('/thumb/a'); - vi.advanceTimersByTime(5_999); - expect(controller.getNonce()).toBe(1); - vi.advanceTimersByTime(1); - expect(controller.getNonce()).toBe(2); - - controller.onError('/thumb/a'); - vi.advanceTimersByTime(30_000); - expect(controller.getNonce()).toBe(3); - - // The attempts are spent: the tile stops asking until something loads. - controller.onError('/thumb/a'); - vi.advanceTimersByTime(120_000); - expect(controller.getNonce()).toBe(3); - expect(vi.getTimerCount()).toBe(0); - expect(listener).toHaveBeenCalledTimes(3); - }); - - it('a load resets the backoff but keeps the nonce, and stop drops only the pending retry', () => { - const controller = createGalleryThumbnailRetryController([3_000, 6_000, 12_000]); - - controller.onError('/thumb/a'); - vi.advanceTimersByTime(3_000); - expect(controller.getNonce()).toBe(1); - - controller.onError('/thumb/a'); - controller.onLoad(); - vi.advanceTimersByTime(60_000); - // No reload of the image that just loaded, and no pending retry. - expect(controller.getNonce()).toBe(1); - expect(vi.getTimerCount()).toBe(0); - - // A later failure starts the backoff over from the first delay. - controller.onError('/thumb/a'); - vi.advanceTimersByTime(3_000); - expect(controller.getNonce()).toBe(2); - - controller.onError('/thumb/a'); - controller.stop(); - vi.advanceTimersByTime(60_000); - expect(controller.getNonce()).toBe(2); - expect(vi.getTimerCount()).toBe(0); - - // Stopping is not disposal: the controller still works, as StrictMode's rehearsal unmount requires. The dropped - // retry still counted as an attempt, so the next one waits the third delay. - controller.onError('/thumb/a'); - vi.advanceTimersByTime(11_999); - expect(controller.getNonce()).toBe(2); - vi.advanceTimersByTime(1); - expect(controller.getNonce()).toBe(3); - }); - - it('starts the attempts over when the tile shows another item', () => { - const controller = createGalleryThumbnailRetryController([3_000, 6_000]); - - controller.onError('/thumb/a'); - vi.advanceTimersByTime(3_000); - controller.onError('/thumb/a'); - vi.advanceTimersByTime(6_000); - expect(controller.getNonce()).toBe(2); - // Item a has spent its attempts. - controller.onError('/thumb/a'); - vi.advanceTimersByTime(60_000); - expect(controller.getNonce()).toBe(2); - - // An index-keyed slot now shows item b: its failure gets the first delay again. - controller.onError('/thumb/b'); - vi.advanceTimersByTime(2_999); - expect(controller.getNonce()).toBe(2); - vi.advanceTimersByTime(1); - expect(controller.getNonce()).toBe(3); - - // A new item while b's retry is pending drops that retry and restarts the backoff for the new one. - controller.onError('/thumb/b'); - controller.onError('/thumb/c'); - vi.advanceTimersByTime(2_999); - expect(controller.getNonce()).toBe(3); - expect(vi.getTimerCount()).toBe(1); - vi.advanceTimersByTime(1); - expect(controller.getNonce()).toBe(4); - }); -}); diff --git a/invokeai/frontend/webv2/src/features/gallery/data/queryCache.ts b/invokeai/frontend/webv2/src/features/gallery/data/queryCache.ts index ce37cf6955f..03ded4b1743 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/queryCache.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/queryCache.ts @@ -65,77 +65,6 @@ export const getRefreshedGalleryThumbnailUrl = (url: string, currentRevision: nu return refreshedUrl.toString(); }; -/** The waits before each retry of a tile image that failed to load; once they are spent the tile gives up. */ -export const GALLERY_THUMBNAIL_RETRY_DELAYS_MS: readonly number[] = [3_000, 6_000, 12_000, 24_000, 30_000]; - -export interface GalleryThumbnailRetryController { - /** How many retries have fired; each one changes the tile's URL through the thumbnail revision parameter. */ - getNonce: () => number; - subscribe: (listener: () => void) => () => void; - /** - * The image at `url` failed to load: schedule the next retry unless one is pending or the attempts are spent. A - * different `url` from the last failure starts the attempts over, since index-keyed slots hand a mounted tile - * another item. - */ - onError: (url: string) => void; - /** The image loaded: forget the failures, keeping the nonce so the image just shown is not reloaded. */ - onLoad: () => void; - /** Drops a scheduled retry, for a tile that left the screen; the controller stays usable. */ - stop: () => void; -} - -/** - * An image move answers thumbnail reads with 409 for everyone and nothing tells a non-admin tab when it ends, so a - * tile asks again by itself on a bounded backoff. The attempts are capped rather than timed: a thumbnail that is - * really gone costs a handful of requests per mount, not one every 30 s for minutes. - */ -export const createGalleryThumbnailRetryController = ( - delaysMs: readonly number[] = GALLERY_THUMBNAIL_RETRY_DELAYS_MS -): GalleryThumbnailRetryController => { - const listeners = new Set<() => void>(); - let nonce = 0; - let failures = 0; - let failingUrl: string | null = null; - let timer: ReturnType | null = null; - - const stop = (): void => { - if (timer !== null) { - clearTimeout(timer); - timer = null; - } - }; - - return { - getNonce: () => nonce, - onError: (url) => { - if (url !== failingUrl) { - failingUrl = url; - failures = 0; - stop(); - } - if (timer !== null || failures >= delaysMs.length) { - return; - } - const delayMs = delaysMs[failures] ?? 0; - failures += 1; - timer = setTimeout(() => { - timer = null; - nonce += 1; - listeners.forEach((listener) => listener()); - }, delayMs); - }, - onLoad: () => { - stop(); - failures = 0; - }, - stop, - subscribe: (listener) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, - }; -}; - /** A list window's pages, or one sparse/strip page. */ type GalleryItemsCacheData = InfiniteData | GalleryItemsPage; diff --git a/invokeai/frontend/webv2/src/features/gallery/queries.ts b/invokeai/frontend/webv2/src/features/gallery/queries.ts index b6dfda9841b..1c01c22655b 100644 --- a/invokeai/frontend/webv2/src/features/gallery/queries.ts +++ b/invokeai/frontend/webv2/src/features/gallery/queries.ts @@ -34,7 +34,6 @@ export { getGalleryItemBoardIdsFromCaches, getGalleryProjectBoardsFromCaches, getGalleryItemStarredFromCaches, - createGalleryThumbnailRetryController, invalidateGallery, invalidateGalleryBoardDestinations, invalidateGalleryItems, diff --git a/invokeai/frontend/webv2/src/features/gallery/react.test.ts b/invokeai/frontend/webv2/src/features/gallery/react.test.ts index 4103bdcd51e..f3f84ac8fe6 100644 --- a/invokeai/frontend/webv2/src/features/gallery/react.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/react.test.ts @@ -12,11 +12,13 @@ const sources = import.meta.glob('./react.ts', { eager: true, import: 'default', * module orders differ, so only the entry's import surface can be checked cheaply. */ describe('gallery React entry', () => { - it('reaches only the eager UI port and core types', () => { + it('reaches only the eager UI port, the image recovery leaf hook and core types', () => { const source = sources['./react.ts']; const specifiers = [...(source ?? '').matchAll(/from '([^']+)'/g)].map(([, specifier]) => specifier); expect(source).toBeDefined(); - expect(new Set(specifiers)).toEqual(new Set(['./ui/GalleryUiContext', './core/types'])); + expect(new Set(specifiers)).toEqual( + new Set(['./ui/GalleryUiContext', './ui/useGalleryImageRecovery', './core/types']) + ); }); }); diff --git a/invokeai/frontend/webv2/src/features/gallery/react.ts b/invokeai/frontend/webv2/src/features/gallery/react.ts index dd57c281ba7..cd8d08fec15 100644 --- a/invokeai/frontend/webv2/src/features/gallery/react.ts +++ b/invokeai/frontend/webv2/src/features/gallery/react.ts @@ -11,7 +11,9 @@ export { useHostGalleryBoards, useOptionalGalleryHostProjectContext, } from './ui/GalleryUiContext'; +export { useGalleryImageRecovery } from './ui/useGalleryImageRecovery'; export type { GalleryProjectRef } from './core/types'; -// Only the eager UI port lives here. This entry is bundled with the eager gallery-state chunk; reaching a lazy -// gallery module (uploads, the picker) from it makes that chunk and the picker chunk import each other, and the -// picker's module-scope code then runs before the state chunk has initialised. Uploads are published by `picker`. +// Only the eager UI port and leaf hooks live here. This entry is bundled with the eager gallery-state chunk; +// reaching a lazy gallery module (uploads, the picker) from it makes that chunk and the picker chunk import each +// other, and the picker's module-scope code then runs before the state chunk has initialised. Uploads are published +// by `picker`. diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx index 8549daebbec..d28bd732e32 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx @@ -92,8 +92,9 @@ const image = (name: string, boardId: string, day = 1): GalleryItem => ({ const page = (items: GalleryItem[], total = items.length): Promise => Promise.resolve({ items, total }); const thumbnailRouteCommands = commands as typeof commands & { - getThumbnailRequests: () => Promise<{ statuses: number[]; urls: string[] }>; - startThumbnailRoute: (pathname: string, options?: { failures?: number; status?: number }) => Promise; + endThumbnailRouteFailures: () => Promise; + getThumbnailRequests: () => Promise<{ statuses: number[]; types: string[]; urls: string[] }>; + startThumbnailRoute: (pathnames: string[], options?: { failures?: number; status?: number }) => Promise; stopThumbnailRoute: () => Promise; }; const fail = (): Promise => Promise.reject(new Error('Service Unavailable')); @@ -368,43 +369,84 @@ describe('Gallery listing failures', () => { await waitFor(() => expect(host?.contains(document.activeElement)).toBe(true)); }); - it('reloads a thumbnail the move refused by itself, without any user action', async () => { - // The real guard answers thumbnail reads with 409 while the files move; nothing tells this tab when it ends. - const thumbnailPath = '/api/v1/images/i/dog-moving.png/thumbnail'; - await thumbnailRouteCommands.startThumbnailRoute(thumbnailPath, { failures: 1, status: 409 }); - transport.listItems.mockImplementation(() => - page([{ ...image('dog-moving.png', 'dogs', 3), thumbnailUrl: thumbnailPath }, ...DOG_ITEMS.slice(1)]) + it('reloads every thumbnail a long image move refused, once, probing one image per interval meanwhile', async () => { + // The real guard answers every image read with 409 while the files move; nothing tells this tab when it ends. + const moving = Array.from({ length: 6 }, (_, index) => ({ + ...image(`moving-${String(index)}.png`, 'dogs', 10 - index), + thumbnailUrl: `/api/v1/images/i/moving-${String(index)}.png/thumbnail`, + })); + const paths = moving.map((item) => item.thumbnailUrl); + const pathOf = (url: string) => new URL(url, window.location.href).pathname; + const countByPath = (urls: string[]) => + Object.fromEntries(paths.map((path) => [path, urls.filter((url) => pathOf(url) === path).length])); + const eachPath = (count: number) => Object.fromEntries(paths.map((path) => [path, count])); + const imageRequests = async () => { + const log = await thumbnailRouteCommands.getThumbnailRequests(); + return log.urls.filter((_, index) => log.types[index] === 'image'); + }; + // The probe's reads are answered in the page, so they settle within the fake clock's steps; the tiles' own + // image reads go to the network route. + let isMoving = true; + const browserFetch = globalThis.fetch; + const probes = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => + paths.includes(pathOf(String(input))) + ? Promise.resolve( + isMoving + ? new Response('moving', { status: 409 }) + : new Response('', { + headers: { 'content-type': 'image/svg+xml' }, + }) + ) + : browserFetch(input, init) ); + const probedPaths = () => probes.mock.calls.map(([input]) => pathOf(String(input))); + await thumbnailRouteCommands.startThumbnailRoute(paths, { failures: 1_000_000, status: 409 }); + transport.listItems.mockImplementation(() => page(moving)); + // Real time keeps flowing for Query and the image loads; the test jumps the clock through the move. + vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['setTimeout', 'clearTimeout'] }); try { - // StrictMode rehearses the tile's unmount; the retry must survive it as it does in the app. + // StrictMode rehearses each tile's unmount; recovery must survive it as it does in the app. await renderGallery({}, 'center', NO_SESSIONS, { strict: true }); - await waitFor(() => expect(thumbnailNames()).toHaveLength(3)); - const thumbnail = host!.querySelector('img[alt="dog-moving.png"]')!; - await expect.poll(() => thumbnail.complete).toBe(true); - expect(thumbnail.naturalWidth).toBe(0); - expect((await thumbnailRouteCommands.getThumbnailRequests()).statuses).toEqual([409]); - - // The first retry, after ~3 s, succeeds. + await waitFor(() => expect(thumbnailNames()).toHaveLength(6)); + const thumbnails = moving.map((item) => host!.querySelector(`img[alt="${item.name}"]`)!); + await expect.poll(() => thumbnails.every((thumbnail) => thumbnail.complete)).toBe(true); + expect(thumbnails.every((thumbnail) => thumbnail.naturalWidth === 0)).toBe(true); + expect(countByPath(await imageRequests())).toEqual(eachPath(1)); + + // Through six minutes of move the probe keeps asking about one image for the whole grid every 15 to 30 s, + // never one per tile. + for (let interval = 1; interval <= 12; interval += 1) { + const before = probes.mock.calls.length; + await act(() => vi.advanceTimersByTimeAsync(30_000)); + expect(probes.mock.calls.length - before).toBeGreaterThanOrEqual(1); + expect(probes.mock.calls.length - before).toBeLessThanOrEqual(2); + } + expect(new Set(probedPaths()).size).toBe(1); + expect(await imageRequests()).toHaveLength(6); + expect(thumbnails.every((thumbnail) => thumbnail.naturalWidth === 0)).toBe(true); + + // The move ends: the next probe sees an image served and every failed tile reloads by itself. + isMoving = false; + await thumbnailRouteCommands.endThumbnailRouteFailures(); + await act(() => vi.advanceTimersByTimeAsync(30_000)); await expect - .poll(async () => (await thumbnailRouteCommands.getThumbnailRequests()).statuses, { timeout: 10_000 }) - .toEqual([409, 200]); - await expect.poll(() => thumbnail.complete && thumbnail.naturalWidth > 0).toBe(true); - const { urls } = await thumbnailRouteCommands.getThumbnailRequests(); - expect(urls[1]).not.toBe(urls[0]); - expect(urls[1]).toContain('gallery_thumbnail_revision='); - // A loaded image stays put: no further request after success. - await act( - () => - new Promise((resolve) => { - setTimeout(resolve, 500); - }) - ); - expect((await thumbnailRouteCommands.getThumbnailRequests()).urls).toHaveLength(2); + .poll(() => + moving.map((item) => host!.querySelector(`img[alt="${item.name}"]`)!.naturalWidth) + ) + .toEqual(moving.map(() => 64)); + + // Each tile asked twice in all: the refused read and one reload. Nothing asks again afterwards. + const probeCount = probes.mock.calls.length; + await act(() => vi.advanceTimersByTimeAsync(5 * 60_000)); + expect(countByPath(await imageRequests())).toEqual(eachPath(2)); + expect(probes.mock.calls.length).toBe(probeCount); } finally { + vi.useRealTimers(); + probes.mockRestore(); await thumbnailRouteCommands.stopThumbnailRoute(); } - }, 20_000); + }, 30_000); it('shows an anchored window that failed as failed, never the unfiltered recents', async () => { transport.listItems.mockImplementation(fail); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx index a68f04a0060..0c9e2325f07 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx @@ -6,12 +6,10 @@ import { useDraggable } from '@dnd-kit/core'; import { CSS } from '@dnd-kit/utilities'; import { formatGalleryVideoDuration, toGalleryItemKey, toGalleryItemRef } from '@features/gallery/core/items'; import { - createGalleryThumbnailRetryController, getGalleryThumbnailRevision, getRefreshedGalleryThumbnailUrl, subscribeGalleryThumbnailRevision, } from '@features/gallery/queries'; -import { useMountEffect } from '@platform/react/useMountEffect'; import { IconButton } from '@platform/ui/Button'; import { StarIcon } from 'lucide-react'; import { @@ -29,6 +27,7 @@ import { useTranslation } from 'react-i18next'; import { getGalleryItemDragData, getGalleryItemDragId } from './galleryDnd'; import { GalleryTileFrame } from './GalleryTileFrame'; +import { useGalleryImageRecovery } from './useGalleryImageRecovery'; /** Desaturate armed and active touch drags, including the portalled preview. */ /** The grid's one thumbnail Tab stop: the tile keyboard focus returns to. */ @@ -61,19 +60,6 @@ const LABEL_MAX_WIDTH = 'calc(100% - 2.25rem)'; */ const LABEL_CSS = { '@starting-style': { opacity: '0 !important' } } as const; -/** This tile's image retry: the nonce that re-asks for the image at `url`, and the load handlers that drive it. */ -const useThumbnailRetry = (url: string) => { - const [controller] = useState(createGalleryThumbnailRetryController); - const nonce = useSyncExternalStore(controller.subscribe, controller.getNonce, controller.getNonce); - const onError = useCallback(() => controller.onError(url), [controller, url]); - - // Only a scheduled retry outlives the tile. The controller itself stays usable, which StrictMode's rehearsal - // unmount relies on: after a real unmount no load event reaches it and nothing subscribes to it. - useMountEffect(() => controller.stop); - - return { nonce, onError, onLoad: controller.onLoad }; -}; - const THUMBNAIL_BUTTON_STYLE = { background: 'transparent', border: 0, @@ -135,13 +121,13 @@ const GalleryThumbnail = ({ getGalleryThumbnailRevision ); const baseThumbnailUrl = item.thumbnailUrl || item.fullUrl; - const retry = useThumbnailRetry(baseThumbnailUrl); - // Only image thumbnails are regenerated by maintenance; a retry re-asks for either kind. - const thumbnailUrl = useMemo( + // Only image thumbnails are regenerated by maintenance; a failed load of either kind recovers after an image move. + const maintainedThumbnailUrl = useMemo( () => - getRefreshedGalleryThumbnailUrl(baseThumbnailUrl, (item.kind === 'image' ? thumbnailRevision : 0) + retry.nonce), - [baseThumbnailUrl, item.kind, retry.nonce, thumbnailRevision] + item.kind === 'image' ? getRefreshedGalleryThumbnailUrl(baseThumbnailUrl, thumbnailRevision) : baseThumbnailUrl, + [baseThumbnailUrl, item.kind, thumbnailRevision] ); + const { onError: handleImageError, src: thumbnailUrl } = useGalleryImageRecovery(maintainedThumbnailUrl); const isCompared = compareRole !== null; const duration = item.kind === 'video' ? formatGalleryVideoDuration(item.durationSeconds) : null; @@ -353,8 +339,7 @@ const GalleryThumbnail = ({ draggable={false} src={thumbnailUrl} style={imageStyle} - onError={retry.onError} - onLoad={retry.onLoad} + onError={handleImageError} /> {/* The compare role owns this corner while comparing. */} diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryImageRecovery.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryImageRecovery.browser.test.tsx new file mode 100644 index 00000000000..bf3aef6abfa --- /dev/null +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryImageRecovery.browser.test.tsx @@ -0,0 +1,83 @@ +import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { commands } from 'vitest/browser'; + +import { useGalleryImageRecovery } from './useGalleryImageRecovery'; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const imageRouteCommands = commands as typeof commands & { + endThumbnailRouteFailures: () => Promise; + startThumbnailRoute: (pathnames: string[], options?: { failures?: number; status?: number }) => Promise; + stopThumbnailRoute: () => Promise; +}; + +/** An index-keyed slot: the element stays, the image it shows changes. */ +const Slot = ({ url }: { url: string }) => { + const { onError, src } = useGalleryImageRecovery(url); + + return slot; +}; + +const FIRST = '/api/v1/images/i/first.png/thumbnail'; +const SECOND = '/api/v1/images/i/second.png/thumbnail'; +const LOADED = `data:image/svg+xml,${encodeURIComponent( + '' +)}`; + +let host: HTMLDivElement; +let root: Root; +const slot = () => host.querySelector('img[alt="slot"]')!; + +beforeEach(() => { + accountLifecycle.activate('image-recovery-slot'); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['setTimeout', 'clearTimeout'] }); +}); + +afterEach(async () => { + vi.useRealTimers(); + await act(() => root.unmount()); + host.remove(); + await imageRouteCommands.stopThumbnailRoute(); + accountLifecycle.invalidate(); +}); + +describe('useGalleryImageRecovery', () => { + it('does not carry a recovered image’s reload into the next image the slot shows', async () => { + await imageRouteCommands.startThumbnailRoute([FIRST, SECOND], { failures: 1, status: 409 }); + await act(() => root.render()); + await expect.poll(() => slot().complete && slot().naturalWidth === 0).toBe(true); + + await act(() => vi.advanceTimersByTimeAsync(30_000)); + await expect.poll(() => slot().naturalWidth).toBe(64); + expect(slot().getAttribute('src')).toContain('gallery_image_recovery='); + + // The slot now shows another image: it asks for that image's own URL, which the HTTP cache can answer. + await act(() => root.render()); + expect(slot().getAttribute('src')).toBe(SECOND); + }); + + it('leaves an image that loaded alone when a recovery reloads the failed ones', async () => { + await imageRouteCommands.startThumbnailRoute([FIRST], { failures: 1, status: 409 }); + await act(() => + root.render( + <> + + + + ) + ); + const [failing] = host.querySelectorAll('img'); + await expect.poll(() => failing!.complete && failing!.naturalWidth === 0).toBe(true); + + await act(() => vi.advanceTimersByTimeAsync(30_000)); + await expect.poll(() => failing!.naturalWidth).toBe(64); + // The image that never failed keeps its plain URL through the recovery. + expect(host.querySelectorAll('img')[1]!.getAttribute('src')).toBe(LOADED); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryImageRecovery.ts b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryImageRecovery.ts new file mode 100644 index 00000000000..d892c5c8f03 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryImageRecovery.ts @@ -0,0 +1,45 @@ +import { + getGalleryImageRecoveryRevision, + getRecoveredGalleryImageUrl, + reportGalleryImageFailure, + subscribeGalleryImageRecovery, +} from '@features/gallery/data/imageRecovery'; +import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'; + +interface ImageRecoveryState { + /** The recovery revision current when the image failed; null once a later one reloaded it. */ + failedAt: number | null; + /** The recovery revision in the image's URL. */ + revision: number; + url: string; +} + +/** + * The `src` for an image element showing the gallery image at `url`, and its `onError`. A failed image reloads once + * when the shared probe sees the server serve images again (an image move answers 409 until it ends). The state + * belongs to `url` alone: an element handed another image, as index-keyed slots are, starts from its plain URL. + */ +export const useGalleryImageRecovery = (url: string): { onError: () => void; src: string } => { + const recoveryRevision = useSyncExternalStore( + subscribeGalleryImageRecovery, + getGalleryImageRecoveryRevision, + getGalleryImageRecoveryRevision + ); + const [state, setState] = useState(null); + const own = state?.url === url ? state : null; + let revision = own?.revision ?? 0; + + if (own && own.failedAt !== null && recoveryRevision > own.failedAt) { + // Pin the reload to the revision that recovered it, so later recoveries of other images leave this one alone. + revision = recoveryRevision; + setState({ failedAt: null, revision, url }); + } + + const src = useMemo(() => getRecoveredGalleryImageUrl(url, revision), [revision, url]); + const onError = useCallback(() => { + setState({ failedAt: getGalleryImageRecoveryRevision(), revision, url }); + reportGalleryImageFailure(url, revision); + }, [revision, url]); + + return { onError, src }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/settings/GalleryMaintenance.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/settings/GalleryMaintenance.browser.test.tsx index ee3e9d9d516..e0ba94f10e0 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/GalleryMaintenance.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/settings/GalleryMaintenance.browser.test.tsx @@ -47,8 +47,6 @@ vi.mock('@platform/transport/http', async (importOriginal) => ({ vi.mock('@features/gallery/queries', async (importOriginal) => ({ ...((actual) => ({ ...actual, - // The tile's own retry must not add a request while this test counts the maintenance refresh. - createGalleryThumbnailRetryController: () => actual.createGalleryThumbnailRetryController([600_000]), invalidateGallery: (...args: unknown[]) => mocks.invalidateGallery(...args), refreshGalleryThumbnails: (...args: Parameters) => { mocks.refreshGalleryThumbnails(...args); @@ -285,6 +283,7 @@ describe('gallery maintenance in Data & workspace settings', () => { afterEach(async () => { await act(() => root.unmount()); await thumbnailRouteCommands.stopThumbnailRoute(); + vi.restoreAllMocks(); host.remove(); queryClient.clear(); accountLifecycle.invalidate(); @@ -525,6 +524,14 @@ describe('gallery maintenance in Data & workspace settings', () => { const owner = accountLifecycle.capture(); const action = operations[2]; await thumbnailRouteCommands.startThumbnailRoute(thumbnailItem.thumbnailUrl); + // The gallery's recovery probe asks about the failed thumbnail with fetch; answer it here as missing so it adds no + // request to the ones this test counts, and reloads nothing. + const browserFetch = globalThis.fetch; + vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => + String(input).includes(thumbnailItem.thumbnailUrl) + ? Promise.resolve(new Response(null, { status: 404 })) + : browserFetch(input, init) + ); mocks.apiFetchJson .mockResolvedValueOnce(preview(action.operation, action.fingerprint)) .mockResolvedValueOnce( diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewFrame.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewFrame.tsx index 20234b5563f..aebad67e228 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewFrame.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewFrame.tsx @@ -52,6 +52,8 @@ interface PreviewFrameProps { frameWidth: number; /** Keep the last denoise frame until source decoding completes, then notify onSourceLoaded. */ holdSource?: StreamingImageSource | null; + /** A settled image failed to load. */ + onSourceError?: () => void; onSourceLoaded?: (src: string) => void; /** A small, usually cached rendition (the thumbnail) shown until a settled image's full pixels load. */ placeholderSrc?: string; @@ -104,6 +106,7 @@ const PreviewImageFrame = ({ isLive, loupeControlsRef, onContextMenu, + onSourceError, onSourceLoaded, onZoomChange, padding, @@ -237,6 +240,13 @@ const PreviewImageFrame = ({ }, [holdSource, onSourceLoaded, placeholderSrc] ); + const handleSourceError = useCallback( + (event: SyntheticEvent) => { + handleSourceSettled(event); + onSourceError?.(); + }, + [handleSourceSettled, onSourceError] + ); // A hold arriving after the image already decoded (a cached image, or the // element reused across a source swap) would never see a load event. useEffect(() => { @@ -291,7 +301,7 @@ const PreviewImageFrame = ({ src={source.src} style={isHolding ? heldImageStyle : imageStyle} width={frameWidth} - onError={handleSourceSettled} + onError={handleSourceError} onLoad={handleSourceSettled} /> {/* The hidden finished image keeps the frame's geometry; the held frame diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx index e313f9d98ec..6e81df52440 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx @@ -25,7 +25,7 @@ import { act, useCallback } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { I18nextProvider, initReactI18next } from 'react-i18next'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { userEvent } from 'vitest/browser'; +import { commands, userEvent } from 'vitest/browser'; import { LivePreviewFollowProvider, useLivePreviewFollow } from './livePreviewFollow'; import { usePreviewHeaderContext, type PreviewItemPosition } from './previewHeaderStore'; @@ -3761,3 +3761,39 @@ describe('preview deletion confirmation', () => { await expect.poll(() => document.querySelector('[role="alertdialog"]')).toBeNull(); }); }); + +describe('preview image recovery', () => { + const imageRouteCommands = commands as typeof commands & { + endThumbnailRouteFailures: () => Promise; + getThumbnailRequests: () => Promise<{ statuses: number[]; types: string[]; urls: string[] }>; + startThumbnailRoute: (pathnames: string[], options?: { failures?: number; status?: number }) => Promise; + stopThumbnailRoute: () => Promise; + }; + const shownImage = () => host?.querySelector('img[alt="newest"]') ?? null; + + it('reloads the full image an image move refused once the move ends', async () => { + // A fresh account lifetime: earlier tests' failed images must not leave a probe waiting on a real timer. + accountLifecycle.activate('preview-image-recovery'); + // The real guard answers full image reads with 409 too while the files move. + await imageRouteCommands.startThumbnailRoute(['/images/newest/full'], { failures: 1_000_000, status: 409 }); + vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['setTimeout', 'clearTimeout'] }); + + try { + await render(); + await expect.poll(() => shownImage()?.complete === true && shownImage()?.naturalWidth === 0).toBe(true); + expect((await imageRouteCommands.getThumbnailRequests()).statuses).toEqual([409]); + + // Two minutes into the move, it still refuses the image, which stays down. + await act(() => vi.advanceTimersByTimeAsync(2 * 60_000)); + expect(shownImage()?.naturalWidth).toBe(0); + + await imageRouteCommands.endThumbnailRouteFailures(); + await act(() => vi.advanceTimersByTimeAsync(30_000)); + await expect.poll(() => shownImage()?.naturalWidth).toBe(64); + expect(shownImage()?.getAttribute('src')).toContain('gallery_image_recovery='); + } finally { + vi.useRealTimers(); + await imageRouteCommands.stopThumbnailRoute(); + } + }, 20_000); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx index 052dacd5b4b..65f58c89034 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx @@ -31,7 +31,7 @@ import { toGalleryItemRef, } from '@features/gallery/contracts'; import { galleryBoardsOptions, getGalleryListingBoardsQuery } from '@features/gallery/queries'; -import { useHostGalleryBoards } from '@features/gallery/react'; +import { useGalleryImageRecovery, useHostGalleryBoards } from '@features/gallery/react'; import { createGenerateFormValuesSelector } from '@features/generation/react'; import { consumeQueueItemSwapProgressImage, @@ -719,12 +719,14 @@ const SelectedImagePreview = ({ shouldAntialiasProgressImage, ...props }: SelectedMediaPreviewProps & { item: GalleryImageItem; shouldAntialiasProgressImage: boolean }) => { + // An image move refuses full image reads too; the image reloads by itself once the gallery sees the move end. + const { onError: handleSourceError, src: fullImageSrc } = useGalleryImageRecovery(item.fullUrl); const previewImage = useStreamingImageSource({ fallbackImage: imageUrlToStreamingSource({ alt: item.name, height: item.height, kind: 'fallback', - src: item.fullUrl, + src: fullImageSrc, width: item.width, }), }); @@ -756,6 +758,7 @@ const SelectedImagePreview = ({ placeholderSrc={item.thumbnailUrl} shouldAntialiasHoldImage={shouldAntialiasProgressImage} source={source} + onSourceError={handleSourceError} onSourceLoaded={handleSourceLoaded} /> ); @@ -828,6 +831,7 @@ const SelectedMediaPreview = ({ item, loupeControlsRef, onCopyAvailabilityChange, + onSourceError, onSourceLoaded, placeholderSrc, shouldAntialiasHoldImage, @@ -840,6 +844,7 @@ const SelectedMediaPreview = ({ frameHeight: number; frameWidth: number; holdSource?: StreamingImageSource | null; + onSourceError?: () => void; onSourceLoaded?: (src: string) => void; placeholderSrc?: string; shouldAntialiasHoldImage?: boolean; @@ -857,6 +862,7 @@ const SelectedMediaPreview = ({ isItemCurrent={isItemCurrent} isLive={false} loupeControlsRef={loupeControlsRef} + onSourceError={onSourceError} onSourceLoaded={onSourceLoaded} onVideoCopyAvailabilityChange={onCopyAvailabilityChange} onZoomChange={setZoomReadout} diff --git a/invokeai/frontend/webv2/vite.config.mts b/invokeai/frontend/webv2/vite.config.mts index 111a46c1965..b16c7f1864e 100644 --- a/invokeai/frontend/webv2/vite.config.mts +++ b/invokeai/frontend/webv2/vite.config.mts @@ -45,6 +45,10 @@ const ROUTE_SHARED_MODULES = [ '/features/nodes/data/nodeExecutionStore.ts', '/features/nodes/index.ts', '/features/nodes/ui/NodesPage.tsx', + // Gallery tiles, the lazy Preview and the Launchpad's gallery picker share the failed-image recovery; alone it costs a + // startup request. + '/features/gallery/data/imageRecovery.ts', + '/features/gallery/ui/useGalleryImageRecovery.ts', '/platform/browser/downloadBlob.ts', // Editor search fields and the lazy pickers share the IME guard; alone it costs a startup request. '/platform/browser/imeComposition.ts', diff --git a/invokeai/frontend/webv2/vitest.browser.config.mts b/invokeai/frontend/webv2/vitest.browser.config.mts index 5b14efe682e..d7ab56a9c61 100644 --- a/invokeai/frontend/webv2/vitest.browser.config.mts +++ b/invokeai/frontend/webv2/vitest.browser.config.mts @@ -37,9 +37,14 @@ const imeCompose: BrowserCommand<[steps: readonly { kind: 'commit' | 'compose'; }; type ThumbnailRouteState = { + /** Requests still to be answered with `failureStatus`. */ + failuresLeft: number; + failureStatus: number; handler: (route: Route) => Promise; - pattern: string; + matches: (url: URL) => boolean; statuses: number[]; + /** Playwright resource types: `image` for elements, `fetch` for scripted reads. */ + types: string[]; urls: string[]; }; @@ -48,27 +53,33 @@ const thumbnailRoutes = new Map(); const stopThumbnailRoute = async (context: BrowserContext, sessionId: string) => { const state = thumbnailRoutes.get(sessionId); if (state) { - await context.unroute(state.pattern, state.handler); + await context.unroute(state.matches, state.handler); thumbnailRoutes.delete(sessionId); } }; -/** Answers `failures` requests for the thumbnail with `status`, then serves it; the default is one 404. */ +/** + * Serves the images at `pathnames`, answering the first `failures` requests with `status` (default: one 404). + * `endThumbnailRouteFailures` serves every later request. + */ const startThumbnailRoute: BrowserCommand< - [pathname: string, options?: { failures?: number; status?: number }] -> = async ({ context, sessionId }, pathname, { failures = 1, status: failureStatus = 404 } = {}) => { + [pathnames: string | string[], options?: { failures?: number; status?: number }] +> = async ({ context, sessionId }, pathnames, { failures = 1, status: failureStatus = 404 } = {}) => { await stopThumbnailRoute(context, sessionId); - const path = new URL(pathname, 'http://localhost').pathname; - const pattern = `**${path}*`; + const paths = new Set( + (Array.isArray(pathnames) ? pathnames : [pathnames]).map( + (pathname) => new URL(pathname, 'http://localhost').pathname + ) + ); const state: ThumbnailRouteState = { + failuresLeft: failures, + failureStatus, handler: async (route) => { - if (new URL(route.request().url()).pathname !== path) { - await route.continue(); - return; - } - - state.urls.push(route.request().url()); - const status = state.urls.length <= failures ? failureStatus : 200; + const request = route.request(); + state.urls.push(request.url()); + state.types.push(request.resourceType()); + const status = state.failuresLeft > 0 ? state.failureStatus : 200; + state.failuresLeft = Math.max(0, state.failuresLeft - 1); state.statuses.push(status); if (status !== 200) { await route.fulfill({ body: 'thumbnail missing', contentType: 'text/plain', status }); @@ -80,17 +91,27 @@ const startThumbnailRoute: BrowserCommand< }); } }, - pattern, + matches: (url) => paths.has(url.pathname), statuses: [], + types: [], urls: [], }; thumbnailRoutes.set(sessionId, state); - await context.route(pattern, state.handler); + await context.route(state.matches, state.handler); +}; + +const endThumbnailRouteFailures: BrowserCommand<[]> = ({ sessionId }) => { + const state = thumbnailRoutes.get(sessionId); + if (state) { + state.failuresLeft = 0; + } }; -const getThumbnailRequests: BrowserCommand<[], { statuses: number[]; urls: string[] }> = ({ sessionId }) => { +const getThumbnailRequests: BrowserCommand<[], { statuses: number[]; types: string[]; urls: string[] }> = ({ + sessionId, +}) => { const state = thumbnailRoutes.get(sessionId); - return { statuses: state?.statuses ?? [], urls: state?.urls ?? [] }; + return { statuses: state?.statuses ?? [], types: state?.types ?? [], urls: state?.urls ?? [] }; }; const stopThumbnailRouteCommand: BrowserCommand<[]> = async ({ context, sessionId }) => { @@ -120,6 +141,7 @@ export default mergeConfig( test: { browser: { commands: { + endThumbnailRouteFailures, getThumbnailRequests, imeCompose, resetBrowserPointer, From 586ead0ff97977e13be614e55d9b343e68258995 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sun, 25 Oct 2026 00:38:05 -0600 Subject: [PATCH 06/10] fix(queue): settle utility jobs canceled or deleted in bulk The utility watch re-reads accepted items on queue_items_canceled, queue clears and reconnects, for PBR and canvas utility graphs alike. Failed PBR runs show a titled notice; canvas enqueue errors use the API error message; the status reader DTO stays inside Queue. --- .../features/queue/data/submissionApi.test.ts | 19 ++ .../queue/runtime/utilityGraph.test.ts | 165 +++++++++- .../features/queue/runtime/utilityGraph.ts | 281 +++++++++++------- .../webv2/src/features/queue/utility.ts | 2 - .../useImageActions.browser.test.tsx | 24 +- .../image-actions/useImageActions.ts | 15 +- 6 files changed, 381 insertions(+), 125 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts index 0b26dde20fc..315838cd225 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts @@ -439,6 +439,25 @@ describe('enqueueWorkflow', () => { }); }); +describe('enqueueUtility', () => { + beforeEach(() => { + mocks.apiFetchJson.mockReset(); + mocks.apiFetchJson.mockResolvedValue({ enqueued: 1, item_ids: [4] }); + }); + + it('appends by default and prepends only when the request asks for the front of the queue', async () => { + const { enqueueUtility } = await import('./submissionApi'); + const request = { graph: { edges: [], id: 'pbr', nodes: {} }, origin: 'webv2:util:1' }; + + await expect(enqueueUtility(request)).resolves.toEqual({ enqueued: 1, itemIds: [4] }); + expect(getSubmittedBody()).toMatchObject({ batch: { origin: 'webv2:util:1', runs: 1 }, prepend: false }); + + mocks.apiFetchJson.mockClear(); + await enqueueUtility({ ...request, prepend: true }); + expect(getSubmittedBody()).toMatchObject({ prepend: true }); + }); +}); + describe('getResultImages', () => { beforeEach(() => { accountLifecycle.activate('test-account'); diff --git a/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.test.ts index 995d1bdab0d..4c4f2e34f3e 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.test.ts @@ -1,4 +1,5 @@ import type { InvocationCompleteEvent, QueueItemStatusChangedEvent } from '@features/queue/contracts'; +import type { QueueItemsCanceledEvent } from '@features/queue/data/events'; import type { SocketHub } from '@platform/transport/socketHub'; import type { BackendConnectionStatus } from '@platform/transport/types'; @@ -136,6 +137,20 @@ const statusEvent = ( ...overrides, }); +const bulkCanceledEvent = (itemIds: number[]): QueueItemsCanceledEvent => ({ + canceled_item_ids: itemIds, + canceled_item_ids_by_user: { 'user-1': itemIds }, + queue_id: 'default', + timestamp: 1, + user_ids: ['user-1'], +}); + +/** Yields a real macrotask, so everything the previous tasks queued (an unhandled rejection report included) runs. */ +const nextMacrotask = () => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + const okEnqueue: UtilityEnqueue = () => Promise.resolve({ enqueued: 1, itemIds: [1] }); const createDeferred = (): { promise: Promise; resolve: (value: T) => void } => { @@ -197,10 +212,11 @@ describe('runUtilityGraph — await + settle', () => { fake.emit('queue_item_status_changed', statusEvent('completed')); await expect(promise).resolves.toEqual({ height: 48, imageName: 'filtered.png', origin: ORIGIN, width: 64 }); - // Both listeners detached on settle. + // Every listener detached on settle: output, status, queue clear, and bulk cancel. expect(fake.handlerCount('invocation_complete')).toBe(0); expect(fake.handlerCount('queue_item_status_changed')).toBe(0); - expect(fake.detachCount).toBe(2); + expect(fake.detachCount).toBe(4); + expect(fake.connectionHandlerCount()).toBe(0); }); it('ignores completions from other output nodes when outputNodeId is set', async () => { @@ -351,7 +367,7 @@ describe('runUtilityGraph — await + settle', () => { expect(fake.handlerCount('queue_item_status_changed')).toBe(0); reconciliation.resolve({ height: 48, imageName: 'late.png', width: 64 }); await Promise.resolve(); - expect(fake.detachCount).toBe(2); + expect(fake.detachCount).toBe(4); }); it('allows delayed socket output to win after a transient reconciliation failure', async () => { @@ -1111,6 +1127,71 @@ describe('runUtilityGraph — enqueue failures', () => { hub: fake.hub, }); await expect(promise).rejects.toMatchObject({ message: 'network down', reason: 'enqueue' }); + + // A backend refusal shows its detail, never the raw JSON body. + const cause = new ApiError('{"detail":"queue paused"}', 409); + await expect( + runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => Promise.reject(cause), + graph: { edges: [], id: 'g', nodes: {} }, + hub: createFakeHub().hub, + }) + ).rejects.toMatchObject({ cause, message: 'queue paused', reason: 'enqueue' }); + }); +}); + +describe('runUtilityGraph — missed settlement', () => { + it('re-reads the item after a queue clear, bulk cancel, or reconnect instead of waiting for the timeout', async () => { + const graph = { edges: [], id: 'g', nodes: {} }; + const removed = createFakeHub(); + const readRemoved = vi.fn(() => + Promise.reject(new ApiError('{"detail":"Queue item not found"}', 404)) + ); + const removedRun = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => Promise.resolve({ enqueued: 1, itemIds: [3] }), + graph, + hub: removed.hub, + readItemStatus: readRemoved, + timeoutMs: 0, + }); + await vi.waitFor(() => expect(removed.handlerCount('queue_cleared')).toBe(1)); + await Promise.resolve(); + removed.emit('queue_cleared', { queue_id: 'default', timestamp: 1, user_id: null }); + await expect(removedRun).rejects.toMatchObject({ reason: 'removed' }); + expect(readRemoved).toHaveBeenCalledExactlyOnceWith(3, undefined); + expect(removed.handlerCount('queue_items_canceled')).toBe(0); + + const canceled = createFakeHub(); + const canceledRun = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => Promise.resolve({ enqueued: 1, itemIds: [3] }), + graph, + hub: canceled.hub, + readItemStatus: () => Promise.resolve({ status: 'canceled' }), + timeoutMs: 0, + }); + await nextMacrotask(); + canceled.emit('queue_items_canceled', bulkCanceledEvent([3])); + await expect(canceledRun).rejects.toMatchObject({ reason: 'canceled' }); + + // A reconnect that finds the item completed reconciles its output as a socket completion would. + const reconnected = createFakeHub(); + const reconnectedRun = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => Promise.resolve({ enqueued: 1, itemIds: [3] }), + graph, + hub: reconnected.hub, + readItemStatus: () => Promise.resolve({ status: 'completed' }), + reconcileCompletedOutput: () => Promise.resolve({ height: 8, imageName: 'out.png', width: 8 }), + timeoutMs: 0, + }); + await nextMacrotask(); + reconnected.connection('disconnected'); + reconnected.connection('connected'); + await expect(reconnectedRun).resolves.toEqual({ height: 8, imageName: 'out.png', origin: ORIGIN, width: 8 }); + expect(reconnected.connectionHandlerCount()).toBe(0); }); }); @@ -1338,9 +1419,10 @@ describe('submitUtilityGraph — fire, then watch settlement', () => { expect(removed).toHaveBeenCalledExactlyOnceWith('abort', handler); }); - // Vitest fails a run on an unhandled rejection, so settling `settled` before the caller holds it must stay quiet. + // `settled` can reject before the caller holds it. Vitest fails the run on an unhandled rejection, and a real + // macrotask between the rejection and the caller's handler gives one the chance to be reported. + it('settles quietly when the account changes while the enqueue is still pending', async () => { - vi.useFakeTimers(); const fake = createFakeHub(); const controller = new AbortController(); const deferred = createDeferred<{ enqueued: number; itemIds: number[] }>(); @@ -1353,7 +1435,8 @@ describe('submitUtilityGraph — fire, then watch settlement', () => { }); controller.abort(); - await Promise.resolve(); + await nextMacrotask(); + vi.useFakeTimers(); deferred.resolve({ enqueued: 1, itemIds: [1] }); const submission = await pending; @@ -1375,7 +1458,7 @@ describe('submitUtilityGraph — fire, then watch settlement', () => { }); fake.emit('queue_item_status_changed', statusEvent('failed', { error_message: 'No model' })); - await Promise.resolve(); + await nextMacrotask(); deferred.resolve({ enqueued: 1, itemIds: [1] }); const submission = await pending; @@ -1446,6 +1529,72 @@ describe('submitUtilityGraph — fire, then watch settlement', () => { expect(fake.handlerCount('queue_cleared')).toBe(0); }); + it('re-reads the item when a bulk cancel or delete names it, and ignores bulk events for other items', async () => { + const fake = createFakeHub(); + const readItemStatus = vi.fn(() => Promise.resolve({ status: 'canceled' })); + const submission = await submitUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => Promise.resolve({ enqueued: 1, itemIds: [9] }), + graph, + hub: fake.hub, + readItemStatus, + }); + const outcome = submission.settled.then( + () => 'resolved', + (error: UtilityQueueError) => error.reason + ); + + // Other owners' items, and the id-less companion other users receive, leave this item watched. + fake.emit('queue_items_canceled', bulkCanceledEvent([4, 5])); + fake.emit('queue_items_canceled', bulkCanceledEvent([])); + await nextMacrotask(); + expect(readItemStatus).not.toHaveBeenCalled(); + expect(fake.handlerCount('queue_items_canceled')).toBe(1); + + // Bulk operations cancel waiting items in one statement and emit no per-item status change. + fake.emit('queue_items_canceled', bulkCanceledEvent([4, 9])); + + await expect(outcome).resolves.toBe('canceled'); + expect(readItemStatus).toHaveBeenCalledExactlyOnceWith(9, undefined); + expect(fake.handlerCount('queue_items_canceled')).toBe(0); + + // A bulk delete removes the row: the read answers 404. + const deleted = createFakeHub(); + const deletedSubmission = await submitUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => Promise.resolve({ enqueued: 1, itemIds: [9] }), + graph, + hub: deleted.hub, + readItemStatus: () => Promise.reject(new ApiError('{"detail":"Queue item not found"}', 404)), + }); + deleted.emit('queue_items_canceled', bulkCanceledEvent([9])); + await expect(deletedSubmission.settled).rejects.toMatchObject({ reason: 'removed' }); + }); + + it('defers a bulk cancel that arrives before the enqueue response until the item id is known', async () => { + const fake = createFakeHub(); + const deferred = createDeferred<{ enqueued: number; itemIds: number[] }>(); + const readItemStatus = vi.fn(() => Promise.resolve({ status: 'canceled' })); + const pending = submitUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => deferred.promise, + graph, + hub: fake.hub, + readItemStatus, + }); + + // The ids are unknown until the enqueue answers, so any bulk event may have named this item. + fake.emit('queue_items_canceled', bulkCanceledEvent([12])); + await Promise.resolve(); + expect(readItemStatus).not.toHaveBeenCalled(); + + deferred.resolve({ enqueued: 1, itemIds: [12] }); + const submission = await pending; + + await expect(submission.settled).rejects.toMatchObject({ reason: 'canceled' }); + expect(readItemStatus).toHaveBeenCalledExactlyOnceWith(12, undefined); + }); + it('defers a clear that arrives before the enqueue response until the item id is known', async () => { const fake = createFakeHub(); const deferred = createDeferred<{ enqueued: number; itemIds: number[] }>(); @@ -1483,7 +1632,7 @@ describe('submitUtilityGraph — fire, then watch settlement', () => { enqueue: okEnqueue, graph, hub: fake.hub, - readItemStatus: () => Promise.resolve({ error_message: 'boom', status }), + readItemStatus: () => Promise.resolve({ errorMessage: 'boom', status }), }); const outcome = submission.settled.then( () => 'resolved', diff --git a/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.ts b/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.ts index abb8ee8c99c..bd4c9605938 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.ts @@ -4,7 +4,11 @@ */ import type { QueueBackendGraph } from '@features/queue/core/types'; -import type { InvocationCompleteEvent, QueueItemStatusChangedEvent } from '@features/queue/data/events'; +import type { + InvocationCompleteEvent, + QueueItemsCanceledEvent, + QueueItemStatusChangedEvent, +} from '@features/queue/data/events'; import type { SocketHub } from '@platform/transport/socketHub'; import { buildUtilityQueueItemOrigin } from '@features/queue/data/events'; @@ -112,12 +116,17 @@ export interface RunUtilityGraphOptions { * When omitted, the first image-bearing completion for our origin is used. */ outputNodeId?: string; - /** The socket hub (only `on` is used). Raw listeners survive socket recreation. */ - hub: Pick; + /** + * The socket hub. Raw listeners survive socket recreation. A queue clear, a bulk cancel or delete, and, when + * observable, a reconnect re-read the item in case it settled or was deleted without a status event. + */ + hub: UtilityHub; /** Enqueue seam (defaults to the real utility enqueue API). */ enqueue?: UtilityEnqueue; /** Cancellation seam (defaults to the real queue cancellation API). */ cancel?: UtilityCancel; + /** Item status seam for reconciliation (defaults to the queue item API). */ + readItemStatus?: UtilityItemStatusReader; /** Abandon after this many ms (default {@link DEFAULT_UTILITY_QUEUE_TIMEOUT_MS}). `0` disables. */ timeoutMs?: number; /** Cancels the await and best-effort cancels accepted backend items. */ @@ -228,31 +237,146 @@ const toTerminalUtilityOutcome = ( return undefined; }; -/** Watches status changes for one utility origin; `onTerminal` gets the outcome of each terminal event. */ -const watchUtilityOrigin = ( - hub: Pick, - origin: string, - onTerminal: (error: UtilityQueueError | null) => void -): (() => void) => - hub.on('queue_item_status_changed', (event: QueueItemStatusChangedEvent) => { - if (event.origin !== origin) { - return; - } - const outcome = toTerminalUtilityOutcome(event.status, event.error_message, event.error_type); - if (outcome !== undefined) { - onTerminal(outcome); - } - }); - /** Reads one accepted item's status when a socket event may have been missed; rejects with a 404 once it is gone. */ export type UtilityItemStatusReader = ( itemId: number, signal?: AbortSignal -) => Promise<{ status: QueueItemStatusChangedEvent['status']; error_message?: unknown; error_type?: unknown }>; +) => Promise<{ status: QueueItemStatusChangedEvent['status']; errorMessage?: unknown; errorType?: unknown }>; const readUtilityItemStatus: UtilityItemStatusReader = async (itemId, signal) => { const item = await getQueueItem(itemId, signal); - return { error_message: item.error_message, error_type: item.error_type, status: item.status }; + return { errorMessage: item.error_message, errorType: item.error_type, status: item.status }; +}; + +/** The socket hub a utility watch needs; a reconnect is only observable when it can report connection changes. */ +type UtilityHub = Pick & Partial>; + +interface UtilityItemWatch { + /** Records the accepted item ids and runs any read requested before they were known. */ + accept: (itemIds: number[]) => void; + /** Detaches every listener; no outcome is reported afterwards. */ + detach: () => void; + /** Reads each accepted item, reporting a terminal status or, on a 404, its removal. */ + read: () => Promise; +} + +/** + * Watches one utility origin and reports each terminal outcome it learns of until detached: `null` for completion, + * the error for failure, cancellation, or removal; callers settle once. Status events report directly. Events that + * can settle or delete an item without a status event for it re-read the accepted items instead: a queue clear, a + * bulk cancel or delete naming one of them (`queue_items_canceled` carries only the owner's ids), and a reconnect + * after a disconnect. + */ +const watchUtilityItems = ({ + hub, + onOutcome, + origin, + readItemStatus, + signal, +}: { + hub: UtilityHub; + onOutcome: (error: UtilityQueueError | null) => void; + origin: string; + readItemStatus: UtilityItemStatusReader; + signal?: AbortSignal; +}): UtilityItemWatch => { + let detached = false; + let itemIds: number[] = []; + let readRequestedBeforeAccept = false; + const detachers: Array<() => void> = []; + + const detach = (): void => { + detached = true; + for (const detachListener of detachers.splice(0)) { + try { + detachListener(); + } catch { + // Settlement must not depend on the listeners detaching cleanly. + } + } + }; + + const read = async (): Promise => { + for (const itemId of itemIds) { + if (detached) { + return; + } + try { + const item = await readItemStatus(itemId, signal); + const outcome = detached ? undefined : toTerminalUtilityOutcome(item.status, item.errorMessage, item.errorType); + if (outcome !== undefined) { + onOutcome(outcome); + return; + } + } catch (cause) { + if (!detached && cause instanceof ApiError && cause.status === 404) { + // A queue clear or bulk delete removes a pending item without a terminal event. + onOutcome(new UtilityQueueError('removed', 'The utility graph item was removed from the queue.', cause)); + return; + } + // Unreadable now; the socket or a later read can still settle it. + } + } + }; + + const requestRead = (): void => { + if (itemIds.length === 0) { + // The event raced the enqueue; read the item once its id is known. + readRequestedBeforeAccept = true; + return; + } + void read(); + }; + + try { + detachers.push( + hub.on('queue_item_status_changed', (event: QueueItemStatusChangedEvent) => { + if (event.origin !== origin) { + return; + } + const outcome = toTerminalUtilityOutcome(event.status, event.error_message, event.error_type); + if (outcome !== undefined) { + onOutcome(outcome); + } + }) + ); + detachers.push(hub.on('queue_cleared', requestRead)); + detachers.push( + hub.on('queue_items_canceled', (event: QueueItemsCanceledEvent) => { + if (itemIds.length === 0 || event.canceled_item_ids.some((itemId) => itemIds.includes(itemId))) { + requestRead(); + } + }) + ); + if (hub.onConnectionChange) { + let wasDisconnected = false; + detachers.push( + hub.onConnectionChange((status) => { + if (status === 'disconnected') { + wasDisconnected = true; + } else if (status === 'connected' && wasDisconnected) { + wasDisconnected = false; + requestRead(); + } + }) + ); + } + } catch (cause) { + detach(); + throw cause; + } + + return { + accept: (acceptedItemIds) => { + itemIds = acceptedItemIds; + if (readRequestedBeforeAccept) { + readRequestedBeforeAccept = false; + void read(); + } + }, + detach, + read, + }; }; /** Dependencies for {@link submitUtilityGraph}. */ @@ -260,10 +384,10 @@ export interface SubmitUtilityGraphOptions { /** The graph to enqueue; its outputs stay wherever the graph sends them (a board, intermediates). */ graph: QueueBackendGraph; /** - * The socket hub. A queue clear and, when observable, a reconnect re-read the item in case its settlement was - * missed or it was deleted without a terminal event. + * The socket hub. A queue clear, a bulk cancel or delete, and, when observable, a reconnect re-read the item in + * case its settlement was missed or it was deleted without a terminal event. */ - hub: Pick & Partial>; + hub: UtilityHub; /** Put the item ahead of queued generations. */ prepend?: boolean; /** Enqueue seam (defaults to the real utility enqueue API). */ @@ -329,8 +453,6 @@ export const submitUtilityGraph = async (options: SubmitUtilityGraphOptions): Pr // never hands it over at all; callers still observe the rejection through their own handlers. settled.catch(() => {}); let isSettled = false; - let itemIds: number[] = []; - let reconcileRequestedBeforeAccept = false; let timer: ReturnType | null = null; const detachers: Array<() => void> = []; @@ -360,76 +482,16 @@ export const submitUtilityGraph = async (options: SubmitUtilityGraphOptions): Pr const onAbort = (): void => finish(new UtilityQueueError('aborted', 'The utility graph watch was aborted.')); - const armReconcileTimer = (): void => { - if (isSettled || reconcileIntervalMs <= 0) { - return; - } - timer = setTimeout(() => { - timer = null; - void reconcile(true); - }, reconcileIntervalMs); - }; - - /** - * A terminal status read from the item settles the watch, and a 404 means the item is gone without an event (a - * queue clear deletes pending items silently). An item still open after the periodic read re-arms that read. - */ - const reconcile = async (rearm: boolean): Promise => { - for (const itemId of itemIds) { - if (isSettled) { - return; - } - try { - const item = await readItemStatus(itemId, signal); - const outcome = toTerminalUtilityOutcome(item.status, item.error_message, item.error_type); - if (outcome !== undefined) { - finish(outcome); - return; - } - } catch (cause) { - if (cause instanceof ApiError && cause.status === 404) { - finish(new UtilityQueueError('removed', 'The utility graph item was removed from the queue.', cause)); - return; - } - // Unreadable now; the socket or the next reconciliation can still settle it. - } - } - if (rearm) { - armReconcileTimer(); - } - }; - - const reconcileAcceptedItems = (): void => { - if (itemIds.length === 0) { - // The clear or reconnect raced the enqueue; read the item once its id is known. - reconcileRequestedBeforeAccept = true; - return; - } - void reconcile(false); - }; - const fail = (error: UtilityQueueError): never => { finish(error); throw error; }; // Attach before enqueue so a very fast completion is not missed. + let watch!: UtilityItemWatch; try { - detachers.push(watchUtilityOrigin(hub, origin, finish)); - detachers.push(hub.on('queue_cleared', reconcileAcceptedItems)); - if (hub.onConnectionChange) { - let wasDisconnected = false; - detachers.push( - hub.onConnectionChange((status) => { - if (status === 'disconnected') { - wasDisconnected = true; - } else if (status === 'connected' && wasDisconnected) { - wasDisconnected = false; - reconcileAcceptedItems(); - } - }) - ); - } + watch = watchUtilityItems({ hub, onOutcome: finish, origin, readItemStatus, signal }); + detachers.push(watch.detach); if (signal) { if (signal.aborted) { fail(new UtilityQueueError('aborted', 'The utility graph watch was aborted.')); @@ -445,6 +507,17 @@ export const submitUtilityGraph = async (options: SubmitUtilityGraphOptions): Pr fail(new UtilityQueueError('setup', `Failed to attach utility graph listeners: ${detail}`, cause)); } + /** An item still open at the periodic read re-arms it. */ + const armReconcileTimer = (): void => { + if (isSettled || reconcileIntervalMs <= 0) { + return; + } + timer = setTimeout(() => { + timer = null; + void watch.read().then(armReconcileTimer); + }, reconcileIntervalMs); + }; + let result: Awaited>; try { result = await enqueue({ graph, origin, prepend }); @@ -460,12 +533,8 @@ export const submitUtilityGraph = async (options: SubmitUtilityGraphOptions): Pr } fail(new UtilityQueueError('enqueue', 'The backend returned an inconsistent utility enqueue response.')); } - itemIds = inspected.result!.itemIds; - - if (reconcileRequestedBeforeAccept && !isSettled) { - reconcileRequestedBeforeAccept = false; - void reconcile(false); - } + const { itemIds } = inspected.result!; + watch.accept(itemIds); armReconcileTimer(); return { itemIds, origin, settled }; @@ -479,6 +548,7 @@ export const runUtilityGraph = (options: RunUtilityGraphOptions): Promise void) | null = null; const detachers: Array<() => void> = []; + let watch: UtilityItemWatch | null = null; let timer: ReturnType | null = null; const cancelAcceptedItems = (): void => { @@ -704,16 +775,21 @@ export const runUtilityGraph = (options: RunUtilityGraphOptions): Promise { + watch = watchUtilityItems({ + hub, + onOutcome: (error) => { if (error) { settleReject(error); return; } completionReceived = true; reconcileCompletion(); - }) - ); + }, + origin, + readItemStatus, + signal, + }); + detachers.push(watch.detach); } catch (cause) { const detail = cause instanceof Error ? cause.message : String(cause); settleReject(new UtilityQueueError('setup', `Failed to attach utility graph listeners: ${detail}`, cause)); @@ -761,6 +837,7 @@ export const runUtilityGraph = (options: RunUtilityGraphOptions): Promise { settleReject( - new UtilityQueueError( - 'enqueue', - error instanceof Error ? error.message : 'Failed to enqueue utility graph.', - error - ) + new UtilityQueueError('enqueue', getApiErrorMessage(error, 'Failed to enqueue utility graph.'), error) ); }); }); diff --git a/invokeai/frontend/webv2/src/features/queue/utility.ts b/invokeai/frontend/webv2/src/features/queue/utility.ts index 72e7eafd2b8..dfa43292508 100644 --- a/invokeai/frontend/webv2/src/features/queue/utility.ts +++ b/invokeai/frontend/webv2/src/features/queue/utility.ts @@ -4,8 +4,6 @@ export { submitUtilityGraph, UtilityQueueError, type RunUtilityGraphOptions, - type SubmitUtilityGraphOptions, type UtilityGraphResult, type UtilityGraphSubmission, - type UtilityItemStatusReader, } from './runtime/utilityGraph'; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx index f9f356c864f..3813b9b044b 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx @@ -13,6 +13,7 @@ import { seedArchitectureCapabilities, } from '@features/generation/core/architectureCapabilities.testing'; import { parseOpenApiToTemplates } from '@features/workflow/react'; +import { clearLogs, getLogSnapshot } from '@platform/logging/logger'; import { accountLifecycle } from '@platform/state/accountLifecycle'; import { ApiError, getApiErrorMessage } from '@platform/transport/http'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -889,8 +890,9 @@ describe('generate PBR maps', () => { expect(mocks.notificationsAdd).not.toHaveBeenCalled(); }); - it('reports an enqueue failure as an error notice and a failed run through the error channel', async () => { + it('reports an enqueue failure and a failed run as logged PBR error notices', async () => { const { UtilityQueueError } = await import('@features/queue/utility'); + clearLogs(); // The runtime wraps the backend's JSON refusal as an ApiError cause and derives the readable message the way // `submitUtilityGraph` does; the toast must show that detail, never the JSON. const cause = new ApiError(JSON.stringify({ detail: 'queue paused' }), 409); @@ -914,13 +916,25 @@ describe('generate PBR maps', () => { await actionsRef.current?.generatePbrMaps(image); }); await act(async () => { - settlement.reject(new Error('No PBR model')); + settlement.reject(new UtilityQueueError('failed', 'No PBR model')); await settlement.settled.catch(() => {}); }); - expect(mocks.reportError).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ area: 'image-actions', message: 'No PBR model', namespace: 'gallery' }) - ); + // A run that fails after it was queued names the action, as an enqueue failure does. + expect(mocks.notificationsAdd).toHaveBeenLastCalledWith({ + kind: 'error', + message: 'No PBR model', + title: 'widgets.gallery.itemActions.pbrMaps.failed', + }); + expect(mocks.reportError).not.toHaveBeenCalled(); + expect( + getLogSnapshot() + .entries.filter((entry) => entry.source.area === 'image-actions') + .map(({ level, message, namespace }) => ({ level, message, namespace })) + ).toEqual([ + { level: 'error', message: 'No PBR model', namespace: 'gallery' }, + { level: 'error', message: 'queue paused', namespace: 'gallery' }, + ]); expect(mocks.invalidateGallery).not.toHaveBeenCalled(); }); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts index af4861c61f4..e142fc521db 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts @@ -43,6 +43,7 @@ import { ensureModelsLoaded, useModelsSelector } from '@features/models'; import { submitUtilityGraph, type UtilityGraphSubmission, UtilityQueueError } from '@features/queue/utility'; import { copyImage } from '@platform/browser/clipboard'; import { downloadBlob } from '@platform/browser/downloadBlob'; +import { createLogger } from '@platform/logging/logger'; import { useMountEffect } from '@platform/react/useMountEffect'; import { assertAccountScopeCurrent, @@ -911,6 +912,12 @@ export const useImageActions = ({ const owner = captureAccountScope(); const galleryValues = getLatestWidgetValues('gallery'); let submission: UtilityGraphSubmission; + const reportFailure = (error: unknown) => { + const message = toErrorMessage(error); + + createLogger({ area: 'image-actions', namespace: 'gallery', projectId }, { owner }).error({ error, message }); + notifications.add({ kind: 'error', message, title: t('widgets.gallery.itemActions.pbrMaps.failed') }); + }; try { const pendingBoards = ensureGalleryBoardDestinations(queryClient); @@ -931,11 +938,7 @@ export const useImageActions = ({ } catch (error: unknown) { if (isAccountScopeCurrent(owner)) { void invalidateGalleryBoardDestinations(queryClient, owner); - notifications.add({ - kind: 'error', - message: toErrorMessage(error), - title: t('widgets.gallery.itemActions.pbrMaps.failed'), - }); + reportFailure(error); } return; } @@ -980,7 +983,7 @@ export const useImageActions = ({ // An abort only ends the watch; the backend item keeps running. if (!(error instanceof UtilityQueueError && error.reason === 'aborted')) { void invalidateGalleryBoardDestinations(queryClient, owner); - recordError(error); + reportFailure(error); } } ); From 6533b7779c947ccde7ae7f34cc8163d900f5b954 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sun, 25 Oct 2026 00:38:05 -0600 Subject: [PATCH 07/10] fix(settings): tie image storage controls to the move job state Start Move and Recover enable only when there is work, lock during layout saves and until a refused start's status re-read lands, and explain why they are disabled. The mock backend refuses moves during queue work and guards enqueue and image reads while a job awaits recovery, as the server does. --- .../06.Gallery/image-storage-maintenance.mdx | 14 +- .../frontend/webv2/public/locales/en.json | 11 +- .../scripts/mock-backend-fixtures.test.mjs | 55 ++++++- .../frontend/webv2/scripts/mock-backend.mjs | 27 +++- .../gallery/data/imageStorage.test.ts | 47 +++++- .../src/features/gallery/data/imageStorage.ts | 8 +- .../ImageStorageSettings.browser.test.tsx | 142 ++++++++++++++++-- .../settings/ImageStorageSettings.tsx | 131 ++++++++++++---- 8 files changed, 368 insertions(+), 67 deletions(-) diff --git a/docs/src/content/docs/Users Guide/06.Gallery/image-storage-maintenance.mdx b/docs/src/content/docs/Users Guide/06.Gallery/image-storage-maintenance.mdx index 81a81596df4..ec8d93f46b3 100644 --- a/docs/src/content/docs/Users Guide/06.Gallery/image-storage-maintenance.mdx +++ b/docs/src/content/docs/Users Guide/06.Gallery/image-storage-maintenance.mdx @@ -9,15 +9,21 @@ This operation changes where image files and thumbnails are stored on disk. It d ## Access -Open **Settings → Server → Image storage**. The **Folder layout for new images** select changes `image_subfolder_strategy` immediately for images saved from then on. Below it, **Move existing images** shows how many images are stored outside the current layout, the state of the last move job, and the **Start Move** and **Recover** buttons; each asks for confirmation before it starts. While a job runs, the status refreshes every few seconds and the controls are unavailable until it finishes. +Open **Settings → Server → Image storage**. The **Folder layout for new images** select changes `image_subfolder_strategy` immediately for images saved from then on. Below it, **Move existing images** shows how many images are stored outside the current layout, the state of the last move job, and the **Start Move** and **Recover** buttons; each asks for confirmation before it starts. + +- **Start Move** is available when images are stored outside the current layout and no interrupted job is waiting for recovery. The server still refuses it while any queue item is pending or in progress. +- **Recover** is available only when a move was interrupted and still needs finishing. +- Both buttons are unavailable while a job runs or a layout change is being saved. The status line or a note below the buttons says why a button is unavailable. + +While a job runs and the Image storage setting is open, the status refreshes every few seconds and the folder layout cannot be changed. Closing Settings stops the refresh; reopen it to see the current state. Administrators can start image moves, force recovery, and inspect the current move status. If multi-user mode is disabled, the single local user has the same access. Non-admin users in multi-user mode do not see the Server section and cannot run or inspect image move status. ## Maintenance Mode -Image moves run as a maintenance operation. Before a move starts, InvokeAI checks that no queue work is active. While the move is running, InvokeAI prevents image reads, uploads, deletes, generation jobs, and gallery mutations from racing with the filesystem move. +Image moves run as a maintenance operation. Before a move starts, InvokeAI checks that no queue work is active. While the move is running, or an interrupted move is waiting for recovery, InvokeAI prevents image reads, uploads, deletes, generation jobs, and gallery mutations from racing with the filesystem move. -The UI shows the move or recovery state until the job is complete or requires manual attention. Gallery images and thumbnails may be unavailable while maintenance is active. +The UI shows the move or recovery state until the job is complete or requires manual attention. Gallery images and thumbnails may be unavailable while maintenance is active; an open gallery keeps checking periodically while its tab is visible and reloads them on its own shortly after maintenance ends, so you do not need to refresh the page. ## Crash Recovery @@ -25,7 +31,7 @@ The move process is crash-recoverable. InvokeAI records each move job in its dat If InvokeAI stops during a move, recovery resumes incomplete jobs. If recovery finds an ambiguous filesystem state, such as both the old and new full-size image files existing or neither file existing, it halts that item for manual repair instead of blindly updating the database. Other items in the same job are still finalized, and the maintenance lock is released when the job is recorded as failed. -Recovery finalizes jobs that were already recorded before the stop. Images that had not yet been planned into a move job are not moved by recovery itself. The Image storage setting reports how many images still need moving; run **Start Move** again to continue. While a job is interrupted, **Start Move** stays unavailable and the status names the job that **Recover** will finish. +Recovery finalizes jobs that were already recorded before the stop. Images that had not yet been planned into a move job are not moved by recovery itself. The Image storage setting reports how many images still need moving; run **Start Move** again to continue. While a job is interrupted, **Start Move** stays unavailable and the status asks you to run **Recover** first. Missing intermediate image files are treated as already cleaned up and do not halt the move. Missing non-intermediate image files, unreadable images, and truncated images are recorded as failed items. When the full-size file is still at its original path, recovery leaves it there so it can be repaired or removed safely. If a previous filesystem move already placed the full-size file at its destination, recovery keeps that destination and reconciles the database before recording the item as failed. After recovery releases the lock, back up the failed file and either replace it with a valid image or delete its image record from the gallery, then run **Start Move** again if images still need moving. Do not delete an active journal row or edit `images.image_subfolder` while maintenance is active. diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index f52974234d6..74936a8d71f 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -2088,8 +2088,8 @@ "statusIdle_other": "{{count}} images are stored outside the current layout.", "statusMoving": "Moving images into the current layout…", "statusRecovering": "Recovering an interrupted image move…", - "statusNeedsRecovery": "Image move job {{jobId}} was interrupted. Run Recover to finish it before starting another move.", - "latestJob": "Last job {{jobId}}: {{state}}", + "statusNeedsRecovery": "An image move was interrupted. Run Recover to finish it before starting another move.", + "latestJob": "Last move: {{state}}", "jobStates": { "planned": "planned", "moving": "moving", @@ -2104,7 +2104,12 @@ "recover": "Recover", "recoverConfirmTitle": "Recover the image move journal?", "recoverConfirmBody": "Recovery finishes moves that were recorded before the server stopped. Items in an ambiguous state are left for manual repair and reported here.", - "recoverFailed": "Image move recovery could not be started." + "recoverFailed": "Image move recovery could not be started.", + "unavailableStatus": "Start Move and Recover become available once the move status has loaded.", + "unavailableRunning": "Start Move and Recover become available again when the current job finishes.", + "unavailableSavingLayout": "Start Move and Recover become available once the new layout is saved.", + "recoverNotNeeded": "Recover is only needed after a move is interrupted.", + "nothingToDo": "Nothing to move, and no interrupted move to recover." }, "galleryMaintenance": { "heading": "Gallery maintenance", diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs index 7de38a1baa4..e4a9dc20075 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs @@ -248,6 +248,16 @@ const getJson = async (backend, path, init) => { return body; }; +/** The representative queue holds pending and in-progress items, which refuse Start Move as on the real server. */ +const clearQueue = (backend) => getJson(backend, '/api/v1/queue/default/clear', { method: 'PUT' }); + +const enqueueBatch = (backend) => + fetch(`${backend.origin}/api/v1/queue/default/enqueue_batch`, { + body: JSON.stringify({ batch: { graph: { edges: [], id: 'pbr', nodes: {} }, runs: 1 }, prepend: false }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }); + test('queue item ids take the backend limit: the head of the order, counted as returned, bounded', async () => { await withRepresentativeBackend(async (backend) => { for (const orderDir of ['DESC', 'ASC']) { @@ -1251,6 +1261,7 @@ test('image storage: a move runs across status polls, commits, and refuses to ov headers: { 'content-type': 'application/json' }, method: 'PATCH', }); + await clearQueue(backend); const accepted = await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' }); @@ -1293,6 +1304,7 @@ test('image storage: a move runs across status polls, commits, and refuses to ov ); assert.equal((await fetch(`${backend.origin}/api/v1/board_images/`)).status, 409); assert.equal((await fetch(`${backend.origin}/api/v1/boards/?all=true`)).status, 200); + assert.equal((await enqueueBatch(backend)).status, 409); assert.deepEqual(await getJson(backend, '/api/v1/image_moves/status'), { active_job_id: null, @@ -1314,6 +1326,7 @@ test('image storage: an instant move commits inside the POST, and a failed job c method: 'PATCH', }); await getJson(backend, '/__faults?imageMoves=instant', { method: 'POST' }); + await clearQueue(backend); const accepted = await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' }); @@ -1341,7 +1354,32 @@ test('image storage: an instant move commits inside the POST, and a failed job c }); }); -test('image storage: faults seed an interrupted job for recovery, a busy queue, and a non-admin caller', async () => { +test('image storage: Start Move waits for an idle queue, and recovery does not', async () => { + await withRepresentativeBackend(async (backend) => { + await getJson(backend, '/api/v1/app/runtime_config', { + body: JSON.stringify({ image_subfolder_strategy: 'date' }), + headers: { 'content-type': 'application/json' }, + method: 'PATCH', + }); + await clearQueue(backend); + assert.equal((await enqueueBatch(backend)).status, 201); + + // A queued item that has not run yet is active work, as `has_active_queue_work` counts it. + const refused = await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' }); + + assert.equal(refused.status, 409); + assert.deepEqual(await refused.json(), { detail: 'Cannot start image move while queue work is active' }); + assert.equal((await fetch(`${backend.origin}/api/v1/image_moves/recover`, { method: 'POST' })).status, 202); + await getJson(backend, '/api/v1/image_moves/status'); + assert.equal((await getJson(backend, '/api/v1/image_moves/status')).is_running, false); + + await clearQueue(backend); + + assert.equal((await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' })).status, 202); + }); +}); + +test('image storage: faults seed an interrupted job for recovery and a non-admin caller', async () => { await withRepresentativeBackend(async (backend) => { await getJson(backend, '/__faults?imageMoves=needs-recovery', { method: 'POST' }); @@ -1352,6 +1390,12 @@ test('image storage: faults seed an interrupted job for recovery, a busy queue, assert.equal(interrupted.last_error, 'Server stopped during an image move'); assert.equal((await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' })).status, 409); + // A job awaiting recovery keeps maintenance active, so the guard still refuses image files and enqueueing. + const imageName = (await getJson(backend, '/api/v1/images/?limit=1&offset=0')).items[0].image_name; + + assert.equal((await fetch(`${backend.origin}/api/v1/images/i/${imageName}/thumbnail`)).status, 409); + assert.equal((await enqueueBatch(backend)).status, 409); + const recovering = await fetch(`${backend.origin}/api/v1/image_moves/recover`, { method: 'POST' }); assert.equal(recovering.status, 202); @@ -1364,13 +1408,8 @@ test('image storage: faults seed an interrupted job for recovery, a busy queue, assert.equal(recovered.is_running, false); assert.equal(recovered.last_error, null); assert.equal(recovered.latest_job.state, 'committed'); - - await getJson(backend, '/__faults?imageMoves=queue-active', { method: 'POST' }); - - const refused = await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' }); - - assert.equal(refused.status, 409); - assert.equal((await fetch(`${backend.origin}/api/v1/image_moves/recover`, { method: 'POST' })).status, 202); + assert.equal((await fetch(`${backend.origin}/api/v1/images/i/${imageName}/thumbnail`)).status, 200); + assert.equal((await enqueueBatch(backend)).status, 201); await getJson(backend, '/__faults?intermediatesCaller=user', { method: 'POST' }); diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs index b5fb5614c90..9866376a972 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs @@ -82,13 +82,14 @@ const ARCHITECTURE_CAPABILITIES = JSON.parse( * caller sees through general access, a membership, or admin rights. `user` also makes the admin-only image storage * routes answer 403. `imageMoves` seeds the image move journal: `idle` (the * default), `needs-recovery` (an interrupted job that only Recover can finish), `failed` (a job that ended in error, - * which counts as finished), `queue-active` (Start Move is refused with 409 while recovery still works) or `instant` - * (a move so small it commits before the POST answers, so `is_running` is already false). While a move or recovery - * runs, image files and media mutations answer 409 like the real maintenance guard; listings stay readable. + * which counts as finished) or `instant` (a move so small it commits before the POST answers, so `is_running` is + * already false). Like the real service, Start Move is refused while any queue item is pending or in progress (the + * representative profile seeds some; clear the queue first). While a move runs or a job awaits recovery, image files, + * media mutations and enqueueing answer 409 like the real maintenance guard; listings stay readable. */ const CAPABILITY_FAULTS = new Set(['ok', 'error', 'empty']); const INTERMEDIATES_CALLERS = new Set(['admin', 'user', 'multiuser-admin']); -const IMAGE_MOVE_FAULTS = new Set(['idle', 'needs-recovery', 'failed', 'queue-active', 'instant']); +const IMAGE_MOVE_FAULTS = new Set(['idle', 'needs-recovery', 'failed', 'instant']); const IMAGE_MOVE_MAINTENANCE_DETAIL = 'Image storage maintenance is active'; /** The job states the real service counts as finished; any other journaled job still needs recovery. */ const FINISHED_IMAGE_MOVE_JOB_STATES = new Set(['committed', 'error']); @@ -181,6 +182,14 @@ const tickImageMove = (state) => { } }; +/** Mirrors `is_maintenance_active`: a running operation, or a journaled job that has not finished. */ +const isImageMoveMaintenanceActive = (state) => + state.imageStorage.running !== null || imageMoveStatus(state).active_job_id !== null; + +/** Mirrors `has_active_queue_work`, which gates Start Move: any queue item pending or in progress. */ +const hasActiveQueueWork = (state) => + [...state.queueItems.values()].some((item) => item.status === 'pending' || item.status === 'in_progress'); + /** * The real maintenance guard covers image uploads, deletions, edits, star changes, copies, downloads, image files * and board-image membership, plus deleting a board with its images; listings and DTO reads stay open. @@ -1440,8 +1449,8 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { if (imageMoveStatus(state).active_job_id !== null) { return json(409, { detail: 'An image move job is already active' }); } - if (faults.imageMoves === 'queue-active') { - return json(409, { detail: 'Cannot move images while the queue has active work' }); + if (hasActiveQueueWork(state)) { + return json(409, { detail: 'Cannot start image move while queue work is active' }); } startImageMoveOperation(state, 'move_all', { instant: faults.imageMoves === 'instant' }); @@ -1457,7 +1466,7 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { } } - if (state.imageStorage.running !== null && isImageMoveMaintenanceRoute(method, path, url)) { + if (isImageMoveMaintenanceActive(state) && isImageMoveMaintenanceRoute(method, path, url)) { return json(409, { detail: IMAGE_MOVE_MAINTENANCE_DETAIL }); } @@ -1921,6 +1930,10 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { const action = queueMatch[1]; if (action === 'enqueue_batch' && method === 'POST') { + if (isImageMoveMaintenanceActive(state)) { + return json(409, { detail: IMAGE_MOVE_MAINTENANCE_DETAIL }); + } + const { batch = {} } = await readJsonBody(request); const runs = Number.isInteger(batch.runs) && batch.runs > 0 ? batch.runs : 1; const now = timestamp(state); diff --git a/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.test.ts index 0cdd9ac503a..6471ba6fece 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.test.ts @@ -1,5 +1,5 @@ import { accountLifecycle } from '@platform/state/accountLifecycle'; -import { QueryClient } from '@tanstack/react-query'; +import { QueryClient, QueryObserver } from '@tanstack/react-query'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ @@ -179,17 +179,52 @@ describe('image move status', () => { expect(mocks.refreshGalleryThumbnails).toHaveBeenCalledExactlyOnceWith(owner); }); - it('re-reads the status when the server refuses to start, since the cached status is what misled the caller', async () => { + it('re-reads the status after a refusal and rejects only once the fresh status is cached', async () => { const client = new QueryClient(); const owner = accountLifecycle.capture(); - client.setQueryData(imageStorageKeys.moveStatus(owner), { isRunning: false }); + const statusRead = createDeferred(); const refusal = new Error('{"detail":"An image move job is already running"}'); - mocks.apiFetchJson.mockRejectedValueOnce(refusal); + mocks.apiFetchJson.mockResolvedValueOnce(committedStatus); + // A mounted editor observes the status, so the invalidation refetches it. + const observer = new QueryObserver(client, imageMoveStatusQueryOptions(owner)); + const unsubscribe = observer.subscribe(() => undefined); + await vi.waitFor(() => expect(client.getQueryData(imageStorageKeys.moveStatus(owner))).toBeDefined()); + mocks.apiFetchJson.mockRejectedValueOnce(refusal).mockReturnValueOnce(statusRead.promise); + + let settled = false; + const started = startImageMove(client, owner).finally(() => { + settled = true; + }); + const outcome = started.catch((error: unknown) => error); - await expect(startImageMove(client, owner)).rejects.toBe(refusal); + await vi.waitFor(() => expect(mocks.apiFetchJson).toHaveBeenCalledTimes(3)); + await Promise.resolve(); + // Until the re-read lands the caller keeps its controls locked against the stale idle status. + expect(settled).toBe(false); - expect(client.getQueryState(imageStorageKeys.moveStatus(owner))?.isInvalidated).toBe(true); + statusRead.resolve(runningStatus); + + await expect(outcome).resolves.toBe(refusal); + expect(client.getQueryData(imageStorageKeys.moveStatus(owner))).toMatchObject({ activeJobId: 3, isRunning: true }); expect(mocks.invalidateGallery).not.toHaveBeenCalled(); + unsubscribe(); + }); + + it('keeps the refusal when the status re-read fails too', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const owner = accountLifecycle.capture(); + const refusal = new Error('{"detail":"Cannot start image move while queue work is active"}'); + mocks.apiFetchJson.mockResolvedValueOnce(committedStatus); + const observer = new QueryObserver(client, imageMoveStatusQueryOptions(owner)); + const unsubscribe = observer.subscribe(() => undefined); + await vi.waitFor(() => expect(client.getQueryData(imageStorageKeys.moveStatus(owner))).toBeDefined()); + mocks.apiFetchJson.mockRejectedValueOnce(refusal).mockRejectedValueOnce(new Error('status unavailable')); + + await expect(startImageMove(client, owner)).rejects.toBe(refusal); + + expect(mocks.apiFetchJson).toHaveBeenCalledTimes(3); + expect(client.getQueryState(imageStorageKeys.moveStatus(owner))?.status).toBe('error'); + unsubscribe(); }); it('drops a response that lands after the account changed', async () => { diff --git a/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.ts b/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.ts index 9e153dd5507..1b3659b5acc 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.ts @@ -160,10 +160,12 @@ const postImageMove = async ( signal: owner.signal, }).then(toImageMoveStatus); } catch (error) { - // A refusal usually means the cached status is stale (another admin's job, finished queue work): re-read it so - // the editor locks or frees its controls from what the server says now. + // A refusal usually means the cached status is stale (another admin's job, finished queue work): re-read it + // before the caller unlocks, so the editor's controls follow what the server says now rather than the stale + // status. A failed re-read leaves the status query in error (which keeps the controls locked) and never + // replaces the refusal: invalidation does not reject on refetch errors. if (isAccountScopeCurrent(owner)) { - void client.invalidateQueries({ queryKey }); + await client.invalidateQueries({ queryKey }); } throw error; } diff --git a/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.browser.test.tsx index d9121a9529e..2c7f9a257f0 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.browser.test.tsx @@ -110,6 +110,7 @@ const confirmIn = (label: string) => [...(dialog()?.querySelectorAll('button') ?? [])].find((candidate) => candidate.textContent?.includes(label) ); +const maintenanceGroup = () => host.querySelector('[role="group"][aria-labelledby]'); const trigger = () => host.querySelector('[data-scope="select"][data-part="trigger"]'); const refetchStatus = () => act(() => queryClient.refetchQueries({ queryKey: ['gallery', 'image-storage'] })); const createDeferred = () => { @@ -156,7 +157,7 @@ describe('image storage settings', () => { await expect.poll(() => trigger()?.textContent).toContain('settings.imageStorage.layouts.flat'); await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 0'); - expect(text()).toContain('settings.imageStorage.latestJob 2 settings.imageStorage.jobStates.committed'); + expect(text()).toContain('settings.imageStorage.latestJob settings.imageStorage.jobStates.committed'); await act(() => userEvent.click(trigger()!)); await expect.poll(() => document.querySelectorAll('[role="option"]').length).toBe(4); @@ -239,7 +240,9 @@ describe('image storage settings', () => { await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 0'); expect(mocks.invalidateGallery).toHaveBeenCalledTimes(1); - expect(button('settings.imageStorage.startMove')?.disabled).toBe(false); + // Every image is in the layout now, so there is nothing left to start. + expect(button('settings.imageStorage.startMove')?.disabled).toBe(true); + expect(text()).toContain('settings.imageStorage.nothingToDo'); expect(trigger()?.disabled).toBe(false); }); @@ -255,7 +258,7 @@ describe('image storage settings', () => { }); await render(); - await expect.poll(() => text()).toContain('settings.imageStorage.statusNeedsRecovery 3'); + await expect.poll(() => text()).toContain('settings.imageStorage.statusNeedsRecovery'); expect(text()).toContain('Server stopped during an image move'); expect(button('settings.imageStorage.startMove')?.disabled).toBe(true); expect(button('settings.imageStorage.recover')?.disabled).toBe(false); @@ -283,20 +286,26 @@ describe('image storage settings', () => { await expect.poll(() => host.querySelector('[role="alert"]')?.textContent).toBe('Queue work is active'); expect(button('settings.imageStorage.startMove')?.disabled).toBe(false); + // The refusal describes the button it came from; the disabled Recover keeps only its own note. + const recoverNote = [...host.querySelectorAll('p')].find((node) => + node.textContent?.includes('settings.imageStorage.recoverNotNeeded') + ); expect(button('settings.imageStorage.startMove')?.getAttribute('aria-describedby')).toBe( host.querySelector('[role="alert"]')?.id ); + expect(button('settings.imageStorage.recover')?.getAttribute('aria-describedby')).toBe(recoverNote?.id); expect(mocks.invalidateGallery).not.toHaveBeenCalled(); }); it('re-reads the status after a refused start and locks the controls on the move it reveals', async () => { + const reread = createDeferred(); let status: unknown = idleStatus(12); installRoutes({ [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'date' } }), [`GET ${STATUS_ROUTE}`]: () => status, [`POST /api/v1/image_moves/start`]: () => { // Another admin started a move after this editor last read the status. - status = runningStatus('move_all'); + status = reread.promise; return new ApiError(JSON.stringify({ detail: 'An image move job is already running' }), 409); }, }); @@ -307,6 +316,17 @@ describe('image storage settings', () => { await act(() => userEvent.click(button('settings.imageStorage.startMove')!)); await expect.poll(() => dialog()).not.toBeNull(); await act(() => userEvent.click(confirmIn('settings.imageStorage.startMove')!)); + await expect.poll(() => calls('GET', STATUS_ROUTE).length).toBe(2); + + // The cache still says idle, but nothing unlocks until the re-read lands. + expect(dialog()).not.toBeNull(); + expect(button('settings.imageStorage.startMove')?.disabled).toBe(true); + expect(trigger()?.disabled).toBe(true); + expect(host.querySelector('[role="alert"]')).toBeNull(); + + await act(() => { + reread.resolve(runningStatus('move_all')); + }); await expect .poll(() => host.querySelector('[role="alert"]')?.textContent) @@ -316,9 +336,9 @@ describe('image storage settings', () => { expect(button('settings.imageStorage.startMove')?.disabled).toBe(true); expect(button('settings.imageStorage.recover')?.disabled).toBe(true); expect(trigger()?.disabled).toBe(true); - // Its opener is disabled now; the closed dialog hands focus to the section reporting the refusal. + // Its opener is disabled now; the closed dialog hands focus to the named section reporting the refusal. await expect.poll(() => dialog()).toBeNull(); - await expect.poll(() => host.contains(document.activeElement)).toBe(true); + await expect.poll(() => document.activeElement).toBe(maintenanceGroup()); }); it('shows the start in flight and locks the layout until the server has answered', async () => { @@ -353,7 +373,7 @@ describe('image storage settings', () => { await expect.poll(() => dialog()).toBeNull(); await expect.poll(() => text()).toContain('settings.imageStorage.statusMoving'); expect(trigger()?.disabled).toBe(true); - await expect.poll(() => host.contains(document.activeElement)).toBe(true); + await expect.poll(() => document.activeElement).toBe(maintenanceGroup()); }); it('keeps the layout locked until the move status has loaded', async () => { @@ -369,11 +389,13 @@ describe('image storage settings', () => { expect(trigger()?.disabled).toBe(true); expect(button('settings.imageStorage.startMove')?.disabled).toBe(true); + expect(text()).toContain('settings.imageStorage.unavailableStatus'); + await act(() => { - statusRead.resolve(idleStatus(0)); + statusRead.resolve(idleStatus(12)); }); - await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 0'); + await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 12'); expect(trigger()?.disabled).toBe(false); expect(button('settings.imageStorage.startMove')?.disabled).toBe(false); }); @@ -383,7 +405,7 @@ describe('image storage settings', () => { installRoutes({ [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'flat' } }), [`GET ${STATUS_ROUTE}`]: () => - fail ? new ApiError('{"detail":"Image move service unavailable"}', 503) : idleStatus(0), + fail ? new ApiError('{"detail":"Image move service unavailable"}', 503) : idleStatus(12), }); await render(); @@ -393,7 +415,107 @@ describe('image storage settings', () => { fail = false; await act(() => userEvent.click(button('common.retry')!)); + await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 12'); + expect(button('settings.imageStorage.startMove')?.disabled).toBe(false); + }); + it('enables each job button only when the status gives it something to do, and says why otherwise', async () => { + let status: unknown = idleStatus(0); + installRoutes({ + [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'date' } }), + [`GET ${STATUS_ROUTE}`]: () => status, + }); + await render(); + await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 0'); + expect(button('settings.imageStorage.startMove')?.disabled).toBe(true); + expect(button('settings.imageStorage.recover')?.disabled).toBe(true); + expect(text()).toContain('settings.imageStorage.nothingToDo'); + + status = idleStatus(12); + await refetchStatus(); + await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 12'); expect(button('settings.imageStorage.startMove')?.disabled).toBe(false); + expect(button('settings.imageStorage.recover')?.disabled).toBe(true); + expect(text()).toContain('settings.imageStorage.recoverNotNeeded'); + const reason = [...host.querySelectorAll('p')].find((node) => + node.textContent?.includes('settings.imageStorage.recoverNotNeeded') + ); + expect(button('settings.imageStorage.recover')?.getAttribute('aria-describedby')).toBe(reason?.id); + // The note explains the disabled Recover only; the enabled Start Move is not described by it. + expect(button('settings.imageStorage.startMove')?.hasAttribute('aria-describedby')).toBe(false); + + status = interruptedStatus; + await refetchStatus(); + await expect.poll(() => text()).toContain('settings.imageStorage.statusNeedsRecovery'); + expect(button('settings.imageStorage.startMove')?.disabled).toBe(true); + expect(button('settings.imageStorage.recover')?.disabled).toBe(false); + + status = runningStatus('recovery'); + await refetchStatus(); + await expect.poll(() => text()).toContain('settings.imageStorage.statusRecovering'); + expect(button('settings.imageStorage.startMove')?.disabled).toBe(true); + expect(button('settings.imageStorage.recover')?.disabled).toBe(true); + expect(text()).toContain('settings.imageStorage.unavailableRunning'); + }); + + it('locks the job buttons while a layout change is being saved', async () => { + const saved = createDeferred(); + let layout = 'date'; + installRoutes({ + [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: layout } }), + [`PATCH ${CONFIG_ROUTE}`]: () => saved.promise, + [`GET ${STATUS_ROUTE}`]: () => idleStatus(12), + }); + await render(); + await expect.poll(() => button('settings.imageStorage.startMove')?.disabled).toBe(false); + + await act(() => userEvent.click(trigger()!)); + await expect.poll(() => document.querySelectorAll('[role="option"]').length).toBe(4); + const hashOption = [...document.querySelectorAll('[role="option"]')].find((option) => + option.textContent?.includes('settings.imageStorage.layouts.hash') + ); + await act(() => userEvent.click(hashOption!)); + await expect.poll(() => calls('PATCH', CONFIG_ROUTE).length).toBe(1); + + // A move started now would read whichever layout the save leaves behind. + expect(button('settings.imageStorage.startMove')?.disabled).toBe(true); + expect(button('settings.imageStorage.recover')?.disabled).toBe(true); + expect(text()).toContain('settings.imageStorage.unavailableSavingLayout'); + + layout = 'hash'; + await act(() => { + saved.resolve({ config: { image_subfolder_strategy: 'hash' } }); + }); + + await expect.poll(() => button('settings.imageStorage.startMove')?.disabled).toBe(false); + expect(trigger()?.textContent).toContain('settings.imageStorage.layouts.hash'); + }); + + it('returns focus to the opener when the confirmation is cancelled or dismissed', async () => { + installRoutes({ + [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'date' } }), + [`GET ${STATUS_ROUTE}`]: () => interruptedStatus, + }); + await render(); + await expect.poll(() => button('settings.imageStorage.recover')?.disabled).toBe(false); + + await act(() => userEvent.click(button('settings.imageStorage.recover')!)); + await expect.poll(() => dialog()).not.toBeNull(); + await act(() => userEvent.click(confirmIn('Cancel')!)); + await expect.poll(() => dialog()).toBeNull(); + await expect.poll(() => document.activeElement).toBe(button('settings.imageStorage.recover')); + + await act(() => userEvent.click(button('settings.imageStorage.recover')!)); + await expect.poll(() => dialog()).not.toBeNull(); + await act(() => userEvent.keyboard('{Escape}')); + await expect.poll(() => dialog()).toBeNull(); + await expect.poll(() => document.activeElement).toBe(button('settings.imageStorage.recover')); + expect(calls('POST', '/api/v1/image_moves/recover')).toHaveLength(0); + + // The section that receives focus after a confirmed action is named by its heading. + const group = maintenanceGroup(); + expect(document.getElementById(group?.getAttribute('aria-labelledby') ?? '')?.textContent).toBe( + 'settings.imageStorage.maintenanceHeading' + ); }); }); diff --git a/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.tsx b/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.tsx index a43a3032f84..4dd6fceef2b 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.tsx +++ b/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.tsx @@ -18,7 +18,7 @@ import { ConfirmDialog } from '@platform/ui/ConfirmDialog'; import { Field } from '@platform/ui/Field'; import { Select } from '@platform/ui/Select'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useCallback, useId, useMemo, useRef, useState } from 'react'; +import { type MouseEvent, useCallback, useId, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; const tKey = (key: string): string => `settings.imageStorage.${key}`; @@ -27,6 +27,52 @@ const SELECT_POSITIONING = { strategy: 'fixed', sameWidth: true, hideWhenDetache type ImageMoveAction = 'start' | 'recover'; +interface JobAvailability { + canRecover: boolean; + canStart: boolean; + /** Visible reason for the disabled buttons, unless the status line already explains it. */ + reasonKey: string | null; +} + +/** + * Each button is enabled only when the server would do something with it: Recover only for an interrupted job (any + * other recovery is a no-op behind the server-wide maintenance lock), Start Move only when images need moving. + */ +const getJobAvailability = ({ + isActing, + isSavingLayout, + status, +}: { + isActing: boolean; + isSavingLayout: boolean; + /** Null until a usable status has loaded. */ + status: ImageMoveStatus | null; +}): JobAvailability => { + if (status === null) { + return { canRecover: false, canStart: false, reasonKey: 'unavailableStatus' }; + } + if (isActing) { + // The confirmation dialog shows the request in flight. + return { canRecover: false, canStart: false, reasonKey: null }; + } + if (status.isRunning) { + return { canRecover: false, canStart: false, reasonKey: 'unavailableRunning' }; + } + if (isSavingLayout) { + // The new layout changes what needs moving. + return { canRecover: false, canStart: false, reasonKey: 'unavailableSavingLayout' }; + } + if (status.activeJobId !== null) { + // The status line tells the admin to run Recover before another move. + return { canRecover: true, canStart: false, reasonKey: null }; + } + if (status.needsMoveCount > 0) { + return { canRecover: false, canStart: true, reasonKey: 'recoverNotNeeded' }; + } + + return { canRecover: false, canStart: false, reasonKey: 'nothingToDo' }; +}; + /** Server image storage: the folder layout for new images and the move/recovery job for existing ones. */ export const ImageStorageSettings = () => { const { t } = useTranslation(); @@ -51,14 +97,23 @@ const ImageStorageEditor = () => { // Polling lives with this editor: the gallery refresh at the end of a move needs it open until the job stops. const statusQuery = useQuery(imageMoveStatusQueryOptions()); const actionErrorId = useId(); - // After an accepted start, or a refusal that reveals another admin's move, every control is disabled; the - // closing confirmation returns focus to the section that now reports the outcome instead of the document body. + const availabilityId = useId(); + const maintenanceHeadingId = useId(); const maintenanceRef = useRef(null); - const getMaintenanceEl = useCallback(() => maintenanceRef.current, []); + // The button that opened the confirmation, and whether it was confirmed rather than dismissed. + const openerRef = useRef(null); + const isConfirmedRef = useRef(false); + // Cancel returns focus to the opener. A confirmed action reports its outcome in the maintenance group, and an + // accepted job disables the opener, so focus lands on that group rather than the document body. + const getFinalFocus = useCallback(() => { + const opener = isConfirmedRef.current ? null : openerRef.current; + + return opener && !opener.disabled ? opener : maintenanceRef.current; + }, []); const { isBusy: isSavingLayout, run: runLayoutSave } = useScopedAction(); const { isBusy: isActing, run: runAction } = useScopedAction(); const [layoutError, setLayoutError] = useState(null); - const [actionError, setActionError] = useState(null); + const [actionError, setActionError] = useState<{ action: ImageMoveAction; message: string } | null>(null); const [pendingAction, setPendingAction] = useState(null); const layout = layoutQuery.data ?? 'flat'; @@ -91,8 +146,19 @@ const ImageStorageEditor = () => { [layout, queryClient, runLayoutSave, t] ); - const requestStart = useCallback(() => setPendingAction('start'), []); - const requestRecover = useCallback(() => setPendingAction('recover'), []); + const requestAction = useCallback((action: ImageMoveAction, opener: HTMLButtonElement) => { + openerRef.current = opener; + isConfirmedRef.current = false; + setPendingAction(action); + }, []); + const requestStart = useCallback( + (event: MouseEvent) => requestAction('start', event.currentTarget), + [requestAction] + ); + const requestRecover = useCallback( + (event: MouseEvent) => requestAction('recover', event.currentTarget), + [requestAction] + ); const closeConfirmation = useCallback(() => setPendingAction(null), []); // Returns the request so the dialog shows it in flight; the dialog closes when it settles, either way. const confirmAction = useCallback(async () => { @@ -102,26 +168,37 @@ const ImageStorageEditor = () => { return; } + isConfirmedRef.current = true; setActionError(null); await runAction( async (owner) => { await (action === 'start' ? startImageMove(queryClient, owner) : startImageMoveRecovery(queryClient, owner)); }, (_message, error) => - setActionError(getApiErrorMessage(error, t(tKey(action === 'start' ? 'startFailed' : 'recoverFailed')))) + setActionError({ + action, + message: getApiErrorMessage(error, t(tKey(action === 'start' ? 'startFailed' : 'recoverFailed'))), + }) ); }, [pendingAction, queryClient, runAction, t]); const retryStatus = useCallback(() => void statusQuery.refetch(), [statusQuery]); const retryLayout = useCallback(() => void layoutQuery.refetch(), [layoutQuery]); const status = statusQuery.data ?? null; - const isRunning = status?.isRunning ?? false; - const hasActiveJob = status !== null && status.activeJobId !== null; - const isStatusUsable = status !== null && !statusQuery.isError; - const isBusy = isRunning || isActing || !isStatusUsable; + const usableStatus = statusQuery.isError ? null : status; + const availability = getJobAvailability({ isActing, isSavingLayout, status: usableStatus }); // The move reads the layout per batch, so changing it while a job runs (or may be running: status unknown, or a // start still being accepted) would leave the images split between two layouts. - const isLayoutLocked = layoutQuery.isPending || isSavingLayout || isBusy; + const isLayoutLocked = + layoutQuery.isPending || isSavingLayout || isActing || usableStatus === null || usableStatus.isRunning; + // Each button references only what explains it: its own action's failure, and the note while it is disabled. + const describedBy = (action: ImageMoveAction, isEnabled: boolean) => + [ + actionError?.action === action ? actionErrorId : null, + availability.reasonKey && !isEnabled ? availabilityId : null, + ] + .filter(Boolean) + .join(' ') || undefined; return ( @@ -153,8 +230,8 @@ const ImageStorageEditor = () => { /> )} - - + + {t(tKey('maintenanceHeading'))} @@ -168,8 +245,8 @@ const ImageStorageEditor = () => { /> + {availability.reasonKey ? ( + + {t(tKey(availability.reasonKey))} + + ) : null} {actionError ? ( - {actionError} + {actionError.message} ) : null} {status.latestJob && !status.isRunning && status.activeJobId === null ? ( - {t(tKey('latestJob'), { - jobId: status.latestJob.id, - state: t(tKey(`jobStates.${status.latestJob.state}`)), - })} + {t(tKey('latestJob'), { state: t(tKey(`jobStates.${status.latestJob.state}`)) })} ) : null} {errorMessage ? ( From 33866d2aa929bd8775586c1015ba756722452e34 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sun, 25 Oct 2026 17:43:10 -0600 Subject: [PATCH 08/10] fix(settings): drop a move refusal once the refusing move ends The refusal alert and its button description now last while the move it was refused against runs, across batch jobs, and clear when it ends. --- .../ImageStorageSettings.browser.test.tsx | 51 +++++++++++++++++++ .../settings/ImageStorageSettings.tsx | 27 ++++++++-- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.browser.test.tsx index 2c7f9a257f0..87688b64238 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.browser.test.tsx @@ -341,6 +341,57 @@ describe('image storage settings', () => { await expect.poll(() => document.activeElement).toBe(maintenanceGroup()); }); + it('drops a refusal once the move it was refused against ends', async () => { + let status: unknown = idleStatus(12); + installRoutes({ + [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'date' } }), + [`GET ${STATUS_ROUTE}`]: () => status, + [`POST /api/v1/image_moves/start`]: () => { + // Another admin started a move after this editor last read the status. + status = runningStatus('move_all'); + return new ApiError(JSON.stringify({ detail: 'An image move job is already running' }), 409); + }, + }); + await render(); + await expect.poll(() => button('settings.imageStorage.startMove')?.disabled).toBe(false); + + await act(() => userEvent.click(button('settings.imageStorage.startMove')!)); + await expect.poll(() => dialog()).not.toBeNull(); + await act(() => userEvent.click(confirmIn('settings.imageStorage.startMove')!)); + + const alert = () => host.querySelector('[role="alert"]'); + await expect.poll(() => alert()?.textContent).toBe('An image move job is already running'); + await expect.poll(() => text()).toContain('settings.imageStorage.statusMoving'); + expect(button('settings.imageStorage.startMove')?.getAttribute('aria-describedby')?.split(' ')).toContain( + alert()?.id + ); + + // The move journals one job per batch: polls during it see the job ids advance, with no active job between + // batches, and keep the refusal it explains. + for (const [activeJobId, latestJobId] of [ + [null, 3], + [4, 4], + [null, 4], + [5, 5], + ] as const) { + status = { + ...runningStatus('move_all'), + active_job_id: activeJobId, + latest_job: { error_message: null, id: latestJobId, state: activeJobId === null ? 'committed' : 'moving' }, + }; + await refetchStatus(); + expect(alert()?.textContent).toBe('An image move job is already running'); + } + + status = { ...idleStatus(12), latest_job: { error_message: null, id: 5, state: 'committed' } }; + await refetchStatus(); + + await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 12'); + expect(alert()).toBeNull(); + expect(button('settings.imageStorage.startMove')?.disabled).toBe(false); + expect(button('settings.imageStorage.startMove')?.hasAttribute('aria-describedby')).toBe(false); + }); + it('shows the start in flight and locks the layout until the server has answered', async () => { const accepted = createDeferred(); let status: unknown = idleStatus(12); diff --git a/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.tsx b/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.tsx index 4dd6fceef2b..ce7deaeed65 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.tsx +++ b/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.tsx @@ -27,6 +27,20 @@ const SELECT_POSITIONING = { strategy: 'fixed', sameWidth: true, hideWhenDetache type ImageMoveAction = 'start' | 'recover'; +/** + * The job state a refusal was answered against. A running move journals one job per batch, so its job ids advance + * (with no active job between batches) while it runs: running is one state however far it has got. Otherwise the + * active and latest jobs tell the states apart. A refusal stops applying once the status moves past it (the move + * that refused the start ends, for example). + */ +const getJobKey = (status: ImageMoveStatus | undefined): string => { + if (!status) { + return 'unknown'; + } + + return status.isRunning ? 'running' : `idle:${String(status.activeJobId)}:${String(status.latestJob?.id ?? null)}`; +}; + interface JobAvailability { canRecover: boolean; canStart: boolean; @@ -113,7 +127,9 @@ const ImageStorageEditor = () => { const { isBusy: isSavingLayout, run: runLayoutSave } = useScopedAction(); const { isBusy: isActing, run: runAction } = useScopedAction(); const [layoutError, setLayoutError] = useState(null); - const [actionError, setActionError] = useState<{ action: ImageMoveAction; message: string } | null>(null); + const [actionError, setActionError] = useState<{ action: ImageMoveAction; jobKey: string; message: string } | null>( + null + ); const [pendingAction, setPendingAction] = useState(null); const layout = layoutQuery.data ?? 'flat'; @@ -177,6 +193,8 @@ const ImageStorageEditor = () => { (_message, error) => setActionError({ action, + // The refusal re-read the status before it settled, so the cache holds the job it was refused against. + jobKey: getJobKey(queryClient.getQueryData(imageMoveStatusQueryOptions().queryKey)), message: getApiErrorMessage(error, t(tKey(action === 'start' ? 'startFailed' : 'recoverFailed'))), }) ); @@ -185,6 +203,7 @@ const ImageStorageEditor = () => { const retryLayout = useCallback(() => void layoutQuery.refetch(), [layoutQuery]); const status = statusQuery.data ?? null; + const shownActionError = actionError?.jobKey === getJobKey(statusQuery.data) ? actionError : null; const usableStatus = statusQuery.isError ? null : status; const availability = getJobAvailability({ isActing, isSavingLayout, status: usableStatus }); // The move reads the layout per batch, so changing it while a job runs (or may be running: status unknown, or a @@ -194,7 +213,7 @@ const ImageStorageEditor = () => { // Each button references only what explains it: its own action's failure, and the note while it is disabled. const describedBy = (action: ImageMoveAction, isEnabled: boolean) => [ - actionError?.action === action ? actionErrorId : null, + shownActionError?.action === action ? actionErrorId : null, availability.reasonKey && !isEnabled ? availabilityId : null, ] .filter(Boolean) @@ -270,9 +289,9 @@ const ImageStorageEditor = () => { {t(tKey(availability.reasonKey))} ) : null} - {actionError ? ( + {shownActionError ? ( - {actionError.message} + {shownActionError.message} ) : null} From f93b370230e79b31d85ce6d86487cb02177fa94d Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sun, 25 Oct 2026 17:43:10 -0600 Subject: [PATCH 09/10] fix(gallery): translate the PBR maps action and document its board fallback The Preview recovery test answers the recovery probe in the page, so a late route response cannot outlast the final poll. --- .../06.Gallery/gallery-reference.mdx | 2 +- .../frontend/webv2/public/locales/en.json | 1 + .../ImageContextMenu.browser.test.tsx | 5 ++-- .../image-actions/ImageContextMenu.tsx | 2 +- .../PreviewNavigation.browser.test.tsx | 26 +++++++++++++++++-- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx b/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx index 197b3c5ebbb..5e6667c5e63 100644 --- a/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx +++ b/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx @@ -74,7 +74,7 @@ For an image, the menu offers: * **Add to Current Canvas** — add the image to the open project's canvas as a new layer of the kind you choose. * **New Project from Image** — start a new project whose canvas holds the image, sized to fit it. * **Send to Upscale**, **Send to Video**, **Use as Reference Image**, **Use as Prompt Template** and **Select for Compare** — use the image elsewhere in InvokeAI. -* **Generate PBR Maps** — queue normal, roughness and displacement maps of the image. They are saved to the board the Gallery is auto-adding to (Uncategorized when there is none), and a notice reports when they are ready. The first run downloads the PBR models. +* **Generate PBR Maps** — queue normal, roughness and displacement maps of the image. They are saved to the board the Gallery is auto-adding to, or to the project's Inbox if you can't add to that board (Uncategorized when there is neither), and a notice reports when they are ready. The first run downloads the PBR models. * **Change Board** and **Delete Image**. A video's menu has the equivalent actions for video, including **Extend in Video**, **Use as Reference Video** and **Use as Conditioning Clip**. With several items selected, the menu offers the actions that apply to all of them, such as **Star All**, **Download Selection**, **Change Board** and **Delete Selection**; for several images it also leads with **Add to Current Canvas** and **New Project from Images**. diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 74936a8d71f..3c633c2d604 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -4740,6 +4740,7 @@ "completed": "PBR maps are ready", "completedMessage": "Normal, roughness and displacement maps for {{name}} were added to the gallery.", "failed": "Could not generate PBR maps", + "label": "Generate PBR Maps", "queued": "Generating PBR maps", "queuedMessage": "Normal, roughness and displacement maps for {{name}} are queued.", "removed": "PBR maps removed from the queue", diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx index 0ecdc87cd1a..45d942ff61d 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx @@ -23,6 +23,7 @@ import { EMPTY_IMAGE_RECALL_CAPABILITIES } from './imageRecall'; const NO_BOARDS: [] = []; const LOAD_WORKFLOW = 'widgets.gallery.itemActions.loadWorkflow.label'; +const GENERATE_PBR_MAPS = 'widgets.gallery.itemActions.pbrMaps.label'; const findGalleryItem = vi.fn(); @@ -347,7 +348,7 @@ describe('ImageContextMenu PBR maps', () => { const actions = createActions(vi.fn()); await renderMenu(actions, [image('texture.png')]); - await interact(() => getMenuItem('Generate PBR Maps').click()); + await interact(() => getMenuItem(GENERATE_PBR_MAPS).click()); expect(actions.generatePbrMaps).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ imageName: 'texture.png' }) @@ -357,7 +358,7 @@ describe('ImageContextMenu PBR maps', () => { it('is absent from bulk menus, which queue nothing per image', async () => { await renderMenu(createActions(vi.fn()), [image('a.png'), image('b.png')]); - expect(document.body.textContent).not.toContain('Generate PBR Maps'); + expect(document.body.textContent).not.toContain(GENERATE_PBR_MAPS); }); }); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx index db6b4198484..bf1891a7687 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx @@ -886,7 +886,7 @@ const SingleImageMenuItems = ({ /> diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx index 6e81df52440..fe7942e067a 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx @@ -3774,8 +3774,25 @@ describe('preview image recovery', () => { it('reloads the full image an image move refused once the move ends', async () => { // A fresh account lifetime: earlier tests' failed images must not leave a probe waiting on a real timer. accountLifecycle.activate('preview-image-recovery'); + const imagePath = '/images/newest/full'; + // The recovery probe's reads are answered in the page, so they settle within the fake clock's steps; the + // image element's own reads go to the network route. + let isMoving = true; + const isProbe = (input: unknown) => new URL(String(input), window.location.href).pathname === imagePath; + const browserFetch = globalThis.fetch; + const probes = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => + isProbe(input) + ? Promise.resolve( + isMoving + ? new Response('moving', { status: 409 }) + : new Response('', { + headers: { 'content-type': 'image/svg+xml' }, + }) + ) + : browserFetch(input, init) + ); // The real guard answers full image reads with 409 too while the files move. - await imageRouteCommands.startThumbnailRoute(['/images/newest/full'], { failures: 1_000_000, status: 409 }); + await imageRouteCommands.startThumbnailRoute([imagePath], { failures: 1_000_000, status: 409 }); vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['setTimeout', 'clearTimeout'] }); try { @@ -3783,16 +3800,21 @@ describe('preview image recovery', () => { await expect.poll(() => shownImage()?.complete === true && shownImage()?.naturalWidth === 0).toBe(true); expect((await imageRouteCommands.getThumbnailRequests()).statuses).toEqual([409]); - // Two minutes into the move, it still refuses the image, which stays down. + // Two minutes into the move, the probe keeps asking and the image stays down. await act(() => vi.advanceTimersByTimeAsync(2 * 60_000)); + expect(probes.mock.calls.filter(([input]) => isProbe(input)).length).toBeGreaterThan(1); expect(shownImage()?.naturalWidth).toBe(0); + expect((await imageRouteCommands.getThumbnailRequests()).statuses).toEqual([409]); + // The move ends: the next probe sees the image served and the preview reloads it. + isMoving = false; await imageRouteCommands.endThumbnailRouteFailures(); await act(() => vi.advanceTimersByTimeAsync(30_000)); await expect.poll(() => shownImage()?.naturalWidth).toBe(64); expect(shownImage()?.getAttribute('src')).toContain('gallery_image_recovery='); } finally { vi.useRealTimers(); + probes.mockRestore(); await imageRouteCommands.stopThumbnailRoute(); } }, 20_000); From 62f76f3ffe626a8daf46a41212d4f7788c64b43c Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sun, 25 Oct 2026 17:43:10 -0600 Subject: [PATCH 10/10] refactor(queue): drop the test-only reconcile interval option The Queue README describes how a utility submission watch ends; the dependency policy test covers gallery's storage entry. --- .../src/architecture/dependencyPolicy.test.ts | 1 + .../frontend/webv2/src/features/queue/README.md | 2 ++ .../features/queue/runtime/utilityGraph.test.ts | 16 ---------------- .../src/features/queue/runtime/utilityGraph.ts | 14 ++++---------- 4 files changed, 7 insertions(+), 26 deletions(-) diff --git a/invokeai/frontend/webv2/src/architecture/dependencyPolicy.test.ts b/invokeai/frontend/webv2/src/architecture/dependencyPolicy.test.ts index 812832d1ce7..d19c4dc5450 100644 --- a/invokeai/frontend/webv2/src/architecture/dependencyPolicy.test.ts +++ b/invokeai/frontend/webv2/src/architecture/dependencyPolicy.test.ts @@ -225,6 +225,7 @@ describe('feature public-interface registry', () => { expect(checkDependency('workbench/palette/paletteProviders.ts', '@features/workflow/queries')).toEqual([]); expect(checkDependency('workbench/palette/paletteProviders.ts', '@features/workflow/paletteSearch')).toEqual([]); expect(checkDependency('workbench/palette/paletteProviders.ts', '@features/gallery/paletteSearch')).toEqual([]); + expect(checkDependency('workbench/settings/ImageStorageSettings.tsx', '@features/gallery/storage')).toEqual([]); expect(checkDependency('workbench/palette/paletteProviders.ts', '@features/queue/queries')).toEqual([]); expect(checkDependency('workbench/palette/WorkbenchCommandPaletteDialog.tsx', '@features/queue/queries')).toEqual( [] diff --git a/invokeai/frontend/webv2/src/features/queue/README.md b/invokeai/frontend/webv2/src/features/queue/README.md index 851788d67e6..736a1ad857a 100644 --- a/invokeai/frontend/webv2/src/features/queue/README.md +++ b/invokeai/frontend/webv2/src/features/queue/README.md @@ -7,3 +7,5 @@ Queue's private `QueueBackendPort` is adapter seam; public callers use domain re Queue exposes board-independent active backend sessions, including preparation and result settlement. Gallery progress section includes every unfinished batch slot, queued work too; Preview follows executing and settling sessions. Both use gallery-destination projections of active project's queue; eligibility is presentation policy, separate from discovery. Workbench owns transient overview/pinned follow selection for its mounted lifetime, clears pins on project changes or when following stops, and never persists pins or preview frames. Live sessions take no part in board counts, saved-image selection, or navigation. Saved outputs keep their actual backend board destinations; automatic result selection does not change browsed board or its paging window. Retry re-runs failed or canceled backend items under new ids; the run owning them reopens around the reruns so its captured destination receives their results. A rerun no open run adopts reaches the Gallery, or the current Canvas of a tab with its project open. Canvas reruns are staged once across every tab and browser of the account through a delivery claim the server keeps per queue item (`POST queue/{id}/i/{item_id}/delivery`): a claimant claims, stages, then confirms; a claim neither confirmed nor released lapses after two minutes. A reopened run claims its reruns as `run:` once its tab holds the run, renews them while it lasts, and gives back what it did not stage when the tab lets the run go, so a reload restores the same claimant and a run never stages a rerun another tab staged first. A tab the browser freezes stops renewing; another tab then stages the rerun at its own current bbox. A tab staging a rerun no run adopted claims as itself, after waiting for any tab of the browser that still tracks the run, and asks again while another claimant holds the rerun. A server without the endpoint lets every tab stage. + +`submitUtilityGraph` (`@features/queue/utility`) returns once the backend accepts the graph; its `settled` watch ends on the first of: a terminal status event for its origin; a re-read of its accepted items that finds a terminal status or answers 404 (`removed`), triggered by `queue_cleared`, a reconnect after a disconnect, `queue_items_canceled` naming an accepted id, or the periodic reconcile read, which re-arms while the item stays open; or an abort of its signal (`aborted`), which stops watching and leaves the backend item alone. diff --git a/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.test.ts index 4c4f2e34f3e..c27d1ac09f8 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.test.ts @@ -1675,20 +1675,4 @@ describe('submitUtilityGraph — fire, then watch settlement', () => { expect(fake.handlerCount('queue_item_status_changed')).toBe(0); expect(vi.getTimerCount()).toBe(0); }); - - it('does not arm a reconcile read when the interval is disabled', async () => { - vi.useFakeTimers(); - const fake = createFakeHub(); - const submission = await submitUtilityGraph({ - createId: () => UTIL_ID, - enqueue: okEnqueue, - graph, - hub: fake.hub, - reconcileIntervalMs: 0, - }); - - expect(vi.getTimerCount()).toBe(0); - fake.emit('queue_item_status_changed', statusEvent('completed')); - await expect(submission.settled).resolves.toBeUndefined(); - }); }); diff --git a/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.ts b/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.ts index bd4c9605938..94fdab2080c 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.ts @@ -210,7 +210,8 @@ export const reconcileUtilityCompletedOutput: UtilityCompletedOutputReconciler = /** * How long a submitted utility graph goes unheard from before its item is read again; the first run may download - * models. An item still open at the read re-arms it, so the watch itself never gives up. + * models. An item still open at the read re-arms it, so the watch never gives up: it ends only on a terminal status, + * the item's removal, or an abort. */ export const DEFAULT_UTILITY_RECONCILE_INTERVAL_MS = 30 * 60_000; @@ -403,12 +404,6 @@ export interface SubmitUtilityGraphOptions { * backend item is never canceled: the caller only asked to stop watching. */ signal?: AbortSignal; - /** - * After this many ms without a settlement (default {@link DEFAULT_UTILITY_RECONCILE_INTERVAL_MS}; `0` disables) - * the item is read again; an item still open re-arms the read. The watch ends only on a terminal status, the - * item's removal, or an abort. - */ - reconcileIntervalMs?: number; } /** An accepted utility submission whose results are not returned, only awaited. */ @@ -434,7 +429,6 @@ export const submitUtilityGraph = async (options: SubmitUtilityGraphOptions): Pr const enqueue = options.enqueue ?? enqueueUtility; const cancel = options.cancel ?? cancelQueueItems; const readItemStatus = options.readItemStatus ?? readUtilityItemStatus; - const reconcileIntervalMs = options.reconcileIntervalMs ?? DEFAULT_UTILITY_RECONCILE_INTERVAL_MS; let origin: string; try { origin = buildUtilityQueueItemOrigin((options.createId ?? createUuid)()); @@ -509,13 +503,13 @@ export const submitUtilityGraph = async (options: SubmitUtilityGraphOptions): Pr /** An item still open at the periodic read re-arms it. */ const armReconcileTimer = (): void => { - if (isSettled || reconcileIntervalMs <= 0) { + if (isSettled) { return; } timer = setTimeout(() => { timer = null; void watch.read().then(armReconcileTimer); - }, reconcileIntervalMs); + }, DEFAULT_UTILITY_RECONCILE_INTERVAL_MS); }; let result: Awaited>;