From 31a87d00380e59497fbb84d748628d0b5cf86daa Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 20:01:06 -0600 Subject: [PATCH 01/35] fix(gallery): recall legacy guidance, Krea-2 seed variance, empty references and reinstalled models Guidance-family records restore their guidance key; Krea-2 seed variance values recall with the rebalance. An explicitly empty reference list clears the current references instead of leaving them active. Main, component and VAE references resolve by install key, then recorded hash, then name and base. --- .../webv2/src/features/generation/settings.ts | 1 + .../image-actions/executeImageRecall.ts | 3 +- .../image-actions/imageRecall.test.ts | 220 ++++++++++++++++++ .../workbench/image-actions/imageRecall.ts | 152 ++++++++---- 4 files changed, 325 insertions(+), 51 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/generation/settings.ts b/invokeai/frontend/webv2/src/features/generation/settings.ts index 8561bbc1a9b..3ab645979f2 100644 --- a/invokeai/frontend/webv2/src/features/generation/settings.ts +++ b/invokeai/frontend/webv2/src/features/generation/settings.ts @@ -19,6 +19,7 @@ export { isVaeModelConfig, isWanLoraTargetingMain, MAX_HIDIFFUSION_RATIO, + MAX_KREA2_SEED_VARIANCE_STRENGTH, MAX_NEGATIVE_PROMPT_HEIGHT_PX, MAX_POSITIVE_PROMPT_HEIGHT_PX, MIN_NEGATIVE_PROMPT_HEIGHT_PX, diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts index e08ad5d6403..ce424083262 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts @@ -287,7 +287,8 @@ export const executeImageRecall = async ({ return false; } - if (result.fields.includes('referenceImages')) { + // An empty recalled list clears the current references; there is nothing to check for it. + if (result.fields.includes('referenceImages') && result.values.referenceImages.length > 0) { result.values.referenceImages = await filterAvailableReferenceImages(result.values.referenceImages, owner.signal); assertAccountScopeCurrent(owner); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts index 83d8be78063..d8253b63ad3 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts @@ -21,6 +21,7 @@ import { satisfiedSettingsFor, } from '@features/generation/core/graphCoverage.testing'; import { compileGenerateGraph } from '@features/generation/graph'; +import { normalizeReferenceImages } from '@features/generation/settings'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { buildImageRecallSettings, getImageRecallCapabilities, type ImageRecallKind } from './imageRecall'; @@ -1233,3 +1234,222 @@ describe('remix round trip', () => { ]); }); }); + +describe('legacy records', () => { + const fluxModel: MainModelConfig = { base: 'flux', key: 'flux-model', name: 'FLUX', type: 'main' }; + const recall = ( + metadata: unknown, + overrides: Partial, + supportedModels: MainModelConfig[], + models: ComponentModelConfig[] = supportedModels, + vaeModels: VaeModelConfig[] = [] + ) => + buildImageRecallSettings({ + currentValues: createValues(overrides), + image, + kind: 'all', + metadata, + models, + supportedModels, + vaeModels, + }); + + it('recalls FLUX guidance, which older records carry only under its own key', () => { + const result = recall({ guidance: 7.25, model: { key: fluxModel.key } }, { cfgScale: 1.5, model: fluxModel }, [ + fluxModel, + ]); + + expect(result?.values.cfgScale).toBe(7.25); + expect(result?.fields).toContain('cfg'); + }); + + it('reads cfg_scale for a CFG model even when a guidance key is present', () => { + const result = recall({ cfg_scale: 4, guidance: 7.25, model: { key: sdxlModel.key } }, {}, [sdxlModel]); + + expect(result?.values.cfgScale).toBe(4); + }); + + it('recalls Krea-2 seed variance alongside the rebalance', () => { + const result = recall( + { + krea2_seed_variance_enabled: true, + krea2_seed_variance_randomize_percent: 73, + krea2_seed_variance_strength: 0.42, + model: { key: krea2Model.key }, + }, + { model: krea2Model, modelKey: krea2Model.key }, + [krea2Model] + ); + + expect(result?.values.krea2SeedVarianceEnabled).toBe(true); + expect(result?.values.krea2SeedVarianceStrength).toBe(0.42); + expect(result?.values.krea2SeedVarianceRandomizePercent).toBe(73); + expect(result?.fields).toContain('krea2SeedVariance'); + }); + + it('keeps the current seed variance for a value the enhancer cannot hold', () => { + const result = recall( + { krea2_seed_variance_enabled: true, krea2_seed_variance_strength: 5, model: { key: krea2Model.key } }, + { model: krea2Model, modelKey: krea2Model.key }, + [krea2Model] + ); + + expect(result?.values.krea2SeedVarianceEnabled).toBe(true); + expect(result?.values.krea2SeedVarianceStrength).toBe(createValues().krea2SeedVarianceStrength); + }); + + it('ignores seed variance recorded for a model that is not Krea-2', () => { + const result = recall({ krea2_seed_variance_enabled: true, model: { key: sdxlModel.key } }, {}, [sdxlModel]); + + expect(result?.values.krea2SeedVarianceEnabled).toBe(false); + expect(result?.fields).not.toContain('krea2SeedVariance'); + }); + + describe('explicitly empty references', () => { + const currentReferenceImages = normalizeReferenceImages([ + { + config: { + image: { height: 512, image_name: 'current.png', width: 512 }, + model: { base: 'sdxl', key: 'adapter', name: 'Adapter', type: 'ip_adapter' }, + type: 'ip_adapter', + }, + id: 'current-reference', + isEnabled: true, + }, + ]); + const legacyCanvasReference = { + referenceImages: { + entities: [ + { + id: 'legacy-canvas-ref', + ipAdapter: { + image: { height: 512, image_name: 'legacy.png', width: 512 }, + model: { base: 'sdxl', key: 'adapter', name: 'Adapter', type: 'ip_adapter' }, + type: 'ip_adapter', + }, + isEnabled: true, + }, + ], + }, + }; + + it('clears the current references when the record lists none', () => { + const result = recall( + { model: { key: sdxlModel.key }, positive_prompt: 'no references', ref_images: [] }, + { referenceImages: currentReferenceImages }, + [sdxlModel] + ); + + expect(result?.values.referenceImages).toEqual([]); + expect(result?.fields).toContain('referenceImages'); + }); + + it('does not fall back to pre-v6 canvas references for an empty modern list', () => { + const result = recall( + { canvas_v2_metadata: legacyCanvasReference, model: { key: sdxlModel.key }, ref_images: [] }, + { referenceImages: currentReferenceImages }, + [sdxlModel] + ); + + expect(result?.values.referenceImages).toEqual([]); + }); + + it('offers recall for an image whose only delta is clearing the references', () => { + const capabilities = getImageRecallCapabilities({ + currentValues: createValues({ referenceImages: currentReferenceImages }), + image, + metadata: { ref_images: [] }, + models: [], + supportedModels: [], + vaeModels: [], + }); + + expect(capabilities.all).toBe(true); + }); + + it('leaves the current references when the record says nothing about them', () => { + const result = recall( + { model: { key: sdxlModel.key }, positive_prompt: 'silent' }, + { referenceImages: currentReferenceImages }, + [sdxlModel] + ); + + expect(result?.values.referenceImages).toEqual(currentReferenceImages); + expect(result?.fields).not.toContain('referenceImages'); + }); + }); + + describe('model identity', () => { + const reinstalled: MainModelConfig = { ...sdxlModel, hash: 'blake3:sdxl', key: 'sdxl-reinstalled' }; + const recorded = { base: 'sdxl', hash: 'blake3:sdxl', key: 'sdxl-old-key', name: 'SDXL', type: 'main' }; + + it('resolves a reinstalled main model by its recorded hash', () => { + const result = recall({ model: recorded, positive_prompt: 'old image' }, { model: sd1Model }, [ + sd1Model, + reinstalled, + ]); + + expect(result?.values.model.key).toBe(reinstalled.key); + expect(result?.fields).toContain('model'); + }); + + it('does not let a recorded hash fall back to a same-named model', () => { + const sameName: MainModelConfig = { ...sdxlModel, hash: 'blake3:other', key: 'sdxl-other' }; + const result = recall({ model: recorded, positive_prompt: 'old image' }, { model: sd1Model }, [ + sd1Model, + sameName, + ]); + + expect(result?.values.model.key).toBe(sd1Model.key); + expect(result?.fields).not.toContain('model'); + }); + + it('resolves a pre-identifier record by model name and base', () => { + const result = recall( + { model: { base_model: 'sdxl', model_name: 'SDXL' }, positive_prompt: 'very old image' }, + { model: sd1Model }, + [sd1Model, sdxlModel] + ); + + expect(result?.values.model.key).toBe(sdxlModel.key); + }); + + it('skips a name-and-base record that matches more than one installed model', () => { + const twin: MainModelConfig = { ...sdxlModel, key: 'sdxl-twin' }; + const result = recall( + { model: { base: 'sdxl', name: 'SDXL', type: 'main' }, positive_prompt: 'ambiguous' }, + { model: sd1Model }, + [sd1Model, sdxlModel, twin] + ); + + expect(result?.values.model.key).toBe(sd1Model.key); + expect(result?.fields).not.toContain('model'); + }); + + it('resolves components and the VAE by hash too', () => { + const t5: ComponentModelConfig = { + base: 'any', + hash: 'blake3:t5', + key: 't5-new', + name: 'T5', + type: 't5_encoder', + }; + const vae: VaeModelConfig = { base: 'flux', hash: 'blake3:vae', key: 'vae-new', name: 'FLUX VAE', type: 'vae' }; + const result = recall( + { + model: { key: fluxModel.key }, + t5_encoder: { base: 'any', hash: 'blake3:t5', key: 't5-old', name: 'T5', type: 't5_encoder' }, + vae: { base: 'flux', hash: 'blake3:vae', key: 'vae-old', name: 'FLUX VAE', type: 'vae' }, + }, + { model: fluxModel, modelKey: fluxModel.key }, + [fluxModel], + [fluxModel, t5], + [vae] + ); + + expect(result?.values.t5EncoderModel?.key).toBe(t5.key); + expect(result?.values.vae?.key).toBe(vae.key); + expect(result?.fields).toEqual(expect.arrayContaining(['components', 'vae'])); + }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts index 829d4119327..cc1ba19564a 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts @@ -3,8 +3,8 @@ import type { ComponentModelConfig, GenerateLora, GenerateModelConfig, + GenerateReferenceImage, GenerateWidgetValues, - LoraModelConfig, MainModelConfig, VaeModelConfig, } from '@features/generation/contracts'; @@ -30,6 +30,7 @@ import { isVaeCompatibleWithGenerateModel, isValidKrea2RebalanceWeights, MAX_HIDIFFUSION_RATIO, + MAX_KREA2_SEED_VARIANCE_STRENGTH, MIN_HIDIFFUSION_T1_RATIO, normalizeReferenceImages, } from '@features/generation/settings'; @@ -73,7 +74,8 @@ type RecalledField = | 'components' | 'loras' | 'referenceImages' - | 'krea2Rebalance'; + | 'krea2Rebalance' + | 'krea2SeedVariance'; export type ImageRecallSkipReason = | 'ambiguous' @@ -182,10 +184,18 @@ export const getSteps = (metadata: unknown): number | null => { return steps !== null && steps >= 1 ? steps : null; }; -export const getCfgScale = (metadata: unknown): number | null => { - const cfgScale = getNumber(metadata, 'cfg_scale'); +/** + * The value the model's single guidance slider holds. Distilled-guidance families record it as `guidance` and + * their older records carry no `cfg_scale` at all, so for them the dedicated key wins. + */ +export const getCfgScale = (metadata: unknown, model?: GenerateModelConfig): number | null => { + const { guidanceLabel, guidanceMin } = getGenerationUiPolicy(model); + const cfgScale = + guidanceLabel === 'Guidance' + ? (getNumber(metadata, 'guidance') ?? getNumber(metadata, 'cfg_scale')) + : getNumber(metadata, 'cfg_scale'); - return cfgScale !== null && cfgScale >= 1 ? cfgScale : null; + return cfgScale !== null && cfgScale >= guidanceMin ? cfgScale : null; }; export const getCfgRescaleMultiplier = (metadata: unknown): number | null => { @@ -282,20 +292,18 @@ export const getMetadataSize = ( }; }; -const getMetadataModelKey = (metadata: unknown, key: 'model' | 'vae'): string | null => { - const model = getRecord(metadata, key); +/** The installed model a recorded reference names, or null when none (or more than one) does. */ +const resolveMetadataModel = (value: unknown, models: readonly T[]): T | null => { + const ref = toRecordedModelRef(value); + const resolved = ref ? resolveRecordedModel(ref, models) : null; - return typeof model?.key === 'string' ? model.key : null; + return resolved === 'ambiguous' ? null : resolved; }; const getSupportedMetadataModel = ( metadata: unknown, supportedModels: GenerateModelConfig[] -): GenerateModelConfig | null => { - const modelKey = getMetadataModelKey(metadata, 'model'); - - return modelKey ? (supportedModels.find((model) => model.key === modelKey) ?? null) : null; -}; +): GenerateModelConfig | null => resolveMetadataModel(getRecord(metadata, 'model'), supportedModels); const getModelFromMetadata = ( metadata: unknown, @@ -311,13 +319,7 @@ const getModelFromMetadata = ( return null; } - const modelKey = getMetadataModelKey(metadata, key as 'model' | 'vae'); - - if (!modelKey) { - return undefined; - } - - const model = models.find((candidate) => candidate.key === modelKey); + const model = resolveMetadataModel(metadata[key], models); return guard(model) ? model : undefined; }; @@ -347,13 +349,7 @@ const getMetadataVae = ( return null; } - const vaeKey = getMetadataModelKey(metadata, 'vae'); - - if (!vaeKey) { - return undefined; - } - - const vae = vaeModels.find((vae) => vae.key === vaeKey); + const vae = resolveMetadataModel(metadata.vae, vaeModels); return vae && isVaeCompatibleWithGenerateModel(model, vae) ? vae : undefined; }; @@ -388,9 +384,9 @@ export const getMetadataPrompts = (metadata: unknown): { negativePrompt: string; const hasMetadataSize = (metadata: unknown, model: GenerateModelConfig): boolean => Object.keys(getMetadataSize(metadata, model)).length > 0; -const hasGenerationSettings = (metadata: unknown): boolean => +const hasGenerationSettings = (metadata: unknown, model: GenerateModelConfig): boolean => getSteps(metadata) !== null || - getCfgScale(metadata) !== null || + getCfgScale(metadata, model) !== null || getCfgRescaleMultiplier(metadata) !== null || getScheduler(metadata) !== null || getBoolean(metadata, 'seamless_x') !== null || @@ -419,6 +415,33 @@ const getMetadataKrea2Rebalance = ( const hasKrea2Rebalance = (metadata: unknown, model: GenerateModelConfig | undefined): boolean => Object.keys(getMetadataKrea2Rebalance(metadata, model)).length > 0; +/** Bounds match the enhancer's own controls; a value outside them keeps the current setting. */ +const getMetadataKrea2SeedVariance = ( + metadata: unknown, + model: GenerateModelConfig | undefined +): Partial => { + if (model?.base !== 'krea-2') { + return {}; + } + + const enabled = getBoolean(metadata, 'krea2_seed_variance_enabled'); + const strength = getNumber(metadata, 'krea2_seed_variance_strength'); + const randomizePercent = getNumber(metadata, 'krea2_seed_variance_randomize_percent'); + + return { + ...(enabled === null ? {} : { krea2SeedVarianceEnabled: enabled }), + ...(strength !== null && strength >= 0 && strength <= MAX_KREA2_SEED_VARIANCE_STRENGTH + ? { krea2SeedVarianceStrength: strength } + : {}), + ...(randomizePercent !== null && randomizePercent >= 0 && randomizePercent <= 100 + ? { krea2SeedVarianceRandomizePercent: randomizePercent } + : {}), + }; +}; + +const hasKrea2SeedVariance = (metadata: unknown, model: GenerateModelConfig | undefined): boolean => + Object.keys(getMetadataKrea2SeedVariance(metadata, model)).length > 0; + type RecalledComponentSetting = keyof Pick< GenerateWidgetValues, | 'clipEmbedModel' @@ -495,12 +518,16 @@ const getComponentPatch = ( const hasComponentModels = (metadata: unknown, models: readonly ComponentModelConfig[]): boolean => Object.keys(getComponentPatch(metadata, models)).length > 0; -/** A recorded `ModelIdentifierField` — `{ key, hash, name, base }` — with every field optional on read. */ +/** + * A recorded `ModelIdentifierField` — `{ key, hash, name, base, type }` — with every field optional on read. Records + * from before identifier fields named the model as `{ model_name, base_model, model_type }`. + */ interface RecordedModelRef { base: string | null; hash: string | null; key: string | null; name: string | null; + type: string | null; } const toRecordedModelRef = (value: unknown): RecordedModelRef | null => { @@ -509,10 +536,11 @@ const toRecordedModelRef = (value: unknown): RecordedModelRef | null => { } const ref = { - base: getString(value, 'base'), + base: getString(value, 'base') ?? getString(value, 'base_model'), hash: getString(value, 'hash'), key: getString(value, 'key'), - name: getString(value, 'name'), + name: getString(value, 'name') ?? getString(value, 'model_name'), + type: getString(value, 'type') ?? getString(value, 'model_type'), }; return ref.key || ref.hash || (ref.name && ref.base) ? ref : null; @@ -520,23 +548,25 @@ const toRecordedModelRef = (value: unknown): RecordedModelRef | null => { /** * Resolve the install-local key first, then a portable identifier: the content hash when one was recorded, otherwise - * name and base. A recorded hash never falls back to a same-named file, and a portable identifier counts only when - * exactly one installed LoRA matches it. + * name and base (and type, when recorded). A recorded hash never falls back to a same-named file, and a portable + * identifier counts only when exactly one installed model matches it. */ -const resolveRecordedLora = ( +const resolveRecordedModel = ( ref: RecordedModelRef, - loraModels: readonly LoraModelConfig[] -): LoraModelConfig | 'ambiguous' | null => { - const byKey = ref.key ? loraModels.find((model) => model.key === ref.key) : undefined; + models: readonly T[] +): T | 'ambiguous' | null => { + const byKey = ref.key ? models.find((model) => model.key === ref.key) : undefined; if (byKey) { return byKey; } const matches = ref.hash - ? loraModels.filter((model) => model.hash === ref.hash) + ? models.filter((model) => model.hash === ref.hash) : ref.name && ref.base - ? loraModels.filter((model) => model.name === ref.name && model.base === ref.base) + ? models.filter( + (model) => model.name === ref.name && model.base === ref.base && (!ref.type || model.type === ref.type) + ) : []; return matches.length > 1 ? 'ambiguous' : (matches[0] ?? null); @@ -605,7 +635,7 @@ const getMetadataConcepts = ( continue; } - const installed = resolveRecordedLora(ref, loraModels); + const installed = resolveRecordedModel(ref, loraModels); if (installed === null || installed === 'ambiguous') { skipped.push({ name: recordedName, reason: installed === 'ambiguous' ? 'ambiguous' : 'unresolved' }); @@ -622,8 +652,16 @@ const getMetadataConcepts = ( return { loras, skipped }; }; -export const getMetadataReferenceImages = (metadata: unknown) => { +/** + * The references the image was generated with, or null when the record says nothing about them. An explicitly + * empty modern list means it used none; only a list with no usable entry falls back to the pre-v6 canvas slot. + */ +export const getMetadataReferenceImages = (metadata: unknown): GenerateReferenceImage[] | null => { if (!isRecord(metadata)) { + return null; + } + + if (Array.isArray(metadata.ref_images) && metadata.ref_images.length === 0) { return []; } @@ -638,16 +676,18 @@ export const getMetadataReferenceImages = (metadata: unknown) => { const referenceImages = getRecord(canvasMetadata, 'referenceImages'); const legacyEntities = referenceImages?.entities; if (!Array.isArray(legacyEntities)) { - return []; + return null; } - return normalizeReferenceImages( + const legacy = normalizeReferenceImages( legacyEntities.map((entry) => isRecord(entry) && isRecord(entry.ipAdapter) ? { config: entry.ipAdapter, id: entry.id, isEnabled: entry.isEnabled } : entry ) ).filter((referenceImage) => referenceImage.config.image !== null); + + return legacy.length > 0 ? legacy : null; }; export const withDimensions = ( @@ -702,11 +742,14 @@ export const getImageRecallCapabilities = ({ const hasSeed = getSeed(metadata) !== null; const hasPrompts = hasPrompt(metadata); const hasSize = hasMetadataSize(metadata, currentValues.model); - const hasSettings = hasGenerationSettings(metadata); + const hasSettings = hasGenerationSettings(metadata, clipSkipModel); const hasComponents = hasComponentModels(metadata, models); - const hasReferenceImages = getMetadataReferenceImages(metadata).length > 0; + const recordedReferenceImages = getMetadataReferenceImages(metadata); + const hasReferenceImages = + recordedReferenceImages !== null && + (recordedReferenceImages.length > 0 || currentValues.referenceImages.length > 0); const hasModel = supportedMetadataModel !== null; - const hasRebalance = hasKrea2Rebalance(metadata, clipSkipModel); + const hasRebalance = hasKrea2Rebalance(metadata, clipSkipModel) || hasKrea2SeedVariance(metadata, clipSkipModel); const hasConcepts = (getMetadataConcepts(metadata, models, clipSkipModel, supportedMetadataModel).loras?.length ?? 0) > 0; const hasAnyMetadata = @@ -803,6 +846,13 @@ export const buildImageRecallSettings = ({ fields.push('krea2Rebalance'); } + const seedVariancePatch = getMetadataKrea2SeedVariance(metadata, values.model); + + if (Object.keys(seedVariancePatch).length > 0) { + values = { ...values, ...seedVariancePatch }; + fields.push('krea2SeedVariance'); + } + const vae = getMetadataVae(metadata, values.model, vaeModels); if (vae !== undefined) { @@ -835,9 +885,11 @@ export const buildImageRecallSettings = ({ // Recalled reference images must fit the effective model — when the // metadata model was not recalled (e.g. uninstalled), `values.model` is // still the current one and incompatible configs are re-targeted or dropped. - const referenceImages = getCompatibleReferenceImages(getMetadataReferenceImages(metadata), values.model, models); + const recordedReferenceImages = getMetadataReferenceImages(metadata); + const referenceImages = getCompatibleReferenceImages(recordedReferenceImages ?? [], values.model, models); - if (referenceImages.length > 0) { + // A record of no references clears the current ones; a record whose references do not fit leaves them. + if (referenceImages.length > 0 || (recordedReferenceImages?.length === 0 && values.referenceImages.length > 0)) { values = { ...values, referenceImages }; fields.push('referenceImages'); } @@ -879,7 +931,7 @@ export const buildImageRecallSettings = ({ if (kind === 'all' || kind === 'remix') { const steps = getSteps(metadata); - const cfgScale = getCfgScale(metadata); + const cfgScale = getCfgScale(metadata, values.model); const cfgRescaleMultiplier = getCfgRescaleMultiplier(metadata); const scheduler = getScheduler(metadata); const seamlessXAxis = getBoolean(metadata, 'seamless_x'); From 68d5f9cceee747d8bdb97c8a6ac32491bcdc9c03 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 20:07:57 -0600 Subject: [PATCH 02/35] feat(gallery): warn on in-use deletion, load video workflows, add details shortcut Gallery deletion scans the project for the selected media and always confirms when a surface still uses it, listing the affected surfaces. Videos gain a Load Workflow action backed by the record's has_workflow flag. The Preview registers I to toggle the details popover. --- .../frontend/webv2/public/locales/en.json | 22 ++- .../webv2/src/features/gallery/core/items.ts | 2 + .../src/features/gallery/data/backend.ts | 2 + .../webv2/src/workbench/hotkeys/catalog.ts | 1 + .../ImageContextMenu.browser.test.tsx | 23 ++++ .../image-actions/ImageContextMenu.tsx | 41 ++++-- .../image-actions/executeLoadVideoWorkflow.ts | 74 ++++++++++ .../image-actions/mediaUsage.test.ts | 129 ++++++++++++++++++ .../src/workbench/image-actions/mediaUsage.ts | 71 ++++++++++ .../useDeletionConfirmation.browser.test.tsx | 42 ++++++ .../image-actions/useDeletionConfirmation.tsx | 38 +++++- .../useImageActions.browser.test.tsx | 120 +++++++++++++++- .../image-actions/useImageActions.ts | 43 +++++- .../webv2/src/workbench/widgetState.ts | 13 +- .../PreviewNavigation.browser.test.tsx | 24 ++++ .../widgets/preview/PreviewWidgetView.tsx | 13 +- 16 files changed, 633 insertions(+), 25 deletions(-) create mode 100644 invokeai/frontend/webv2/src/workbench/image-actions/executeLoadVideoWorkflow.ts create mode 100644 invokeai/frontend/webv2/src/workbench/image-actions/mediaUsage.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/image-actions/mediaUsage.ts diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 728a9fad1f5..cf7b76d31a0 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -2768,6 +2768,7 @@ "previousItem": "Show previous item", "swapComparisonImages": "Swap comparison images", "toggleFilmstrip": "Toggle filmstrip", + "toggleMetadata": "Toggle details", "zoomToActual": "Zoom preview to 100%", "zoomToFit": "Zoom preview to fit" }, @@ -4070,6 +4071,22 @@ "deleteConfirmLabel": "Delete", "deleteConfirmSkip": "Don't ask me again", "deleteConfirmSkipHint": "You can turn this confirmation back on in Settings under Gallery.", + "deleteInUse": { + "heading": "Still used in this project:", + "surface": { + "canvasControl": "Canvas control layers", + "canvasInpaintMask": "Canvas inpaint masks", + "canvasRaster": "Canvas raster layers", + "canvasRegionalGuidance": "Canvas regional guidance", + "generateReferenceImages": "Generate reference images", + "upscale": "Upscale source image", + "video": "Video panel", + "workflow": "Workflow “{{name}}”" + }, + "usedCount_one": "{{count}} selected item", + "usedCount_other": "{{count}} selected items", + "warning": "The affected work keeps pointing at media that will no longer exist." + }, "deleteImagesConfirmBody_one": "This permanently deletes the image from every board and from disk. This cannot be undone.", "deleteImagesConfirmBody_other": "This permanently deletes these images from every board and from disk. This cannot be undone.", "deleteImagesConfirmTitle_one": "Delete image?", @@ -4102,8 +4119,11 @@ "loadWorkflow": { "editorUnavailable": "The workflow editor is not available in this layout.", "failed": "Could not load the image's workflow", + "failedVideo": "Could not load the video's workflow", "loadedLabel": "Loaded “{{name}}” from image", - "missing": "This image has no workflow embedded." + "loadedLabelVideo": "Loaded “{{name}}” from video", + "missing": "This image has no workflow embedded.", + "missingVideo": "This video has no workflow embedded." }, "delete": { "failure_one": "Could not delete {{count}} item.", diff --git a/invokeai/frontend/webv2/src/features/gallery/core/items.ts b/invokeai/frontend/webv2/src/features/gallery/core/items.ts index 082c32a1104..ce4b7b0b35d 100644 --- a/invokeai/frontend/webv2/src/features/gallery/core/items.ts +++ b/invokeai/frontend/webv2/src/features/gallery/core/items.ts @@ -34,6 +34,8 @@ export interface GalleryImageItem extends GalleryItemBase { export interface GalleryVideoItem extends GalleryItemBase { durationSeconds: number; fps?: number; + /** Whether the video embeds the workflow that made it. Video records say; the mixed-media listing does not. */ + hasWorkflow?: boolean; kind: 'video'; /** `audio_upload` identifies audio converted into a waveform video rather than footage. */ mediaOrigin?: string; diff --git a/invokeai/frontend/webv2/src/features/gallery/data/backend.ts b/invokeai/frontend/webv2/src/features/gallery/data/backend.ts index 8df401ff536..388168dba30 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/backend.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/backend.ts @@ -114,6 +114,7 @@ interface BackendVideoDTO { created_at: string; duration: number; fps?: number | null; + has_workflow?: boolean; height: number; is_intermediate: boolean; media_origin?: string | null; @@ -310,6 +311,7 @@ const mapVideo = (video: BackendVideoDTO): GalleryVideoItem => { durationSeconds: video.duration, ...(video.fps === null || video.fps === undefined ? {} : { fps: video.fps }), fullUrl: absolutizeApiUrl(video.video_url), + ...(video.has_workflow === undefined ? {} : { hasWorkflow: video.has_workflow }), height: video.height, isIntermediate: video.is_intermediate, kind: 'video', diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts index 5d5ee073a4d..c33806f5366 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts @@ -89,6 +89,7 @@ const implemented = new Set([ 'viewer.recallSeed', 'viewer.remix', 'viewer.swapImages', + 'viewer.toggleMetadata', 'viewer.useSize', 'workflows.addNode', 'workflows.copySelection', 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..66f7f231459 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 @@ -67,6 +67,8 @@ const createActions = (deleteItems: ImageActions['deleteItems']): ImageActions = openImageInPreview: vi.fn(), recallImageData: vi.fn(() => Promise.resolve()), getVideoRecallCapabilities: vi.fn(() => Promise.resolve({ all: false, prompts: false, remix: false, seed: false })), + hasVideoWorkflow: vi.fn(() => Promise.resolve(false)), + loadVideoWorkflow: vi.fn(() => Promise.resolve()), recallVideoData: vi.fn(() => Promise.resolve()), selectForCompare: vi.fn(), sendToCanvas: vi.fn(() => Promise.resolve()), @@ -446,6 +448,27 @@ describe('ImageContextMenu video placement', () => { expect(getMenuItem('Extend in Video').getAttribute('aria-disabled')).not.toBe('true'); }); + it('enables Load Workflow once the video is known to embed one and hands the video to the action', async () => { + const video = item('video', 'made-by-workflow.mp4'); + const actions = createActions(vi.fn()); + actions.hasVideoWorkflow = vi.fn(() => Promise.resolve(true)); + await renderVideoMenu(actions, video); + + await vi.waitFor(() => expect(getMenuItem('Load Workflow').getAttribute('aria-disabled')).not.toBe('true')); + await interact(() => getMenuItem('Load Workflow').click()); + + expect(actions.loadVideoWorkflow).toHaveBeenCalledExactlyOnceWith(video); + }); + + it('keeps Load Workflow disabled for a video without one', async () => { + const actions = createActions(vi.fn()); + await renderVideoMenu(actions, item('video', 'plain.mp4')); + + await vi.waitFor(() => expect(actions.hasVideoWorkflow).toHaveBeenCalled()); + expect(getMenuItem('Load Workflow').getAttribute('aria-disabled')).toBe('true'); + expect(actions.loadVideoWorkflow).not.toHaveBeenCalled(); + }); + const openConditioningSubmenu = async (): Promise => { await hoverItem(getMenuItem('Use as Conditioning Clip')); await settleUntil( diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx index 0df00546e56..c24234ca355 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx @@ -55,6 +55,17 @@ import type { ImageActions } from './useImageActions'; import { EMPTY_IMAGE_RECALL_CAPABILITIES, type ImageRecallCapabilities } from './imageRecall'; import { EMPTY_VIDEO_RECALL_CAPABILITIES, type VideoRecallCapabilities, type VideoRecallKind } from './videoRecall'; +/** What the menu learns about a video after it opens: metadata recall and whether a workflow is embedded. */ +interface VideoItemCapabilities { + recall: VideoRecallCapabilities; + workflow: boolean; +} + +const EMPTY_VIDEO_ITEM_CAPABILITIES: VideoItemCapabilities = { + recall: EMPTY_VIDEO_RECALL_CAPABILITIES, + workflow: false, +}; + export interface LegacyImageContextMenuTarget { /** Right-clicked image first; more entries switch the menu into bulk mode. */ images: GalleryImage[]; @@ -321,12 +332,12 @@ const SingleItemMenuItems = ({ [actions, itemRef] ); const handleDelete = useCallback(() => onRequestDeletion([itemRef]), [itemRef, onRequestDeletion]); - const [videoRecallCapabilities, setVideoRecallCapabilities] = useState( - EMPTY_VIDEO_RECALL_CAPABILITIES - ); + const [videoCapabilities, setVideoCapabilities] = useState(EMPTY_VIDEO_ITEM_CAPABILITIES); + const { recall: videoRecallCapabilities, workflow: hasVideoWorkflow } = videoCapabilities; const [isLoadingVideoRecall, setIsLoadingVideoRecall] = useState(false); const videoItemName = item.kind === 'video' ? item.name : null; const getVideoRecallCapabilities = actions.getVideoRecallCapabilities; + const getHasVideoWorkflow = actions.hasVideoWorkflow; useEffect(() => { if (!videoItemName || item.kind !== 'video') { @@ -336,17 +347,17 @@ const SingleItemMenuItems = ({ let isCancelled = false; const videoItem = item; - setVideoRecallCapabilities(EMPTY_VIDEO_RECALL_CAPABILITIES); + setVideoCapabilities(EMPTY_VIDEO_ITEM_CAPABILITIES); setIsLoadingVideoRecall(true); - getVideoRecallCapabilities(videoItem) - .then((capabilities) => { + Promise.all([getVideoRecallCapabilities(videoItem), getHasVideoWorkflow(videoItem)]) + .then(([recall, workflow]) => { if (!isCancelled) { - setVideoRecallCapabilities(capabilities); + setVideoCapabilities({ recall, workflow }); } }) .catch(() => { if (!isCancelled) { - setVideoRecallCapabilities(EMPTY_VIDEO_RECALL_CAPABILITIES); + setVideoCapabilities(EMPTY_VIDEO_ITEM_CAPABILITIES); } }) .finally(() => { @@ -359,7 +370,12 @@ const SingleItemMenuItems = ({ isCancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by name; the item identity churns per render - }, [getVideoRecallCapabilities, videoItemName]); + }, [getHasVideoWorkflow, getVideoRecallCapabilities, videoItemName]); + const handleLoadVideoWorkflow = useCallback(() => { + if (item.kind === 'video') { + void actions.loadVideoWorkflow(item); + } + }, [actions, item]); const makeVideoRecallHandler = useCallback( (kind: VideoRecallKind) => () => { @@ -421,6 +437,13 @@ const SingleItemMenuItems = ({ {item.kind === 'video' ? ( <> + (error instanceof Error ? error.message : String(error)); + +/** + * The video counterpart of `executeLoadImageWorkflow`: open the workflow editor before requesting replacement with + * the workflow embedded in the video; false from `openWorkflowEditor` means no editor can consume it. + */ +export const executeLoadVideoWorkflow = async ({ + isProjectActive, + item, + notifications, + openWorkflowEditor, + t, +}: { + /** Re-checked after the fetch: a store-resident request must not land on another project's editor. */ + isProjectActive: () => boolean; + item: GalleryVideoItem; + notifications: Pick; + openWorkflowEditor: () => boolean; + t: TFunction; +}): Promise => { + const owner = captureAccountScope(); + + try { + const { workflow } = await galleryVideos.workflow(item.name, owner.signal); + + assertAccountScopeCurrent(owner); + + if (!workflow) { + notifications.add({ kind: 'info', title: t('widgets.gallery.itemActions.loadWorkflow.missingVideo') }); + return; + } + + if (!isProjectActive()) { + return; + } + + if (!openWorkflowEditor()) { + throw new Error(t('widgets.gallery.itemActions.loadWorkflow.editorUnavailable')); + } + + const raw: unknown = JSON.parse(workflow); + const name = + typeof raw === 'object' && raw !== null && typeof (raw as { name?: unknown }).name === 'string' + ? (raw as { name: string }).name + : ''; + + requestWorkflowDocumentLoad( + raw, + t('widgets.gallery.itemActions.loadWorkflow.loadedLabelVideo', { name: name || item.name }) + ); + } catch (error) { + if (!isAccountScopeCurrent(owner)) { + return; + } + + notifications.add({ + kind: 'error', + message: toErrorMessage(error), + title: t('widgets.gallery.itemActions.loadWorkflow.failedVideo'), + }); + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/mediaUsage.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/mediaUsage.test.ts new file mode 100644 index 00000000000..f7e2f949022 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/image-actions/mediaUsage.test.ts @@ -0,0 +1,129 @@ +import type { GalleryItemRef } from '@features/gallery/contracts'; +import type { CanvasNodeContract } from '@workbench/canvas-engine/contracts'; + +import { describe, expect, it } from 'vitest'; + +import { collectMediaUsage } from './mediaUsage'; + +const widget = (typeId: string, values: Record) => ({ + createdAt: '2026-01-01T00:00:00.000Z', + id: typeId, + state: { id: typeId, label: typeId, values, version: 1 as const }, + typeId, +}); + +const rasterLayer = (id: string, imageName: string) => + ({ + id, + isEnabled: true, + isLocked: false, + name: id, + source: { image: { height: 64, imageName, width: 64 }, type: 'image' }, + type: 'raster', + }) as unknown as CanvasNodeContract; + +const project = ({ + stacks = {}, + widgets = {}, + workflowNodes = [], +}: { + stacks?: Partial>; + widgets?: Record>; + workflowNodes?: unknown[]; +} = {}) => + ({ + canvas: { + document: { + stacks: { control: [], inpaint_mask: [], raster: [], regional_guidance: [], ...stacks }, + }, + // Snapshots are history: a deleted name here must not count as in use. + snapshots: [{ document: { stacks: { raster: [rasterLayer('old', 'snapshot-only.png')] } } }], + }, + widgetInstances: Object.fromEntries( + Object.entries(widgets).map(([typeId, values]) => [typeId, widget(typeId, values)]) + ), + workflows: { + activeWorkflowId: 'wf', + entries: [{ document: { edges: [], name: 'Upscale pass', nodes: workflowNodes } }], + }, + }) as unknown as Parameters[0]; + +const image = (name: string): GalleryItemRef => ({ kind: 'image', name }); +const video = (name: string): GalleryItemRef => ({ kind: 'video', name }); + +describe('collectMediaUsage', () => { + it('reports nothing for an unused selection or an empty one', () => { + const doc = project({ stacks: { raster: [rasterLayer('a', 'kept.png')] } }); + + expect(collectMediaUsage(doc, [image('other.png')])).toEqual([]); + expect(collectMediaUsage(doc, [])).toEqual([]); + }); + + it('names each surface that uses a selected image, in panel order, with only the selected names', () => { + const doc = project({ + stacks: { + control: [rasterLayer('c', 'control.png')], + inpaint_mask: [rasterLayer('m', 'mask.png')], + raster: [rasterLayer('r1', 'raster.png'), rasterLayer('r2', 'raster.png'), rasterLayer('r3', 'other.png')], + regional_guidance: [ + { + id: 'rg', + referenceImages: [{ config: { image: { image_name: 'regional-ref.png' } } }], + type: 'regional_guidance', + } as unknown as CanvasNodeContract, + ], + }, + widgets: { + generate: { referenceImages: [{ config: { image: { image_name: 'ref.png' } } }] }, + upscale: { inputImage: { height: 1, image_name: 'upscale.png', width: 1 } }, + video: { firstFrameImage: { height: 1, image_name: 'frame.png', width: 1 } }, + }, + workflowNodes: [{ data: { inputs: { image: { value: { image_name: 'wf.png' } } } } }], + }); + const selected = [ + image('wf.png'), + image('frame.png'), + image('upscale.png'), + image('regional-ref.png'), + image('mask.png'), + image('control.png'), + image('raster.png'), + image('ref.png'), + image('unused.png'), + ]; + + expect(collectMediaUsage(doc, selected)).toEqual([ + { names: ['ref.png'], surface: 'generateReferenceImages' }, + { names: ['raster.png'], surface: 'canvasRaster' }, + { names: ['control.png'], surface: 'canvasControl' }, + { names: ['regional-ref.png'], surface: 'canvasRegionalGuidance' }, + { names: ['mask.png'], surface: 'canvasInpaintMask' }, + { names: ['upscale.png'], surface: 'upscale' }, + { names: ['frame.png'], surface: 'video' }, + { label: 'Upscale pass', names: ['wf.png'], surface: 'workflow' }, + ]); + }); + + it('matches videos by video keys only, so an image and a video sharing a name do not cross over', () => { + const doc = project({ + widgets: { + video: { + references: [{ clip: { video_name: 'same-name' }, kind: 'video' }], + sourceVideo: { video_name: 'clip.mp4' }, + }, + }, + workflowNodes: [{ data: { inputs: { image: { value: { image_name: 'same-name' } } } } }], + }); + + expect(collectMediaUsage(doc, [video('clip.mp4'), video('same-name'), image('same-name')])).toEqual([ + { names: ['clip.mp4', 'same-name'], surface: 'video' }, + { label: 'Upscale pass', names: ['same-name'], surface: 'workflow' }, + ]); + }); + + it('ignores canvas snapshots and media the gallery merely selects', () => { + const doc = project({ widgets: { gallery: { selectedImageName: 'snapshot-only.png' } } }); + + expect(collectMediaUsage(doc, [image('snapshot-only.png')])).toEqual([]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/mediaUsage.ts b/invokeai/frontend/webv2/src/workbench/image-actions/mediaUsage.ts new file mode 100644 index 00000000000..d4cc72ba904 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/image-actions/mediaUsage.ts @@ -0,0 +1,71 @@ +/** + * Where a project still uses gallery media the user is about to delete. Deleting such media removes the backing + * file under authored work, so the deletion flow must warn even when ordinary confirmations are turned off. + */ + +import type { GalleryItemRef } from '@features/gallery/contracts'; +import type { Project } from '@workbench/projectContracts'; + +import { collectMediaNames, MAX_HELD_NAME_LENGTH, type MediaNameRefs } from '@workbench/mediaReferences'; +import { getProjectWidgetValues } from '@workbench/widgetState'; + +export type MediaUsageSurface = + | 'generateReferenceImages' + | 'canvasRaster' + | 'canvasControl' + | 'canvasRegionalGuidance' + | 'canvasInpaintMask' + | 'upscale' + | 'video' + | 'workflow'; + +export interface MediaUsage { + surface: MediaUsageSurface; + /** The workflow's name, for the `workflow` surface. */ + label?: string; + /** Selected names this surface uses, in selection order. */ + names: string[]; +} + +type UsageProject = Pick; + +const scan = (roots: readonly unknown[]): MediaNameRefs => collectMediaNames(roots, () => false, MAX_HELD_NAME_LENGTH); + +const selectedNamesIn = (refs: MediaNameRefs, selected: readonly GalleryItemRef[]): string[] => + selected.filter((ref) => (ref.kind === 'image' ? refs.images : refs.videos).has(ref.name)).map((ref) => ref.name); + +/** + * The project's live surfaces that use any of `selected`, in panel order. History (undo, snapshots, queue) does + * not count: deleting what only history could restore is a loss, not a hole in current work. + */ +export const collectMediaUsage = (project: UsageProject, selected: readonly GalleryItemRef[]): MediaUsage[] => { + if (selected.length === 0) { + return []; + } + + const usage: MediaUsage[] = []; + const add = (surface: MediaUsageSurface, roots: readonly unknown[], label?: string): void => { + const names = selectedNamesIn(scan(roots), selected); + + if (names.length > 0) { + usage.push(label === undefined ? { names, surface } : { label, names, surface }); + } + }; + + add('generateReferenceImages', [getProjectWidgetValues(project, 'generate').referenceImages]); + + const { stacks } = project.canvas.document; + add('canvasRaster', [stacks.raster]); + add('canvasControl', [stacks.control]); + add('canvasRegionalGuidance', [stacks.regional_guidance]); + add('canvasInpaintMask', [stacks.inpaint_mask]); + + add('upscale', [getProjectWidgetValues(project, 'upscale').inputImage]); + add('video', [getProjectWidgetValues(project, 'video')]); + + for (const entry of project.workflows.entries) { + add('workflow', [entry.document.nodes], entry.document.name); + } + + return usage; +}; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.browser.test.tsx index 8c4e1d7772d..da4b77bd497 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.browser.test.tsx @@ -35,6 +35,11 @@ vi.mock('react-i18next', () => ({ 'widgets.gallery.deleteItemsConfirmBody': count === 1 ? 'Permanently delete the item.' : 'Permanently delete these items.', 'widgets.gallery.deleteItemsConfirmTitle': count === 1 ? 'Delete item?' : `Delete ${count} items?`, + 'widgets.gallery.deleteInUse.heading': 'Still used in this project:', + 'widgets.gallery.deleteInUse.surface.canvasRaster': 'Canvas raster layers', + 'widgets.gallery.deleteInUse.surface.workflow': `Workflow “${String(values?.name)}”`, + 'widgets.gallery.deleteInUse.usedCount': count === 1 ? '1 selected item' : `${count} selected items`, + 'widgets.gallery.deleteInUse.warning': 'The affected work keeps pointing at media that will no longer exist.', }; return messages[key] ?? key; @@ -141,6 +146,43 @@ describe('useDeletionConfirmation', () => { expect(getWorkbenchPreferences().confirmImageDeletion).toBe(true); }); + it('lists where the project still uses the items, with the count each surface loses', async () => { + const executeDeletion = vi.fn(async () => {}); + + await act(() => { + void confirmationRef.current!.requestDeletionConfirmation(mixedRefs, executeDeletion, undefined, [ + { names: ['image.png'], surface: 'canvasRaster' }, + { label: 'Upscale pass', names: ['image.png', 'video.mp4'], surface: 'workflow' }, + ]); + }); + + const dialog = openDialog(); + const group = dialog?.querySelector('[role="group"]'); + expect(group?.getAttribute('aria-labelledby')).toBeTruthy(); + expect(dialog?.querySelector(`#${group?.getAttribute('aria-labelledby')}`)?.textContent).toBe( + 'Still used in this project:' + ); + const rows = Array.from(group?.querySelectorAll('li') ?? []).map((row) => row.textContent); + expect(rows).toEqual(['Canvas raster layers · 1 selected item', 'Workflow “Upscale pass” · 2 selected items']); + expect(dialog?.textContent).toContain('The affected work keeps pointing at media that will no longer exist.'); + // The opt-out stays available: it governs routine deletions, not this forced confirmation. + expect(skipCheckbox()).not.toBeNull(); + + await clickButton('Delete'); + expect(executeDeletion).toHaveBeenCalledOnce(); + }); + + it('shows no usage section for a deletion nothing depends on', async () => { + await act(() => { + void confirmationRef.current!.requestDeletionConfirmation( + imageRefs, + vi.fn(async () => {}) + ); + }); + + expect(openDialog()?.querySelector('[role="group"]')).toBeNull(); + }); + it('cancels without executing deletion', async () => { const executeDeletion = vi.fn(async () => {}); let request!: Promise; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.tsx index a7cf06aaea1..8f65d809e57 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.tsx @@ -1,26 +1,33 @@ import type { GalleryItemRef } from '@features/gallery/contracts'; import type { ReactNode } from 'react'; -import { Checkbox, Stack, Text } from '@chakra-ui/react'; +import { Checkbox, List, Stack, Text } from '@chakra-ui/react'; import { useMountEffect } from '@platform/react/useMountEffect'; import { ConfirmDialog } from '@platform/ui/ConfirmDialog'; import { patchWorkbenchPreferences } from '@workbench/settings/store'; import { useCallback, useId, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import type { MediaUsage } from './mediaUsage'; + export type RequestDeletionConfirmation = ( itemRefs: readonly GalleryItemRef[], executeDeletion: () => Promise, /** Where focus goes when the dialog closes; without it, back to the control that opened it. */ - returnFocus?: () => HTMLElement | null + returnFocus?: () => HTMLElement | null, + /** Project surfaces that still use some of the items; listed so the user sees what the deletion breaks. */ + usage?: readonly MediaUsage[] ) => Promise; interface PendingDeletion { executeDeletion: () => Promise; itemRefs: readonly GalleryItemRef[]; resolve: () => void; + usage: readonly MediaUsage[]; } +const NO_USAGE: readonly MediaUsage[] = []; + export const useDeletionConfirmation = (): { dialog: ReactNode; requestDeletionConfirmation: RequestDeletionConfirmation; @@ -34,6 +41,7 @@ export const useDeletionConfirmation = (): { // Read only when deletion is confirmed, so ticking it and then cancelling changes nothing. const [skipFutureConfirmations, setSkipFutureConfirmations] = useState(false); const skipHintId = useId(); + const usageHeadingId = useId(); const settlePendingDeletion = useCallback(() => { const pending = pendingDeletionRef.current; @@ -49,13 +57,13 @@ export const useDeletionConfirmation = (): { }, []); const requestDeletionConfirmation = useCallback( - (itemRefs, executeDeletion, getReturnFocus) => { + (itemRefs, executeDeletion, getReturnFocus, usage = NO_USAGE) => { if (pendingDeletionRef.current) { return Promise.resolve(); } return new Promise((resolve) => { - const pending = { executeDeletion, itemRefs: [...itemRefs], resolve }; + const pending = { executeDeletion, itemRefs: [...itemRefs], resolve, usage }; pendingDeletionRef.current = pending; setSkipFutureConfirmations(false); @@ -101,6 +109,7 @@ export const useDeletionConfirmation = (): { const isImageOnly = pendingDeletion?.itemRefs.every((item) => item.kind === 'image') ?? false; const titleKey = isImageOnly ? 'widgets.gallery.deleteImagesConfirmTitle' : 'widgets.gallery.deleteItemsConfirmTitle'; const bodyKey = isImageOnly ? 'widgets.gallery.deleteImagesConfirmBody' : 'widgets.gallery.deleteItemsConfirmBody'; + const usage = pendingDeletion?.usage ?? NO_USAGE; return { dialog: ( @@ -108,6 +117,27 @@ export const useDeletionConfirmation = (): { body={ <> {t(bodyKey, { count: itemCount })} + {usage.length > 0 ? ( + + + {t('widgets.gallery.deleteInUse.heading')} + + + {usage.map((entry, index) => ( + + {t(`widgets.gallery.deleteInUse.surface.${entry.surface}`, { name: entry.label })} + + {' · '} + {t('widgets.gallery.deleteInUse.usedCount', { count: entry.names.length })} + + + ))} + + + {t('widgets.gallery.deleteInUse.warning')} + + + ) : null} 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..53351c96bed 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 @@ -48,6 +48,9 @@ const mocks = vi.hoisted(() => ({ ), requestWorkflowDocumentLoad: vi.fn(), videoMetadata: vi.fn((..._args: unknown[]) => Promise.resolve(null)), + videoWorkflow: vi.fn((..._args: unknown[]): Promise<{ graph: string | null; workflow: string | null }> => + Promise.resolve({ graph: null, workflow: null }) + ), invalidateGallery: vi.fn(), invalidateGalleryItems: vi.fn(), itemDelete: vi.fn(), @@ -88,7 +91,10 @@ vi.mock('@features/gallery', () => ({ setStarred: (...args: unknown[]) => mocks.setStarred(...args), }, galleryTransfers: { downloadArchive: (...args: unknown[]) => mocks.downloadArchive(...args) }, - galleryVideos: { metadata: (...args: unknown[]) => mocks.videoMetadata(...args) }, + galleryVideos: { + metadata: (...args: unknown[]) => mocks.videoMetadata(...args), + workflow: (...args: unknown[]) => mocks.videoWorkflow(...args), + }, legacyGeneratedImageToGalleryItem: (image: { boardId?: string; imageName: string; starred?: boolean }) => ({ boardId: image.boardId ?? 'none', kind: 'image' as const, @@ -199,7 +205,7 @@ vi.mock('react-i18next', () => ({ if (key === 'widgets.gallery.itemActions.move.success') { return `Moved to ${String(values?.board)}`; } - if (key === 'widgets.gallery.itemActions.loadWorkflow.loadedLabel') { + if (key.startsWith('widgets.gallery.itemActions.loadWorkflow.loadedLabel')) { return `${key}:${String(values?.name)}`; } @@ -228,7 +234,9 @@ const makeMockProject = ( galleryValues: Record = {}, upscaleValues: Record = {} ) => ({ + canvas: { document: { stacks: { control: [], inpaint_mask: [], raster: [], regional_guidance: [] } } }, id, + workflows: { activeWorkflowId: 'wf', entries: [] }, widgetInstances: { gallery: { createdAt: '2026-01-01T00:00:00.000Z', @@ -590,6 +598,82 @@ describe('load workflow from image', () => { }); }); +describe('video workflow loading', () => { + const video = { + boardId: 'none', + category: 'general' as const, + createdAt: '2026-07-30T00:00:00.000Z', + durationSeconds: 4, + fullUrl: '/full/clip.mp4', + height: 512, + isIntermediate: false, + kind: 'video' as const, + name: 'clip.mp4', + starred: false, + thumbnailUrl: '/thumb/clip.mp4', + width: 512, + }; + + it('answers from the item when it knows, otherwise from the video record', async () => { + await expect(actionsRef.current?.hasVideoWorkflow({ ...video, hasWorkflow: true })).resolves.toBe(true); + expect(mocks.resolveItem).not.toHaveBeenCalled(); + + mocks.resolveItem.mockResolvedValueOnce({ ...video, hasWorkflow: true }); + await expect(actionsRef.current?.hasVideoWorkflow(video)).resolves.toBe(true); + expect(mocks.resolveItem).toHaveBeenCalledWith({ kind: 'video', name: 'clip.mp4' }, expect.any(AbortSignal)); + + mocks.resolveItem.mockRejectedValueOnce(new Error('offline')); + await expect(actionsRef.current?.hasVideoWorkflow(video)).resolves.toBe(false); + }); + + it('hands the embedded workflow to the editor and opens it', async () => { + mocks.videoWorkflow.mockResolvedValueOnce({ + graph: null, + workflow: JSON.stringify({ name: 'Embedded', nodes: [] }), + }); + + await act(async () => { + await actionsRef.current?.loadVideoWorkflow(video); + }); + + expect(mocks.videoWorkflow).toHaveBeenCalledWith('clip.mp4', expect.any(AbortSignal)); + expect(mocks.openWorkbenchWidget).toHaveBeenCalledWith('workflow', { + preferredRegions: ['center'], + requireCenterView: true, + }); + expect(mocks.requestWorkflowDocumentLoad).toHaveBeenCalledWith( + { name: 'Embedded', nodes: [] }, + 'widgets.gallery.itemActions.loadWorkflow.loadedLabelVideo:Embedded' + ); + }); + + it('says so, and leaves the editor alone, when the video embeds no workflow', async () => { + await act(async () => { + await actionsRef.current?.loadVideoWorkflow(video); + }); + + expect(mocks.requestWorkflowDocumentLoad).not.toHaveBeenCalled(); + expect(mocks.openWorkbenchWidget).not.toHaveBeenCalled(); + expect(mocks.notificationsAdd).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'info', title: 'widgets.gallery.itemActions.loadWorkflow.missingVideo' }) + ); + }); + + it('reports when the workflow widget cannot open', async () => { + mocks.videoWorkflow.mockResolvedValueOnce({ graph: null, workflow: JSON.stringify({ nodes: [] }) }); + mocks.openWorkbenchWidget.mockReturnValueOnce({ ok: false, reason: 'unavailable' }); + + await act(async () => { + await actionsRef.current?.loadVideoWorkflow(video); + }); + + expect(mocks.requestWorkflowDocumentLoad).not.toHaveBeenCalled(); + expect(mocks.notificationsAdd).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'error', title: 'widgets.gallery.itemActions.loadWorkflow.failedVideo' }) + ); + }); +}); + 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']])); @@ -717,7 +801,7 @@ describe('mixed item mutation outcomes', () => { }); // The dialog learns where focus goes once the deletion has removed the control that asked for it. - expect(mocks.requestDeletionConfirmation).toHaveBeenCalledWith(refs, expect.any(Function), returnFocus); + expect(mocks.requestDeletionConfirmation).toHaveBeenCalledWith(refs, expect.any(Function), returnFocus, []); expect(mocks.itemDelete).not.toHaveBeenCalled(); await act(async () => { @@ -738,6 +822,36 @@ describe('mixed item mutation outcomes', () => { expect(mocks.itemDelete).toHaveBeenCalledWith(refs, expect.any(AbortSignal)); }); + it('still confirms, naming the surfaces, when the project uses an item and confirmation is off', async () => { + const refs = [ + { kind: 'image' as const, name: 'source.png' }, + { kind: 'image' as const, name: 'unused.png' }, + ]; + let executeDeletion: (() => Promise) | null = null; + mocks.requestDeletionConfirmation.mockImplementation((_itemRefs, execute) => { + executeDeletion = execute; + return Promise.resolve(); + }); + mocks.getProject.mockReturnValue( + makeMockProject('project-1', {}, { inputImage: { height: 1, image_name: 'source.png', width: 1 } }) + ); + mocks.itemDelete.mockResolvedValue({ affectedBoardIds: ['none'], failed: [], succeeded: refs }); + + await act(async () => { + await getItemActions().deleteItems(refs); + }); + + expect(mocks.requestDeletionConfirmation).toHaveBeenCalledWith(refs, expect.any(Function), undefined, [ + { names: ['source.png'], surface: 'upscale' }, + ]); + expect(mocks.itemDelete).not.toHaveBeenCalled(); + + await act(async () => { + await executeDeletion?.(); + }); + expect(mocks.itemDelete).toHaveBeenCalledWith(refs, expect.any(AbortSignal)); + }); + it('uses the localized Uncategorized label in move notifications', async () => { const refs = [{ kind: 'image' as const, name: 'image.png' }]; mocks.itemMoveToBoard.mockResolvedValue({ affectedBoardIds: ['none'], failed: [], succeeded: refs }); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts index 7fec9fa100f..196e53fcff5 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts @@ -64,6 +64,7 @@ import type { RequestDeletionConfirmation } from './useDeletionConfirmation'; import { appendReferenceImage } from './appendReferenceImage'; import { recordCanvasImportError } from './canvasImportError'; import { executeImageRecall, executeLoadImageWorkflow, getCurrentGenerateValues } from './executeImageRecall'; +import { executeLoadVideoWorkflow } from './executeLoadVideoWorkflow'; import { appendReferenceVideo, executeVideoRecall, @@ -87,6 +88,7 @@ import { type ImageRecallCapabilities, type ImageRecallKind, } from './imageRecall'; +import { collectMediaUsage } from './mediaUsage'; import { EMPTY_VIDEO_RECALL_CAPABILITIES, getVideoRecallCapabilities as deriveVideoRecallCapabilitiesFromMetadata, @@ -121,6 +123,10 @@ export interface ImageActions extends GalleryItemActions { loadImageWorkflow: (image: GalleryImage) => Promise; /** Recall availability for a gallery video, from its recorded core_metadata. */ getVideoRecallCapabilities: (item: GalleryVideoItem, signal?: AbortSignal) => Promise; + /** Whether the video embeds a workflow; the listing does not say, so an unknown answer reads the record. */ + hasVideoWorkflow: (item: GalleryVideoItem, signal?: AbortSignal) => Promise; + /** Replaces the project graph with the workflow embedded in the video and opens the editor on it. */ + loadVideoWorkflow: (item: GalleryVideoItem) => Promise; moveImagesToBoard: (imageNames: string[], boardId: string) => Promise; openImageInPreview: (image: GalleryImage) => void; recallImageData: (image: GalleryImage, kind: ImageRecallKind) => Promise; @@ -534,10 +540,16 @@ export const useImageActions = ({ }, }); }; - const deleteItems: ImageActions['deleteItems'] = (items, options) => - confirmImageDeletion - ? requestDeletionConfirmation(items, () => deleteItemsConfirmed(items), options?.returnFocus) + const deleteItems: ImageActions['deleteItems'] = (items, options) => { + const project = projectId ? queries.getProject(projectId) : queries.getSnapshot().activeProject; + const usage = project ? collectMediaUsage(project, items) : []; + + // Media the project still uses is always confirmed: the opt-out covers routine deletions, not ones that + // leave authored work pointing at nothing. + return confirmImageDeletion || usage.length > 0 + ? requestDeletionConfirmation(items, () => deleteItemsConfirmed(items), options?.returnFocus, usage) : deleteItemsConfirmed(items); + }; const moveItemsToBoard = (items: GalleryItemRef[], boardId: string): Promise => { // On partial move failure, restore then reapply confirmed items. Capture prior boards from cache and store, // preferring cache; invalidation reconciles conflicts. @@ -941,6 +953,31 @@ export const useImageActions = ({ return EMPTY_VIDEO_RECALL_CAPABILITIES; } }, + hasVideoWorkflow: async (item, signal) => { + if (item.hasWorkflow !== undefined) { + return item.hasWorkflow; + } + + const owner = captureAccountScope(); + + try { + const requestSignal = signal ? AbortSignal.any([signal, owner.signal]) : owner.signal; + const record = await galleryItems.resolve({ kind: 'video', name: item.name }, requestSignal); + + return record.kind === 'video' && record.hasWorkflow === true; + } catch { + return false; + } + }, + loadVideoWorkflow: (item) => + executeLoadVideoWorkflow({ + isProjectActive: () => !projectId || queries.isActiveProject(projectId), + item, + notifications, + openWorkflowEditor: () => + openWorkbenchWidget('workflow', { preferredRegions: ['center'], requireCenterView: true }).ok, + t, + }), recallVideoData: async (item, kind) => { const owner = captureAccountScope(); const didRecall = await executeVideoRecall({ diff --git a/invokeai/frontend/webv2/src/workbench/widgetState.ts b/invokeai/frontend/webv2/src/workbench/widgetState.ts index d2474fe83e7..7da8104c270 100644 --- a/invokeai/frontend/webv2/src/workbench/widgetState.ts +++ b/invokeai/frontend/webv2/src/workbench/widgetState.ts @@ -8,15 +8,20 @@ const emptyWidgetState = (widgetId: WidgetTypeId): WidgetStateContract => ({ version: 1, }); +/** The widget instances alone: readers of a widget's values need nothing else from the project. */ +export type WidgetInstanceHolder = Pick; + export const getProjectWidgetInstance = ( - project: Project, + project: WidgetInstanceHolder, widgetId: WidgetTypeId ): WidgetInstanceContract | undefined => project.widgetInstances[widgetId] ?? Object.values(project.widgetInstances).find((instance) => instance.typeId === widgetId); -export const getProjectWidgetState = (project: Project, widgetId: WidgetTypeId): WidgetStateContract => +export const getProjectWidgetState = (project: WidgetInstanceHolder, widgetId: WidgetTypeId): WidgetStateContract => getProjectWidgetInstance(project, widgetId)?.state ?? emptyWidgetState(widgetId); -export const getProjectWidgetValues = (project: Project, widgetId: WidgetTypeId): Record => - getProjectWidgetState(project, widgetId).values; +export const getProjectWidgetValues = ( + project: WidgetInstanceHolder, + widgetId: WidgetTypeId +): Record => getProjectWidgetState(project, widgetId).values; 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 bd7f087ef08..aaba17e2035 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 @@ -3289,6 +3289,30 @@ describe('preview keyboard navigation boundary', () => { expect([...registeredHotkeys.keys()]).not.toContain('viewer.zoomToFit'); }); + it('binds I to toggling the details popover through the preview widget value', async () => { + await render(); + + expect(registeredHotkeys.get('viewer.toggleMetadata')).toEqual(['i']); + const toggle = registeredCommands.get('viewer.toggleMetadata'); + expect(toggle).toBeDefined(); + + await act(async () => { + toggle?.(); + await Promise.resolve(); + }); + + expect(mocks.commands.widgets.patchValues).toHaveBeenLastCalledWith('preview', { metadataOpen: true }); + + mocks.project.widgetInstances.preview.state.values = { metadataOpen: true }; + await render(); + await act(async () => { + registeredCommands.get('viewer.toggleMetadata')?.(); + await Promise.resolve(); + }); + + expect(mocks.commands.widgets.patchValues).toHaveBeenLastCalledWith('preview', { metadataOpen: false }); + }); + it('retains compare and zoom hotkey registrations for images', async () => { await render(); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx index 465aac1c6f1..99c4c38719a 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx @@ -88,7 +88,12 @@ import { type PreviewVideoFrameCopyResult, } from './PreviewFrame'; import { previewHeaderStore, previewStageStore, type PreviewZoomCommands } from './previewHeaderStore'; -import { getPreviewComparisonMode, getPreviewFilmstripVisible, type PreviewComparisonMode } from './previewSettings'; +import { + getPreviewComparisonMode, + getPreviewFilmstripVisible, + getPreviewMetadataOpen, + type PreviewComparisonMode, +} from './previewSettings'; import { usePreviewNavigation } from './usePreviewNavigation'; /** For the live footer's Details slot, which renders disabled and never fires. */ @@ -579,6 +584,11 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { if (commandId === 'viewer.toggleFilmstrip') { widgets.patchValues('preview', { filmstripVisible: !getPreviewFilmstripVisible(previewValues) }); + return; + } + + if (commandId === 'viewer.toggleMetadata') { + widgets.patchValues('preview', { metadataOpen: !getPreviewMetadataOpen(previewValues) }); } }); @@ -586,6 +596,7 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => { const hotkeys = [ ['viewer.deleteImage', t('widgets.preview.commands.deletePreviewImage'), ['delete', 'backspace']], ['viewer.toggleFilmstrip', t('widgets.preview.commands.toggleFilmstrip'), ['t']], + ['viewer.toggleMetadata', t('widgets.preview.commands.toggleMetadata'), ['i']], // Widget-scoped, so the arrows step wherever Preview holds the keys: focus inside it, or on the region it was // just opened in. Comparison does not step saved images, so it leaves the arrows unclaimed. ...(isComparing From b7f7175c383ad2c364bd81d27baca2ac361f298c Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 20:08:23 -0600 Subject: [PATCH 03/35] feat(workflow): honor authored form controls, link form URLs, arrange nodes Form fields render their authored dropdown, slider and bounds; validation and shuffle use the narrowed bounds, and the form builder edits them. Form text and field descriptions turn http(s) URLs into new-tab links. The editor toolbar arranges all or selected nodes as one undoable step and fits the result. --- .../frontend/webv2/public/locales/en.json | 24 ++ .../features/workflow/core/document.test.ts | 46 +++ .../src/features/workflow/core/document.ts | 26 ++ .../workflow/core/formFieldSettings.test.ts | 217 +++++++++++ .../workflow/core/formFieldSettings.ts | 237 ++++++++++++ .../workflow/core/graphLayout.test.ts | 41 +- .../src/features/workflow/core/graphLayout.ts | 75 ++++ .../ui/editor/EditorToolbar.browser.test.tsx | 18 +- .../workflow/ui/editor/EditorToolbar.tsx | 16 + .../workflow/ui/editor/WorkflowEditorView.tsx | 61 ++- .../workflow/ui/fields/NumericInput.tsx | 69 ++++ .../WorkflowFieldInput.browser.test.tsx | 66 ++++ .../workflow/ui/fields/WorkflowFieldInput.tsx | 64 +++- .../workflow/ui/linear/FormBuilderTab.tsx | 2 + .../workflow/ui/linear/LinearFormView.tsx | 5 +- .../ui/linear/LinkifiedText.browser.test.tsx | 46 +++ .../workflow/ui/linear/LinkifiedText.tsx | 32 ++ .../workflow/ui/linear/NodeFieldControl.tsx | 34 +- .../ui/linear/NodeFieldSettingsPopover.tsx | 357 ++++++++++++++++++ .../workflow/ui/linear/linkify.test.ts | 40 ++ .../features/workflow/ui/linear/linkify.ts | 43 +++ .../webv2/src/features/workflow/utility.ts | 1 + .../webv2/src/workbench/autoRoutePolicy.ts | 2 + 23 files changed, 1498 insertions(+), 24 deletions(-) create mode 100644 invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts create mode 100644 invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts create mode 100644 invokeai/frontend/webv2/src/features/workflow/ui/linear/LinkifiedText.browser.test.tsx create mode 100644 invokeai/frontend/webv2/src/features/workflow/ui/linear/LinkifiedText.tsx create mode 100644 invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx create mode 100644 invokeai/frontend/webv2/src/features/workflow/ui/linear/linkify.test.ts create mode 100644 invokeai/frontend/webv2/src/features/workflow/ui/linear/linkify.ts diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 728a9fad1f5..5a33c99478c 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -3275,6 +3275,28 @@ "form": "Form", "formBuilder": { "addElement": "Add form element", + "addOption": "Add option", + "boundsHint": "Blank bounds use the node's own limits; bounds never widen them.", + "control": "Control", + "controlDropdown": "Dropdown", + "controlNumberInput": "Input", + "controlNumberInputAndSlider": "Input + slider", + "controlSlider": "Slider", + "controlTextInput": "Input", + "controlTextarea": "Text area", + "dropdownOptions": "Dropdown options", + "fieldSettings": "Field control settings", + "fieldSettingsHint": "How the form shows this field.", + "maximum": "Maximum", + "minimum": "Minimum", + "noOptions": "No options yet; the dropdown stays disabled.", + "optionLabel": "Option {{index}} label", + "optionLabelPlaceholder": "Label", + "optionValue": "Option {{index}} value", + "optionValuePlaceholder": "Value", + "removeOption": "Remove option {{index}}", + "sliderNeedsBounds": "A slider needs a minimum and a maximum; until both are known the form shows an input.", + "step": "Step", "containerEmpty": "Empty container — drag cards here", "dropHere": "Drop here", "elements": { @@ -3365,6 +3387,8 @@ "tags": "Tags", "title": "Title", "toolbar": { + "arrangeNodes": "Arrange nodes", + "arrangeSelectedNodes": "Arrange selected nodes", "boxSelect": "Box select (drag to select nodes)", "eraser": "Eraser (click nodes or edges to delete)", "fitView": "Fit view", diff --git a/invokeai/frontend/webv2/src/features/workflow/core/document.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/document.test.ts index 39a84b79e48..d13d75c529f 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/document.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/document.test.ts @@ -702,3 +702,49 @@ describe('node updates', () => { expect(projectGraphReducer(next, { templates: { add: templateV110 }, type: 'updateNodes' })).toBe(next); }); }); + +describe('form field settings and arrangement', () => { + it('stores a form element settings bag verbatim and keeps the shuffle flag in step with it', () => { + const { doc, nodeAId } = createDocWithNodes(); + let next = projectGraphReducer(doc, { fieldIdentifier: { fieldName: 'a', nodeId: nodeAId }, type: 'exposeField' }); + const field = Object.values(next.form.elements).find((element) => element.type === 'node-field'); + const bag = { component: 'slider', max: 10, min: 1, showShuffle: false, type: 'integer-field-config' }; + + next = projectGraphReducer(next, { elementId: field?.id ?? '', settings: bag, type: 'setNodeFieldSettings' }); + next = projectGraphReducer(next, { + elementId: field?.id ?? '', + showShuffle: true, + type: 'setNodeFieldShowShuffle', + }); + + const updated = next.form.elements[field?.id ?? '']; + + expect(updated?.type === 'node-field' && updated.data.settings).toEqual({ ...bag, showShuffle: true }); + expect(updated?.type === 'node-field' && updated.data.showShuffle).toBe(true); + expect(getProjectGraphUndoEntry({ elementId: 'e', settings: bag, type: 'setNodeFieldSettings' })).toEqual({ + label: 'Edit workflow form', + mergeKey: 'setNodeFieldSettings:e', + }); + }); + + it('moves several nodes in one undoable step and leaves the document alone when nothing moves', () => { + const { doc } = createDocWithNodes(); + const [first, second] = doc.nodes; + const next = projectGraphReducer(doc, { + positions: { [first!.id]: { x: 10, y: 20 }, [second!.id]: { x: 30, y: 40 }, missing: { x: 0, y: 0 } }, + type: 'setNodePositions', + }); + + expect(next.nodes.map((node) => node.position)).toEqual([ + { x: 10, y: 20 }, + { x: 30, y: 40 }, + ...doc.nodes.slice(2).map((node) => node.position), + ]); + expect(getProjectGraphUndoEntry({ positions: {}, type: 'setNodePositions' })).toEqual({ + label: 'Arrange workflow nodes', + }); + expect(projectGraphReducer(next, { positions: { [first!.id]: { x: 10, y: 20 } }, type: 'setNodePositions' })).toBe( + next + ); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/document.ts b/invokeai/frontend/webv2/src/features/workflow/core/document.ts index 76b40f9524b..96f334d17f5 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/document.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/document.ts @@ -675,6 +675,8 @@ export type ProjectGraphAction = | { type: 'addGraphElements'; nodes: WorkflowNode[]; edges: WorkflowEdge[] } | { type: 'removeNodes'; nodeIds: string[] } | { type: 'setNodePosition'; nodeId: string; position: XYPosition } + /** Moves several nodes at once as one undoable step (an automatic arrangement); unknown ids are ignored. */ + | { type: 'setNodePositions'; positions: Record } | { type: 'setNodeLabel'; nodeId: string; label: string } | { type: 'setNodeNotes'; nodeId: string; notes: string } | { type: 'setNodeIsOpen'; nodeId: string; isOpen: boolean } @@ -715,6 +717,8 @@ export type ProjectGraphAction = | { type: 'setFormElementContent'; elementId: string; content: string } | { type: 'setNodeFieldShowDescription'; elementId: string; showDescription: boolean } | { type: 'setNodeFieldShowShuffle'; elementId: string; showShuffle: boolean } + /** Replaces the element's control settings bag, already serialized in the legacy shape by the caller. */ + | { type: 'setNodeFieldSettings'; elementId: string; settings: Record } | { type: 'setContainerLayout'; elementId: string; layout: 'row' | 'column' } | { type: 'setMetadata'; patch: Partial } /** A committed rename (a dialog, an adopted template name): one undo step of its own, unlike typing a name. */ @@ -742,7 +746,9 @@ const undoLabels: Partial> = { setFormElementContent: 'Edit workflow form', setMetadata: 'Edit workflow details', setNodeFieldShowDescription: 'Edit workflow form', + setNodeFieldSettings: 'Edit workflow form', setNodeFieldShowShuffle: 'Edit workflow form', + setNodePositions: 'Arrange workflow nodes', setNodeIsIntermediate: 'Change workflow node output saving', setNodeLabel: 'Rename workflow node', setNodeNotes: 'Edit workflow node notes', @@ -790,6 +796,7 @@ const getUndoMergeKey = (action: ProjectGraphAction): string | undefined => { case 'setNodeNotes': return `${action.type}:${action.nodeId}`; case 'setFormElementContent': + case 'setNodeFieldSettings': return `${action.type}:${action.elementId}`; case 'setMetadata': return `${action.type}:${Object.keys(action.patch).sort().join(',')}`; @@ -956,6 +963,22 @@ const applyProjectGraphAction = (document: ProjectGraphState, action: ProjectGra case 'setNodePosition': { return updateNode(document, action.nodeId, (node) => ({ ...node, position: { ...action.position } })); } + case 'setNodePositions': { + let moved = false; + const nodes = document.nodes.map((node) => { + const position = action.positions[node.id]; + + if (!position || (position.x === node.position.x && position.y === node.position.y)) { + return node; + } + + moved = true; + + return { ...node, position: { x: position.x, y: position.y } }; + }); + + return moved ? { ...document, nodes } : document; + } case 'updateNodes': { return updateWorkflowNodes(document, action.templates, action.nodeIds).document; } @@ -1165,6 +1188,9 @@ const applyProjectGraphAction = (document: ProjectGraphState, action: ProjectGra }, }; } + case 'setNodeFieldSettings': { + return patchNodeFieldElement(document, action.elementId, { settings: { ...action.settings } }); + } case 'setContainerLayout': { const element = document.form.elements[action.elementId]; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts new file mode 100644 index 00000000000..ed5108ba5a6 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest'; + +import type { FieldInputTemplate } from './types'; + +import { + applyNodeFieldFormSettings, + getDefaultNodeFieldFormSettings, + getNodeFieldFormSettings, + getNodeFieldSliderRange, + serializeNodeFieldFormSettings, +} from './formFieldSettings'; + +const template = ( + typeName: 'FloatField' | 'IntegerField' | 'StringField', + overrides: Partial = {} +): FieldInputTemplate => ({ + default: undefined, + description: '', + exclusiveMaximum: null, + exclusiveMinimum: null, + fieldKind: 'input', + input: 'any', + maximum: null, + minimum: null, + multipleOf: null, + name: 'value', + options: null, + required: true, + title: 'Value', + type: { batch: false, cardinality: 'SINGLE', name: typeName }, + uiChoiceLabels: null, + uiComponent: null, + uiHidden: false, + uiModelBase: null, + uiModelFormat: null, + uiModelType: null, + uiOrder: null, + ...overrides, +}); + +describe('getNodeFieldFormSettings', () => { + it('reads a legacy integer bag with its control and bounds', () => { + const settings = getNodeFieldFormSettings( + { component: 'number-input-and-slider', max: 50, min: 1, showShuffle: true, type: 'integer-field-config' }, + template('IntegerField') + ); + + expect(settings).toEqual({ component: 'number-input-and-slider', kind: 'number', max: 50, min: 1 }); + }); + + it('reads a legacy dropdown bag and tolerates malformed options', () => { + const settings = getNodeFieldFormSettings( + { + component: 'dropdown', + options: [{ label: 'Fast', value: 'fast' }, { value: 'slow' }, 'junk', null], + type: 'string-field-config', + }, + template('StringField') + ); + + expect(settings).toEqual({ + component: 'dropdown', + kind: 'string', + options: [ + { label: 'Fast', value: 'fast' }, + { label: '', value: 'slow' }, + ], + }); + }); + + it('ignores a bag tagged for another field kind and bags on fields that cannot carry one', () => { + expect( + getNodeFieldFormSettings({ component: 'slider', type: 'float-field-config' }, template('IntegerField')) + ).toBeNull(); + expect( + getNodeFieldFormSettings( + { component: 'slider', type: 'integer-field-config' }, + template('IntegerField', { type: { batch: false, cardinality: 'COLLECTION', name: 'IntegerField' } }) + ) + ).toBeNull(); + expect(getNodeFieldFormSettings(undefined, template('IntegerField'))).toBeNull(); + }); + + it('keeps the control but drops bounds that cannot form a range', () => { + expect( + getNodeFieldFormSettings({ component: 'slider', max: 1, min: 10, step: -2 }, template('FloatField')) + ).toEqual({ component: 'slider', kind: 'number' }); + }); + + it('falls back to the plain control for an unknown component name', () => { + expect(getNodeFieldFormSettings({ component: 'knob' }, template('FloatField'))).toEqual({ + component: 'number-input', + kind: 'number', + }); + expect(getNodeFieldFormSettings({ component: 'rich-text' }, template('StringField'))).toEqual({ + component: 'input', + kind: 'string', + options: [], + }); + }); +}); + +describe('getDefaultNodeFieldFormSettings', () => { + it('starts a prompt-like string field as a text area and numbers as inputs', () => { + expect(getDefaultNodeFieldFormSettings(template('StringField', { uiComponent: 'textarea' }))).toEqual({ + component: 'textarea', + kind: 'string', + options: [], + }); + expect(getDefaultNodeFieldFormSettings(template('FloatField'))).toEqual({ + component: 'number-input', + kind: 'number', + }); + }); +}); + +describe('serializeNodeFieldFormSettings', () => { + it('writes the legacy shape the v6 editor reads back, round-tripping through the reader', () => { + const numberBag = serializeNodeFieldFormSettings( + { component: 'slider', kind: 'number', max: 8, min: 2, step: 2 }, + template('IntegerField'), + true + ); + + expect(numberBag).toEqual({ + component: 'slider', + max: 8, + min: 2, + showShuffle: true, + step: 2, + type: 'integer-field-config', + }); + expect(getNodeFieldFormSettings(numberBag, template('IntegerField'))).toEqual({ + component: 'slider', + kind: 'number', + max: 8, + min: 2, + step: 2, + }); + + const stringBag = serializeNodeFieldFormSettings( + { component: 'dropdown', kind: 'string', options: [{ label: 'A', value: 'a' }] }, + template('StringField'), + false + ); + + expect(stringBag).toEqual({ + component: 'dropdown', + options: [{ label: 'A', value: 'a' }], + type: 'string-field-config', + }); + }); + + it('does not keep dropdown options on a field switched back to a text input', () => { + expect( + serializeNodeFieldFormSettings( + { component: 'input', kind: 'string', options: [] }, + template('StringField'), + false + ) + ).toEqual({ component: 'input', type: 'string-field-config' }); + }); +}); + +describe('applyNodeFieldFormSettings', () => { + it('narrows the template to the authored bounds but never widens them', () => { + const base = template('IntegerField', { maximum: 100, minimum: 0 }); + + expect(applyNodeFieldFormSettings(base, { component: 'slider', kind: 'number', max: 200, min: 10 })).toMatchObject({ + maximum: 100, + minimum: 10, + }); + }); + + it('replaces an exclusive template bound only with a tighter inclusive one', () => { + const base = template('FloatField', { exclusiveMinimum: 0 }); + + expect(applyNodeFieldFormSettings(base, { component: 'slider', kind: 'number', min: 0.5 })).toMatchObject({ + exclusiveMinimum: null, + minimum: 0.5, + }); + expect(applyNodeFieldFormSettings(base, { component: 'slider', kind: 'number', min: -1 })).toBe(base); + }); + + it('applies a step only where it keeps every value valid for the node', () => { + const free = template('FloatField'); + const eights = template('IntegerField', { multipleOf: 8 }); + + expect(applyNodeFieldFormSettings(free, { component: 'slider', kind: 'number', step: 0.25 }).multipleOf).toBe(0.25); + expect(applyNodeFieldFormSettings(eights, { component: 'slider', kind: 'number', step: 64 }).multipleOf).toBe(64); + expect(applyNodeFieldFormSettings(eights, { component: 'slider', kind: 'number', step: 12 })).toBe(eights); + }); + + it('returns the template itself for string settings and unchanged bounds', () => { + const base = template('StringField'); + + expect(applyNodeFieldFormSettings(base, { component: 'dropdown', kind: 'string', options: [] })).toBe(base); + expect(applyNodeFieldFormSettings(base, null)).toBe(base); + }); +}); + +describe('getNodeFieldSliderRange', () => { + it('derives a closed range with the template step, stepping inside exclusive ends', () => { + expect(getNodeFieldSliderRange(template('IntegerField', { maximum: 10, minimum: 1 }))).toEqual({ + max: 10, + min: 1, + step: 1, + }); + expect( + getNodeFieldSliderRange(template('FloatField', { exclusiveMaximum: 1, exclusiveMinimum: 0, multipleOf: 0.1 })) + ).toEqual({ max: 0.9, min: 0.1, step: 0.1 }); + }); + + it('has no range while an end is open', () => { + expect(getNodeFieldSliderRange(template('IntegerField', { minimum: 0 }))).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts new file mode 100644 index 00000000000..d0c696efdac --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts @@ -0,0 +1,237 @@ +import type { FieldInputTemplate } from './types'; + +/** + * Per-element control settings an author gives a form field: which control renders it and, for numbers, the + * bounds the form offers. They are stored in the element's legacy settings bag (`NodeFieldFormElement.data.settings`) + * in the shape the v6 editor wrote, so a workflow authored in either editor opens the same in the other. + */ + +export type NumberFieldComponent = 'number-input' | 'slider' | 'number-input-and-slider'; +export type StringFieldComponent = 'input' | 'textarea' | 'dropdown'; + +export interface NodeFieldStringOption { + label: string; + value: string; +} + +export interface NodeFieldNumberSettings { + kind: 'number'; + component: NumberFieldComponent; + /** Authored bounds and step; each applies only where it tightens the invocation template's own constraint. */ + min?: number; + max?: number; + step?: number; +} + +export interface NodeFieldStringSettings { + kind: 'string'; + component: StringFieldComponent; + /** The choices a dropdown offers; ignored by the other controls. */ + options: NodeFieldStringOption[]; +} + +export type NodeFieldFormSettings = NodeFieldNumberSettings | NodeFieldStringSettings; + +export const NUMBER_FIELD_COMPONENTS: readonly NumberFieldComponent[] = [ + 'number-input', + 'slider', + 'number-input-and-slider', +]; +export const STRING_FIELD_COMPONENTS: readonly StringFieldComponent[] = ['input', 'textarea', 'dropdown']; + +// The v6 editor tags each bag with the field kind it was authored for. +const LEGACY_SETTINGS_TYPES = { + FloatField: 'float-field-config', + IntegerField: 'integer-field-config', + StringField: 'string-field-config', +} as const; + +type SettingsFieldTypeName = keyof typeof LEGACY_SETTINGS_TYPES; + +const isSettingsFieldTypeName = (name: string): name is SettingsFieldTypeName => name in LEGACY_SETTINGS_TYPES; + +/** True for the scalar fields a form element can carry control settings for. */ +export const hasNodeFieldFormSettings = (template: FieldInputTemplate): boolean => + template.type.cardinality === 'SINGLE' && isSettingsFieldTypeName(template.type.name); + +const finiteOrUndefined = (value: unknown): number | undefined => + typeof value === 'number' && Number.isFinite(value) ? value : undefined; + +const isNumberComponent = (value: unknown): value is NumberFieldComponent => + typeof value === 'string' && (NUMBER_FIELD_COMPONENTS as readonly string[]).includes(value); + +const isStringComponent = (value: unknown): value is StringFieldComponent => + typeof value === 'string' && (STRING_FIELD_COMPONENTS as readonly string[]).includes(value); + +const toOptions = (value: unknown): NodeFieldStringOption[] => + Array.isArray(value) + ? value.flatMap((option) => + typeof option === 'object' && option !== null + ? [ + { + label: + typeof (option as { label?: unknown }).label === 'string' ? (option as { label: string }).label : '', + value: + typeof (option as { value?: unknown }).value === 'string' ? (option as { value: string }).value : '', + }, + ] + : [] + ) + : []; + +/** The control settings the template's own metadata implies, used until an author chooses otherwise. */ +export const getDefaultNodeFieldFormSettings = (template: FieldInputTemplate): NodeFieldFormSettings | null => { + if (!hasNodeFieldFormSettings(template)) { + return null; + } + + return template.type.name === 'StringField' + ? { component: template.uiComponent === 'textarea' ? 'textarea' : 'input', kind: 'string', options: [] } + : { component: 'number-input', kind: 'number' }; +}; + +/** + * The authored settings of a form element for `template`, or null when the element has none for this kind of + * field. A bag tagged for another field kind (the field's type changed under it) is ignored rather than misread; + * a bag with unusable bounds keeps its control choice and drops the bounds. + */ +export const getNodeFieldFormSettings = ( + settings: Record | undefined, + template: FieldInputTemplate +): NodeFieldFormSettings | null => { + if (!settings || !hasNodeFieldFormSettings(template)) { + return null; + } + + const typeName = template.type.name as SettingsFieldTypeName; + + if (settings.type !== undefined && settings.type !== LEGACY_SETTINGS_TYPES[typeName]) { + return null; + } + + if (typeName === 'StringField') { + const component = isStringComponent(settings.component) ? settings.component : 'input'; + + return { component, kind: 'string', options: component === 'dropdown' ? toOptions(settings.options) : [] }; + } + + const component = isNumberComponent(settings.component) ? settings.component : 'number-input'; + const min = finiteOrUndefined(settings.min); + const max = finiteOrUndefined(settings.max); + const step = finiteOrUndefined(settings.step); + const boundsUsable = min === undefined || max === undefined || min <= max; + + return { + component, + kind: 'number', + ...(boundsUsable && min !== undefined ? { min } : {}), + ...(boundsUsable && max !== undefined ? { max } : {}), + ...(step !== undefined && step > 0 ? { step } : {}), + }; +}; + +/** + * The settings bag to store for `settings` on a field of `template`, in the legacy shape: tagged with the field + * kind, and carrying `showShuffle` on number fields as the v6 editor does. + */ +export const serializeNodeFieldFormSettings = ( + settings: NodeFieldFormSettings, + template: FieldInputTemplate, + showShuffle: boolean +): Record => { + const type = isSettingsFieldTypeName(template.type.name) ? LEGACY_SETTINGS_TYPES[template.type.name] : undefined; + + if (settings.kind === 'string') { + return { + component: settings.component, + type, + ...(settings.component === 'dropdown' ? { options: settings.options.map((option) => ({ ...option })) } : {}), + }; + } + + return { + component: settings.component, + showShuffle, + type, + ...(settings.min !== undefined ? { min: settings.min } : {}), + ...(settings.max !== undefined ? { max: settings.max } : {}), + ...(settings.step !== undefined ? { step: settings.step } : {}), + }; +}; + +const isMultipleOf = (step: number, base: number): boolean => { + const ratio = step / base; + + return Math.abs(ratio - Math.round(ratio)) < 1e-9; +}; + +/** + * The template narrowed by the authored number settings: a bound applies only inside the template's own range, and + * a step only when the template has none or the step keeps every value a multiple of the template's. Validation, + * shuffle and the control all read this template, so the form can never offer a value the node would reject. + */ +export const applyNodeFieldFormSettings = ( + template: FieldInputTemplate, + settings: NodeFieldFormSettings | null +): FieldInputTemplate => { + if (!settings || settings.kind !== 'number') { + return template; + } + + let next = template; + const patch = (fields: Partial) => { + next = next === template ? { ...template, ...fields } : { ...next, ...fields }; + }; + + if (settings.min !== undefined) { + if (template.minimum !== null) { + if (settings.min > template.minimum) { + patch({ minimum: settings.min }); + } + } else if (template.exclusiveMinimum === null || settings.min > template.exclusiveMinimum) { + patch({ exclusiveMinimum: null, minimum: settings.min }); + } + } + + if (settings.max !== undefined) { + if (template.maximum !== null) { + if (settings.max < template.maximum) { + patch({ maximum: settings.max }); + } + } else if (template.exclusiveMaximum === null || settings.max < template.exclusiveMaximum) { + patch({ exclusiveMaximum: null, maximum: settings.max }); + } + } + + if ( + settings.step !== undefined && + (template.multipleOf === null || template.multipleOf <= 0 || isMultipleOf(settings.step, template.multipleOf)) + ) { + patch({ multipleOf: settings.step }); + } + + return next; +}; + +export interface NodeFieldSliderRange { + max: number; + min: number; + step: number; +} + +/** + * The range a slider can show for `template` (already narrowed by `applyNodeFieldFormSettings`), or null when an + * end is unbounded: a slider over an open range has no meaningful track, so the host keeps the number input. + */ +export const getNodeFieldSliderRange = (template: FieldInputTemplate): NodeFieldSliderRange | null => { + const isInteger = template.type.name === 'IntegerField'; + const step = template.multipleOf !== null && template.multipleOf > 0 ? template.multipleOf : isInteger ? 1 : 0.01; + const min = template.minimum ?? (template.exclusiveMinimum !== null ? template.exclusiveMinimum + step : null); + const max = template.maximum ?? (template.exclusiveMaximum !== null ? template.exclusiveMaximum - step : null); + + if (min === null || max === null || !Number.isFinite(min) || !Number.isFinite(max) || min > max) { + return null; + } + + return { max, min, step }; +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.test.ts index 321a7dff1e3..53c0f095020 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { LayoutGraphEdge, LayoutGraphNode } from './graphLayout'; -import { getLayeredPositions, getNodeDepths, getTopologicalOrder } from './graphLayout'; +import { getLayeredPositions, getNodeDepths, getSizedLayeredPositions, getTopologicalOrder } from './graphLayout'; // Diamond: a -> b, a -> c, b -> d, c -> d const DIAMOND_NODES: LayoutGraphNode[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }]; @@ -62,3 +62,42 @@ describe('getLayeredPositions', () => { expect(positions.d).toEqual({ x: 600, y: 0 }); }); }); + +describe('getSizedLayeredPositions', () => { + it('stacks each layer by node height and places the next layer past the widest node', () => { + const nodes = [ + { height: 100, id: 'a', width: 200 }, + { height: 300, id: 'b', width: 250 }, + { height: 120, id: 'c', width: 180 }, + { height: 80, id: 'd', width: 200 }, + ]; + + expect( + getSizedLayeredPositions(nodes, DIAMOND_EDGES, { layerGap: 50, origin: { x: 10, y: 20 }, rowGap: 10 }) + ).toEqual({ + a: { x: 10, y: 20 }, + b: { x: 260, y: 20 }, + c: { x: 260, y: 330 }, + d: { x: 560, y: 20 }, + }); + }); + + it('orders a layer by the mean row of its parents so edges do not cross', () => { + // p -> y and q -> x, listed in the "wrong" order: x and y swap rows to follow their parents. + const nodes = [ + { height: 10, id: 'p', width: 10 }, + { height: 10, id: 'q', width: 10 }, + { height: 10, id: 'x', width: 10 }, + { height: 10, id: 'y', width: 10 }, + ]; + const edges = [ + { sourceNodeId: 'p', targetNodeId: 'y' }, + { sourceNodeId: 'q', targetNodeId: 'x' }, + ]; + + const positions = getSizedLayeredPositions(nodes, edges, { rowGap: 0 }); + + expect(positions.y!.y).toBeLessThan(positions.x!.y); + expect(positions.x!.x).toBe(positions.y!.x); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts index 4915acddcb0..1ffe51f8f42 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts @@ -78,3 +78,78 @@ export const getTopologicalOrder = (nodes: LayoutGraphNode[], edges: LayoutGraph .sort((a, b) => a.depth - b.depth || a.index - b.index) .map((entry) => entry.id); }; + +export interface LayoutSizedNode extends LayoutGraphNode { + height: number; + width: number; +} + +export interface SizedLayoutOptions { + /** Horizontal gap between layers; defaults to 80. */ + layerGap?: number; + /** Top-left corner of the arrangement; defaults to the origin. */ + origin?: { x: number; y: number }; + /** Vertical gap between the nodes of one layer; defaults to 40. */ + rowGap?: number; +} + +const DEFAULT_LAYER_GAP = 80; +const DEFAULT_ROW_GAP = 40; + +/** + * Layered layout that respects each node's size: layers are columns as wide as their widest node, nodes stack top + * to bottom within a layer, and a layer's rows follow the mean row of their parents in the layer before (ties keep + * node-array order) so edges tend not to cross. + */ +export const getSizedLayeredPositions = ( + nodes: LayoutSizedNode[], + edges: LayoutGraphEdge[], + { layerGap = DEFAULT_LAYER_GAP, origin = { x: 0, y: 0 }, rowGap = DEFAULT_ROW_GAP }: SizedLayoutOptions = {} +): Record => { + const depths = getNodeDepths(nodes, edges); + const layers = new Map(); + + for (const node of nodes) { + const depth = depths.get(node.id) ?? 0; + + layers.set(depth, [...(layers.get(depth) ?? []), node]); + } + + const parents = new Map(); + + for (const edge of edges) { + parents.set(edge.targetNodeId, [...(parents.get(edge.targetNodeId) ?? []), edge.sourceNodeId]); + } + + const positions: Record = {}; + const rowOf = new Map(); + let x = origin.x; + + for (const depth of [...layers.keys()].sort((a, b) => a - b)) { + const members = layers.get(depth)!; + const ordered = members + .map((node, index) => { + const parentRows = (parents.get(node.id) ?? []) + .map((parent) => rowOf.get(parent)) + .filter((row): row is number => row !== undefined); + const barycenter = + parentRows.length === 0 ? index : parentRows.reduce((sum, row) => sum + row, 0) / parentRows.length; + + return { barycenter, index, node }; + }) + .sort((a, b) => a.barycenter - b.barycenter || a.index - b.index); + let y = origin.y; + let width = 0; + + ordered.forEach(({ node }, row) => { + positions[node.id] = { x, y }; + rowOf.set(node.id, row); + y += node.height + rowGap; + width = Math.max(width, node.width); + }); + + x += width + layerGap; + } + + return positions; +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.browser.test.tsx index 44fd4cf8479..57b9bd3d975 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.browser.test.tsx @@ -71,7 +71,10 @@ beforeEach(() => { toolbarMocks.workflowName = 'Test workflow'; }); -const render = async (nodeOpacity: number) => { +const render = async ( + nodeOpacity: number, + arrangeProps: { arrangeScope?: 'all' | 'selection' | null; onArrangeNodes?: () => void } = {} +) => { applyThemeToRoot('mono'); host = document.createElement('div'); host.className = 'react-flow'; @@ -85,6 +88,7 @@ const render = async (nodeOpacity: number) => { { }); describe('editor toolbar', () => { + it('arranges the selection or the whole graph, and has nothing to arrange without two nodes', async () => { + const onArrangeNodes = vi.fn(); + + await render(1, { arrangeScope: null, onArrangeNodes }); + const disabledButton = host!.querySelector('button[aria-label="Arrange nodes"]')!; + expect(disabledButton.disabled).toBe(true); + + await render(1, { arrangeScope: 'selection', onArrangeNodes }); + await settle(() => host!.querySelector('button[aria-label="Arrange selected nodes"]')!.click()); + expect(onArrangeNodes).toHaveBeenCalledTimes(1); + }); + it('keeps every button on one shared square toolbar box', async () => { // Match custom control sizing; one wider child stretches every button in the column toolbar. await render(1); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.tsx index 964c4edd78d..a2b011fbee4 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.tsx @@ -13,6 +13,7 @@ import { EraserIcon, HandIcon, LassoIcon, + LayoutGridIcon, CircleArrowUpIcon, MaximizeIcon, ZoomInIcon, @@ -55,19 +56,24 @@ const waitForExportFrame = () => }); export const EditorToolbar = ({ + arrangeScope = null, nodeOpacity, tool, updatableNodeCount = 0, + onArrangeNodes, onExportPrepare, onExportComplete, onNodeOpacityChange, onToolChange, onUpdateNodes, }: { + /** What Arrange would lay out: the selection, the whole graph, or nothing (fewer than two nodes). */ + arrangeScope?: 'all' | 'selection' | null; nodeOpacity: number; tool: EditorTool; /** Nodes with a newer same-major template; the update button shows only while there are some. */ updatableNodeCount?: number; + onArrangeNodes?: () => void; onNodeOpacityChange: (opacity: number) => void; onExportPrepare: () => Promise; onExportComplete: () => void; @@ -180,6 +186,16 @@ export const EditorToolbar = ({ label={t('widgets.workflow.toolbar.fitView')} onClick={onFitViewClick} /> + { ); // No id list: the reducer already skips every node that cannot move. const onUpdateNodes = useCallback(() => editGraph({ templates, type: 'updateNodes' }), [editGraph, templates]); + // A lone selected node has nothing to arrange against, so the whole graph is laid out instead. + const arrangeScope: 'all' | 'selection' | null = + selectedNodeIds.length > 1 ? 'selection' : projectGraph.nodes.length > 1 ? 'all' : null; + const onArrangeNodes = useCallback(() => { + if (!flowInstance || arrangeScope === null) { + return; + } + + const targetIds = arrangeScope === 'selection' ? new Set(selectedNodeIds) : null; + const measuredById = new Map(flowInstance.getNodes().map((node) => [node.id, node.measured])); + const targets = projectGraph.nodes.filter((node) => !targetIds || targetIds.has(node.id)); + const origin = targets.reduce( + (corner, node) => ({ x: Math.min(corner.x, node.position.x), y: Math.min(corner.y, node.position.y) }), + { x: Infinity, y: Infinity } + ); + const sized = targets.map((node) => { + const measured = measuredById.get(node.id); + + return { + height: measured?.height || UNMEASURED_ARRANGE_SIZE.height, + id: node.id, + width: measured?.width || UNMEASURED_ARRANGE_SIZE.width, + }; + }); + const sizedIds = new Set(sized.map((node) => node.id)); + // Loop linkages point backwards; ranking by them would fold a loop body onto its own head. + const edges = projectGraph.edges + .filter((edge) => edge.type !== 'loop_linkage' && sizedIds.has(edge.source) && sizedIds.has(edge.target)) + .map((edge) => ({ sourceNodeId: edge.source, targetNodeId: edge.target })); + const positions = getSizedLayeredPositions(sized, edges, { origin }); + + editGraph({ positions, type: 'setNodePositions' }); + + // The flow shows the new positions after the model rebuilds; the bounds are known now, so fit them directly. + const bounds = sized.reduce( + (rect, node) => { + const position = positions[node.id]!; + + return { + maxX: Math.max(rect.maxX, position.x + node.width), + maxY: Math.max(rect.maxY, position.y + node.height), + minX: Math.min(rect.minX, position.x), + minY: Math.min(rect.minY, position.y), + }; + }, + { maxX: -Infinity, maxY: -Infinity, minX: Infinity, minY: Infinity } + ); + + void flowInstance.fitBounds( + { height: bounds.maxY - bounds.minY, width: bounds.maxX - bounds.minX, x: bounds.minX, y: bounds.minY }, + { duration: reduceMotion ? 0 : 300 } + ); + }, [arrangeScope, editGraph, flowInstance, projectGraph.edges, projectGraph.nodes, reduceMotion, selectedNodeIds]); if (isPreparing) { return ; @@ -1283,9 +1340,11 @@ const WorkflowFlow = ({ runtime }: { runtime: WorkflowRuntimeApi }) => { /> ); }; + +export interface NumericSliderInputProps { + ariaLabel?: string; + disabled?: boolean; + /** The track; `getNodeFieldSliderRange` derives it from a bounded template. */ + range: { max: number; min: number; step: number }; + onChange: (value: number) => void; + template: NumericInputTemplate; + value: unknown; + /** The combined control pairs the slider with a number input; the slider alone shows its value on the thumb. */ + withNumberInput: boolean; +} + +/** An authored slider for a bounded number field; a value outside the track still moves the thumb to its end. */ +export const NumericSliderInput = ({ + ariaLabel, + disabled, + onChange, + range, + template, + value, + withNumberInput, +}: NumericSliderInputProps) => { + const label = ariaLabel ?? template.title; + const current = typeof value === 'number' && Number.isFinite(value) ? value : range.min; + const sliderAriaLabel = useMemo(() => [label], [label]); + const sliderValue = useMemo(() => [Math.min(range.max, Math.max(range.min, current))], [current, range]); + const onSliderChange = useCallback( + ({ value: values }: { value: number[] }) => { + const next = values[0]; + + if (typeof next === 'number' && Number.isFinite(next)) { + onChange(next); + } + }, + [onChange] + ); + + if (withNumberInput) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.browser.test.tsx index 8c72444bdd4..85923120300 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.browser.test.tsx @@ -347,6 +347,72 @@ describe('WorkflowFieldInput textarea', () => { }); }); +describe('WorkflowFieldInput authored form controls', () => { + const renderWithSettings = async ( + template: FieldInputTemplate, + value: unknown, + onChange: (value: unknown) => void, + formSettings: WorkflowFieldInputProps['formSettings'] + ) => { + await act(() => { + root.render( + + + + + + + + ); + }); + }; + + it('restricts a dropdown string field to the authored options and commits the option value', async () => { + const onChange = vi.fn(); + await renderWithSettings(fullTemplate('StringField'), 'slow', onChange, { + component: 'dropdown', + kind: 'string', + options: [ + { label: 'Fast', value: 'fast' }, + { label: '', value: 'slow' }, + ], + }); + + expect(host.querySelector('input:not([type="hidden"]), textarea')).toBeNull(); + const trigger = host.querySelector('[data-scope="select"][data-part="trigger"]')!; + expect(trigger.textContent).toContain('slow'); + + await act(() => userEvent.click(trigger)); + const items = Array.from(document.querySelectorAll('[data-scope="select"][data-part="item"]')); + expect(items.map((item) => item.textContent)).toEqual(['Fast', 'slow']); + + await act(() => userEvent.click(items[0]!)); + expect(onChange).toHaveBeenCalledWith('fast'); + }); + + it('shows a slider for a bounded number field and keeps the input when an end is open', async () => { + const bounded = fullTemplate('IntegerField', { maximum: 20, minimum: 4, multipleOf: 4 }); + await renderWithSettings(bounded, 8, vi.fn(), { component: 'slider', kind: 'number' }); + + const slider = host.querySelector('[role="slider"]')!; + expect(slider.getAttribute('aria-valuemin')).toBe('4'); + expect(slider.getAttribute('aria-valuemax')).toBe('20'); + expect(slider.getAttribute('aria-valuenow')).toBe('8'); + expect(host.querySelector('input[type="number"]')).toBeNull(); + + await renderWithSettings(bounded, 8, vi.fn(), { component: 'number-input-and-slider', kind: 'number' }); + expect(host.querySelector('[role="slider"]')).not.toBeNull(); + expect(host.querySelector('[data-scope="number-input"] input')?.value).toBe('8'); + + await renderWithSettings(fullTemplate('IntegerField', { minimum: 0 }), 8, vi.fn(), { + component: 'slider', + kind: 'number', + }); + expect(host.querySelector('[role="slider"]')).toBeNull(); + expect(host.querySelector('input[type="number"]')?.value).toBe('8'); + }); +}); + describe('WorkflowFieldInput typed enums', () => { it.each([ { label: 'numeric', options: [256, 512], selected: 512, next: 256 }, diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx index 358599f56de..45e806a5d3b 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx @@ -1,7 +1,7 @@ import type { ModelConfig, ModelTaxonomyType } from '@features/models/react'; import type { FieldInputTemplate } from '@features/workflow/contracts'; import type { WorkflowLibraryPage, WorkflowRecordDTO } from '@features/workflow/data/api'; -import type { LoraFieldCollectionEntry } from '@features/workflow/utility'; +import type { LoraFieldCollectionEntry, NodeFieldFormSettings } from '@features/workflow/utility'; import type { SeedInputPatch } from '@platform/ui/SeedInput'; import { @@ -66,10 +66,12 @@ import { finiteNumberOrUndefined, invalidProps, NumericInput, + NumericSliderInput, useFocusedDraft, } from '@features/workflow/ui/fields/NumericInput'; import { useWorkflowProjectSelector, useWorkflowUi } from '@features/workflow/ui/WorkflowUiContext'; import { + getNodeFieldSliderRange, getResolvedWorkflowEdges, isLoraFieldCollectionEntry, isLoraFieldWeightValid, @@ -159,6 +161,11 @@ export interface WorkflowFieldInputProps { /** The instance's seed mode; read only for seed inputs (`isSeedInputField`), which render the mode menu. */ seedMode?: SeedMode; onSeedModeChange?: (seedMode: SeedMode) => void; + /** + * The form element's authored control (dropdown, slider, text area) for a scalar string or number field; the + * template's bounds are expected to be narrowed by `applyNodeFieldFormSettings` already. + */ + formSettings?: NodeFieldFormSettings | null; } // The media well's hover matches DropZone's accent preview; an invalid well keeps its error border and tints instead. @@ -171,7 +178,7 @@ const MEDIA_WELL_INVALID_PROPS = { /** A row of a list names itself by position; a scalar field is named by its title. */ type ScalarInputProps = WorkflowFieldInputProps & { ariaLabel?: string }; -const StringInput = ({ ariaLabel, id, invalid, onChange, template, value }: ScalarInputProps) => { +const StringInput = ({ ariaLabel, formSettings, id, invalid, onChange, template, value }: ScalarInputProps) => { const [draft, setDraft, clearDraft] = useFocusedDraft(); const text = draft ?? (typeof value === 'string' ? value : ''); const onTextChange = useCallback( @@ -181,8 +188,33 @@ const StringInput = ({ ariaLabel, id, invalid, onChange, template, value }: Scal }, [onChange, setDraft] ); + const stringSettings = formSettings?.kind === 'string' ? formSettings : null; + const component = stringSettings?.component ?? (template.uiComponent === 'textarea' ? 'textarea' : 'input'); + const dropdownOptions = useMemo( + () => + stringSettings?.component === 'dropdown' + ? stringSettings.options.map((option, index) => ({ + label: option.label || option.value || `Option ${index + 1}`, + value: option.value, + })) + : [], + [stringSettings] + ); + + if (component === 'dropdown') { + return ( + + ); + } - if (template.uiComponent === 'textarea') { + if (component === 'textarea') { return ( { + const { formSettings, template } = props; + const component = formSettings?.kind === 'number' ? formSettings.component : 'number-input'; + const range = component === 'number-input' ? null : getNodeFieldSliderRange(template); + + if (!range) { + return ; + } + + return ( + + ); +}; + /** * Plan stepping previews from workflow iterations and template defaults; nokey prevents node shortcuts inside the * row and portalled menu. @@ -1817,9 +1871,9 @@ export const WorkflowFieldInput = (props: WorkflowFieldInputProps) => { ); } - return ; + return ; case 'FloatField': - return ; + return ; case 'BooleanField': return ; case 'EnumField': diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/FormBuilderTab.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/FormBuilderTab.tsx index 4d970aa5d31..a1a05ae2408 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/FormBuilderTab.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/FormBuilderTab.tsx @@ -81,6 +81,7 @@ import { type AddableFormElementKey, } from './formElementMeta'; import { NodeFieldControl, useNodeFieldBinding } from './NodeFieldControl'; +import { NodeFieldSettingsPopover } from './NodeFieldSettingsPopover'; /** * Commit form moves only at DndContext onDragEnd so reparenting remounts cannot lose completion; all edits use the @@ -433,6 +434,7 @@ const BuilderElementBase = ({ + { @@ -29,9 +30,9 @@ const ViewElement = ({ element, projectGraph }: { element: WorkflowFormElement; ); case 'text': return ( - + {element.data.content} - + ); case 'divider': return ; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/LinkifiedText.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/LinkifiedText.browser.test.tsx new file mode 100644 index 00000000000..ea77071f2c4 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/LinkifiedText.browser.test.tsx @@ -0,0 +1,46 @@ +import { ChakraProvider } from '@chakra-ui/react'; +import { system } from '@theme/system'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { LinkifiedText } from './LinkifiedText'; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let host: HTMLDivElement; +let root: Root; + +beforeEach(() => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root.unmount()); + host.remove(); +}); + +describe('LinkifiedText', () => { + it('renders URLs as safe new-tab links and leaves the rest of the text as written', async () => { + const content = 'Get the model at https://example.com/models?x=1. not html'; + + await act(() => { + root.render( + + {content} + + ); + }); + + const anchors = host.querySelectorAll('a'); + expect(anchors).toHaveLength(1); + expect(anchors[0]!.getAttribute('href')).toBe('https://example.com/models?x=1'); + expect(anchors[0]!.getAttribute('target')).toBe('_blank'); + expect(anchors[0]!.getAttribute('rel')).toBe('noopener noreferrer'); + expect(anchors[0]!.textContent).toBe('https://example.com/models?x=1'); + expect(host.querySelector('b')).toBeNull(); + expect(host.textContent).toBe(content); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/LinkifiedText.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/LinkifiedText.tsx new file mode 100644 index 00000000000..7cb9e594e34 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/LinkifiedText.tsx @@ -0,0 +1,32 @@ +import type { TextProps } from '@chakra-ui/react'; + +import { Link, Text } from '@chakra-ui/react'; +import { useMemo } from 'react'; + +import { splitTextIntoLinkSegments } from './linkify'; + +/** Prose with its `http(s)` URLs rendered as links that open in a new tab; the text itself is left as written. */ +export const LinkifiedText = ({ children, ...textProps }: { children: string } & Omit) => { + const segments = useMemo(() => splitTextIntoLinkSegments(children), [children]); + + return ( + + {segments.map((segment, index) => + segment.kind === 'link' ? ( + + {segment.value} + + ) : ( + segment.value + ) + )} + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx index f8e49c5211d..e5b4c1cf2d1 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx @@ -8,8 +8,10 @@ import { WorkflowFieldInput } from '@features/workflow/ui/fields/WorkflowFieldIn import { useProjectGraphCommands } from '@features/workflow/ui/useProjectGraphCommands'; import { useWorkflowProjectSelector } from '@features/workflow/ui/WorkflowUiContext'; import { + applyNodeFieldFormSettings, cloneWorkflowFieldDefault, getEffectiveWorkflowFieldDescription, + getNodeFieldFormSettings, getRandomWorkflowFieldValue, getResolvedWorkflowEdges, getWorkflowFieldInvalidReason, @@ -24,6 +26,8 @@ import { DicesIcon, RotateCcwIcon } from 'lucide-react'; import { useCallback, useMemo, useState, type ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; +import { LinkifiedText } from './LinkifiedText'; + /** Resolves a form element's node, field instance, and input template against the document. */ export const useNodeFieldBinding = (element: NodeFieldFormElement, projectGraph: ProjectGraphState) => { const { t } = useTranslation(); @@ -35,6 +39,9 @@ export const useNodeFieldBinding = (element: NodeFieldFormElement, projectGraph: ? (invocationNode.data.dynamicInputTemplates?.[fieldName] ?? templates[invocationNode.data.type]?.inputs[fieldName]) : undefined; const instance = invocationNode?.data.inputs[fieldName]; + const formSettings = template ? getNodeFieldFormSettings(element.data.settings, template) : null; + // The authored bounds narrow the template once here; validation, shuffle and the control all read the result. + const effectiveTemplate = template ? applyNodeFieldFormSettings(template, formSettings) : undefined; const groupId = invocationNode && isWorkflowBatchNodeType(invocationNode.data.type) ? getWorkflowBatchGroupId(invocationNode) @@ -44,7 +51,7 @@ export const useNodeFieldBinding = (element: NodeFieldFormElement, projectGraph: ? `${invocationNode.data.label || templates[invocationNode.data.type]?.title || invocationNode.data.type}${groupSuffix}` : ''; - return { fieldName, instance, invocationNode, nodeContext, nodeId, template }; + return { effectiveTemplate, fieldName, formSettings, instance, invocationNode, nodeContext, nodeId, template }; }; export const NodeFieldControl = ({ @@ -60,10 +67,8 @@ export const NodeFieldControl = ({ const { t } = useTranslation(); const { editGraph } = useProjectGraphCommands(); const projectId = useWorkflowProjectSelector((project) => project.id); - const { fieldName, instance, invocationNode, nodeContext, nodeId, template } = useNodeFieldBinding( - element, - projectGraph - ); + const { effectiveTemplate, fieldName, formSettings, instance, invocationNode, nodeContext, nodeId, template } = + useNodeFieldBinding(element, projectGraph); // The form stays mounted across a workflow switch; a value that arrives later (an upload) must still land in // the workflow this control rendered. const target = useMemo(() => ({ projectId, workflowId: projectGraph.id }), [projectId, projectGraph.id]); @@ -75,8 +80,8 @@ export const NodeFieldControl = ({ ); const label = instance?.label || template?.title || ''; const description = getEffectiveWorkflowFieldDescription(instance, template); - const invalidReason = template - ? getWorkflowFieldInvalidReason({ isConnected, template, value: instance?.value }) + const invalidReason = effectiveTemplate + ? getWorkflowFieldInvalidReason({ isConnected, template: effectiveTemplate, value: instance?.value }) : null; const isInvalid = invalidReason !== null; const canReset = @@ -120,12 +125,12 @@ export const NodeFieldControl = ({ isShuffleableField(template) && !isSeedInputField(template); const onShuffleClick = useCallback(() => { - if (template) { - editGraph({ fieldName, nodeId, type: 'setFieldValue', value: getRandomWorkflowFieldValue(template) }); + if (effectiveTemplate) { + editGraph({ fieldName, nodeId, type: 'setFieldValue', value: getRandomWorkflowFieldValue(effectiveTemplate) }); } - }, [editGraph, fieldName, nodeId, template]); + }, [editGraph, effectiveTemplate, fieldName, nodeId]); - if (!invocationNode || !template) { + if (!invocationNode || !template || !effectiveTemplate) { return ( @@ -181,9 +186,9 @@ export const NodeFieldControl = ({ )} {element.data.showDescription && description ? ( - + {description} - + ) : null} {isConnected ? ( @@ -193,11 +198,12 @@ export const NodeFieldControl = ({ { + const parsed = Number(raw); + + return raw.trim() !== '' && Number.isFinite(parsed) ? parsed : undefined; +}; + +/** One authored bound: the text stays as typed while focused; a blank or unparseable entry clears the bound. */ +const BoundInput = ({ + label, + onChange, + placeholder, + value, +}: { + label: string; + onChange: (value: number | undefined) => void; + placeholder: string; + value: number | undefined; +}) => { + const [draft, setDraft, clearDraft] = useFocusedDraft(); + const onInputChange = useCallback( + (event: ChangeEvent) => { + setDraft(event.currentTarget.value); + onChange(toOptionalNumber(event.currentTarget.value)); + }, + [onChange, setDraft] + ); + + return ( + + + {label} + + + + ); +}; + +const NumberSettingsBody = ({ + onChange, + settings, + templateBounds, +}: { + onChange: (settings: NodeFieldFormSettings) => void; + settings: NodeFieldNumberSettings; + templateBounds: { max: string; min: string; step: string }; +}) => { + const { t } = useTranslation(); + const controlOptions = useMemo( + () => [ + { label: t('widgets.workflow.formBuilder.controlNumberInput'), value: 'number-input' }, + { label: t('widgets.workflow.formBuilder.controlSlider'), value: 'slider' }, + { label: t('widgets.workflow.formBuilder.controlNumberInputAndSlider'), value: 'number-input-and-slider' }, + ], + [t] + ); + const onComponentChange = useCallback( + (component: string) => onChange({ ...settings, component: component as NodeFieldNumberSettings['component'] }), + [onChange, settings] + ); + const onMinChange = useCallback((min: number | undefined) => onChange({ ...settings, min }), [onChange, settings]); + const onMaxChange = useCallback((max: number | undefined) => onChange({ ...settings, max }), [onChange, settings]); + const onStepChange = useCallback( + (step: number | undefined) => onChange({ ...settings, step: step !== undefined && step > 0 ? step : undefined }), + [onChange, settings] + ); + const needsBounds = + settings.component !== 'number-input' && + ((settings.min === undefined && templateBounds.min === '') || + (settings.max === undefined && templateBounds.max === '')); + + return ( + + + + + + + + + {needsBounds + ? t('widgets.workflow.formBuilder.sliderNeedsBounds') + : t('widgets.workflow.formBuilder.boundsHint')} + + + ); +}; + +const OptionRow = ({ + index, + onChange, + onRemove, + option, +}: { + index: number; + onChange: (index: number, option: NodeFieldStringOption) => void; + onRemove: (index: number) => void; + option: NodeFieldStringOption; +}) => { + const { t } = useTranslation(); + const onLabelChange = useCallback( + (event: ChangeEvent) => onChange(index, { ...option, label: event.currentTarget.value }), + [index, onChange, option] + ); + const onValueChange = useCallback( + (event: ChangeEvent) => onChange(index, { ...option, value: event.currentTarget.value }), + [index, onChange, option] + ); + const onRemoveClick = useCallback(() => onRemove(index), [index, onRemove]); + const removeLabel = t('widgets.workflow.formBuilder.removeOption', { index: index + 1 }); + + return ( + + + + + + + + + + ); +}; + +const StringSettingsBody = ({ + onChange, + settings, +}: { + onChange: (settings: NodeFieldFormSettings) => void; + settings: NodeFieldStringSettings; +}) => { + const { t } = useTranslation(); + const controlOptions = useMemo( + () => [ + { label: t('widgets.workflow.formBuilder.controlTextInput'), value: 'input' }, + { label: t('widgets.workflow.formBuilder.controlTextarea'), value: 'textarea' }, + { label: t('widgets.workflow.formBuilder.controlDropdown'), value: 'dropdown' }, + ], + [t] + ); + const onComponentChange = useCallback( + (component: string) => onChange({ ...settings, component: component as NodeFieldStringSettings['component'] }), + [onChange, settings] + ); + const onOptionChange = useCallback( + (index: number, option: NodeFieldStringOption) => + onChange({ ...settings, options: settings.options.map((current, i) => (i === index ? option : current)) }), + [onChange, settings] + ); + const onOptionRemove = useCallback( + (index: number) => onChange({ ...settings, options: settings.options.filter((_, i) => i !== index) }), + [onChange, settings] + ); + const onAddOption = useCallback( + () => onChange({ ...settings, options: [...settings.options, { label: '', value: '' }] }), + [onChange, settings] + ); + + return ( + + + {settings.component === 'dropdown' ? ( + + + {t('widgets.workflow.formBuilder.dropdownOptions')} + + {settings.options.length === 0 ? ( + + {t('widgets.workflow.formBuilder.noOptions')} + + ) : null} + {settings.options.map((option, index) => ( + + ))} + + + ) : null} + + ); +}; + +const formatBound = (value: number | null): string => (value === null ? '' : String(value)); + +/** The builder's per-field control settings: which control the form shows and, for numbers, its bounds. */ +export const NodeFieldSettingsPopover = ({ + element, + projectGraph, +}: { + element: NodeFieldFormElement; + projectGraph: ProjectGraphState; +}) => { + const { t } = useTranslation(); + const { editGraph } = useProjectGraphCommands(); + const triggerIds = useTooltipTriggerIds(); + const { formSettings, template } = useNodeFieldBinding(element, projectGraph); + const onSettingsChange = useCallback( + (settings: NodeFieldFormSettings) => { + if (template) { + editGraph({ + elementId: element.id, + settings: serializeNodeFieldFormSettings(settings, template, element.data.showShuffle), + type: 'setNodeFieldSettings', + }); + } + }, + [editGraph, element.data.showShuffle, element.id, template] + ); + const templateBounds = useMemo( + () => + template + ? { + max: formatBound(template.maximum ?? template.exclusiveMaximum), + min: formatBound(template.minimum ?? template.exclusiveMinimum), + step: formatBound(template.multipleOf), + } + : { max: '', min: '', step: '' }, + [template] + ); + + if (!template || !hasNodeFieldFormSettings(template) || isSeedInputField(template)) { + return null; + } + + const settings = formSettings ?? getDefaultNodeFieldFormSettings(template); + + if (!settings) { + return null; + } + + const label = t('widgets.workflow.formBuilder.fieldSettings'); + + return ( + + + + + + + + + + + + + + + {t('widgets.workflow.formBuilder.fieldSettingsHint')} + + {settings.kind === 'number' ? ( + + ) : ( + + )} + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/linkify.test.ts b/invokeai/frontend/webv2/src/features/workflow/ui/linear/linkify.test.ts new file mode 100644 index 00000000000..b8474fa7300 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/linkify.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { splitTextIntoLinkSegments } from './linkify'; + +describe('splitTextIntoLinkSegments', () => { + it('keeps plain text as one segment', () => { + expect(splitTextIntoLinkSegments('Pick a model.')).toEqual([{ kind: 'text', value: 'Pick a model.' }]); + expect(splitTextIntoLinkSegments('')).toEqual([]); + }); + + it('separates URLs from the surrounding text without losing a character', () => { + const text = 'Docs: https://invoke.ai/docs and http://example.com/a?b=1&c=2 — mirror.'; + + expect(splitTextIntoLinkSegments(text)).toEqual([ + { kind: 'text', value: 'Docs: ' }, + { kind: 'link', value: 'https://invoke.ai/docs' }, + { kind: 'text', value: ' and ' }, + { kind: 'link', value: 'http://example.com/a?b=1&c=2' }, + { kind: 'text', value: ' — mirror.' }, + ]); + }); + + it('leaves sentence punctuation and unmatched brackets outside the link', () => { + expect(splitTextIntoLinkSegments('(see https://a.b/c).')).toEqual([ + { kind: 'text', value: '(see ' }, + { kind: 'link', value: 'https://a.b/c' }, + { kind: 'text', value: ').' }, + ]); + expect(splitTextIntoLinkSegments('https://en.wikipedia.org/wiki/Foo_(bar), ok')).toEqual([ + { kind: 'link', value: 'https://en.wikipedia.org/wiki/Foo_(bar)' }, + { kind: 'text', value: ', ok' }, + ]); + }); + + it('does not treat other schemes or bare domains as links', () => { + expect(splitTextIntoLinkSegments('ftp://x.y or example.com or javascript:alert(1)')).toEqual([ + { kind: 'text', value: 'ftp://x.y or example.com or javascript:alert(1)' }, + ]); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/linkify.ts b/invokeai/frontend/webv2/src/features/workflow/ui/linear/linkify.ts new file mode 100644 index 00000000000..03576c81e5d --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/linkify.ts @@ -0,0 +1,43 @@ +export type TextSegment = { kind: 'text'; value: string } | { kind: 'link'; value: string }; + +const URL_PATTERN = /https?:\/\/[^\s<>"'`]+/g; +// Sentence punctuation that follows a URL in prose is not part of it. +const TRAILING_PUNCTUATION = /[.,;:!?]+$/; + +const trimUrl = (raw: string): string => { + let url = raw.replace(TRAILING_PUNCTUATION, ''); + + // A closing bracket belongs to the URL only when the URL opened it ("(see https://x/y(z))" vs. "(https://x)"). + while (url.endsWith(')') && (url.match(/\(/g) ?? []).length < (url.match(/\)/g) ?? []).length) { + url = url.slice(0, -1).replace(TRAILING_PUNCTUATION, ''); + } + + return url; +}; + +/** Splits prose into literal text and `http(s)` URLs, in order, so a renderer can turn the URLs into anchors. */ +export const splitTextIntoLinkSegments = (text: string): TextSegment[] => { + const segments: TextSegment[] = []; + let cursor = 0; + + for (const match of text.matchAll(URL_PATTERN)) { + const url = trimUrl(match[0]); + + if (url.length === 0) { + continue; + } + + if (match.index > cursor) { + segments.push({ kind: 'text', value: text.slice(cursor, match.index) }); + } + + segments.push({ kind: 'link', value: url }); + cursor = match.index + url.length; + } + + if (cursor < text.length) { + segments.push({ kind: 'text', value: text.slice(cursor) }); + } + + return segments; +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/utility.ts b/invokeai/frontend/webv2/src/features/workflow/utility.ts index e08296a4ff5..6c7ff1c7137 100644 --- a/invokeai/frontend/webv2/src/features/workflow/utility.ts +++ b/invokeai/frontend/webv2/src/features/workflow/utility.ts @@ -3,6 +3,7 @@ export * from './core/connectorHandles'; export * from './core/connectors'; export * from './core/document'; export * from './core/forLoops'; +export * from './core/formFieldSettings'; export * from './core/fields'; export * from './core/graphIndex'; export * from './core/outputFields'; diff --git a/invokeai/frontend/webv2/src/workbench/autoRoutePolicy.ts b/invokeai/frontend/webv2/src/workbench/autoRoutePolicy.ts index b62aa55c557..bc2b60165bf 100644 --- a/invokeai/frontend/webv2/src/workbench/autoRoutePolicy.ts +++ b/invokeai/frontend/webv2/src/workbench/autoRoutePolicy.ts @@ -89,6 +89,7 @@ const GRAPH_EDIT_CONFIDENCE = { setFieldValue: true, setFormElementContent: false, setMetadata: false, + setNodeFieldSettings: false, setNodeFieldShowDescription: false, setNodeFieldShowShuffle: false, setNodeIsIntermediate: false, @@ -96,6 +97,7 @@ const GRAPH_EDIT_CONFIDENCE = { setNodeLabel: false, setNodeNotes: false, setNodePosition: false, + setNodePositions: false, setNodeUseCache: false, setCallSavedWorkflowStatus: false, renameWorkflow: false, From 5f414f22abb5b8c0377a527475542d11b0225547 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 20:08:36 -0600 Subject: [PATCH 04/35] docs(workflows): describe the Arrange nodes toolbar action --- .../content/docs/Users Guide/08.Workflows/01.introduction.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/content/docs/Users Guide/08.Workflows/01.introduction.mdx b/docs/src/content/docs/Users Guide/08.Workflows/01.introduction.mdx index 336cb380931..ae326de90a9 100644 --- a/docs/src/content/docs/Users Guide/08.Workflows/01.introduction.mdx +++ b/docs/src/content/docs/Users Guide/08.Workflows/01.introduction.mdx @@ -54,7 +54,7 @@ Hover over a numbered circle, or tap it on a touchscreen, for a short descriptio |---|---|---| | 1 | **Workflow panel** | The workflow's **form**: its most important settings as simple controls, like a Generate panel for this workflow. | | 2 | **View** / **Edit** | **View** uses the form. **Edit** lets you design the form, edit the workflow's details (name, description, author, tags, notes), and inspect the selected node. | -| 3 | **Editor toolbar** | Tools for panning, box and lasso selection, and erasing nodes or connections; **Zoom in**, **Zoom out** and **Fit view**; **Download workflow as PNG**; and **Node opacity**. | +| 3 | **Editor toolbar** | Tools for panning, box and lasso selection, and erasing nodes or connections; **Zoom in**, **Zoom out** and **Fit view**; **Arrange nodes**, which lays out the whole graph, or just the selected nodes in place, as one undoable step; **Download workflow as PNG**; and **Node opacity**. | | 4 | **Workflow name** | The active workflow. Click it to open the **Workflows** library. | | 5 | **Workflow actions** | **Add node** (**+**), **Save to library…**, the save status, settings, and the **⋯** menu with **Workflow details…**, **Rename workflow…**, **Import workflow JSON…**, **Export workflow JSON**, **New workflow** and more. | | 6 | **The graph** | The workflow's nodes and connections. Drag nodes by their title bar; scroll to zoom; drag empty space to pan. | From 91a7f6a330c40bb3d548071e39890fef0875b6f0 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 20:10:21 -0600 Subject: [PATCH 05/35] feat(queue): restore front-of-queue invoke, retry, batch cancel, history pruning and execution diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invoke Front now sends prepend=true through every submission source; ordinary Invoke still appends. Queue rows cancel waiting workflow-call parents and retry failed or canceled roots; expanded rows cancel their batch and View JSON reads the saved session graph, results and workflow on demand. The header menu clears finished history, administrators manage the invocation cache beneath the queue and set max_queue_history from Settings → Server; the mock backend gains the matching endpoints. --- .../04.Image Generation/01.introduction.mdx | 4 +- .../Multi-User Mode/admin-guide.mdx | 2 +- .../frontend/webv2/public/locales/en.json | 57 +++++++- .../scripts/mock-backend-fixtures.test.mjs | 70 ++++++++- .../frontend/webv2/scripts/mock-backend.mjs | 99 ++++++++++++- .../frontend/webv2/src/app/QueueUiAdapter.tsx | 14 +- .../src/features/queue/core/historyTypes.ts | 2 + .../webv2/src/features/queue/core/types.ts | 34 ++++- .../queue/data/generationDevicesStore.test.ts | 48 +++++- .../queue/data/generationDevicesStore.ts | 43 +++++- .../queue/data/httpRealtimeQueueBackend.ts | 14 +- .../queue/data/invocationCacheQueries.ts | 55 +++++++ .../src/features/queue/data/mappers.test.ts | 74 +++++++++- .../webv2/src/features/queue/data/mappers.ts | 16 ++ .../src/features/queue/data/queries.test.ts | 3 + .../webv2/src/features/queue/data/queries.ts | 20 +++ .../src/features/queue/data/serverApi.test.ts | 48 ++++++ .../src/features/queue/data/serverApi.ts | 51 ++++++- .../src/features/queue/data/serverTypes.ts | 22 +++ .../features/queue/data/submissionApi.test.ts | 23 +++ .../src/features/queue/data/submissionApi.ts | 4 +- .../webv2/src/features/queue/devices.ts | 2 + .../webv2/src/features/queue/publicApi.ts | 7 +- .../webv2/src/features/queue/react.ts | 3 +- .../webv2/src/features/queue/runtime.test.ts | 25 ++++ .../webv2/src/features/queue/runtime.ts | 2 + .../queue/ui/InvocationCacheFooter.tsx | 138 ++++++++++++++++++ .../src/features/queue/ui/QueueFooter.tsx | 15 ++ .../src/features/queue/ui/QueueHeaderMenu.tsx | 32 +++- .../queue/ui/QueueItemDetailActions.tsx | 63 ++++++++ .../features/queue/ui/QueueItemDetails.tsx | 16 +- .../queue/ui/QueueItemRow.browser.test.tsx | 54 ++++++- .../src/features/queue/ui/QueueItemRow.tsx | 14 +- .../src/features/queue/ui/QueueUiContext.tsx | 2 + .../queue/ui/RetryQueueItemButton.tsx | 57 ++++++++ .../webv2/src/features/queue/ui/index.ts | 2 +- .../src/features/queue/ui/queueDataStore.ts | 39 ++++- .../features/queue/ui/queueOwnership.test.ts | 21 ++- .../src/features/queue/ui/queueOwnership.ts | 9 ++ .../webv2/src/features/queue/ui/statusMeta.ts | 2 +- .../webv2/src/features/queue/widget.ts | 2 +- .../activeInvocationSubmission.test.ts | 19 +++ .../workbench/activeInvocationSubmission.ts | 12 +- .../workbench/hotkeys/firstPartyCommands.ts | 12 +- .../webv2/src/workbench/invocationSubmit.ts | 20 ++- .../queue-integration/QueueItemActions.tsx | 94 +++++++++++- .../settings/CustomSettingsEditors.tsx | 4 + ...GenerationDevicesSettings.browser.test.tsx | 9 +- .../settings/MaxQueueHistorySettings.tsx | 130 +++++++++++++++++ .../settings/applicationContributions.ts | 1 + .../canvas/invoke/prepareCanvasInvocation.ts | 6 + .../src/workbench/workbenchState.test.ts | 18 ++- .../webv2/src/workbench/workbenchState.ts | 45 +++++- 53 files changed, 1518 insertions(+), 60 deletions(-) create mode 100644 invokeai/frontend/webv2/src/features/queue/data/invocationCacheQueries.ts create mode 100644 invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.tsx create mode 100644 invokeai/frontend/webv2/src/features/queue/ui/QueueFooter.tsx create mode 100644 invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.tsx create mode 100644 invokeai/frontend/webv2/src/features/queue/ui/RetryQueueItemButton.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/settings/MaxQueueHistorySettings.tsx diff --git a/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx b/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx index 5d5d0eb28b5..986d8d10d94 100644 --- a/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx +++ b/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx @@ -49,7 +49,9 @@ If a required component is missing, **Invoke** is disabled and its tooltip names -Hover over **Invoke** to see what will be queued, for example *1 prompt × 4 iterations → 4 generations*. When Invoke is disabled, the same tooltip lists what is stopping it. +Hover over **Invoke** to see what will be queued, for example *1 prompt × 4 iterations → 4 generations*. When Invoke is disabled, the same tooltip lists what is stopping it. To run a batch ahead of the work you already have queued, press **Ctrl+Shift+Enter** (**Cmd+Shift+Enter** on macOS) or choose **Invoke (Front of Queue)** from the command palette; the batch still waits its turn behind other users' jobs. + +The **Queue** panel lists recent jobs. Cancel a pending, waiting or running job from its row, and retry a failed or canceled job from the session the server saved for it, independently of your current settings. Expand a row to cancel its whole batch while other batches keep running, or to open **View JSON**, which shows the graph the server actually ran, its results and the embedded workflow. The panel menu clears failed items, clears every finished item while keeping queued and running work, or clears the whole queue. Administrators also see the **Invocation Cache** beneath the queue, next to the model cache, where cached node outputs can be cleared, disabled or re-enabled. ## Routing images to the Gallery or the Canvas diff --git a/docs/src/content/docs/configuration/Multi-User Mode/admin-guide.mdx b/docs/src/content/docs/configuration/Multi-User Mode/admin-guide.mdx index c9894b15405..721e6f544de 100644 --- a/docs/src/content/docs/configuration/Multi-User Mode/admin-guide.mdx +++ b/docs/src/content/docs/configuration/Multi-User Mode/admin-guide.mdx @@ -68,7 +68,7 @@ INVOKEAI_SESSION_QUEUE_MODE=FIFO `session_queue_mode` only applies in multiuser mode. In single-user mode the queue is always FIFO regardless of this setting, since all jobs belong to the same account. ::: -Round-robin fairness is determined from when each user's jobs were last started. Retained terminal queue history (see [`max_queue_history`](#configuration-reference)) does not slow dequeue scheduling — each dequeue's cost scales with the number of users who currently have pending jobs, not with the size of the history. +Round-robin fairness is determined from when each user's jobs were last started. Retained terminal queue history (see [`max_queue_history`](#configuration-reference)) does not slow dequeue scheduling — each dequeue's cost scales with the number of users who currently have pending jobs, not with the size of the history. Administrators can change `max_queue_history` without editing the file: open **Settings → Server → Queue history**, where a blank value keeps every finished item and `0` prunes them all at the next start. ### First Administrator Account diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 728a9fad1f5..e4bed75df3c 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -259,7 +259,8 @@ "canceled": "Canceled", "completed": "Completed", "failed": "Failed", - "pending": "Pending" + "pending": "Pending", + "waiting": "Waiting" }, "total": "Total", "unknownError": "Unknown error.", @@ -1973,6 +1974,10 @@ "label": "Generation devices", "description": "Server-wide accelerator selection. Changes require a server restart." }, + "maxQueueHistory": { + "label": "Queue history", + "description": "How many finished queue items the server keeps on startup. Changes require a server restart." + }, "workspaceActions": { "label": "Data & workspace", "description": "Reset the layout, manage intermediates, or permanently delete saved projects and settings. Administrators can also run database maintenance." @@ -2832,7 +2837,13 @@ "actionConfirmationTitle": "{{action}}?", "backendItem": "Backend queue item {{id}}.", "batch": "Batch", + "batchCancellationRequested": "Batch cancellation requested", + "batchCanceledCount_one": "{{count}} queue item was canceled.", + "batchCanceledCount_other": "{{count}} queue items were canceled.", "cancelAllExceptCurrent": "Cancel all except current item", + "cancelBatch": "Cancel batch", + "cancelBatchConfirmationBody": "This cancels every unfinished item in this batch. Other batches keep running.", + "cancelBatchTitle": "Cancel this batch?", "cancelAllExceptCurrentRequested": "All pending scoped items except the current item were requested.", "cancelAllItems": "Cancel All Items", "cancelConfirmationBody": "This requests cancellation for queue work in the current scope. In-progress items may stop after the backend acknowledges cancellation.", @@ -2844,6 +2855,12 @@ "clearFailedConfirmationBody": "This permanently removes failed queue items in the current queue scope.", "clearFailedItems": "Clear Failed Items", "clearFailedTitle": "Clear failed queue items?", + "clearFinishedConfirmationBody": "This permanently removes completed, canceled and failed queue items in the current queue scope. Pending and running work is kept.", + "clearFinishedItems": "Clear Finished Items", + "clearFinishedTitle": "Clear finished queue items?", + "copyFailed": "Failed to copy JSON", + "copyJson": "Copy JSON", + "couldNotCancelBatch": "Could not cancel this batch.", "clearQueue": "Clear Queue", "clearQueueConfirmationBody": "This permanently clears queue items in the current queue scope.", "clearQueueTitle": "Clear queue?", @@ -2852,9 +2869,20 @@ "couldNotCancelItems": "Could not cancel scoped queue items.", "couldNotChangeProcessor": "Could not change the queue processor.", "couldNotClearFailedItems": "Could not clear failed queue items.", + "couldNotClearFinishedItems": "Could not clear finished queue items.", + "couldNotRetryItem": "Could not retry this item.", "couldNotClearQueue": "Could not clear the queue.", "currentBatch": "Current Batch", + "detailLoadFailed": "The saved execution data could not be loaded.", + "detailLoading": "Loading saved execution data…", + "detailViews": { + "graph": "Graph", + "item": "Item", + "results": "Results", + "workflow": "Workflow" + }, "failedItemsCleared": "Failed queue items cleared", + "failedToCancelBatch": "Failed to cancel batch", "failedToCancelCurrentItem": "Failed to cancel current item", "failedToCancelItems": "Failed to cancel queue items", "failedToClearItems": "Failed to clear queue items", @@ -2862,8 +2890,29 @@ "failedToPauseProcessor": "Failed to pause processor", "failedToResumeProcessor": "Failed to resume processor", "filterRecentByStatus": "Filter recent queue items by status", + "finishedItemsCleared": "Finished queue items cleared", "generating_one": "{{count}} generating", "generating_other": "{{count}} generating", + "invocationCache": { + "cached": "Cached", + "clear": "Clear cache", + "clearDone": "Invocation cache cleared", + "clearFailed": "Clear invocation cache failed", + "couldNotChange": "Could not change the invocation cache.", + "disable": "Disable", + "disableDone": "Invocation cache disabled", + "disableFailed": "Disable invocation cache failed", + "disabled": "Disabled", + "enable": "Enable", + "enableDone": "Invocation cache enabled", + "enableFailed": "Enable invocation cache failed", + "hits": "Hits", + "label": "Invocation Cache", + "loading": "Loading…", + "misses": "Misses", + "sizeOfMax": "{{size}} / {{max}} cached", + "unavailable": "Unavailable" + }, "itemJsonLabel": "Queue item {{id}} JSON", "itemProgress": "Queue item progress", "itemTitle": "Queue item #{{id}}", @@ -2902,6 +2951,12 @@ "recallUnavailableCapabilities": "Nothing recallable yet — model capabilities have not loaded. If loading failed, retry from the Generate panel.", "recallUnavailableForItem": "Nothing recallable for this item.", "resumeProcessor": "Resume Processor", + "retryFailed": "Retry failed", + "retryItem": "Retry queue item #{{id}}", + "retryNotQueued": "Nothing to retry", + "retryNotQueuedDescription": "Queue item #{{id}} is not a failed or canceled job the server can retry.", + "retryQueued": "Retry queued", + "retryQueuedDescription": "Queue item #{{id}} was queued again from its saved session.", "sendToCanvas": "Send to canvas", "sendToCanvasComingSoon": "Sending queue results to the canvas is coming soon.", "settingsRecalled": "Settings recalled", diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs index ded68687021..c4f4945ddf7 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', max_queue_history: null }, + set_fields: [], + }); assert.deepEqual(await readJson('/__reset', { method: 'POST' }), { counts: MOCK_BACKEND_PROFILE_COUNTS.empty, @@ -938,3 +941,68 @@ test('workflow thumbnails round-trip their bytes and refresh the listed URL on e assert.equal(await listedUrl(), null); }); }); + +test('queue retry clones failed or canceled roots once, cancel-by-batch spares other batches, prune keeps active work', async () => { + await withRepresentativeBackend(async (backend) => { + const items = await getJson(backend, '/api/v1/queue/default/list_all'); + const failed = items.find((item) => item.status === 'failed'); + const pending = items.find((item) => item.status === 'pending'); + const completed = items.find((item) => item.status === 'completed'); + + assert.ok(failed && pending && completed, 'the representative queue has failed, pending and completed items'); + + const retried = await getJson(backend, '/api/v1/queue/default/retry_items_by_id', { + body: JSON.stringify([failed.item_id, failed.item_id, completed.item_id]), + method: 'PUT', + }); + + assert.equal(retried.retried_item_ids.length, 1); + const clone = await getJson(backend, `/api/v1/queue/default/i/${retried.retried_item_ids[0]}`); + assert.equal(clone.status, 'pending'); + assert.equal(clone.retried_from_item_id, failed.item_id); + + const canceled = await getJson(backend, '/api/v1/queue/default/cancel_by_batch_ids', { + body: JSON.stringify({ batch_ids: [pending.batch_id] }), + method: 'PUT', + }); + + assert.ok(canceled.canceled >= 1); + assert.equal((await getJson(backend, `/api/v1/queue/default/i/${pending.item_id}`)).status, 'canceled'); + if (clone.batch_id !== pending.batch_id) { + assert.equal((await getJson(backend, `/api/v1/queue/default/i/${clone.item_id}`)).status, 'pending'); + } + + const pruned = await getJson(backend, '/api/v1/queue/default/prune', { method: 'PUT' }); + const remaining = await getJson(backend, '/api/v1/queue/default/list_all'); + + assert.ok(pruned.deleted >= 1); + assert.ok(remaining.every((item) => ['pending', 'waiting', 'in_progress'].includes(item.status))); + }); +}); + +test('runtime config PATCH keeps the other writable field and validates the queue history limit', async () => { + await withRepresentativeBackend(async (backend) => { + await getJson(backend, '/api/v1/app/runtime_config', { + body: JSON.stringify({ generation_devices: ['cpu'] }), + method: 'PATCH', + }); + const withHistory = await getJson(backend, '/api/v1/app/runtime_config', { + body: JSON.stringify({ max_queue_history: 0 }), + method: 'PATCH', + }); + + assert.deepEqual(withHistory.config, { generation_devices: ['cpu'], max_queue_history: 0 }); + + const invalid = await fetch(`${backend.origin}/api/v1/app/runtime_config`, { + body: JSON.stringify({ max_queue_history: -1 }), + headers: { 'Content-Type': 'application/json' }, + method: 'PATCH', + }); + + assert.equal(invalid.status, 422); + + const cache = await getJson(backend, '/api/v1/app/invocation_cache/disable', { method: 'PUT' }); + assert.equal(cache, null); + assert.equal((await getJson(backend, '/api/v1/app/invocation_cache/status')).enabled, false); + }); +}); diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs index 74836005032..4f9952dbd01 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs @@ -137,6 +137,8 @@ const createState = (profile) => { intermediatesOperations: new Map(), intermediatesPreviews: new Map(), queueItems: new Map(fixture.queueItems.map((item) => [item.item_id, clone(item)])), + runtimeConfig: { generation_devices: 'auto', max_queue_history: null }, + invocationCache: { enabled: true, hits: 0, max_size: 1000, misses: 0, size: 0 }, videos: new Map(fixture.videos.map((video) => [video.video_name, clone(video)])), workflows: new Map(fixture.workflows.map((workflow) => [workflow.workflow_id, clone(workflow)])), nextWorkflowNumber: fixture.workflows.length + 1, @@ -907,8 +909,45 @@ 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') { + if (method === 'PATCH') { + const changes = await readJsonBody(request); + + if ('generation_devices' in changes) { + state.runtimeConfig.generation_devices = changes.generation_devices; + } + if ('max_queue_history' in changes) { + const value = changes.max_queue_history; + + if (value !== null && !(Number.isInteger(value) && value >= 0)) { + return json(422, { detail: 'max_queue_history must be a non-negative integer or null' }); + } + state.runtimeConfig.max_queue_history = value; + } + } + if (method === 'GET' || method === 'PATCH') { + return json(200, { config: { ...state.runtimeConfig }, set_fields: [] }); + } + } + + if (path.startsWith('/api/v1/app/invocation_cache')) { + const cache = state.invocationCache; + + if (path === '/api/v1/app/invocation_cache/status' && method === 'GET') { + return json(200, { ...cache }); + } + if (path === '/api/v1/app/invocation_cache' && method === 'DELETE') { + cache.size = 0; + return json(200, null); + } + if (path === '/api/v1/app/invocation_cache/enable' && method === 'PUT') { + cache.enabled = true; + return json(200, null); + } + if (path === '/api/v1/app/invocation_cache/disable' && method === 'PUT') { + cache.enabled = false; + return json(200, null); + } } if (path.startsWith('/api/v1/app/external_providers')) { @@ -1197,12 +1236,66 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { return json(200, { deleted: true }); } if (action === 'prune' && method === 'PUT') { + let deleted = 0; + for (const item of state.queueItems.values()) { if (item.status === 'completed' || item.status === 'failed' || item.status === 'canceled') { state.queueItems.delete(item.item_id); + deleted += 1; } } - return json(200, { deleted: true }); + return json(200, { deleted }); + } + if (action === 'cancel_by_batch_ids' && method === 'PUT') { + const body = await readJsonBody(request); + const batchIds = new Set(Array.isArray(body.batch_ids) ? body.batch_ids : []); + let canceled = 0; + + for (const item of state.queueItems.values()) { + if (batchIds.has(item.batch_id) && ['pending', 'waiting', 'in_progress'].includes(item.status)) { + item.status = 'canceled'; + item.completed_at = timestamp(state); + item.updated_at = item.completed_at; + canceled += 1; + } + } + return json(200, { canceled }); + } + if (action === 'retry_items_by_id' && method === 'PUT') { + // Like the backend: only failed or canceled roots are cloned, once each, as new pending items. + const itemIds = await readJsonBody(request, []); + const retriedItemIds = []; + const seenRoots = new Set(); + + for (const itemId of Array.isArray(itemIds) ? itemIds : []) { + const item = state.queueItems.get(itemId); + const rootId = item?.root_item_id ?? item?.item_id; + const root = rootId === undefined ? undefined : state.queueItems.get(rootId); + + if (!root || seenRoots.has(root.item_id) || !['failed', 'canceled'].includes(root.status)) { + continue; + } + seenRoots.add(root.item_id); + + const nextId = Math.max(0, ...state.queueItems.keys()) + 1; + const now = timestamp(state); + + state.queueItems.set(nextId, { + ...clone(root), + completed_at: null, + created_at: now, + error_message: null, + error_traceback: null, + error_type: null, + item_id: nextId, + retried_from_item_id: root.item_id, + started_at: null, + status: 'pending', + updated_at: now, + }); + retriedItemIds.push(nextId); + } + return json(200, { queue_id: 'default', retried_item_ids: retriedItemIds }); } return json(200, null); diff --git a/invokeai/frontend/webv2/src/app/QueueUiAdapter.tsx b/invokeai/frontend/webv2/src/app/QueueUiAdapter.tsx index 2f3b596596d..652e3ae3609 100644 --- a/invokeai/frontend/webv2/src/app/QueueUiAdapter.tsx +++ b/invokeai/frontend/webv2/src/app/QueueUiAdapter.tsx @@ -12,7 +12,7 @@ const QueueItemActions = lazy(() => importQueueItemActions().then((module) => ({ export const QueueUiAdapterProvider = ({ children }: { children: ReactNode }) => { const notify = useNotify(); const activeProjectId = useActiveProjectSelector((project) => project.id); - const { canManageModels } = useCapabilities(); + const { canManageAppConfig, canManageModels } = useCapabilities(); const session = useAuthSession(); const { queueJobsScope } = useWorkbenchPreferences(); const openWorkbenchWidget = useOpenWorkbenchWidget(); @@ -27,6 +27,7 @@ export const QueueUiAdapterProvider = ({ children }: { children: ReactNode }) => return { activeProjectId, ItemActions: QueueItemActions, + canManageInvocationCache: canManageAppConfig, canManageProcessor: canManageModels, canManageItem: (item) => getQueueItemAccess(item, viewer).canManage, canViewItemDetails: (item) => getQueueItemAccess(item, viewer).canViewDetails, @@ -36,7 +37,16 @@ export const QueueUiAdapterProvider = ({ children }: { children: ReactNode }) => preloadItemActions: () => void importQueueItemActions(), queueJobsScope, }; - }, [activeProjectId, canManageModels, isConnected, notify, openWorkbenchWidget, queueJobsScope, session]); + }, [ + activeProjectId, + canManageAppConfig, + canManageModels, + isConnected, + notify, + openWorkbenchWidget, + queueJobsScope, + session, + ]); return {children}; }; diff --git a/invokeai/frontend/webv2/src/features/queue/core/historyTypes.ts b/invokeai/frontend/webv2/src/features/queue/core/historyTypes.ts index 27c801a045a..d109793e0a4 100644 --- a/invokeai/frontend/webv2/src/features/queue/core/historyTypes.ts +++ b/invokeai/frontend/webv2/src/features/queue/core/historyTypes.ts @@ -20,6 +20,8 @@ export interface QueueSubmissionSnapshot { filterIntermediateResults: boolean; resultNodeIds?: readonly string[]; presentation: QueueSubmissionPresentation; + /** Set by Invoke Front: the backend runs the batch ahead of the user's other pending work. */ + prepend?: boolean; submittedAt: string; } diff --git a/invokeai/frontend/webv2/src/features/queue/core/types.ts b/invokeai/frontend/webv2/src/features/queue/core/types.ts index 685e0071531..bc9fe6998e5 100644 --- a/invokeai/frontend/webv2/src/features/queue/core/types.ts +++ b/invokeai/frontend/webv2/src/features/queue/core/types.ts @@ -43,6 +43,8 @@ interface QueueEnqueueRequestBase { batchCount: number; destination: QueueResultDestination; graph: QueueBackendGraph; + /** Ask the backend to run the batch ahead of the user's other pending work (Invoke Front). */ + prepend?: boolean; projectId: string; sourceQueueItemId: string; } @@ -189,6 +191,8 @@ export interface QueueItemReadModel { fieldValues?: QueueNodeFieldValue[] | null; id: number; origin?: string | null; + /** The workflow-call parent this item runs for; null or absent on root items. */ + parentItemId?: number | null; resultImageNames: string[]; retriedFromItemId?: number | null; sessionId: string; @@ -200,6 +204,26 @@ export interface QueueItemReadModel { userId?: string; } +/** + * The saved execution data of one queue item, read on demand for diagnostics. It carries the session graph the + * backend actually ran, its per-node results and the embedded workflow, which the live read model leaves out. + */ +export interface QueueItemDetailReadModel { + errorMessage: string | null; + errorTraceback: string | null; + errorType: string | null; + /** The compiled graph the session executed; empty when the backend redacted another user's item. */ + graph: Record; + id: number; + priority: number | null; + /** Node results keyed by executed node id; empty until the session produced output. */ + results: Record; + sessionId: string; + status: QueueItemStatus; + /** The workflow embedded with the batch, when one was submitted. */ + workflow: Record | null; +} + export interface QueueCounts { batchId?: string | null; canceled: number; @@ -281,13 +305,19 @@ export interface QueueResultVideoOptions extends QueueResultImageOptions { /** User-facing Queue commands; transport and adapter details stay private. */ export interface QueueFeatureCommands { + /** Cancels every unfinished item of one batch; resolves with how many the backend canceled. */ + cancelBatch(batchId: string): Promise; cancelCurrentItem(): Promise; cancelItem(itemId: number): Promise; cancelScopedItems(scope?: QueueQueryScope, options?: { keepCurrent?: boolean }): Promise; clearFailedItems(scope?: QueueQueryScope): Promise; + /** Removes completed, canceled and failed items; pending and running work is kept. */ + clearFinishedItems(scope?: QueueQueryScope): Promise; clearItems(scope?: QueueQueryScope): Promise; pauseProcessor(): Promise; resumeProcessor(): Promise; + /** Re-queues failed or canceled root items from their saved sessions; resolves with the ids actually retried. */ + retryItems(itemIds: number[]): Promise; } /** Own command transport and realtime events together so runtimes do not assemble HTTP/socket plumbing. */ @@ -338,7 +368,9 @@ export interface QueueBackendPort extends QueueFeatureCommands { readItemsById(itemIds: number[], signal?: AbortSignal): Promise; readNext(scope?: QueueQueryScope, signal?: AbortSignal): Promise; readStatus(scope?: QueueQueryScope, signal?: AbortSignal): Promise; - retryItems(itemIds: number[]): Promise; + /** The saved execution data of one item, read on demand. */ + readItemDetail(itemId: number, signal?: AbortSignal): Promise; + retryItems(itemIds: number[]): Promise; emit(event: string, payload: unknown): void; on(event: string, handler: (payload: never) => void): () => void; onConnectionChange(handler: (status: QueueConnectionStatus, error?: string) => void): () => void; diff --git a/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.test.ts b/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.test.ts index fa917e0adea..2dd94fba60f 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.test.ts @@ -9,7 +9,7 @@ vi.mock('@platform/transport/http', () => ({ getApiErrorMessage: (_error: unknown, fallback: string) => fallback, })); -const { getGenerationDevicesSnapshot, refreshGenerationDevices, updateGenerationDevices } = +const { getGenerationDevicesSnapshot, refreshGenerationDevices, updateGenerationDevices, updateMaxQueueHistory } = await import('./generationDevicesStore'); const options = [ @@ -83,4 +83,50 @@ describe('generationDevicesStore', () => { await expect(updateGenerationDevices(['cuda:9'])).rejects.toThrow('422 invalid device'); }); + + it('reads the queue history limit, telling "keep everything" apart from "not readable"', async () => { + mocks.apiFetchJson.mockImplementation((url: string) => + url.includes('generation_device_options') + ? Promise.resolve(options) + : Promise.resolve({ config: { generation_devices: 'auto', max_queue_history: 0 }, set_fields: [] }) + ); + await refreshGenerationDevices(); + expect(getGenerationDevicesSnapshot().maxQueueHistory).toBe(0); + + mocks.apiFetchJson.mockImplementation((url: string) => + url.includes('generation_device_options') + ? Promise.resolve(options) + : Promise.resolve({ config: { generation_devices: 'auto', max_queue_history: null }, set_fields: [] }) + ); + await refreshGenerationDevices(); + expect(getGenerationDevicesSnapshot().maxQueueHistory).toBeNull(); + + mocks.apiFetchJson.mockImplementation((url: string) => + url.includes('generation_device_options') ? Promise.resolve(options) : Promise.reject(new Error('403')) + ); + await refreshGenerationDevices(); + expect(getGenerationDevicesSnapshot().maxQueueHistory).toBeUndefined(); + }); + + it('PATCHes only the queue history limit so the device selection is left untouched', async () => { + mocks.apiFetchJson.mockResolvedValue({ + config: { generation_devices: ['cuda:1'], max_queue_history: 25 }, + set_fields: ['generation_devices', 'max_queue_history'], + }); + + await updateMaxQueueHistory(25); + + expect(mocks.apiFetchJson).toHaveBeenCalledWith('/api/v1/app/runtime_config', { + body: JSON.stringify({ max_queue_history: 25 }), + method: 'PATCH', + signal: expect.anything(), + }); + expect(getGenerationDevicesSnapshot().maxQueueHistory).toBe(25); + + mocks.apiFetchJson.mockResolvedValue({ config: { generation_devices: ['cuda:1'], max_queue_history: null } }); + await updateMaxQueueHistory(null); + const lastInit = mocks.apiFetchJson.mock.lastCall?.[1] as RequestInit | undefined; + expect(JSON.parse(String(lastInit?.body))).toEqual({ max_queue_history: null }); + expect(getGenerationDevicesSnapshot().maxQueueHistory).toBeNull(); + }); }); diff --git a/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.ts b/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.ts index 326c21c1173..35c0d659dd2 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.ts @@ -10,18 +10,25 @@ import { createExternalStore } from '@platform/state/externalStore'; import { apiFetchJson, getApiErrorMessage } from '@platform/transport/http'; /** - * Read available accelerators and server-wide generation_devices without polling; configuration takes effect only - * after restart and has no socket updates. + * Read available accelerators and the server's writable runtime settings (generation_devices, max_queue_history) + * without polling; configuration takes effect only after restart and has no socket updates. */ /** `auto` uses every available accelerator; an explicit list pins generation to those devices. */ export type GenerationDevicesSetting = 'auto' | string[]; +/** + * How many finished (completed, failed, canceled) items the server keeps at startup: a count, `null` when the + * server prunes nothing, or `undefined` while unknown (not loaded, or the reader is not an administrator). + */ +export type MaxQueueHistorySetting = number | null; + export interface GenerationDevicesSnapshot { /** Every installed generation device, in backend order. Empty until loaded. */ options: GenerationDeviceOption[]; /** The configured setting, or null while unknown. */ setting: GenerationDevicesSetting | null; + maxQueueHistory: MaxQueueHistorySetting | undefined; loadState: 'idle' | 'loading' | 'loaded' | 'error'; error: string | null; } @@ -29,6 +36,7 @@ export interface GenerationDevicesSnapshot { const EMPTY_SNAPSHOT: GenerationDevicesSnapshot = { error: null, loadState: 'idle', + maxQueueHistory: undefined, options: [], setting: null, }; @@ -52,6 +60,7 @@ registerAccountOwnedResource({ interface RuntimeConfigResponse { config?: { generation_devices?: GenerationDevicesSetting | null; + max_queue_history?: number | null; } | null; set_fields?: string[]; } @@ -59,6 +68,17 @@ interface RuntimeConfigResponse { const readGenerationDevices = (response: RuntimeConfigResponse | null): GenerationDevicesSetting | null => response?.config?.generation_devices ?? null; +/** Distinguishes "the server keeps everything" (`null`) from "the config was not readable" (`undefined`). */ +const readMaxQueueHistory = (response: RuntimeConfigResponse | null): MaxQueueHistorySetting | undefined => { + if (!response?.config) { + return undefined; + } + + const value = response.config.max_queue_history; + + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null; +}; + /** * Non-admins can read device options but not runtime config; return a null setting without failing the read-only * view. @@ -86,6 +106,7 @@ export const refreshGenerationDevices = (): Promise => { store.patchSnapshot({ error: null, loadState: 'loaded', + maxQueueHistory: readMaxQueueHistory(runtimeConfig), options, setting: readGenerationDevices(runtimeConfig), }); @@ -126,6 +147,24 @@ export const updateGenerationDevices = async (setting: GenerationDevicesSetting) store.patchSnapshot({ error: null, setting: readGenerationDevices(runtimeConfig) ?? setting }); }; +/** + * Sends only `max_queue_history`, so the PATCH leaves generation devices alone; `null` clears the limit. Takes + * effect at the next startup, when the server prunes its history. + */ +export const updateMaxQueueHistory = async (value: MaxQueueHistorySetting): Promise => { + const owner = captureAccountScope(); + + const runtimeConfig = await apiFetchJson('/api/v1/app/runtime_config', { + body: JSON.stringify({ max_queue_history: value }), + method: 'PATCH', + signal: owner.signal, + }); + + assertAccountScopeCurrent(owner); + + store.patchSnapshot({ error: null, maxQueueHistory: readMaxQueueHistory(runtimeConfig) ?? value }); +}; + export const getGenerationDevicesSnapshot = (): GenerationDevicesSnapshot => store.getSnapshot(); export const useGenerationDevices = (): GenerationDevicesSnapshot => store.useSnapshot(); diff --git a/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.ts b/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.ts index 61901ba45d7..69c07bf572d 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.ts @@ -2,7 +2,13 @@ import type { QueueBackendPort } from '@features/queue/core/types'; import { socketHub } from '@platform/transport/socketHub'; -import { mapQueueBackendItemDTO, mapQueueItemDTO, mapQueueItemIdsDTO, mapQueueStatusDTO } from './mappers'; +import { + mapQueueBackendItemDTO, + mapQueueItemDetailDTO, + mapQueueItemDTO, + mapQueueItemIdsDTO, + mapQueueStatusDTO, +} from './mappers'; import { cancelByBatchIds, cancelCurrentQueueItem, @@ -10,6 +16,7 @@ import { cancelQueueItems, cancelScopedQueueItems, clearFailedQueueItems, + clearFinishedQueueItems, clearScopedQueue, getCurrentQueueItem, getNextQueueItem, @@ -36,6 +43,7 @@ import { /** Production adapter for the queue backend port. */ export const queueBackend: QueueBackendPort = { acknowledgeEnqueue: acknowledgeQueueEnqueue, + cancelBatch: async (batchId) => (await cancelByBatchIds([batchId])).canceled, cancelCurrentItem: cancelCurrentQueueItem, cancelQueueItems, cancelQueueItemsByBatchIds: async (batchIds) => { @@ -46,6 +54,7 @@ export const queueBackend: QueueBackendPort = { }, cancelScopedItems: cancelScopedQueueItems, clearFailedItems: clearFailedQueueItems, + clearFinishedItems: clearFinishedQueueItems, clearItems: clearScopedQueue, emit: socketHub.emit, enqueueGenerate, @@ -61,6 +70,7 @@ export const queueBackend: QueueBackendPort = { pauseProcessor: async () => { await pauseQueueProcessor(); }, + readItemDetail: async (itemId, signal) => mapQueueItemDetailDTO(await getQueueItem(itemId, signal)), readCurrent: async (scope, signal) => { const item = await getCurrentQueueItem(scope, signal); @@ -76,7 +86,7 @@ export const queueBackend: QueueBackendPort = { return item ? mapQueueItemDTO(item) : null; }, readStatus: async (scope, signal) => mapQueueStatusDTO(await getQueueStatus(scope, signal)), - retryItems: retryItemsById, + retryItems: async (itemIds) => (await retryItemsById(itemIds)).retried_item_ids, resumeProcessor: async () => { await resumeQueueProcessor(); }, diff --git a/invokeai/frontend/webv2/src/features/queue/data/invocationCacheQueries.ts b/invokeai/frontend/webv2/src/features/queue/data/invocationCacheQueries.ts new file mode 100644 index 00000000000..80ed3892764 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/data/invocationCacheQueries.ts @@ -0,0 +1,55 @@ +import { assertAccountScopeCurrent, captureAccountScope } from '@platform/state/accountLifecycle'; +import { queryOptions, type QueryClient } from '@tanstack/react-query'; + +import type { InvocationCacheStatusDTO } from './serverTypes'; + +import { queueKeys } from './queries'; +import { clearInvocationCache, getInvocationCacheStatus, setInvocationCacheEnabled } from './serverApi'; + +/** The invocation (node output) cache: hit/miss counts, size and whether it is enabled. Admin-only on the server. */ +export interface InvocationCacheStatus { + enabled: boolean; + hits: number; + maxSize: number; + misses: number; + size: number; +} + +const mapInvocationCacheStatus = (dto: InvocationCacheStatusDTO): InvocationCacheStatus => ({ + enabled: dto.enabled, + hits: dto.hits, + maxSize: dto.max_size, + misses: dto.misses, + size: dto.size, +}); + +export const invocationCacheKey = [...queueKeys.all, 'invocation-cache'] as const; + +/** Statistics have no socket stream; they refresh on mount and after each cache command. */ +export const invocationCacheStatusOptions = () => + (() => { + const owner = captureAccountScope(); + + return queryOptions({ + queryFn: async ({ signal }): Promise => { + const status = await getInvocationCacheStatus(AbortSignal.any([signal, owner.signal])); + + assertAccountScopeCurrent(owner); + + return mapInvocationCacheStatus(status); + }, + queryKey: invocationCacheKey, + staleTime: 5_000, + }); + })(); + +export const runInvocationCacheCommand = async ( + queryClient: QueryClient, + command: 'clear' | 'disable' | 'enable' +): Promise => { + const owner = captureAccountScope(); + + await (command === 'clear' ? clearInvocationCache() : setInvocationCacheEnabled(command === 'enable')); + assertAccountScopeCurrent(owner); + await queryClient.invalidateQueries({ queryKey: invocationCacheKey }); +}; diff --git a/invokeai/frontend/webv2/src/features/queue/data/mappers.test.ts b/invokeai/frontend/webv2/src/features/queue/data/mappers.test.ts index 67dc95e5c91..866b2770cd3 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/mappers.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/mappers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { mapQueueItemDTO, mapQueueItemIdsDTO, mapQueueStatusDTO } from './mappers'; +import { mapQueueItemDetailDTO, mapQueueItemDTO, mapQueueItemIdsDTO, mapQueueStatusDTO } from './mappers'; describe('queue transport mappers', () => { it('maps a backend queue item into the live read model', () => { @@ -117,4 +117,76 @@ describe('queue transport mappers', () => { }, }); }); + + it('maps the saved execution data of an item separately from the live row', () => { + const graph = { edges: [], id: 'graph-1', nodes: { noise: { id: 'noise', type: 'noise' } } }; + const results = { noise: { noise: { image_name: 'noise.png' } } }; + const workflow = { name: 'Portrait', nodes: [] }; + + expect( + mapQueueItemDetailDTO({ + batch_id: 'batch-1', + created_at: '2026-07-18T00:00:00Z', + error_message: 'boom', + error_traceback: 'Traceback…', + error_type: 'RuntimeError', + item_id: 7, + priority: 1, + session: { graph, results }, + session_id: 'session-1', + status: 'failed', + updated_at: '2026-07-18T01:00:00Z', + workflow, + }) + ).toEqual({ + errorMessage: 'boom', + errorTraceback: 'Traceback…', + errorType: 'RuntimeError', + graph, + id: 7, + priority: 1, + results, + sessionId: 'session-1', + status: 'failed', + workflow, + }); + }); + + it('reads an empty detail for a redacted or still-pending item', () => { + expect( + mapQueueItemDetailDTO({ + batch_id: 'redacted', + created_at: '2026-07-18T00:00:00Z', + item_id: 8, + session_id: 'redacted', + status: 'pending', + updated_at: '2026-07-18T00:00:00Z', + }) + ).toEqual({ + errorMessage: null, + errorTraceback: null, + errorType: null, + graph: {}, + id: 8, + priority: null, + results: {}, + sessionId: 'redacted', + status: 'pending', + workflow: null, + }); + }); + + it('keeps the workflow-call parent on the live row so child items can hide retry', () => { + expect( + mapQueueItemDTO({ + batch_id: 'batch-1', + created_at: '2026-07-18T00:00:00Z', + item_id: 9, + parent_item_id: 7, + session_id: 'session-9', + status: 'failed', + updated_at: '2026-07-18T00:00:00Z', + }).parentItemId + ).toBe(7); + }); }); diff --git a/invokeai/frontend/webv2/src/features/queue/data/mappers.ts b/invokeai/frontend/webv2/src/features/queue/data/mappers.ts index 260f1f92175..f23edf73717 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/mappers.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/mappers.ts @@ -1,5 +1,6 @@ import type { QueueBackendItem, + QueueItemDetailReadModel, QueueItemIdsReadModel, QueueItemReadModel, QueueNodeFieldValue, @@ -40,6 +41,7 @@ export const mapQueueItemDTO = (dto: QueueServerItemDTO): QueueItemReadModel => : dto.field_values.map(mapNodeFieldValue), id: dto.item_id, origin: dto.origin, + parentItemId: dto.parent_item_id, resultImageNames: getResultImageNames(dto), retriedFromItemId: dto.retried_from_item_id, sessionId: dto.session_id, @@ -51,6 +53,20 @@ export const mapQueueItemDTO = (dto: QueueServerItemDTO): QueueItemReadModel => userId: dto.user_id, }); +/** The saved execution data alone; the live row keeps its own narrower mapping. */ +export const mapQueueItemDetailDTO = (dto: QueueServerItemDTO): QueueItemDetailReadModel => ({ + errorMessage: dto.error_message ?? null, + errorTraceback: dto.error_traceback ?? null, + errorType: dto.error_type ?? null, + graph: dto.session?.graph ?? {}, + id: dto.item_id, + priority: dto.priority ?? null, + results: dto.session?.results ?? {}, + sessionId: dto.session_id, + status: dto.status, + workflow: dto.workflow ?? null, +}); + export const mapQueueBackendItemDTO = (dto: QueueServerItemDTO): QueueBackendItem => ({ batchId: dto.batch_id, destination: dto.destination, diff --git a/invokeai/frontend/webv2/src/features/queue/data/queries.test.ts b/invokeai/frontend/webv2/src/features/queue/data/queries.test.ts index c3c9e548bd2..9126fdda17f 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/queries.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/queries.test.ts @@ -18,6 +18,9 @@ const createBackend = (): QueueBackendPort => ({ cancelCurrentItem: vi.fn(), cancelQueueItems: vi.fn(), cancelQueueItemsByBatchIds: vi.fn(), + cancelBatch: vi.fn(), + clearFinishedItems: vi.fn(), + readItemDetail: vi.fn(), cancelItem: vi.fn(), cancelScopedItems: vi.fn(), clearFailedItems: vi.fn(), diff --git a/invokeai/frontend/webv2/src/features/queue/data/queries.ts b/invokeai/frontend/webv2/src/features/queue/data/queries.ts index 46dddcbf8dd..ee7223adf27 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/queries.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/queries.ts @@ -1,5 +1,6 @@ import type { QueueBackendPort, + QueueItemDetailReadModel, QueueQueryScope, QueueReadModel, QueueStatusReadModel, @@ -12,6 +13,7 @@ export const QUEUE_RECENT_WINDOW = 50; export const queueKeys = { all: ['queue'] as const, + itemDetail: (itemId: number) => [...queueKeys.all, 'item-detail', itemId] as const, readModel: (scope: QueueQueryScope) => [...queueKeys.all, 'read-model', scope.originPrefix ?? 'all'] as const, status: (scope: QueueQueryScope) => [...queueKeys.all, 'status', scope.originPrefix ?? 'all'] as const, }; @@ -70,6 +72,24 @@ export const queueReadModelOptions = ( }); })(); +/** One item's saved execution data, read when a details view opens; an item's session changes only while it runs. */ +export const queueItemDetailOptions = (backend: QueueBackendPort, itemId: number) => + (() => { + const owner = captureAccountScope(); + + return queryOptions({ + queryFn: async ({ signal }): Promise => { + const detail = await backend.readItemDetail(itemId, AbortSignal.any([signal, owner.signal])); + + assertAccountScopeCurrent(owner); + + return detail; + }, + queryKey: queueKeys.itemDetail(itemId), + staleTime: 5_000, + }); + })(); + /** Coalesced by QueryClient; only active queue observers refetch. */ export const invalidateQueueReadModels = async (queryClient: QueryClient): Promise => { await queryClient.invalidateQueries({ queryKey: queueKeys.all }); diff --git a/invokeai/frontend/webv2/src/features/queue/data/serverApi.test.ts b/invokeai/frontend/webv2/src/features/queue/data/serverApi.test.ts index c630b3d0be7..3b8f5049fc9 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/serverApi.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/serverApi.test.ts @@ -12,6 +12,7 @@ import { cancelQueueItems, cancelScopedQueueItems, clearFailedQueueItems, + clearFinishedQueueItems, clearScopedQueue, getQueueItemsByIds, } from './serverApi'; @@ -167,3 +168,50 @@ describe('getQueueItemsByIds', () => { expect(transport.apiFetchJson).not.toHaveBeenCalled(); }); }); + +describe('clearFinishedQueueItems', () => { + beforeEach(() => { + accountLifecycle.activate('user-a'); + transport.apiFetchJson.mockReset(); + }); + + it('prunes the whole queue in one server sweep when no scope is given', async () => { + transport.apiFetchJson.mockResolvedValue({ deleted: 3 }); + + await clearFinishedQueueItems(); + + expect(requestedUrls()).toEqual(['/api/v1/queue/default/prune']); + expect(transport.apiFetchJson.mock.calls[0]?.[1]).toMatchObject({ method: 'PUT' }); + }); + + it('deletes only the terminal items of a scope and leaves active work alone', async () => { + transport.apiFetchJson.mockImplementation((url: string) => { + if (url.includes('item_ids')) { + return Promise.resolve({ item_ids: [5, 4, 3, 2, 1] }); + } + if (url.endsWith('items_by_ids')) { + return Promise.resolve([ + { item_id: 5, status: 'in_progress' }, + { item_id: 4, status: 'pending' }, + { item_id: 3, status: 'completed' }, + { item_id: 2, status: 'canceled' }, + { item_id: 1, status: 'failed' }, + ]); + } + return Promise.resolve({}); + }); + + await clearFinishedQueueItems({ originPrefix: 'webv2:p:a:' }); + + const deletions = transport.apiFetchJson.mock.calls + .filter((call) => (call[1] as RequestInit | undefined)?.method === 'DELETE') + .map((call) => String(call[0])); + + expect(deletions.sort()).toEqual([ + '/api/v1/queue/default/i/1', + '/api/v1/queue/default/i/2', + '/api/v1/queue/default/i/3', + ]); + expect(requestedUrls()).not.toContain('/api/v1/queue/default/prune'); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts b/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts index a9b8dd9080a..0c8bfdd593a 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts @@ -1,8 +1,11 @@ import type { QueueProgressPreviewPayload, QueueQueryScope } from '@features/queue/core/types'; import type { + CancelByBatchIdsResultDTO, + InvocationCacheStatusDTO, QueueAndProcessorStatusDTO, QueueItemIdsResultDTO, QueueServerItemDTO, + RetryItemsResultDTO, } from '@features/queue/data/serverTypes'; import { assertAccountScopeCurrent, captureAccountScope } from '@platform/state/accountLifecycle'; @@ -144,6 +147,35 @@ export const clearFailedQueueItems = async (scope: QueueQueryScope = {}): Promis assertAccountScopeCurrent(owner); }; +const isTerminalStatus = (status: QueueServerItemDTO['status']): boolean => + status === 'completed' || status === 'canceled' || status === 'failed'; + +/** + * Removes finished history and keeps pending, waiting and running items. Without a scope the server prunes in one + * sweep; a scoped prune deletes the scope's terminal items individually, as the clear commands do. + */ +export const clearFinishedQueueItems = async (scope: QueueQueryScope = {}): Promise => { + const owner = captureAccountScope(); + + if (!scope.originPrefix) { + await pruneQueue(owner.signal); + assertAccountScopeCurrent(owner); + return; + } + + const idsResult = await getQueueItemIds('desc', scope, owner.signal); + + assertAccountScopeCurrent(owner); + const items = await getQueueItemsByIds(idsResult.item_ids, owner.signal); + + assertAccountScopeCurrent(owner); + await deleteQueueItems( + items.filter((item) => isTerminalStatus(item.status)).map((item) => item.item_id), + owner.signal + ); + assertAccountScopeCurrent(owner); +}; + export const clearScopedQueue = async (scope: QueueQueryScope = {}): Promise => { const owner = captureAccountScope(); @@ -197,18 +229,29 @@ export const cancelScopedQueueItems = async ( assertAccountScopeCurrent(owner); }; -export const cancelByBatchIds = (batchIds: string[]): Promise => - apiFetchJson(buildQueueUrl('cancel_by_batch_ids'), { +export const cancelByBatchIds = (batchIds: string[]): Promise => + apiFetchJson(buildQueueUrl('cancel_by_batch_ids'), { body: JSON.stringify({ batch_ids: batchIds }), method: 'PUT', }); -export const retryItemsById = (itemIds: number[]): Promise => - apiFetchJson(buildQueueUrl('retry_items_by_id'), { +export const retryItemsById = (itemIds: number[]): Promise => + apiFetchJson(buildQueueUrl('retry_items_by_id'), { body: JSON.stringify(itemIds), method: 'PUT', }); +const INVOCATION_CACHE_URL = '/api/v1/app/invocation_cache'; + +/** Admin-only: the invocation (node output) cache, distinct from the model cache. */ +export const getInvocationCacheStatus = (signal?: AbortSignal): Promise => + apiFetchJson(`${INVOCATION_CACHE_URL}/status`, { signal }); + +export const clearInvocationCache = (): Promise => apiFetchJson(INVOCATION_CACHE_URL, { method: 'DELETE' }); + +export const setInvocationCacheEnabled = (enabled: boolean): Promise => + apiFetchJson(`${INVOCATION_CACHE_URL}/${enabled ? 'enable' : 'disable'}`, { method: 'PUT' }); + export const pauseQueueProcessor = (): Promise => apiFetchJson(buildQueueUrl('processor/pause'), { method: 'PUT' }); diff --git a/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts b/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts index 48d53fa70ce..1d059a8ed64 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts @@ -20,8 +20,12 @@ export interface QueueServerItemDTO { field_values?: QueueNodeFieldValueDTO[] | null; item_id: number; origin?: string | null; + parent_item_id?: number | null; + priority?: number | null; retried_from_item_id?: number | null; + root_item_id?: number | null; session?: { + graph?: Record; prepared_source_mapping?: Record; results?: Record; }; @@ -32,6 +36,24 @@ export interface QueueServerItemDTO { user_display_name?: string | null; user_email?: string | null; user_id?: string; + workflow?: Record | null; +} + +export interface RetryItemsResultDTO { + queue_id: string; + retried_item_ids: number[]; +} + +export interface CancelByBatchIdsResultDTO { + canceled: number; +} + +export interface InvocationCacheStatusDTO { + enabled: boolean; + hits: number; + max_size: number; + misses: number; + size: number; } export interface QueueImageDTO { 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 6026bb6a39c..05497748a8b 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts @@ -107,6 +107,7 @@ const getSubmittedBody = () => { expect(init?.body).toEqual(expect.any(String)); return JSON.parse(init?.body as string) as { + prepend: boolean; batch: { batch_id?: string; data: { field_name: string; items: unknown[]; node_path: string }[][]; @@ -163,6 +164,17 @@ describe('enqueueGenerate', () => { ]); }); + it('appends by default and prepends only when the request asks for the front of the queue', async () => { + const { enqueueGenerate } = await import('./submissionApi'); + + await enqueueGenerate(createRequest()); + expect(getSubmittedBody()).toMatchObject({ prepend: false }); + + mocks.apiFetchJson.mockClear(); + await enqueueGenerate(createRequest({ prepend: true })); + expect(getSubmittedBody()).toMatchObject({ prepend: true }); + }); + it('includes project-aware webv2 origin metadata', async () => { const { enqueueGenerate } = await import('./submissionApi'); @@ -328,6 +340,17 @@ describe('enqueueWorkflow', () => { }); }); + it('prepends a workflow batch only when the request asks for the front of the queue', async () => { + const { enqueueWorkflow } = await import('./submissionApi'); + + await enqueueWorkflow(createWorkflowRequest()); + expect(getSubmittedBody()).toMatchObject({ prepend: false }); + + mocks.apiFetchJson.mockClear(); + await enqueueWorkflow(createWorkflowRequest({ prepend: true })); + expect(getSubmittedBody()).toMatchObject({ prepend: true }); + }); + it('submits workflow runs for the requested batch count', async () => { const { enqueueWorkflow } = await import('./submissionApi'); diff --git a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.ts b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.ts index 22a081e8a4e..4b22a8d4721 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.ts @@ -103,7 +103,7 @@ export const enqueueGenerate = async (request: QueueEnqueueGenerateRequest): Pro origin: buildQueueItemOrigin(request.sourceQueueItemId, request.projectId), runs: plan.runs, }, - prepend: false, + prepend: request.prepend === true, }), method: 'POST', }); @@ -129,7 +129,7 @@ export const enqueueWorkflow = async (request: QueueEnqueueWorkflowRequest): Pro runs: plan.runs, ...(request.workflow ? { workflow: request.workflow } : {}), }, - prepend: false, + prepend: request.prepend === true, }), method: 'POST', }); diff --git a/invokeai/frontend/webv2/src/features/queue/devices.ts b/invokeai/frontend/webv2/src/features/queue/devices.ts index bea9005b333..f19eba01ad9 100644 --- a/invokeai/frontend/webv2/src/features/queue/devices.ts +++ b/invokeai/frontend/webv2/src/features/queue/devices.ts @@ -10,8 +10,10 @@ export { type GenerationDevicesSetting, type GenerationDevicesSnapshot, getGenerationDevicesSnapshot, + type MaxQueueHistorySetting, refreshGenerationDevices, updateGenerationDevices, + updateMaxQueueHistory, useGenerationDevices, } from './data/generationDevicesStore'; export { useDeviceLabel } from './ui/useDeviceLabel'; diff --git a/invokeai/frontend/webv2/src/features/queue/publicApi.ts b/invokeai/frontend/webv2/src/features/queue/publicApi.ts index cac51c48f01..cca3bc63c40 100644 --- a/invokeai/frontend/webv2/src/features/queue/publicApi.ts +++ b/invokeai/frontend/webv2/src/features/queue/publicApi.ts @@ -10,7 +10,7 @@ import type { import type { QueueModelLoadPort, QueueNodeExecutionPort } from './runtime/coordinator'; import { queueBackend } from './data/httpRealtimeQueueBackend'; -import { queueReadModelOptions } from './data/queries'; +import { queueItemDetailOptions, queueReadModelOptions } from './data/queries'; import { createQueueRealtimeRuntime } from './data/realtimeRuntime'; import { createQueueRuntime } from './runtime'; import { createQueueReceiptAcknowledgements, type QueueReceiptStorePort } from './runtime/receiptAcknowledgements'; @@ -23,12 +23,14 @@ export const createProductionQueueReceiptAcknowledgements = (store: QueueReceipt }); export const queueCommands: QueueFeatureCommands = { + cancelBatch: queueBackend.cancelBatch, cancelCurrentItem: queueBackend.cancelCurrentItem, cancelItem: async (itemId) => { await queueBackend.cancelItem(itemId); }, cancelScopedItems: queueBackend.cancelScopedItems, clearFailedItems: queueBackend.clearFailedItems, + clearFinishedItems: queueBackend.clearFinishedItems, clearItems: queueBackend.clearItems, pauseProcessor: async () => { await queueBackend.pauseProcessor(); @@ -36,8 +38,11 @@ export const queueCommands: QueueFeatureCommands = { resumeProcessor: async () => { await queueBackend.resumeProcessor(); }, + retryItems: queueBackend.retryItems, }; +export const getQueueItemDetailOptions = (itemId: number) => queueItemDetailOptions(queueBackend, itemId); + export const getQueueReadModelOptions = (scope: QueueQueryScope, onRead?: (model: QueueReadModel) => void) => queueReadModelOptions(queueBackend, scope, onRead); diff --git a/invokeai/frontend/webv2/src/features/queue/react.ts b/invokeai/frontend/webv2/src/features/queue/react.ts index 98f6468ed4c..63fe91fd361 100644 --- a/invokeai/frontend/webv2/src/features/queue/react.ts +++ b/invokeai/frontend/webv2/src/features/queue/react.ts @@ -17,4 +17,5 @@ export { } from './data/progressImageStore'; export { type QueueItemProgressSink, useQueueItemProgress } from './data/progressStore'; export { getQueueItemAccess } from './ui/queueOwnership'; -export { useIsProcessorPaused } from './ui/queueDataStore'; +export { type QueueItemDetailState, useIsProcessorPaused, useQueueItemDetail } from './ui/queueDataStore'; +export type { QueueItemDetailReadModel } from './core/types'; diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts index 9f9e7a86260..8539d59132a 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts @@ -54,6 +54,9 @@ const createTestBackend = (overrides: Partial = {}): QueueBack cancelItem: vi.fn(), cancelQueueItems: vi.fn(), cancelQueueItemsByBatchIds: vi.fn(), + cancelBatch: vi.fn(), + clearFinishedItems: vi.fn(), + readItemDetail: vi.fn(), cancelScopedItems: vi.fn(), clearFailedItems: vi.fn(), clearItems: vi.fn(), @@ -129,6 +132,22 @@ describe('queue runtime', () => { }); }); + it('carries an Invoke Front snapshot to the backend request and leaves ordinary runs appending', () => { + const ordinary = createQueueItemBackendSubmission({ id: 'project-1' }, createPendingQueueItem()) as { + request: object; + }; + + expect(ordinary.request).not.toHaveProperty('prepend'); + + const front = createPendingQueueItem(); + front.snapshot.prepend = true; + + expect(createQueueItemBackendSubmission({ id: 'project-1' }, front)).toMatchObject({ + kind: 'generate', + request: { prepend: true }, + }); + }); + it('replays items queued before seed modes with the step their random toggle implied', () => { const asLegacy = (shouldRandomizeSeed: boolean) => { const queueItem = createPendingQueueItem(); @@ -301,6 +320,9 @@ describe('queue runtime', () => { cancelItem: vi.fn(), cancelQueueItems: vi.fn(), cancelQueueItemsByBatchIds: vi.fn(), + cancelBatch: vi.fn(), + clearFinishedItems: vi.fn(), + readItemDetail: vi.fn(), cancelScopedItems: vi.fn(), clearFailedItems: vi.fn(), clearItems: vi.fn(), @@ -2228,6 +2250,9 @@ describe('queue runtime video board routing', () => { cancelItem: vi.fn(), cancelQueueItems: vi.fn(), cancelQueueItemsByBatchIds: vi.fn(), + cancelBatch: vi.fn(), + clearFinishedItems: vi.fn(), + readItemDetail: vi.fn(), cancelScopedItems: vi.fn(), clearFailedItems: vi.fn(), clearItems: vi.fn(), diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.ts b/invokeai/frontend/webv2/src/features/queue/runtime.ts index faf82093555..37480af96b8 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.ts @@ -291,6 +291,7 @@ export const createQueueItemBackendSubmission = ( ...compiled, destination: queueItem.snapshot.destination, ...(isQueueSeedStep(submission.seedStep) ? {} : { legacySeedPlan: true as const }), + ...(queueItem.snapshot.prepend === true ? { prepend: true } : {}), projectId: project.id, seedStep, sourceQueueItemId: queueItem.id, @@ -324,6 +325,7 @@ export const createQueueItemBackendSubmission = ( request: { ...compiled, destination: queueItem.snapshot.destination, + ...(queueItem.snapshot.prepend === true ? { prepend: true } : {}), projectId: project.id, sourceQueueItemId: queueItem.id, }, diff --git a/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.tsx b/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.tsx new file mode 100644 index 00000000000..cc04985fd9e --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.tsx @@ -0,0 +1,138 @@ +import { Collapsible, HStack, Icon, Stack, Text } from '@chakra-ui/react'; +import { invocationCacheStatusOptions, runInvocationCacheCommand } from '@features/queue/data/invocationCacheQueries'; +import { queryClient } from '@platform/query/client'; +import { getApiErrorMessage } from '@platform/transport/http'; +import { Button } from '@platform/ui/Button'; +import { useQuery } from '@tanstack/react-query'; +import { ChevronUpIcon, LayersIcon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useQueueUi } from './QueueUiContext'; + +const INDICATOR_OPEN = { transform: 'rotate(180deg)' } as const; +const TRIGGER_HOVER = { color: 'fg' } as const; + +const CacheStat = ({ label, value }: { label: string; value: number }) => ( + + {label}{' '} + + {value} + + +); + +/** + * The invocation cache keeps node outputs so an unchanged node is not re-executed; it is not the model cache. The + * server exposes it to administrators only, so the footer renders for them alone. + */ +export const InvocationCacheFooter = () => { + const { t } = useTranslation(); + const { notify } = useQueueUi(); + const [expanded, setExpanded] = useState(false); + const [busy, setBusy] = useState<'clear' | 'disable' | 'enable' | null>(null); + const query = useQuery(invocationCacheStatusOptions()); + const status = query.data ?? null; + + const handleOpenChange = useCallback((event: { open: boolean }) => setExpanded(event.open), []); + const run = useCallback( + async (command: 'clear' | 'disable' | 'enable') => { + setBusy(command); + + try { + await runInvocationCacheCommand(queryClient, command); + notify.success(t(`widgets.queue.invocationCache.${command}Done`)); + } catch (error) { + notify.error( + t(`widgets.queue.invocationCache.${command}Failed`), + getApiErrorMessage(error, t('widgets.queue.invocationCache.couldNotChange')) + ); + } finally { + setBusy(null); + } + }, + [notify, t] + ); + const onClear = useCallback(() => run('clear'), [run]); + const onToggleEnabled = useCallback(() => run(status?.enabled ? 'disable' : 'enable'), [run, status?.enabled]); + + const summary = query.isError + ? t('widgets.queue.invocationCache.unavailable') + : status + ? status.enabled + ? t('widgets.queue.invocationCache.sizeOfMax', { max: status.maxSize, size: status.size }) + : t('widgets.queue.invocationCache.disabled') + : t('widgets.queue.invocationCache.loading'); + + return ( + + + + {query.isError ? ( + + {getApiErrorMessage(query.error, t('widgets.queue.invocationCache.unavailable'))} + + ) : ( + + + + + + )} + + + + + + + + + + + {t('widgets.queue.invocationCache.label')} + + + {summary} + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueFooter.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueFooter.tsx new file mode 100644 index 00000000000..76a364aa3ec --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueFooter.tsx @@ -0,0 +1,15 @@ +import { InvocationCacheFooter } from './InvocationCacheFooter'; +import { ModelCacheFooter } from './ModelCacheFooter'; +import { useQueueUi } from './QueueUiContext'; + +/** Both server caches sit under the queue; the invocation cache is only readable by administrators. */ +export const QueueFooter = () => { + const { canManageInvocationCache } = useQueueUi(); + + return ( + <> + {canManageInvocationCache ? : null} + + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueHeaderMenu.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueHeaderMenu.tsx index ea8e53ab6ad..748a078cb9a 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueHeaderMenu.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueHeaderMenu.tsx @@ -1,7 +1,7 @@ import { Icon, Menu } from '@chakra-ui/react'; import { queueCommands } from '@features/queue/publicApi'; import { getApiErrorMessage } from '@platform/transport/http'; -import { Trash2Icon, TrashIcon } from 'lucide-react'; +import { BrushCleaningIcon, Trash2Icon, TrashIcon } from 'lucide-react'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -32,6 +32,19 @@ export const QueueHeaderMenu = () => { } }, [notify, scope, t]); + const clearFinished = useCallback(async () => { + try { + await queueCommands.clearFinishedItems(scope); + await refreshQueue(); + notify.success(t('widgets.queue.finishedItemsCleared')); + } catch (error) { + notify.error( + t('widgets.queue.failedToClearItems'), + getApiErrorMessage(error, t('widgets.queue.couldNotClearFinishedItems')) + ); + } + }, [notify, scope, t]); + const clearAll = useCallback(async () => { try { await queueCommands.clearItems(scope); @@ -52,6 +65,14 @@ export const QueueHeaderMenu = () => { title: t('widgets.queue.clearFailedTitle'), }); }, [clearFailed, t]); + const onClearFinished = useCallback(() => { + requestQueueConfirmation({ + body: t('widgets.queue.clearFinishedConfirmationBody'), + confirmLabel: t('widgets.queue.clearFinishedItems'), + onConfirm: clearFinished, + title: t('widgets.queue.clearFinishedTitle'), + }); + }, [clearFinished, t]); const onClearAll = useCallback(() => { requestQueueConfirmation({ body: t('widgets.queue.clearQueueConfirmationBody'), @@ -73,6 +94,15 @@ export const QueueHeaderMenu = () => { {t('widgets.queue.clearFailedItems')} + + + {t('widgets.queue.clearFinishedItems')} + {t('widgets.queue.clearAllItems')} diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.tsx new file mode 100644 index 00000000000..f09bd20060d --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.tsx @@ -0,0 +1,63 @@ +import type { QueueItemReadModel } from '@features/queue/core/types'; + +import { Icon } from '@chakra-ui/react'; +import { queueCommands } from '@features/queue/publicApi'; +import { getApiErrorMessage } from '@platform/transport/http'; +import { Button } from '@platform/ui/Button'; +import { LayersIcon } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { requestQueueConfirmation } from './queueConfirmationStore'; +import { refreshQueue } from './queueDataStore'; +import { useQueueUi } from './QueueUiContext'; + +/** + * Queue-owned actions of an expanded row. Cancel batch stops every unfinished sibling of this item without touching + * other batches; it stays available on finished rows because their siblings may still be queued. + */ +export const QueueItemDetailActions = ({ item }: { item: QueueItemReadModel }) => { + const { t } = useTranslation(); + const { canManageItem, notify } = useQueueUi(); + const [busy, setBusy] = useState(false); + + const cancelBatch = useCallback(async () => { + setBusy(true); + + try { + const canceled = await queueCommands.cancelBatch(item.batchId); + await refreshQueue(); + notify.success( + t('widgets.queue.batchCancellationRequested'), + t('widgets.queue.batchCanceledCount', { count: canceled }) + ); + } catch (error) { + notify.error( + t('widgets.queue.failedToCancelBatch'), + getApiErrorMessage(error, t('widgets.queue.couldNotCancelBatch')) + ); + } finally { + setBusy(false); + } + }, [item.batchId, notify, t]); + + const onCancelBatch = useCallback(() => { + requestQueueConfirmation({ + body: t('widgets.queue.cancelBatchConfirmationBody'), + confirmLabel: t('widgets.queue.cancelBatch'), + onConfirm: cancelBatch, + title: t('widgets.queue.cancelBatchTitle'), + }); + }, [cancelBatch, t]); + + if (!canManageItem(item)) { + return null; + } + + return ( + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetails.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetails.tsx index 35e924adb7c..b5cf9fcc98e 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetails.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetails.tsx @@ -1,13 +1,14 @@ import type { QueueItemReadModel } from '@features/queue/core/types'; import type { ReactNode } from 'react'; -import { Box, DataList, Separator, Text } from '@chakra-ui/react'; +import { Box, DataList, Flex, Separator, Text } from '@chakra-ui/react'; import { extractGenerationMeta } from '@features/queue/core/generationMeta'; import { MiddleTruncate } from '@platform/ui/MiddleTruncate'; import { Suspense } from 'react'; import { useTranslation } from 'react-i18next'; import { formatDuration } from './formatDuration'; +import { QueueItemDetailActions } from './QueueItemDetailActions'; import { useQueueUi } from './QueueUiContext'; import { useDeviceLabel } from './useDeviceLabel'; @@ -71,11 +72,14 @@ export const QueueItemDetails = ({ item }: { item: QueueItemReadModel }) => { ) : null} - {/* ItemActions is a lazy chunk. Without this boundary the first expand - suspends against the widget root and blanks the whole queue panel. */} - - - + + + {/* ItemActions is a lazy chunk. Without this boundary the first expand + suspends against the widget root and blanks the whole queue panel. */} + + + + ); }; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.browser.test.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.browser.test.tsx index e883edc7738..fe711a521ed 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.browser.test.tsx @@ -13,9 +13,18 @@ import { QueueItemRow } from './QueueItemRow'; import { QueueUiProvider, type QueueUiAdapter } from './QueueUiContext'; vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +const commands = vi.hoisted(() => ({ + cancelItem: vi.fn(() => Promise.resolve()), + retryItems: vi.fn((itemIds: number[]) => Promise.resolve(itemIds.map((id) => id + 100))), +})); + vi.mock('@features/queue/publicApi', () => ({ + getQueueItemDetailOptions: (itemId: number) => ({ + queryFn: () => Promise.resolve(null), + queryKey: ['queue', 'item-detail', itemId], + }), getQueueReadModelOptions: () => ({ queryFn: () => Promise.resolve(null), queryKey: ['queue'] }), - queueCommands: { cancelItem: () => Promise.resolve() }, + queueCommands: commands, })); vi.mock('./queueDataStore', async (importOriginal) => ({ ...(await importOriginal()), @@ -40,6 +49,7 @@ const item = (overrides: Partial = {}): QueueItemReadModel = const adapter = (overrides: Partial = {}): QueueUiAdapter => ({ ItemActions: () => null, activeProjectId: null, + canManageInvocationCache: false, canManageItem: () => true, canManageProcessor: true, canViewItemDetails: () => true, @@ -136,4 +146,46 @@ describe('QueueItemRow', () => { expect(preloadItemActions).toHaveBeenCalledTimes(1); }); + + it('lets a waiting workflow-call parent be canceled from its own row', async () => { + commands.cancelItem.mockClear(); + await render(adapter(), item({ id: 11, status: 'waiting' })); + + const cancel = host.querySelector('button[aria-label="widgets.queue.cancelItem"]')!; + + expect(cancel).not.toBeNull(); + expect(host.textContent).toContain('common.status.waiting'); + await act(() => cancel.click()); + expect(commands.cancelItem).toHaveBeenCalledWith(11); + }); + + it('offers retry on a failed root item and reports the retried id', async () => { + const success = vi.fn(); + commands.retryItems.mockClear(); + await render( + adapter({ notify: { error: () => undefined, info: () => undefined, success } }), + item({ id: 12, status: 'failed' }) + ); + + expect(host.querySelector('button[aria-label="widgets.queue.cancelItem"]')).toBeNull(); + const retry = host.querySelector('button[aria-label="widgets.queue.retryItem"]')!; + + expect(retry).not.toBeNull(); + await act(async () => { + retry.click(); + await Promise.resolve(); + }); + expect(commands.retryItems).toHaveBeenCalledWith([12]); + expect(success).toHaveBeenCalledWith('widgets.queue.retryQueued', 'widgets.queue.retryQueuedDescription'); + }); + + it('hides retry on a failed workflow-call child and on rows the viewer cannot manage', async () => { + await render(adapter(), item({ id: 13, parentItemId: 5, status: 'failed' })); + expect(host.querySelector('button[aria-label="widgets.queue.retryItem"]')).toBeNull(); + + await act(() => root.unmount()); + root = createRoot(host); + await render(adapter({ canManageItem: () => false }), item({ id: 14, status: 'canceled' })); + expect(host.querySelector('button[aria-label="widgets.queue.retryItem"]')).toBeNull(); + }); }); diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.tsx index abb677a1a87..847148ca444 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.tsx @@ -13,10 +13,12 @@ import { CancelQueueItemButton } from './CancelQueueItemButton'; import { formatCompactAge, formatDuration } from './formatDuration'; import { QueueItemDetails } from './QueueItemDetails'; import { QueueItemThumbnail } from './QueueItemThumbnail'; +import { isCancellableQueueItem, isRetryableQueueItem } from './queueOwnership'; import { QueueStatusDot } from './QueueStatusDot'; import { QueueStepProgress } from './QueueStepProgress'; import { useQueueUi } from './QueueUiContext'; import { clearPendingQueueItemReveal, type QueueItemRevealRequest } from './queueUiStore'; +import { RetryQueueItemButton } from './RetryQueueItemButton'; import { getStatusMeta } from './statusMeta'; import { useDeviceLabel } from './useDeviceLabel'; @@ -36,7 +38,9 @@ export const QueueItemRow = memo( const age = formatCompactAge(item.completedAt ?? item.createdAt); const ageLabel = [duration, age].filter(Boolean).join(' · '); const isFailed = item.status === 'failed'; - const isCancellable = (item.status === 'pending' || item.status === 'in_progress') && canManageItem(item); + const canManage = canManageItem(item); + const isCancellable = isCancellableQueueItem(item) && canManage; + const isRetryable = isRetryableQueueItem(item) && canManage; const canExpand = canViewItemDetails(item); const consumeReveal = useCallback( @@ -70,7 +74,13 @@ export const QueueItemRow = memo( return ( : undefined} + actions={ + isCancellable ? ( + + ) : isRetryable ? ( + + ) : undefined + } description={ diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueUiContext.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueUiContext.tsx index 8ec8cc678f7..e561fd4d8fa 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueUiContext.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueUiContext.tsx @@ -12,6 +12,8 @@ export interface QueueUiNotificationPort { export interface QueueUiAdapter { ItemActions: ComponentType<{ item: QueueItemReadModel }>; activeProjectId: string | null; + /** The invocation cache endpoints are admin-only; the footer hides for everyone else. */ + canManageInvocationCache: boolean; canManageProcessor: boolean; canManageItem(item: QueueItemReadModel): boolean; canViewItemDetails(item: QueueItemReadModel): boolean; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/RetryQueueItemButton.tsx b/invokeai/frontend/webv2/src/features/queue/ui/RetryQueueItemButton.tsx new file mode 100644 index 00000000000..74209d67689 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/ui/RetryQueueItemButton.tsx @@ -0,0 +1,57 @@ +import type { MouseEvent as ReactMouseEvent } from 'react'; + +import { Icon } from '@chakra-ui/react'; +import { queueCommands } from '@features/queue/publicApi'; +import { getApiErrorMessage } from '@platform/transport/http'; +import { IconButton } from '@platform/ui/Button'; +import { Tooltip } from '@platform/ui/Tooltip'; +import { RotateCcwIcon } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { refreshQueue } from './queueDataStore'; +import { useQueueUi } from './QueueUiContext'; + +/** Re-queues a failed or canceled root item from its saved session; the backend reports which ids it took. */ +export const RetryQueueItemButton = ({ itemId }: { itemId: number }) => { + const { t } = useTranslation(); + const { notify } = useQueueUi(); + const [busy, setBusy] = useState(false); + + const onRetry = useCallback( + async (event: ReactMouseEvent) => { + event.stopPropagation(); + setBusy(true); + + try { + const retriedItemIds = await queueCommands.retryItems([itemId]); + await refreshQueue(); + if (retriedItemIds.length === 0) { + notify.info(t('widgets.queue.retryNotQueued'), t('widgets.queue.retryNotQueuedDescription', { id: itemId })); + } else { + notify.success(t('widgets.queue.retryQueued'), t('widgets.queue.retryQueuedDescription', { id: itemId })); + } + } catch (error) { + notify.error(t('widgets.queue.retryFailed'), getApiErrorMessage(error, t('widgets.queue.couldNotRetryItem'))); + } finally { + setBusy(false); + } + }, + [itemId, notify, t] + ); + + return ( + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/index.ts b/invokeai/frontend/webv2/src/features/queue/ui/index.ts index 7066b26cc0f..79691d72b53 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/index.ts +++ b/invokeai/frontend/webv2/src/features/queue/ui/index.ts @@ -1,4 +1,4 @@ -export { ModelCacheFooter } from './ModelCacheFooter'; +export { QueueFooter } from './QueueFooter'; export { QueueHeaderActions } from './QueueHeaderActions'; export { QueueHeaderLabel } from './QueueHeaderLabel'; export { QueueHeaderMenu } from './QueueHeaderMenu'; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts b/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts index 6ae74c4c078..bfbeb07c8bf 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts +++ b/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts @@ -1,6 +1,11 @@ -import type { QueueCounts, QueueItemReadModel, QueueQueryScope } from '@features/queue/core/types'; - -import { getQueueReadModelOptions } from '@features/queue/publicApi'; +import type { + QueueCounts, + QueueItemDetailReadModel, + QueueItemReadModel, + QueueQueryScope, +} from '@features/queue/core/types'; + +import { getQueueItemDetailOptions, getQueueReadModelOptions } from '@features/queue/publicApi'; import { queryClient } from '@platform/query/client'; import { useQuery } from '@tanstack/react-query'; @@ -75,3 +80,31 @@ export const useQueueLoadState = (): { loadState: 'loading' | 'loaded' | 'error' return { error: null, loadState: query.isPending ? 'loading' : 'loaded' }; }; + +export interface QueueItemDetailState { + detail: QueueItemDetailReadModel | null; + error: string | null; + loadState: 'idle' | 'loading' | 'loaded' | 'error'; + retry: () => void; +} + +/** One item's saved execution data, fetched only while `enabled` (a details dialog is open). */ +export const useQueueItemDetail = (itemId: number, enabled: boolean): QueueItemDetailState => { + const query = useQuery({ ...getQueueItemDetailOptions(itemId), enabled }); + const retry = query.refetch; + + if (!enabled) { + return { detail: null, error: null, loadState: 'idle', retry }; + } + + if (query.isError) { + return { + detail: null, + error: query.error instanceof Error ? query.error.message : 'Failed to load queue item', + loadState: 'error', + retry, + }; + } + + return { detail: query.data ?? null, error: null, loadState: query.isPending ? 'loading' : 'loaded', retry }; +}; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.test.ts b/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.test.ts index 6f625359d9a..78516613af2 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.test.ts @@ -2,7 +2,7 @@ import type { QueueItemReadModel } from '@features/queue/core/types'; import { describe, expect, it } from 'vitest'; -import { getQueueItemAccess } from './queueOwnership'; +import { getQueueItemAccess, isCancellableQueueItem, isRetryableQueueItem } from './queueOwnership'; const item = { userId: 'owner' } as QueueItemReadModel; @@ -31,3 +31,22 @@ describe('queue item ownership', () => { ); }); }); + +describe('queue item row actions', () => { + it('offers cancel to pending, waiting and running items only', () => { + expect(isCancellableQueueItem({ status: 'pending' })).toBe(true); + expect(isCancellableQueueItem({ status: 'waiting' })).toBe(true); + expect(isCancellableQueueItem({ status: 'in_progress' })).toBe(true); + expect(isCancellableQueueItem({ status: 'completed' })).toBe(false); + expect(isCancellableQueueItem({ status: 'failed' })).toBe(false); + expect(isCancellableQueueItem({ status: 'canceled' })).toBe(false); + }); + + it('offers retry to failed and canceled roots but not to workflow-call children', () => { + expect(isRetryableQueueItem({ parentItemId: null, status: 'failed' })).toBe(true); + expect(isRetryableQueueItem({ status: 'canceled' })).toBe(true); + expect(isRetryableQueueItem({ parentItemId: 3, status: 'failed' })).toBe(false); + expect(isRetryableQueueItem({ parentItemId: null, status: 'completed' })).toBe(false); + expect(isRetryableQueueItem({ parentItemId: null, status: 'pending' })).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.ts b/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.ts index 8a2110c8fdf..5667dc22a6f 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.ts +++ b/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.ts @@ -19,3 +19,12 @@ export const getQueueItemAccess = (item: QueueItemReadModel, viewer: QueueViewer (viewer.currentUserId !== null && item.userId === viewer.currentUserId), canViewDetails: item.userId !== 'redacted', }); + +/** Only failed or canceled roots retry: the backend clones the root session, so a child offers nothing extra. */ +export const isRetryableQueueItem = (item: Pick): boolean => + (item.status === 'failed' || item.status === 'canceled') && + (item.parentItemId === null || item.parentItemId === undefined); + +/** Pending, waiting (a workflow-call parent) and running items can all be canceled. */ +export const isCancellableQueueItem = (item: Pick): boolean => + item.status === 'pending' || item.status === 'waiting' || item.status === 'in_progress'; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/statusMeta.ts b/invokeai/frontend/webv2/src/features/queue/ui/statusMeta.ts index f121e04280d..505273c1fef 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/statusMeta.ts +++ b/invokeai/frontend/webv2/src/features/queue/ui/statusMeta.ts @@ -12,7 +12,7 @@ const STATUS_META: Record = { failed: { colorPalette: 'red', dotColor: 'red.solid', labelKey: 'common.status.failed' }, in_progress: { colorPalette: 'accent', dotColor: 'accent.solid', labelKey: 'common.generating' }, pending: { colorPalette: 'gray', dotColor: 'fg.muted', labelKey: 'common.status.pending' }, - waiting: { colorPalette: 'gray', dotColor: 'fg.muted', labelKey: 'common.status.pending' }, + waiting: { colorPalette: 'gray', dotColor: 'fg.muted', labelKey: 'common.status.waiting' }, }; export const getStatusMeta = (status: QueueItemStatus): QueueStatusMeta => STATUS_META[status]; diff --git a/invokeai/frontend/webv2/src/features/queue/widget.ts b/invokeai/frontend/webv2/src/features/queue/widget.ts index 61dcbec706a..0b4c8c84b6a 100644 --- a/invokeai/frontend/webv2/src/features/queue/widget.ts +++ b/invokeai/frontend/webv2/src/features/queue/widget.ts @@ -1,7 +1,7 @@ /** Queue-owned deferred widget slots; Workbench supplies the manifest adapter. */ export const loadQueueWidgetImplementation = () => import('./ui').then((module) => ({ - footer: module.ModelCacheFooter, + footer: module.QueueFooter, headerActions: module.QueueHeaderActions, headerLabel: module.QueueHeaderLabel, headerMenu: module.QueueHeaderMenu, diff --git a/invokeai/frontend/webv2/src/workbench/activeInvocationSubmission.test.ts b/invokeai/frontend/webv2/src/workbench/activeInvocationSubmission.test.ts index b1482090ebd..5b32a4d8e9c 100644 --- a/invokeai/frontend/webv2/src/workbench/activeInvocationSubmission.test.ts +++ b/invokeai/frontend/webv2/src/workbench/activeInvocationSubmission.test.ts @@ -93,6 +93,25 @@ const createWorkflowState = () => { }; describe('active invocation submission', () => { + it('forwards Invoke Front to the submission and omits it otherwise', async () => { + const prepends: (boolean | undefined)[] = []; + const runtime: ActiveInvocationSubmissionRuntime = { + assertCurrent: () => undefined, + capture: () => owner, + flushDrafts: () => undefined, + isCurrent: () => true, + loadPrepareCanvasInvocation: () => Promise.reject(new Error('Only Canvas loads the Canvas chunk')), + submit: (deps) => { + prepends.push(deps.prepend); + }, + }; + + await submitActiveInvocation(createArgs(createGenerateState()), runtime); + await submitActiveInvocation({ ...createArgs(createGenerateState()), prepend: true }, runtime); + + expect(prepends).toEqual([undefined, true]); + }); + it('single-flights a canvas submission before its lazy module loads and until preparation settles', async () => { let resolveModule = (_value: { prepareCanvasInvocation: () => Promise }): void => undefined; const modulePromise = new Promise<{ prepareCanvasInvocation: () => Promise }>((resolve) => { diff --git a/invokeai/frontend/webv2/src/workbench/activeInvocationSubmission.ts b/invokeai/frontend/webv2/src/workbench/activeInvocationSubmission.ts index 761dfe89ac5..29dcfb15e3b 100644 --- a/invokeai/frontend/webv2/src/workbench/activeInvocationSubmission.ts +++ b/invokeai/frontend/webv2/src/workbench/activeInvocationSubmission.ts @@ -24,6 +24,8 @@ export interface ActiveInvocationSubmissionArgs { /** Words a control-layer rejection from the locale; its error message is for logs only. */ formatControlLayerError: PrepareCanvasInvocationArgs['formatControlLayerError']; getModels: () => readonly ModelConfig[] | undefined; + /** Invoke Front: the backend runs this batch ahead of the user's other pending work. */ + prepend?: boolean; queries: WorkbenchQueries; } @@ -62,7 +64,14 @@ const runtime: ActiveInvocationSubmissionRuntime = { }; export const submitActiveInvocation = async ( - { commands, destinationOverride, formatControlLayerError, getModels, queries }: ActiveInvocationSubmissionArgs, + { + commands, + destinationOverride, + formatControlLayerError, + getModels, + prepend, + queries, + }: ActiveInvocationSubmissionArgs, activeRuntime: ActiveInvocationSubmissionRuntime = runtime ): Promise => { const owner = activeRuntime.capture(); @@ -98,6 +107,7 @@ export const submitActiveInvocation = async ( models, owner, prepareCanvasInvocation, + ...(prepend ? { prepend } : {}), project: snapshot.activeProject, route, }); diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts index 6263e484cf1..e5e28ddcd6d 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts @@ -114,14 +114,16 @@ export const useRegisterFirstPartyCommands = () => { /** * Submits the active route. `destinationOverride` applies to this run only and * is never written back to the project — "just this once, send it to the - * gallery" must not silently retarget every subsequent invoke. + * gallery" must not silently retarget every subsequent invoke. `prepend` is + * likewise per run: Invoke Front puts this batch ahead of the user's pending work. */ - const submitInvocation = async (destinationOverride?: ResultDestination) => { + const submitInvocation = async (destinationOverride?: ResultDestination, prepend = false) => { await submitActiveInvocation({ commands, destinationOverride, formatControlLayerError: (rejection) => describeControlLayerIssue(t, rejection), getModels: getAvailableModels, + ...(prepend ? { prepend } : {}), queries, }); }; @@ -263,7 +265,11 @@ export const useRegisterFirstPartyCommands = () => { }) ), commandApi.register({ handler: () => submitInvocation(), id: 'app.invoke', title: 'Invoke' }), - commandApi.register({ handler: () => submitInvocation(), id: 'app.invokeFront', title: 'Invoke front' }), + commandApi.register({ + handler: () => submitInvocation(undefined, true), + id: 'app.invokeFront', + title: 'Invoke front', + }), commandApi.register({ handler: () => { const { destination } = queries.getSnapshot().activeProject.invocation; diff --git a/invokeai/frontend/webv2/src/workbench/invocationSubmit.ts b/invokeai/frontend/webv2/src/workbench/invocationSubmit.ts index e759d3abb69..10bfe9166c5 100644 --- a/invokeai/frontend/webv2/src/workbench/invocationSubmit.ts +++ b/invokeai/frontend/webv2/src/workbench/invocationSubmit.ts @@ -52,6 +52,8 @@ export interface SubmitResolvedInvocationDeps { workflowId?: string; /** Identity lifetime that initiated this submission. */ owner: AccountScope; + /** Invoke Front: the backend runs the batch ahead of the user's other pending work. */ + prepend?: boolean; commands: Pick; /** * Resolves after Canvas preparation queues or reports failure; Canvas never dispatches @@ -178,6 +180,7 @@ export const submitResolvedInvocation = async ({ models, owner, prepareCanvasInvocation, + prepend, project, route, workflowId, @@ -236,7 +239,17 @@ export const submitResolvedInvocation = async ({ } await dispatchResolvedInvocation( - { commands, formatControlLayerError, models, owner, prepareCanvasInvocation, project, route, workflowId }, + { + commands, + formatControlLayerError, + models, + owner, + prepareCanvasInvocation, + prepend, + project, + route, + workflowId, + }, expansion.prompts.length > 0 ? { positivePrompts: expansion.prompts, positivePromptsSampleSeed: sampleSeed } : undefined, @@ -247,7 +260,7 @@ export const submitResolvedInvocation = async ({ } await dispatchResolvedInvocation( - { commands, formatControlLayerError, models, owner, prepareCanvasInvocation, project, route, workflowId }, + { commands, formatControlLayerError, models, owner, prepareCanvasInvocation, prepend, project, route, workflowId }, undefined, workflowGenerators, workflowDocument?.id @@ -261,6 +274,7 @@ const dispatchResolvedInvocation = async ( models, owner, prepareCanvasInvocation, + prepend, project, route, }: SubmitResolvedInvocationDeps, @@ -280,6 +294,7 @@ const dispatchResolvedInvocation = async ( models, owner, expansion, + ...(prepend ? { prepend } : {}), projectId: project.id, canvasValues: getProjectWidgetValues(project, 'canvas'), projectSettings: project.settings, @@ -292,6 +307,7 @@ const dispatchResolvedInvocation = async ( backendSupportsCancellation: true, models, ...expansion, + ...(prepend ? { prepend } : {}), projectId: project.id, route, ...(workflowGenerators ? { workflowGenerators } : {}), diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx b/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx index d4d21d1c721..b4de69bedc5 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx @@ -1,14 +1,102 @@ import type { QueueItemReadModel } from '@features/queue/contracts'; +import type { QueueItemDetailReadModel } from '@features/queue/react'; -import { ButtonGroup, Icon, Portal } from '@chakra-ui/react'; +import { ButtonGroup, Icon, Portal, Spinner, Stack, Text } from '@chakra-ui/react'; +import { useQueueItemDetail } from '@features/queue/react'; import { Button, CloseButton } from '@platform/ui/Button'; import { Dialog } from '@platform/ui/Dialog'; import { JsonPreview } from '@platform/ui/JsonPreview'; +import { Tabs } from '@platform/ui/Tabs'; import { useNotify } from '@workbench/useNotify'; import { FileTextIcon, WandSparklesIcon } from 'lucide-react'; import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; +type DetailView = 'graph' | 'item' | 'results' | 'workflow'; + +const getDetailViews = (detail: QueueItemDetailReadModel): DetailView[] => [ + 'item', + 'graph', + 'results', + ...(detail.workflow ? (['workflow'] as const) : []), +]; + +const getDetailValue = (item: QueueItemReadModel, detail: QueueItemDetailReadModel, view: DetailView): unknown => { + switch (view) { + case 'graph': + return detail.graph; + case 'results': + return detail.results; + case 'workflow': + return detail.workflow; + default: + return { ...item, errorTraceback: detail.errorTraceback, priority: detail.priority }; + } +}; + +/** The saved execution data, read when the dialog opens so a failed or outputless job can still be inspected. */ +const QueueItemDetailBody = ({ item, open }: { item: QueueItemReadModel; open: boolean }) => { + const { t } = useTranslation(); + const { detail, error, loadState, retry } = useQueueItemDetail(item.id, open); + const [view, setView] = useState('item'); + const onViewChange = useCallback((details: { value: string }) => setView(details.value as DetailView), []); + + if (loadState === 'error') { + return ( + + + {t('widgets.queue.detailLoadFailed')} + + {error ? ( + + {error} + + ) : null} + + + ); + } + + if (!detail) { + return ( + + + + {t('widgets.queue.detailLoading')} + + + ); + } + + const views = getDetailViews(detail); + const activeView = views.includes(view) ? view : 'item'; + + return ( + + + {views.map((candidate) => ( + + {t(`widgets.queue.detailViews.${candidate}`)} + + ))} + + {views.map((candidate) => ( + + + + ))} + + ); +}; + export const QueueItemActions = ({ item }: { item: QueueItemReadModel }) => { const { t } = useTranslation(); const notify = useNotify(); @@ -24,7 +112,7 @@ export const QueueItemActions = ({ item }: { item: QueueItemReadModel }) => { return ( <> - + + {typeof maxQueueHistory === 'number' ? : null} + + {parsed === 'invalid' ? ( + + Enter a whole number of items, or leave the field blank. + + ) : null} + {saveError ? ( + + {saveError} + + ) : null} + {didChange ? ( + + Restart InvokeAI for changes to take effect. + + ) : null} + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/settings/applicationContributions.ts b/invokeai/frontend/webv2/src/workbench/settings/applicationContributions.ts index 76c78a0c0ad..78747622d2a 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/applicationContributions.ts +++ b/invokeai/frontend/webv2/src/workbench/settings/applicationContributions.ts @@ -145,6 +145,7 @@ export const developerSettings = section('developer', 'Developer', [ ]); export const serverSettings = section('server', 'Server', [ custom('generationDevices', 'Generation devices', 'server'), + custom('maxQueueHistory', 'Queue history', 'server'), ]); export const workspaceSettings = section('workspace', 'Data & workspace', [ custom('workspaceActions', 'Data & workspace', 'none'), diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts index 4215cfc6b1f..3e3e0d9735b 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts @@ -83,6 +83,8 @@ export interface RunCanvasInvocationDeps { composeForGeneration: (options: ComposeForGenerationOptions) => Promise; /** Expanded by the caller before submitting. */ expansion?: ExpandedPositivePrompts; + /** Invoke Front: the backend runs the batch ahead of the user's other pending work. */ + prepend?: boolean; /** Cancels raster capture for this invocation. */ signal: AbortSignal; /** Paint-bitmap persistence barrier, awaited before compositing. */ @@ -368,6 +370,7 @@ export const runCanvasInvocation = async (deps: RunCanvasInvocationDeps): Promis graph: compiled.graph, canvas: composites.canvas, ...deps.expansion, + ...(deps.prepend ? { prepend: true } : {}), projectId, }); composed.dedupeCommit.commit(); @@ -409,6 +412,8 @@ export interface PrepareCanvasInvocationArgs { owner?: AccountScope; /** Expanded by the caller before submitting. */ expansion?: ExpandedPositivePrompts; + /** Invoke Front: the backend runs the batch ahead of the user's other pending work. */ + prepend?: boolean; projectSettings: Pick; /** * The canvas widget's persisted values. The scaling policy is read here, in @@ -457,6 +462,7 @@ export const prepareCanvasInvocation = async (args: PrepareCanvasInvocationArgs) inFlight: inFlightProjects, inFlightKey: `${owner.epoch}:${args.projectId}`, models: args.models, + ...(args.prepend ? { prepend: true } : {}), projectId: args.projectId, projectSettings: args.projectSettings, randDevice: resolveRandDeviceMetadata(args.projectSettings.useCpuNoise, getGenerationDevicesSnapshot().options), diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts index c9db763eea2..996e2024cea 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts @@ -438,8 +438,22 @@ const primeGenerate = ( overrides: Partial = {} ) => workbenchReducer(state, { type: 'setGenerateSettings', values: createGenerateValues(overrides) }); -const submitGenerate = (state: WorkbenchState) => - workbenchReducer(state, { backendSupportsCancellation: true, type: 'submitInvocationSnapshot' }); +const submitGenerate = (state: WorkbenchState, prepend?: boolean) => + workbenchReducer(state, { + backendSupportsCancellation: true, + ...(prepend === undefined ? {} : { prepend }), + type: 'submitInvocationSnapshot', + }); + +describe('Invoke Front', () => { + it('records front priority on the queued snapshot only when the command asks for it', () => { + const appended = submitGenerate(primeGenerate()); + const prepended = submitGenerate(primeGenerate(), true); + + expect(getActiveProject(appended).queue.items[0]?.snapshot).not.toHaveProperty('prepend'); + expect(getActiveProject(prepended).queue.items[0]?.snapshot.prepend).toBe(true); + }); +}); const getQueuedRandDevice = (state: WorkbenchState): unknown => { const submission = getActiveProject(state).queue.items[0]?.snapshot.backendSubmission; diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.ts index 6112f9212af..82ed9167466 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.ts @@ -385,10 +385,18 @@ type WorkbenchReducerAction = } | { type: 'undoWorkflowChange'; projectId?: string; workflowId?: string } | { type: 'redoWorkflowChange'; projectId?: string; workflowId?: string } - | { type: 'submitInvocationSnapshot'; backendSupportsCancellation: boolean; models?: readonly ModelConfig[] } + | { + type: 'submitInvocationSnapshot'; + backendSupportsCancellation: boolean; + models?: readonly ModelConfig[]; + /** Invoke Front: the backend runs the batch ahead of the user's other pending work. */ + prepend?: boolean; + } | ({ type: 'submitResolvedInvocationSnapshot'; backendSupportsCancellation: boolean; + /** Invoke Front: the backend runs the batch ahead of the user's other pending work. */ + prepend?: boolean; /** Async workflow generator outputs, resolved by the caller before dispatch. */ workflowGenerators?: WorkflowGeneratorResolutions; route: InvocationRoute; @@ -510,6 +518,8 @@ type WorkbenchReducerAction = destination: ResultDestination; generate: QueueGenerateSnapshot; graph: GraphContract; + /** Invoke Front: the backend runs the batch ahead of the user's other pending work. */ + prepend?: boolean; projectId: string; } & Partial) | { @@ -3439,7 +3449,8 @@ const enqueueCompiledSnapshot = ( projectWorkflowId?: string; }, backendSupportsCancellation: boolean, - canvasSnapshot?: CanvasStateContractV3 + canvasSnapshot?: CanvasStateContractV3, + prepend = false ): Project => { const submittedAt = now(); const queueItemId = createId('queue-item'); @@ -3606,6 +3617,7 @@ const enqueueCompiledSnapshot = ( : {}), submittedAt, ...(recall ? { recall } : {}), + ...(prepend ? { prepend: true } : {}), }, status: 'pending', }; @@ -3686,7 +3698,8 @@ const submitInvocationSnapshot = ( models?: readonly ModelConfig[], expansion: Partial = {}, workflowGenerators?: WorkflowGeneratorResolutions, - workflowDocument?: ProjectGraphState + workflowDocument?: ProjectGraphState, + prepend = false ): Project => { if (!isInvocationRouteValid(route)) { return project; @@ -3698,7 +3711,14 @@ const submitInvocationSnapshot = ( return project; } - return enqueueCompiledSnapshot(project, route, { ...compiledSnapshot, ...expansion }, backendSupportsCancellation); + return enqueueCompiledSnapshot( + project, + route, + { ...compiledSnapshot, ...expansion }, + backendSupportsCancellation, + undefined, + prepend + ); }; export const createInitialWorkbenchState = (): WorkbenchState => { @@ -4773,7 +4793,16 @@ export const __workbenchReducerInternal = ( return withEnqueueNotification( state, updateActiveProject(state, (project) => - submitInvocationSnapshot(project, action.backendSupportsCancellation, undefined, action.models) + submitInvocationSnapshot( + project, + action.backendSupportsCancellation, + undefined, + action.models, + undefined, + undefined, + undefined, + action.prepend === true + ) ), state.activeProjectId ); @@ -4814,7 +4843,8 @@ export const __workbenchReducerInternal = ( action.models, { positivePrompts: action.positivePrompts, positivePromptsSampleSeed: action.positivePromptsSampleSeed }, action.workflowGenerators, - workflow?.document + workflow?.document, + action.prepend === true ) ), action.projectId @@ -5373,7 +5403,8 @@ export const __workbenchReducerInternal = ( widgetStates: getWidgetStatesSnapshot(project.widgetInstances), }, action.backendSupportsCancellation, - action.canvas + action.canvas, + action.prepend === true ) ), action.projectId From 898ffb52a8ac58245880b9741e7b4aca1c94e08b Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 20:10:38 -0600 Subject: [PATCH 06/35] refactor(gallery): share the embedded-workflow loader between images and videos Load Workflow for a video and for an image now go through one loader keyed by media kind. The W shortcut loads the workflow of a selected video as well as a selected image. --- .../workbench/hotkeys/firstPartyCommands.ts | 16 ++--- .../image-actions/executeImageRecall.ts | 62 ------------------- ...orkflow.ts => executeLoadMediaWorkflow.ts} | 31 ++++++---- .../workbench/image-actions/selectedImage.ts | 8 ++- .../image-actions/useImageActions.ts | 12 ++-- 5 files changed, 40 insertions(+), 89 deletions(-) rename invokeai/frontend/webv2/src/workbench/image-actions/{executeLoadVideoWorkflow.ts => executeLoadMediaWorkflow.ts} (58%) diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts index 6263e484cf1..997f479f321 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts @@ -133,10 +133,10 @@ export const useRegisterFirstPartyCommands = () => { title: 'No image selected', }); - const loadSelectedImageWorkflow = async () => { + const loadSelectedItemWorkflow = async () => { const owner = captureAccountScope(); - const [{ executeLoadImageWorkflow }, { getSelectedGalleryImage }] = await Promise.all([ - import('@workbench/image-actions/executeImageRecall'), + const [{ executeLoadMediaWorkflow }, { getSelectedGalleryItem }] = await Promise.all([ + import('@workbench/image-actions/executeLoadMediaWorkflow'), import('@workbench/image-actions/selectedImage'), ]); @@ -145,16 +145,16 @@ export const useRegisterFirstPartyCommands = () => { } const activeProject = queries.getSnapshot().activeProject; - const image = getSelectedGalleryImage(activeProject); + const item = getSelectedGalleryItem(activeProject); - if (!image) { + if (!item) { notifyNoImageSelected(); return; } - await executeLoadImageWorkflow({ - image, + await executeLoadMediaWorkflow({ isProjectActive: () => queries.isActiveProject(activeProject.id), + media: { kind: item.kind, name: item.name }, notifications, openWorkflowEditor: () => openWidgetPlacement({ @@ -474,7 +474,7 @@ export const useRegisterFirstPartyCommands = () => { commandApi.register({ handler: () => recallSelectedImage(kind), id, title: id }) ), commandApi.register({ - handler: () => loadSelectedImageWorkflow(), + handler: () => loadSelectedItemWorkflow(), id: LOAD_WORKFLOW_COMMAND_ID, title: 'Load workflow from image', }), diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts index ce424083262..bd9f7d49238 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts @@ -18,7 +18,6 @@ import { isVaeModelConfig, normalizeGenerateWidgetValues, } from '@features/generation/settings'; -import { requestWorkflowDocumentLoad } from '@features/workflow/react'; import { assertAccountScopeCurrent, captureAccountScope, @@ -55,67 +54,6 @@ const getSkippedConceptMessage = ({ name, reason }: ImageRecallSkip, t: TFunctio } }; -/** - * Open the workflow editor before requesting replacement with the image's embedded graph; false means no editor - * can consume it. - */ -export const executeLoadImageWorkflow = async ({ - image, - isProjectActive, - notifications, - openWorkflowEditor, - t, -}: { - image: GalleryImage; - /** Re-checked after the fetch: a store-resident request must not land on another project's editor. */ - isProjectActive: () => boolean; - notifications: Pick; - openWorkflowEditor: () => boolean; - t: TFunction; -}): Promise => { - const owner = captureAccountScope(); - - try { - const { workflow } = await galleryImages.workflow(image.imageName, owner.signal); - - assertAccountScopeCurrent(owner); - - if (!workflow) { - notifications.add({ kind: 'info', title: t('widgets.gallery.itemActions.loadWorkflow.missing') }); - return; - } - - if (!isProjectActive()) { - return; - } - - if (!openWorkflowEditor()) { - throw new Error(t('widgets.gallery.itemActions.loadWorkflow.editorUnavailable')); - } - - const raw: unknown = JSON.parse(workflow); - const name = - typeof raw === 'object' && raw !== null && typeof (raw as { name?: unknown }).name === 'string' - ? (raw as { name: string }).name - : ''; - - requestWorkflowDocumentLoad( - raw, - t('widgets.gallery.itemActions.loadWorkflow.loadedLabel', { name: name || image.imageName }) - ); - } catch (error) { - if (!isAccountScopeCurrent(owner)) { - return; - } - - notifications.add({ - kind: 'error', - message: toErrorMessage(error), - title: t('widgets.gallery.itemActions.loadWorkflow.failed'), - }); - } -}; - const imageMetadataRequests = new Map }>(); registerAccountOwnedResource({ diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/executeLoadVideoWorkflow.ts b/invokeai/frontend/webv2/src/workbench/image-actions/executeLoadMediaWorkflow.ts similarity index 58% rename from invokeai/frontend/webv2/src/workbench/image-actions/executeLoadVideoWorkflow.ts rename to invokeai/frontend/webv2/src/workbench/image-actions/executeLoadMediaWorkflow.ts index fce899de572..c6dd3746697 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/executeLoadVideoWorkflow.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/executeLoadMediaWorkflow.ts @@ -1,8 +1,7 @@ -import type { GalleryVideoItem } from '@features/gallery'; import type { WorkbenchCommands } from '@workbench/workbenchStore'; import type { TFunction } from 'i18next'; -import { galleryVideos } from '@features/gallery'; +import { galleryImages, galleryVideos } from '@features/gallery'; import { requestWorkflowDocumentLoad } from '@features/workflow/react'; import { assertAccountScopeCurrent, @@ -10,35 +9,45 @@ import { isAccountScopeCurrent, } from '@platform/state/accountLifecycle'; +export interface WorkflowMediaRef { + kind: 'image' | 'video'; + /** The image or video name the backend stores the embedded workflow under. */ + name: string; +} + const toErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const fetchEmbeddedWorkflow = (media: WorkflowMediaRef, signal: AbortSignal) => + media.kind === 'image' ? galleryImages.workflow(media.name, signal) : galleryVideos.workflow(media.name, signal); + /** - * The video counterpart of `executeLoadImageWorkflow`: open the workflow editor before requesting replacement with - * the workflow embedded in the video; false from `openWorkflowEditor` means no editor can consume it. + * Open the workflow editor before requesting replacement with the workflow embedded in an image or video; false + * from `openWorkflowEditor` means no editor can consume it. */ -export const executeLoadVideoWorkflow = async ({ +export const executeLoadMediaWorkflow = async ({ isProjectActive, - item, + media, notifications, openWorkflowEditor, t, }: { /** Re-checked after the fetch: a store-resident request must not land on another project's editor. */ isProjectActive: () => boolean; - item: GalleryVideoItem; + media: WorkflowMediaRef; notifications: Pick; openWorkflowEditor: () => boolean; t: TFunction; }): Promise => { const owner = captureAccountScope(); + const suffix = media.kind === 'video' ? 'Video' : ''; try { - const { workflow } = await galleryVideos.workflow(item.name, owner.signal); + const { workflow } = await fetchEmbeddedWorkflow(media, owner.signal); assertAccountScopeCurrent(owner); if (!workflow) { - notifications.add({ kind: 'info', title: t('widgets.gallery.itemActions.loadWorkflow.missingVideo') }); + notifications.add({ kind: 'info', title: t(`widgets.gallery.itemActions.loadWorkflow.missing${suffix}`) }); return; } @@ -58,7 +67,7 @@ export const executeLoadVideoWorkflow = async ({ requestWorkflowDocumentLoad( raw, - t('widgets.gallery.itemActions.loadWorkflow.loadedLabelVideo', { name: name || item.name }) + t(`widgets.gallery.itemActions.loadWorkflow.loadedLabel${suffix}`, { name: name || media.name }) ); } catch (error) { if (!isAccountScopeCurrent(owner)) { @@ -68,7 +77,7 @@ export const executeLoadVideoWorkflow = async ({ notifications.add({ kind: 'error', message: toErrorMessage(error), - title: t('widgets.gallery.itemActions.loadWorkflow.failedVideo'), + title: t(`widgets.gallery.itemActions.loadWorkflow.failed${suffix}`), }); } }; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/selectedImage.ts b/invokeai/frontend/webv2/src/workbench/image-actions/selectedImage.ts index f833c704d66..8c78885654d 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/selectedImage.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/selectedImage.ts @@ -1,10 +1,14 @@ -import type { GalleryImage } from '@features/gallery'; +import type { GalleryImage, GalleryItem } from '@features/gallery'; import type { Project } from '@workbench/projectContracts'; -import { getSelectedGalleryImageFromValues } from '@features/gallery/contracts'; +import { getSelectedGalleryImageFromValues, getSelectedGalleryItemFromValues } from '@features/gallery/contracts'; import { getProjectWidgetValues } from '@workbench/widgetState'; export { getSelectedGalleryImageFromValues } from '@features/gallery/contracts'; export const getSelectedGalleryImage = (project: Project): GalleryImage | null => getSelectedGalleryImageFromValues(getProjectWidgetValues(project, 'gallery')); + +/** The selected image or video, for commands that apply to either. */ +export const getSelectedGalleryItem = (project: Project): GalleryItem | null => + getSelectedGalleryItemFromValues(getProjectWidgetValues(project, 'gallery')); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts index 196e53fcff5..e70d3d5b139 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts @@ -63,8 +63,8 @@ import type { RequestDeletionConfirmation } from './useDeletionConfirmation'; import { appendReferenceImage } from './appendReferenceImage'; import { recordCanvasImportError } from './canvasImportError'; -import { executeImageRecall, executeLoadImageWorkflow, getCurrentGenerateValues } from './executeImageRecall'; -import { executeLoadVideoWorkflow } from './executeLoadVideoWorkflow'; +import { executeImageRecall, getCurrentGenerateValues } from './executeImageRecall'; +import { executeLoadMediaWorkflow } from './executeLoadMediaWorkflow'; import { appendReferenceVideo, executeVideoRecall, @@ -932,9 +932,9 @@ export const useImageActions = ({ } }, loadImageWorkflow: (image) => - executeLoadImageWorkflow({ - image, + executeLoadMediaWorkflow({ isProjectActive: () => !projectId || queries.isActiveProject(projectId), + media: { kind: 'image', name: image.imageName }, notifications, openWorkflowEditor: () => openWorkbenchWidget('workflow', { preferredRegions: ['center'], requireCenterView: true }).ok, @@ -970,9 +970,9 @@ export const useImageActions = ({ } }, loadVideoWorkflow: (item) => - executeLoadVideoWorkflow({ + executeLoadMediaWorkflow({ isProjectActive: () => !projectId || queries.isActiveProject(projectId), - item, + media: { kind: 'video', name: item.name }, notifications, openWorkflowEditor: () => openWorkbenchWidget('workflow', { preferredRegions: ['center'], requireCenterView: true }).ok, From 4e821af87670b3003fd7b6bcf742940437f698e5 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 20:23:14 -0600 Subject: [PATCH 07/35] fix(workflow): arrange unconnected nodes into a grid Each connected component is laid out on its own and components fill wrapped rows, so Arrange nodes no longer stacks isolated nodes into one tall column. --- .../workflow/core/graphLayout.test.ts | 63 +++++++++- .../src/features/workflow/core/graphLayout.ts | 116 ++++++++++++++++-- 2 files changed, 170 insertions(+), 9 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.test.ts index 53c0f095020..4666511df6c 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.test.ts @@ -83,14 +83,17 @@ describe('getSizedLayeredPositions', () => { }); it('orders a layer by the mean row of its parents so edges do not cross', () => { - // p -> y and q -> x, listed in the "wrong" order: x and y swap rows to follow their parents. + // r -> p, r -> q, p -> y, q -> x: x and y are listed in the "wrong" order and swap rows to follow their parents. const nodes = [ + { height: 10, id: 'r', width: 10 }, { height: 10, id: 'p', width: 10 }, { height: 10, id: 'q', width: 10 }, { height: 10, id: 'x', width: 10 }, { height: 10, id: 'y', width: 10 }, ]; const edges = [ + { sourceNodeId: 'r', targetNodeId: 'p' }, + { sourceNodeId: 'r', targetNodeId: 'q' }, { sourceNodeId: 'p', targetNodeId: 'y' }, { sourceNodeId: 'q', targetNodeId: 'x' }, ]; @@ -100,4 +103,62 @@ describe('getSizedLayeredPositions', () => { expect(positions.y!.y).toBeLessThan(positions.x!.y); expect(positions.x!.x).toBe(positions.y!.x); }); + + it('packs unconnected nodes into a near-square grid instead of one column', () => { + const nodes = Array.from({ length: 9 }, (_, index) => ({ height: 50, id: `n${index}`, width: 100 })); + + const positions = getSizedLayeredPositions(nodes, [], { layerGap: 10, rowGap: 10 }); + const xs = new Set(Object.values(positions).map((position) => position.x)); + const ys = new Set(Object.values(positions).map((position) => position.y)); + + expect(xs).toEqual(new Set([0, 110, 220])); + expect(ys).toEqual(new Set([0, 60, 120])); + expect(positions.n0).toEqual({ x: 0, y: 0 }); + expect(positions.n3).toEqual({ x: 0, y: 60 }); + }); + + it('lays out each connected component separately and shares a row while components fit', () => { + const nodes = [ + { height: 50, id: 'a', width: 100 }, + { height: 50, id: 'b', width: 100 }, + { height: 50, id: 'lone', width: 100 }, + ]; + const edges = [{ sourceNodeId: 'a', targetNodeId: 'b' }]; + + expect(getSizedLayeredPositions(nodes, edges, { layerGap: 10, rowGap: 10 })).toEqual({ + a: { x: 0, y: 0 }, + b: { x: 110, y: 0 }, + lone: { x: 220, y: 0 }, + }); + }); + + it('gives a component wider than the row budget its own row', () => { + const nodes = [ + { height: 50, id: 'wide', width: 500 }, + { height: 50, id: 'p', width: 100 }, + { height: 50, id: 'q', width: 100 }, + ]; + + expect(getSizedLayeredPositions(nodes, [], { layerGap: 10, rowGap: 10 })).toEqual({ + wide: { x: 0, y: 0 }, + p: { x: 0, y: 60 }, + q: { x: 110, y: 60 }, + }); + }); + + it('ignores edges that reference nodes outside the arranged set', () => { + const nodes = [ + { height: 50, id: 'a', width: 100 }, + { height: 50, id: 'b', width: 100 }, + ]; + const edges = [ + { sourceNodeId: 'outside', targetNodeId: 'b' }, + { sourceNodeId: 'a', targetNodeId: 'b' }, + ]; + + expect(getSizedLayeredPositions(nodes, edges, { layerGap: 10, rowGap: 10 })).toEqual({ + a: { x: 0, y: 0 }, + b: { x: 110, y: 0 }, + }); + }); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts index 1ffe51f8f42..8ab884f910a 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts @@ -96,16 +96,64 @@ export interface SizedLayoutOptions { const DEFAULT_LAYER_GAP = 80; const DEFAULT_ROW_GAP = 40; +/** Nodes grouped by undirected connectivity, each group in node-array order; groups ordered by first member. */ +const getConnectedComponents = (nodes: LayoutSizedNode[], edges: LayoutGraphEdge[]): LayoutSizedNode[][] => { + const neighbours = new Map(); + + for (const edge of edges) { + neighbours.set(edge.sourceNodeId, [...(neighbours.get(edge.sourceNodeId) ?? []), edge.targetNodeId]); + neighbours.set(edge.targetNodeId, [...(neighbours.get(edge.targetNodeId) ?? []), edge.sourceNodeId]); + } + + const componentOf = new Map(); + const components: LayoutSizedNode[][] = []; + + for (const node of nodes) { + if (componentOf.has(node.id)) { + continue; + } + + const index = components.length; + const stack = [node.id]; + + componentOf.set(node.id, index); + + while (stack.length > 0) { + for (const next of neighbours.get(stack.pop()!) ?? []) { + if (!componentOf.has(next)) { + componentOf.set(next, index); + stack.push(next); + } + } + } + + components.push([]); + } + + for (const node of nodes) { + components[componentOf.get(node.id)!]!.push(node); + } + + return components; +}; + +interface ComponentLayout { + height: number; + positions: Record; + width: number; +} + /** - * Layered layout that respects each node's size: layers are columns as wide as their widest node, nodes stack top - * to bottom within a layer, and a layer's rows follow the mean row of their parents in the layer before (ties keep - * node-array order) so edges tend not to cross. + * Layered layout of one connected graph at the origin: layers are columns as wide as their widest node, nodes stack + * top to bottom within a layer, and a layer's rows follow the mean row of their parents in the layer before (ties + * keep node-array order) so edges tend not to cross. */ -export const getSizedLayeredPositions = ( +const layoutComponent = ( nodes: LayoutSizedNode[], edges: LayoutGraphEdge[], - { layerGap = DEFAULT_LAYER_GAP, origin = { x: 0, y: 0 }, rowGap = DEFAULT_ROW_GAP }: SizedLayoutOptions = {} -): Record => { + layerGap: number, + rowGap: number +): ComponentLayout => { const depths = getNodeDepths(nodes, edges); const layers = new Map(); @@ -123,7 +171,8 @@ export const getSizedLayeredPositions = ( const positions: Record = {}; const rowOf = new Map(); - let x = origin.x; + let x = 0; + let height = 0; for (const depth of [...layers.keys()].sort((a, b) => a - b)) { const members = layers.get(depth)!; @@ -138,12 +187,13 @@ export const getSizedLayeredPositions = ( return { barycenter, index, node }; }) .sort((a, b) => a.barycenter - b.barycenter || a.index - b.index); - let y = origin.y; + let y = 0; let width = 0; ordered.forEach(({ node }, row) => { positions[node.id] = { x, y }; rowOf.set(node.id, row); + height = Math.max(height, y + node.height); y += node.height + rowGap; width = Math.max(width, node.width); }); @@ -151,5 +201,55 @@ export const getSizedLayeredPositions = ( x += width + layerGap; } + return { height, positions, width: Math.max(0, x - layerGap) }; +}; + +/** + * Layered layout that respects each node's size. Each connected component is laid out on its own (see + * `layoutComponent`); components then fill rows left to right in node-array order, wrapping at a width that keeps + * the arrangement roughly square, so unconnected nodes form a grid rather than one tall column. A single connected + * graph is simply its layered layout at the origin. + */ +export const getSizedLayeredPositions = ( + nodes: LayoutSizedNode[], + edges: LayoutGraphEdge[], + { layerGap = DEFAULT_LAYER_GAP, origin = { x: 0, y: 0 }, rowGap = DEFAULT_ROW_GAP }: SizedLayoutOptions = {} +): Record => { + const nodeIds = new Set(nodes.map((node) => node.id)); + const knownEdges = edges.filter((edge) => nodeIds.has(edge.sourceNodeId) && nodeIds.has(edge.targetNodeId)); + const components = getConnectedComponents(nodes, knownEdges).map((members) => { + const memberIds = new Set(members.map((node) => node.id)); + + return layoutComponent( + members, + knownEdges.filter((edge) => memberIds.has(edge.sourceNodeId)), + layerGap, + rowGap + ); + }); + const gap = Math.max(layerGap, rowGap); + const perRow = Math.ceil(Math.sqrt(components.length)); + const meanWidth = components.reduce((sum, component) => sum + component.width, 0) / components.length; + const rowBudget = Math.max(...components.map((component) => component.width), perRow * (meanWidth + gap) - gap); + const positions: Record = {}; + let x = 0; + let y = 0; + let rowHeight = 0; + + for (const component of components) { + if (x > 0 && x + component.width > rowBudget) { + x = 0; + y += rowHeight + gap; + rowHeight = 0; + } + + for (const [id, position] of Object.entries(component.positions)) { + positions[id] = { x: origin.x + x + position.x, y: origin.y + y + position.y }; + } + + x += component.width + gap; + rowHeight = Math.max(rowHeight, component.height); + } + return positions; }; From 4c5a27b872066fb6ee0f3cc375b3e9b22cbc0067 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 20:33:56 -0600 Subject: [PATCH 08/35] fix(workflow): honor authored form bounds on invoke and keep inverted bounds editable Invoke readiness and builder markers validate against the form-narrowed template, an inverted min/max pair is kept as authored and ignored with a warning, and integer fields reject fractional steps. Scoped Clear Finished deletes terminal root items only, and retry/cancel-batch are account-fenced. --- .../frontend/webv2/public/locales/en.json | 1 + .../queue/data/invocationCacheQueries.ts | 5 +- .../src/features/queue/data/serverApi.test.ts | 4 +- .../src/features/queue/data/serverApi.ts | 30 ++++-- .../src/features/queue/ui/QueueHeaderMenu.tsx | 4 +- .../features/workflow/core/buildGraph.test.ts | 44 ++++++++ .../src/features/workflow/core/buildGraph.ts | 3 +- .../workflow/core/formFieldSettings.test.ts | 16 ++- .../workflow/core/formFieldSettings.ts | 53 ++++++++-- .../src/features/workflow/core/graphLayout.ts | 21 +++- .../workflow/ui/editor/WorkflowEditorView.tsx | 6 +- .../workflow/ui/linear/FormBuilderTab.tsx | 15 ++- .../workflow/ui/linear/NodeFieldControl.tsx | 13 ++- .../ui/linear/NodeFieldSettingsPopover.tsx | 12 ++- .../WorkflowLinearPanel.browser.test.tsx | 100 +++++++++++++++++- 15 files changed, 289 insertions(+), 38 deletions(-) diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 6737ad7fff4..9b91a820509 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -3351,6 +3351,7 @@ "optionValue": "Option {{index}} value", "optionValuePlaceholder": "Value", "removeOption": "Remove option {{index}}", + "invertedBounds": "The minimum is above the maximum; the form ignores both until one is corrected.", "sliderNeedsBounds": "A slider needs a minimum and a maximum; until both are known the form shows an input.", "step": "Step", "containerEmpty": "Empty container — drag cards here", diff --git a/invokeai/frontend/webv2/src/features/queue/data/invocationCacheQueries.ts b/invokeai/frontend/webv2/src/features/queue/data/invocationCacheQueries.ts index 80ed3892764..8f4a9ea1e7f 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/invocationCacheQueries.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/invocationCacheQueries.ts @@ -25,7 +25,10 @@ const mapInvocationCacheStatus = (dto: InvocationCacheStatusDTO): InvocationCach export const invocationCacheKey = [...queueKeys.all, 'invocation-cache'] as const; -/** Statistics have no socket stream; they refresh on mount and after each cache command. */ +/** + * Statistics have no socket stream. The key sits under the queue's so every queue refresh (status changes, enqueue, + * cancel) also refetches them, which is when hit and miss counts move; cache commands invalidate them directly. + */ export const invocationCacheStatusOptions = () => (() => { const owner = captureAccountScope(); diff --git a/invokeai/frontend/webv2/src/features/queue/data/serverApi.test.ts b/invokeai/frontend/webv2/src/features/queue/data/serverApi.test.ts index 3b8f5049fc9..0d012c7ff51 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/serverApi.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/serverApi.test.ts @@ -191,7 +191,9 @@ describe('clearFinishedQueueItems', () => { } if (url.endsWith('items_by_ids')) { return Promise.resolve([ - { item_id: 5, status: 'in_progress' }, + { item_id: 7, parent_item_id: 5, root_item_id: 5, status: 'completed' }, + { item_id: 6, parent_item_id: 3, root_item_id: 3, status: 'completed' }, + { item_id: 5, status: 'waiting' }, { item_id: 4, status: 'pending' }, { item_id: 3, status: 'completed' }, { item_id: 2, status: 'canceled' }, diff --git a/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts b/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts index 0c8bfdd593a..481ccd6c4f5 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts @@ -152,7 +152,9 @@ const isTerminalStatus = (status: QueueServerItemDTO['status']): boolean => /** * Removes finished history and keeps pending, waiting and running items. Without a scope the server prunes in one - * sweep; a scoped prune deletes the scope's terminal items individually, as the clear commands do. + * sweep; a scoped prune deletes the scope's terminal root items individually, as the clear commands do. Deleting a + * root removes its whole workflow-call chain, so children are never addressed on their own: a second delete would + * miss, and a finished child must not take a still-waiting parent with it. */ export const clearFinishedQueueItems = async (scope: QueueQueryScope = {}): Promise => { const owner = captureAccountScope(); @@ -170,7 +172,9 @@ export const clearFinishedQueueItems = async (scope: QueueQueryScope = {}): Prom assertAccountScopeCurrent(owner); await deleteQueueItems( - items.filter((item) => isTerminalStatus(item.status)).map((item) => item.item_id), + items + .filter((item) => isTerminalStatus(item.status) && (item.parent_item_id ?? null) === null) + .map((item) => item.item_id), owner.signal ); assertAccountScopeCurrent(owner); @@ -229,18 +233,32 @@ export const cancelScopedQueueItems = async ( assertAccountScopeCurrent(owner); }; -export const cancelByBatchIds = (batchIds: string[]): Promise => - apiFetchJson(buildQueueUrl('cancel_by_batch_ids'), { +export const cancelByBatchIds = async (batchIds: string[]): Promise => { + const owner = captureAccountScope(); + const result = await apiFetchJson(buildQueueUrl('cancel_by_batch_ids'), { body: JSON.stringify({ batch_ids: batchIds }), method: 'PUT', + signal: owner.signal, }); -export const retryItemsById = (itemIds: number[]): Promise => - apiFetchJson(buildQueueUrl('retry_items_by_id'), { + assertAccountScopeCurrent(owner); + + return result; +}; + +export const retryItemsById = async (itemIds: number[]): Promise => { + const owner = captureAccountScope(); + const result = await apiFetchJson(buildQueueUrl('retry_items_by_id'), { body: JSON.stringify(itemIds), method: 'PUT', + signal: owner.signal, }); + assertAccountScopeCurrent(owner); + + return result; +}; + const INVOCATION_CACHE_URL = '/api/v1/app/invocation_cache'; /** Admin-only: the invocation (node output) cache, distinct from the model cache. */ diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueHeaderMenu.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueHeaderMenu.tsx index 748a078cb9a..b5a5cb43d7e 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueHeaderMenu.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueHeaderMenu.tsx @@ -35,13 +35,15 @@ export const QueueHeaderMenu = () => { const clearFinished = useCallback(async () => { try { await queueCommands.clearFinishedItems(scope); - await refreshQueue(); notify.success(t('widgets.queue.finishedItemsCleared')); } catch (error) { notify.error( t('widgets.queue.failedToClearItems'), getApiErrorMessage(error, t('widgets.queue.couldNotClearFinishedItems')) ); + } finally { + // A scoped clear deletes item by item, so a failure part-way has still changed the list. + await refreshQueue(); } }, [notify, scope, t]); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts index cde68b1cccb..fb616cb37d9 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts @@ -250,6 +250,50 @@ describe('getProjectGraphReadiness', () => { ).toEqual({ batch: null, canInvoke: false, reasons: ['"optional-constrained" has invalid input "count".'] }); }); + it('validates a form-exposed field against its authored bounds', () => { + const constrainedTemplate = template('form-bounded', { + count: input('count', { + maximum: 100, + minimum: 0, + required: false, + type: { batch: false, cardinality: 'SINGLE', name: 'IntegerField' }, + }), + }); + const snapshot = { error: null, status: 'loaded', templates: { 'form-bounded': constrainedTemplate } } as const; + const node = buildInvocationNode(constrainedTemplate, { x: 0, y: 0 }); + let doc = createProjectGraph('form-bounds'); + + doc = projectGraphReducer(doc, { node, type: 'addNode' }); + doc = projectGraphReducer(doc, { fieldName: 'count', nodeId: node.id, type: 'setFieldValue', value: 50 }); + doc = projectGraphReducer(doc, { fieldIdentifier: { fieldName: 'count', nodeId: node.id }, type: 'exposeField' }); + + const element = Object.values(doc.form.elements).find((candidate) => candidate.type === 'node-field'); + + expect(element).toBeDefined(); + expect(getProjectGraphReadiness(doc, snapshot).canInvoke).toBe(true); + + doc = projectGraphReducer(doc, { + elementId: element!.id, + settings: { component: 'slider', max: 20, min: 0, type: 'integer-field-config' }, + type: 'setNodeFieldSettings', + }); + + expect(getProjectGraphReadiness(doc, snapshot)).toEqual({ + batch: null, + canInvoke: false, + reasons: ['"form-bounded" has invalid input "count".'], + }); + + // An inverted pair is ignored rather than blocking the invoke. + doc = projectGraphReducer(doc, { + elementId: element!.id, + settings: { component: 'slider', max: 20, min: 30, type: 'integer-field-config' }, + type: 'setNodeFieldSettings', + }); + + expect(getProjectGraphReadiness(doc, snapshot).canInvoke).toBe(true); + }); + it('treats only explicitly externally satisfied connection inputs as ready', () => { const connectionTemplate = template('connection-sink', { image: input('image', { diff --git a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts index f99edcc98de..238efbb4b3d 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts @@ -30,6 +30,7 @@ import { validateForLoopGraph, type ForLoopValidationReason, } from './forLoops'; +import { getFormFieldTemplate } from './formFieldSettings'; import { isInvocationNode } from './types'; import { hasAnyCycle } from './validation'; @@ -187,7 +188,7 @@ export const getProjectGraphReadiness = ( const invalidReason = getWorkflowFieldInvalidReason({ isConnected: false, - template: inputTemplate, + template: getFormFieldTemplate(document.form, node.id, inputTemplate.name, inputTemplate), value: node.data.inputs[inputTemplate.name]?.value, }); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts index ed5108ba5a6..9745e5c1208 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts @@ -81,10 +81,10 @@ describe('getNodeFieldFormSettings', () => { expect(getNodeFieldFormSettings(undefined, template('IntegerField'))).toBeNull(); }); - it('keeps the control but drops bounds that cannot form a range', () => { + it('keeps inverted bounds as authored and drops a non-positive step', () => { expect( getNodeFieldFormSettings({ component: 'slider', max: 1, min: 10, step: -2 }, template('FloatField')) - ).toEqual({ component: 'slider', kind: 'number' }); + ).toEqual({ component: 'slider', kind: 'number', max: 1, min: 10 }); }); it('falls back to the plain control for an unknown component name', () => { @@ -189,6 +189,18 @@ describe('applyNodeFieldFormSettings', () => { expect(applyNodeFieldFormSettings(free, { component: 'slider', kind: 'number', step: 0.25 }).multipleOf).toBe(0.25); expect(applyNodeFieldFormSettings(eights, { component: 'slider', kind: 'number', step: 64 }).multipleOf).toBe(64); expect(applyNodeFieldFormSettings(eights, { component: 'slider', kind: 'number', step: 12 })).toBe(eights); + // An integer field cannot take a fractional step: the slider would commit values the node rejects. + const whole = template('IntegerField'); + expect(applyNodeFieldFormSettings(whole, { component: 'slider', kind: 'number', step: 0.5 })).toBe(whole); + }); + + it('ignores an inverted pair of bounds but still applies the step', () => { + const base = template('IntegerField', { maximum: 100, minimum: 0 }); + + expect(applyNodeFieldFormSettings(base, { component: 'slider', kind: 'number', max: 5, min: 10 })).toBe(base); + expect( + applyNodeFieldFormSettings(base, { component: 'slider', kind: 'number', max: 5, min: 10, step: 5 }) + ).toMatchObject({ maximum: 100, minimum: 0, multipleOf: 5 }); }); it('returns the template itself for string settings and unchanged bounds', () => { diff --git a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts index d0c696efdac..39b477a9a56 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts @@ -1,4 +1,4 @@ -import type { FieldInputTemplate } from './types'; +import type { FieldInputTemplate, WorkflowForm } from './types'; /** * Per-element control settings an author gives a form field: which control renders it and, for numbers, the @@ -119,17 +119,25 @@ export const getNodeFieldFormSettings = ( const min = finiteOrUndefined(settings.min); const max = finiteOrUndefined(settings.max); const step = finiteOrUndefined(settings.step); - const boundsUsable = min === undefined || max === undefined || min <= max; + // Bounds are kept as authored, even inverted, so the builder edits what is stored; `applyNodeFieldFormSettings` + // decides what the form honours. return { component, kind: 'number', - ...(boundsUsable && min !== undefined ? { min } : {}), - ...(boundsUsable && max !== undefined ? { max } : {}), + ...(min !== undefined ? { min } : {}), + ...(max !== undefined ? { max } : {}), ...(step !== undefined && step > 0 ? { step } : {}), }; }; +/** Authored bounds that cannot form a range; the form ignores both until the author fixes one. */ +export const hasInvertedNodeFieldBounds = (settings: NodeFieldFormSettings | null): boolean => + settings?.kind === 'number' && + settings.min !== undefined && + settings.max !== undefined && + settings.min > settings.max; + /** * The settings bag to store for `settings` on a field of `template`, in the legacy shape: tagged with the field * kind, and carrying `showShuffle` on number fields as the v6 editor does. @@ -166,9 +174,10 @@ const isMultipleOf = (step: number, base: number): boolean => { }; /** - * The template narrowed by the authored number settings: a bound applies only inside the template's own range, and - * a step only when the template has none or the step keeps every value a multiple of the template's. Validation, - * shuffle and the control all read this template, so the form can never offer a value the node would reject. + * The template narrowed by the authored number settings: a bound applies only inside the template's own range (and + * an inverted pair not at all), and a step only when the template has none or the step keeps every value a multiple + * of the template's. Validation, shuffle and the control all read this template, so the form can never offer a + * value the node would reject. */ export const applyNodeFieldFormSettings = ( template: FieldInputTemplate, @@ -178,12 +187,13 @@ export const applyNodeFieldFormSettings = ( return template; } + const inverted = hasInvertedNodeFieldBounds(settings); let next = template; const patch = (fields: Partial) => { next = next === template ? { ...template, ...fields } : { ...next, ...fields }; }; - if (settings.min !== undefined) { + if (settings.min !== undefined && !inverted) { if (template.minimum !== null) { if (settings.min > template.minimum) { patch({ minimum: settings.min }); @@ -193,7 +203,7 @@ export const applyNodeFieldFormSettings = ( } } - if (settings.max !== undefined) { + if (settings.max !== undefined && !inverted) { if (template.maximum !== null) { if (settings.max < template.maximum) { patch({ maximum: settings.max }); @@ -205,6 +215,7 @@ export const applyNodeFieldFormSettings = ( if ( settings.step !== undefined && + (template.type.name !== 'IntegerField' || Number.isInteger(settings.step)) && (template.multipleOf === null || template.multipleOf <= 0 || isMultipleOf(settings.step, template.multipleOf)) ) { patch({ multipleOf: settings.step }); @@ -235,3 +246,27 @@ export const getNodeFieldSliderRange = (template: FieldInputTemplate): NodeField return { max, min, step }; }; + +/** + * The template a node field is validated against: the invocation template narrowed by the settings of the form + * element exposing it, or the template itself when the field is not on the form. Invoke readiness and the builder's + * invalid markers read this so they agree with the control. + */ +export const getFormFieldTemplate = ( + form: WorkflowForm, + nodeId: string, + fieldName: string, + template: FieldInputTemplate +): FieldInputTemplate => { + for (const element of Object.values(form.elements)) { + if ( + element.type === 'node-field' && + element.data.fieldIdentifier.nodeId === nodeId && + element.data.fieldIdentifier.fieldName === fieldName + ) { + return applyNodeFieldFormSettings(template, getNodeFieldFormSettings(element.data.settings, template)); + } + } + + return template; +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts index 8ab884f910a..7ef7312d8b0 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts @@ -9,6 +9,17 @@ export interface LayoutGraphEdge { targetNodeId: string; } +/** Append to a map of lists, creating the list on first use. */ +const pushTo = (lists: Map, key: K, value: V): void => { + const list = lists.get(key); + + if (list) { + list.push(value); + } else { + lists.set(key, [value]); + } +}; + const LAYER_WIDTH = 300; const ROW_HEIGHT = 100; @@ -18,7 +29,7 @@ export const getNodeDepths = (nodes: LayoutGraphNode[], edges: LayoutGraphEdge[] const incoming = new Map(); for (const edge of edges) { - incoming.set(edge.targetNodeId, [...(incoming.get(edge.targetNodeId) ?? []), edge.sourceNodeId]); + pushTo(incoming, edge.targetNodeId, edge.sourceNodeId); } const resolve = (nodeId: string, seen: Set): number => { @@ -101,8 +112,8 @@ const getConnectedComponents = (nodes: LayoutSizedNode[], edges: LayoutGraphEdge const neighbours = new Map(); for (const edge of edges) { - neighbours.set(edge.sourceNodeId, [...(neighbours.get(edge.sourceNodeId) ?? []), edge.targetNodeId]); - neighbours.set(edge.targetNodeId, [...(neighbours.get(edge.targetNodeId) ?? []), edge.sourceNodeId]); + pushTo(neighbours, edge.sourceNodeId, edge.targetNodeId); + pushTo(neighbours, edge.targetNodeId, edge.sourceNodeId); } const componentOf = new Map(); @@ -160,13 +171,13 @@ const layoutComponent = ( for (const node of nodes) { const depth = depths.get(node.id) ?? 0; - layers.set(depth, [...(layers.get(depth) ?? []), node]); + pushTo(layers, depth, node); } const parents = new Map(); for (const edge of edges) { - parents.set(edge.targetNodeId, [...(parents.get(edge.targetNodeId) ?? []), edge.sourceNodeId]); + pushTo(parents, edge.targetNodeId, edge.sourceNodeId); } const positions: Record = {}; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/WorkflowEditorView.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/editor/WorkflowEditorView.tsx index 66c1901c396..e68e73ef35e 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/WorkflowEditorView.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/WorkflowEditorView.tsx @@ -70,9 +70,6 @@ import { buildDuplicateElements, buildPasteElements, copyNodesToClipboard, useHa import { ConnectorFlowNode } from './ConnectorFlowNode'; import { CurrentImageFlowNode } from './CurrentImageFlowNode'; import { EditorToolbar, type EditorTool } from './EditorToolbar'; - -// A node xyflow has not measured yet (a large graph renders only what is visible) counts at a typical node size. -const UNMEASURED_ARRANGE_SIZE = { height: 160, width: 288 }; import { toFlowEdges, toFlowNodes, @@ -104,6 +101,9 @@ import { WORKFLOW_HOTKEYS } from './workflowHotkeys'; import { WorkflowSelectionRequestRuntime } from './WorkflowSelectionRequestRuntime'; import { getWorkflowViewport, getWorkflowViewportKey, setWorkflowViewport } from './workflowViewportStore'; +// A node xyflow has not measured yet (a large graph renders only what is visible) counts at a typical node size. +const UNMEASURED_ARRANGE_SIZE = { height: 160, width: 288 }; + const nodeTypes: NodeTypes = { connector: ConnectorFlowNode, current_image: CurrentImageFlowNode, diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/FormBuilderTab.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/FormBuilderTab.tsx index a1a05ae2408..984c5e0828e 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/FormBuilderTab.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/FormBuilderTab.tsx @@ -34,7 +34,9 @@ import { getWorkflowNodeChromeProps } from '@features/workflow/ui/nodeChrome'; import { useProjectGraphCommands } from '@features/workflow/ui/useProjectGraphCommands'; import { useWorkflowHostCommands } from '@features/workflow/ui/WorkflowUiContext'; import { + applyNodeFieldFormSettings, getFormChildren, + getNodeFieldFormSettings, getResolvedWorkflowEdges, getWorkflowFieldInvalidReason, isShuffleableField, @@ -596,7 +598,18 @@ export const getInvalidNodeFieldElementIds = ( const isConnected = connectedInputKeys.has(`${nodeId}:${fieldName}`); - if (getWorkflowFieldInvalidReason({ isConnected, template, value: node.data.inputs[fieldName]?.value }) !== null) { + const effectiveTemplate = applyNodeFieldFormSettings( + template, + getNodeFieldFormSettings(element.data.settings, template) + ); + + if ( + getWorkflowFieldInvalidReason({ + isConnected, + template: effectiveTemplate, + value: node.data.inputs[fieldName]?.value, + }) !== null + ) { invalidElementIds.add(element.id); } } diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx index e5b4c1cf2d1..75ea5c43a82 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx @@ -39,9 +39,16 @@ export const useNodeFieldBinding = (element: NodeFieldFormElement, projectGraph: ? (invocationNode.data.dynamicInputTemplates?.[fieldName] ?? templates[invocationNode.data.type]?.inputs[fieldName]) : undefined; const instance = invocationNode?.data.inputs[fieldName]; - const formSettings = template ? getNodeFieldFormSettings(element.data.settings, template) : null; - // The authored bounds narrow the template once here; validation, shuffle and the control all read the result. - const effectiveTemplate = template ? applyNodeFieldFormSettings(template, formSettings) : undefined; + // The authored bounds narrow the template once here; validation, shuffle and the control all read the result, + // and the memo keeps its identity stable for the input's own memos while the bag is unchanged. + const { effectiveTemplate, formSettings } = useMemo(() => { + const settings = template ? getNodeFieldFormSettings(element.data.settings, template) : null; + + return { + effectiveTemplate: template ? applyNodeFieldFormSettings(template, settings) : undefined, + formSettings: settings, + }; + }, [element.data.settings, template]); const groupId = invocationNode && isWorkflowBatchNodeType(invocationNode.data.type) ? getWorkflowBatchGroupId(invocationNode) diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx index 6a0acfbf23d..d63bd9b0b3a 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx @@ -12,6 +12,7 @@ import { useFocusedDraft } from '@features/workflow/ui/fields/NumericInput'; import { useProjectGraphCommands } from '@features/workflow/ui/useProjectGraphCommands'; import { getDefaultNodeFieldFormSettings, + hasInvertedNodeFieldBounds, hasNodeFieldFormSettings, serializeNodeFieldFormSettings, } from '@features/workflow/utility'; @@ -102,6 +103,7 @@ const NumberSettingsBody = ({ (step: number | undefined) => onChange({ ...settings, step: step !== undefined && step > 0 ? step : undefined }), [onChange, settings] ); + const inverted = hasInvertedNodeFieldBounds(settings); const needsBounds = settings.component !== 'number-input' && ((settings.min === undefined && templateBounds.min === '') || @@ -136,10 +138,12 @@ const NumberSettingsBody = ({ onChange={onStepChange} /> - - {needsBounds - ? t('widgets.workflow.formBuilder.sliderNeedsBounds') - : t('widgets.workflow.formBuilder.boundsHint')} + + {inverted + ? t('widgets.workflow.formBuilder.invertedBounds') + : needsBounds + ? t('widgets.workflow.formBuilder.sliderNeedsBounds') + : t('widgets.workflow.formBuilder.boundsHint')} ); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx index cc277543caa..70e651af951 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx @@ -6,7 +6,7 @@ import type { ProjectGraphAction } from '@features/workflow/utility'; import { ChakraProvider } from '@chakra-ui/react'; import { useDroppable } from '@dnd-kit/core'; import { WorkflowUiProvider } from '@features/workflow/react'; -import { createProjectGraph, projectGraphReducer } from '@features/workflow/utility'; +import { buildInvocationNode, createProjectGraph, projectGraphReducer } from '@features/workflow/utility'; import { system } from '@theme/system'; import { createInstance } from 'i18next'; import { act, useCallback, useMemo, useState } from 'react'; @@ -730,3 +730,101 @@ describe('Workflow form empty states', () => { expect(overflowingElements()).toEqual([]); }); }); + +describe('Form builder field settings', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + host = document.createElement('div'); + host.style.width = '480px'; + document.body.append(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(() => root.unmount()); + host.remove(); + }); + + const renderBuilder = async () => { + const node = buildInvocationNode(entryTemplate, { x: 0, y: 0 }); + let latest = projectGraphReducer(createProjectGraph('form-settings-test'), { node, type: 'addNode' }); + + latest = projectGraphReducer(latest, { + fieldIdentifier: { fieldName: 'steps', nodeId: node.id }, + type: 'exposeField', + }); + + const Harness = () => { + const [projectGraph, setProjectGraph] = useState(latest); + const editGraph = useCallback((action: ProjectGraphAction) => { + setProjectGraph((current) => { + latest = projectGraphReducer(current, action); + + return latest; + }); + }, []); + const adapter = useMemo( + () => + ({ + commands: { editGraph, redo: () => undefined, undo: () => undefined }, + project: TEST_PROJECT_PORT, + widgets: { open: () => undefined, patchValues: () => undefined }, + }) as unknown as WorkflowUiAdapter, + [editGraph] + ); + + return ( + + + + + + ); + }; + + await act(() => { + root.render( + + + + ); + }); + + const storedSettings = () => { + const element = Object.values(latest.form.elements).find((candidate) => candidate.type === 'node-field'); + + return element?.type === 'node-field' ? element.data.settings : undefined; + }; + + return { storedSettings }; + }; + + it('keeps both authored bounds while they are inverted and warns instead of dropping one', async () => { + const { storedSettings } = await renderBuilder(); + + await userEvent.click( + host.querySelector(`[aria-label="${i18n.t('widgets.workflow.formBuilder.fieldSettings')}"]`)! + ); + + const maxInput = document.querySelector( + `input[aria-label="${i18n.t('widgets.workflow.formBuilder.maximum')}"]` + )!; + const minInput = document.querySelector( + `input[aria-label="${i18n.t('widgets.workflow.formBuilder.minimum')}"]` + )!; + + await userEvent.fill(maxInput, '5'); + expect(storedSettings()).toMatchObject({ max: 5, type: 'integer-field-config' }); + + await userEvent.fill(minInput, '10'); + expect(storedSettings()).toMatchObject({ max: 5, min: 10 }); + expect(document.body.textContent).toContain(i18n.t('widgets.workflow.formBuilder.invertedBounds')); + expect(maxInput.value).toBe('5'); + + await userEvent.fill(minInput, '1'); + expect(storedSettings()).toMatchObject({ max: 5, min: 1 }); + expect(document.body.textContent).not.toContain(i18n.t('widgets.workflow.formBuilder.invertedBounds')); + }); +}); From 429a6cb0c32d0f7230c2f7e5f1b93be13158c149 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 20:43:54 -0600 Subject: [PATCH 09/35] perf(workbench): keep editor boot request count unchanged The media workflow loader lives in the recall module again, and the slider controls join the editor boot chunk; each had become its own startup request. --- .../workbench/hotkeys/firstPartyCommands.ts | 2 +- .../image-actions/executeImageRecall.ts | 74 ++++++++++++++++- .../image-actions/executeLoadMediaWorkflow.ts | 83 ------------------- .../image-actions/useImageActions.ts | 3 +- invokeai/frontend/webv2/vite.config.mts | 4 + 5 files changed, 79 insertions(+), 87 deletions(-) delete mode 100644 invokeai/frontend/webv2/src/workbench/image-actions/executeLoadMediaWorkflow.ts diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts index 7cfcf75fafb..df689976655 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts @@ -138,7 +138,7 @@ export const useRegisterFirstPartyCommands = () => { const loadSelectedItemWorkflow = async () => { const owner = captureAccountScope(); const [{ executeLoadMediaWorkflow }, { getSelectedGalleryItem }] = await Promise.all([ - import('@workbench/image-actions/executeLoadMediaWorkflow'), + import('@workbench/image-actions/executeImageRecall'), import('@workbench/image-actions/selectedImage'), ]); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts index bd9f7d49238..2928c778064 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts @@ -9,7 +9,7 @@ import type { ModelConfig } from '@features/models'; import type { WorkbenchCommands } from '@workbench/workbenchStore'; import type { TFunction } from 'i18next'; -import { galleryImages } from '@features/gallery'; +import { galleryImages, galleryVideos } from '@features/gallery'; import { getDefaultGenerateSettings, getEffectiveReferenceImage, @@ -18,6 +18,7 @@ import { isVaeModelConfig, normalizeGenerateWidgetValues, } from '@features/generation/settings'; +import { requestWorkflowDocumentLoad } from '@features/workflow/react'; import { assertAccountScopeCurrent, captureAccountScope, @@ -65,6 +66,77 @@ registerAccountOwnedResource({ const toErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +export interface WorkflowMediaRef { + kind: 'image' | 'video'; + /** The image or video name the backend stores the embedded workflow under. */ + name: string; +} + +const fetchEmbeddedWorkflow = (media: WorkflowMediaRef, signal: AbortSignal) => + media.kind === 'image' ? galleryImages.workflow(media.name, signal) : galleryVideos.workflow(media.name, signal); + +/** + * Open the workflow editor before requesting replacement with the workflow embedded in an image or video; false + * from `openWorkflowEditor` means no editor can consume it. + */ +export const executeLoadMediaWorkflow = async ({ + isProjectActive, + media, + notifications, + openWorkflowEditor, + t, +}: { + /** Re-checked after the fetch: a store-resident request must not land on another project's editor. */ + isProjectActive: () => boolean; + media: WorkflowMediaRef; + notifications: Pick; + openWorkflowEditor: () => boolean; + t: TFunction; +}): Promise => { + const owner = captureAccountScope(); + const suffix = media.kind === 'video' ? 'Video' : ''; + + try { + const { workflow } = await fetchEmbeddedWorkflow(media, owner.signal); + + assertAccountScopeCurrent(owner); + + if (!workflow) { + notifications.add({ kind: 'info', title: t(`widgets.gallery.itemActions.loadWorkflow.missing${suffix}`) }); + return; + } + + if (!isProjectActive()) { + return; + } + + if (!openWorkflowEditor()) { + throw new Error(t('widgets.gallery.itemActions.loadWorkflow.editorUnavailable')); + } + + const raw: unknown = JSON.parse(workflow); + const name = + typeof raw === 'object' && raw !== null && typeof (raw as { name?: unknown }).name === 'string' + ? (raw as { name: string }).name + : ''; + + requestWorkflowDocumentLoad( + raw, + t(`widgets.gallery.itemActions.loadWorkflow.loadedLabel${suffix}`, { name: name || media.name }) + ); + } catch (error) { + if (!isAccountScopeCurrent(owner)) { + return; + } + + notifications.add({ + kind: 'error', + message: toErrorMessage(error), + title: t(`widgets.gallery.itemActions.loadWorkflow.failed${suffix}`), + }); + } +}; + const loadImageMetadata = (imageName: string, owner: AccountScope): Promise => { const cachedRequest = imageMetadataRequests.get(imageName); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/executeLoadMediaWorkflow.ts b/invokeai/frontend/webv2/src/workbench/image-actions/executeLoadMediaWorkflow.ts deleted file mode 100644 index c6dd3746697..00000000000 --- a/invokeai/frontend/webv2/src/workbench/image-actions/executeLoadMediaWorkflow.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { WorkbenchCommands } from '@workbench/workbenchStore'; -import type { TFunction } from 'i18next'; - -import { galleryImages, galleryVideos } from '@features/gallery'; -import { requestWorkflowDocumentLoad } from '@features/workflow/react'; -import { - assertAccountScopeCurrent, - captureAccountScope, - isAccountScopeCurrent, -} from '@platform/state/accountLifecycle'; - -export interface WorkflowMediaRef { - kind: 'image' | 'video'; - /** The image or video name the backend stores the embedded workflow under. */ - name: string; -} - -const toErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error)); - -const fetchEmbeddedWorkflow = (media: WorkflowMediaRef, signal: AbortSignal) => - media.kind === 'image' ? galleryImages.workflow(media.name, signal) : galleryVideos.workflow(media.name, signal); - -/** - * Open the workflow editor before requesting replacement with the workflow embedded in an image or video; false - * from `openWorkflowEditor` means no editor can consume it. - */ -export const executeLoadMediaWorkflow = async ({ - isProjectActive, - media, - notifications, - openWorkflowEditor, - t, -}: { - /** Re-checked after the fetch: a store-resident request must not land on another project's editor. */ - isProjectActive: () => boolean; - media: WorkflowMediaRef; - notifications: Pick; - openWorkflowEditor: () => boolean; - t: TFunction; -}): Promise => { - const owner = captureAccountScope(); - const suffix = media.kind === 'video' ? 'Video' : ''; - - try { - const { workflow } = await fetchEmbeddedWorkflow(media, owner.signal); - - assertAccountScopeCurrent(owner); - - if (!workflow) { - notifications.add({ kind: 'info', title: t(`widgets.gallery.itemActions.loadWorkflow.missing${suffix}`) }); - return; - } - - if (!isProjectActive()) { - return; - } - - if (!openWorkflowEditor()) { - throw new Error(t('widgets.gallery.itemActions.loadWorkflow.editorUnavailable')); - } - - const raw: unknown = JSON.parse(workflow); - const name = - typeof raw === 'object' && raw !== null && typeof (raw as { name?: unknown }).name === 'string' - ? (raw as { name: string }).name - : ''; - - requestWorkflowDocumentLoad( - raw, - t(`widgets.gallery.itemActions.loadWorkflow.loadedLabel${suffix}`, { name: name || media.name }) - ); - } catch (error) { - if (!isAccountScopeCurrent(owner)) { - return; - } - - notifications.add({ - kind: 'error', - message: toErrorMessage(error), - title: t(`widgets.gallery.itemActions.loadWorkflow.failed${suffix}`), - }); - } -}; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts index e70d3d5b139..7bfbf18a13a 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts @@ -63,8 +63,7 @@ import type { RequestDeletionConfirmation } from './useDeletionConfirmation'; import { appendReferenceImage } from './appendReferenceImage'; import { recordCanvasImportError } from './canvasImportError'; -import { executeImageRecall, getCurrentGenerateValues } from './executeImageRecall'; -import { executeLoadMediaWorkflow } from './executeLoadMediaWorkflow'; +import { executeImageRecall, executeLoadMediaWorkflow, getCurrentGenerateValues } from './executeImageRecall'; import { appendReferenceVideo, executeVideoRecall, diff --git a/invokeai/frontend/webv2/vite.config.mts b/invokeai/frontend/webv2/vite.config.mts index c060f6be154..2c6072eb156 100644 --- a/invokeai/frontend/webv2/vite.config.mts +++ b/invokeai/frontend/webv2/vite.config.mts @@ -53,6 +53,10 @@ const ROUTE_SHARED_MODULES = [ '/workbench/projects/editorSession.ts', '/platform/ui/BrandIcon.tsx', '/platform/ui/Button.tsx', + // The Fonts page, the Generate render section and the lazy workflow number fields share the slider controls; + // alone they cost the editor a boot request, and the editor boot chunk would drag the editor into the Launchpad. + '/platform/ui/Slider.tsx', + '/platform/ui/SliderNumberField.tsx', '/platform/ui/Tooltip.tsx', '/platform/ui/RetryBoundary.tsx', '/platform/ui/PanelHeader.tsx', From 73cb5801bc99e589600fca369910359ab33d1873 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 20:52:48 -0600 Subject: [PATCH 10/35] chore(perf): re-record route byte baselines for the added locale strings and modules Request counts and chunk sets are unchanged; the growth is the new user-facing strings and the form-settings, media-usage and queue-detail modules joining their routes' existing chunks. --- .../performance/architecture-baseline.json | 25 +- .../webv2/performance/browser-baseline.json | 233 ++++++++++-------- 2 files changed, 138 insertions(+), 120 deletions(-) diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index e8a49c4183f..8d2183f5cc8 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -2,15 +2,15 @@ "build": { "launchpad": { "baseline": { - "brotliBytes": 794977, + "brotliBytes": 796613, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 793689, + "gzipBytes": 795480, "imageRawBytes": 44398, - "initialRawBytes": 2042114, - "largestAssetRawBytes": 749029, + "initialRawBytes": 2048139, + "largestAssetRawBytes": 752421, "otherAssetRawBytes": 0, - "ownedRawBytes": 98621, + "ownedRawBytes": 98845, "requestCount": 35, "scriptRequestCount": 19, "sourceOwners": [ @@ -241,6 +241,7 @@ "source:src/platform/ui/SegmentedControl.tsx", "source:src/platform/ui/Select.tsx", "source:src/platform/ui/Slider.tsx", + "source:src/platform/ui/SliderNumberField.tsx", "source:src/platform/ui/StatusWidgetChip.tsx", "source:src/platform/ui/Tabs.tsx", "source:src/platform/ui/ToggleDot.tsx", @@ -346,15 +347,15 @@ }, "editor": { "baseline": { - "brotliBytes": 1171873, + "brotliBytes": 1177491, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 1165072, + "gzipBytes": 1170684, "imageRawBytes": 44398, - "initialRawBytes": 3208701, - "largestAssetRawBytes": 749029, + "initialRawBytes": 3226866, + "largestAssetRawBytes": 752421, "otherAssetRawBytes": 0, - "ownedRawBytes": 150716, + "ownedRawBytes": 151806, "requestCount": 72, "scriptRequestCount": 56, "sourceOwners": [ @@ -696,6 +697,7 @@ "source:src/features/workflow/core/document.ts", "source:src/features/workflow/core/fields.ts", "source:src/features/workflow/core/forLoops.ts", + "source:src/features/workflow/core/formFieldSettings.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/types.ts", "source:src/features/workflow/core/validation.ts", @@ -776,6 +778,7 @@ "source:src/platform/ui/SegmentedControl.tsx", "source:src/platform/ui/Select.tsx", "source:src/platform/ui/Slider.tsx", + "source:src/platform/ui/SliderNumberField.tsx", "source:src/platform/ui/StatusWidgetChip.tsx", "source:src/platform/ui/Tabs.tsx", "source:src/platform/ui/ToggleDot.tsx", @@ -1092,7 +1095,7 @@ "source": "src/app/WorkbenchApp.tsx" } }, - "capturedAt": "2026-10-09", + "capturedAt": "2026-10-11", "developmentInvalidation": { "platformUiBarrel": { "specifier": "@platform/ui", diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 99f91f948c9..3ee483d761e 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -1,6 +1,6 @@ { "browserExecutable": "/home/toast/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome", - "capturedAt": "2026-10-09", + "capturedAt": "2026-10-11", "routes": [ { "activatedResourceBaseline": { @@ -14,13 +14,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 130.60000000149012, + "domContentLoadedMedianMs": 124.79999999701977, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 130.79999999701977, - "longestTaskMaxMs": 82, + "loadMedianMs": 124.89999999850988, + "longestTaskMaxMs": 78, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -29,14 +29,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 0, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 206030, "requestCount": 30, - "scriptRawBytes": 1842561, + "scriptRawBytes": 1850486, "scriptRequestCount": 26, - "totalRawBytes": 2068864 + "totalRawBytes": 2082339 }, - "routeReadyMedianMs": 275.80000000447035, + "routeReadyMedianMs": 269.3999999985099, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "empty" }, @@ -52,13 +52,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 125.10000000149012, + "domContentLoadedMedianMs": 129.89999999850988, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 125.20000000298023, - "longestTaskMaxMs": 81, + "loadMedianMs": 130.10000000149012, + "longestTaskMaxMs": 90, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -67,14 +67,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 0, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 206030, "requestCount": 30, - "scriptRawBytes": 1842561, + "scriptRawBytes": 1850486, "scriptRequestCount": 26, - "totalRawBytes": 2068864 + "totalRawBytes": 2082339 }, - "routeReadyMedianMs": 277, + "routeReadyMedianMs": 285.8999999985099, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "representative" }, @@ -90,29 +90,29 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 125.5, + "domContentLoadedMedianMs": 128.89999999850988, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 125.70000000298023, - "longestTaskMaxMs": 173, + "loadMedianMs": 129, + "longestTaskMaxMs": 177, "owner": "workbench", - "projectSwitchMedianMs": 38.30000000447035, + "projectSwitchMedianMs": 41.400000005960464, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 206030, "requestCount": 88, - "scriptRawBytes": 4063609, + "scriptRawBytes": 4096156, "scriptRequestCount": 83, - "totalRawBytes": 4334310 + "totalRawBytes": 4372407 }, - "routeReadyMedianMs": 430.19999999552965, + "routeReadyMedianMs": 438.8999999985099, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -128,29 +128,29 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 129.60000000149012, + "domContentLoadedMedianMs": 124.90000000596046, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 129.69999999552965, - "longestTaskMaxMs": 180, + "loadMedianMs": 125.10000000149012, + "longestTaskMaxMs": 176, "owner": "workbench", - "projectSwitchMedianMs": 44.099999994039536, + "projectSwitchMedianMs": 46.79999999701977, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 206030, "requestCount": 88, - "scriptRawBytes": 4063609, + "scriptRawBytes": 4096156, "scriptRequestCount": 83, - "totalRawBytes": 4334310 + "totalRawBytes": 4372407 }, - "routeReadyMedianMs": 466.3999999985099, + "routeReadyMedianMs": 448.8999999985099, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" }, @@ -159,20 +159,20 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 305162, + "largestAssetRawBytes": 305164, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 514518, + "scriptRawBytes": 514587, "scriptRequestCount": 9, - "totalRawBytes": 514518 + "totalRawBytes": 514587 }, - "domContentLoadedMedianMs": 125.5, + "domContentLoadedMedianMs": 132.90000000596046, "id": "editor-canvas", - "layoutAckMedianMs": 2.6999999955296516, - "layoutReturnSwitchMedianMs": 57.899999998509884, - "layoutSwitchMedianMs": 76.60000000149012, - "loadMedianMs": 125.60000000149012, - "longestTaskMaxMs": 179, + "layoutAckMedianMs": 3.100000001490116, + "layoutReturnSwitchMedianMs": 61.69999999552965, + "layoutSwitchMedianMs": 78.30000000447035, + "loadMedianMs": 133.10000000149012, + "longestTaskMaxMs": 181, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -181,14 +181,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 206030, "requestCount": 97, - "scriptRawBytes": 4578127, + "scriptRawBytes": 4610743, "scriptRequestCount": 92, - "totalRawBytes": 4848828 + "totalRawBytes": 4886994 }, - "routeReadyMedianMs": 1153.7999999970198, + "routeReadyMedianMs": 1202.4000000059605, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "empty" }, @@ -197,20 +197,20 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 305162, + "largestAssetRawBytes": 305164, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 514518, + "scriptRawBytes": 514587, "scriptRequestCount": 9, - "totalRawBytes": 514518 + "totalRawBytes": 514587 }, - "domContentLoadedMedianMs": 126.59999999403954, + "domContentLoadedMedianMs": 127.5, "id": "editor-canvas", - "layoutAckMedianMs": 3.100000001490116, - "layoutReturnSwitchMedianMs": 52.70000000298023, - "layoutSwitchMedianMs": 72.20000000298023, - "loadMedianMs": 126.69999999552965, - "longestTaskMaxMs": 196, + "layoutAckMedianMs": 2.899999998509884, + "layoutReturnSwitchMedianMs": 52.400000005960464, + "layoutSwitchMedianMs": 76.80000000447035, + "loadMedianMs": 127.5, + "longestTaskMaxMs": 190, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -219,14 +219,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 206030, "requestCount": 97, - "scriptRawBytes": 4578127, + "scriptRawBytes": 4610743, "scriptRequestCount": 92, - "totalRawBytes": 4848828 + "totalRawBytes": 4886994 }, - "routeReadyMedianMs": 1147.1000000014901, + "routeReadyMedianMs": 1164.6000000014901, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "representative" }, @@ -235,20 +235,20 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 187094, + "largestAssetRawBytes": 201440, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 420919, + "scriptRawBytes": 441995, "scriptRequestCount": 6, - "totalRawBytes": 436332 + "totalRawBytes": 457408 }, - "domContentLoadedMedianMs": 125, + "domContentLoadedMedianMs": 128.30000000447035, "id": "editor-workflow", - "layoutAckMedianMs": 2.7000000029802322, - "layoutReturnSwitchMedianMs": 38.600000001490116, - "layoutSwitchMedianMs": 82.60000000149012, - "loadMedianMs": 125.09999999403954, - "longestTaskMaxMs": 174, + "layoutAckMedianMs": 3.2000000029802322, + "layoutReturnSwitchMedianMs": 39.600000001490116, + "layoutSwitchMedianMs": 74.79999999701977, + "loadMedianMs": 128.40000000596046, + "longestTaskMaxMs": 173, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -257,14 +257,14 @@ "cssRawBytes": 17572, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 206030, "requestCount": 95, - "scriptRawBytes": 4484528, + "scriptRawBytes": 4538151, "scriptRequestCount": 89, - "totalRawBytes": 4770642 + "totalRawBytes": 4829815 }, - "routeReadyMedianMs": 1171.6000000014901, + "routeReadyMedianMs": 1184.7999999970198, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "empty" }, @@ -273,20 +273,20 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 187094, + "largestAssetRawBytes": 201440, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 420919, + "scriptRawBytes": 441995, "scriptRequestCount": 6, - "totalRawBytes": 436332 + "totalRawBytes": 457408 }, - "domContentLoadedMedianMs": 123.70000000298023, + "domContentLoadedMedianMs": 126.29999999701977, "id": "editor-workflow", - "layoutAckMedianMs": 2.6999999955296516, - "layoutReturnSwitchMedianMs": 41.80000000447035, - "layoutSwitchMedianMs": 128.80000000447035, - "loadMedianMs": 123.80000000447035, - "longestTaskMaxMs": 259, + "layoutAckMedianMs": 3.100000001490116, + "layoutReturnSwitchMedianMs": 45.099999994039536, + "layoutSwitchMedianMs": 143.79999999701977, + "loadMedianMs": 126.5, + "longestTaskMaxMs": 286, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -295,14 +295,14 @@ "cssRawBytes": 17572, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 206030, "requestCount": 95, - "scriptRawBytes": 4484528, + "scriptRawBytes": 4538151, "scriptRequestCount": 89, - "totalRawBytes": 4770642 + "totalRawBytes": 4829815 }, - "routeReadyMedianMs": 1192.8999999985099, + "routeReadyMedianMs": 1238.8999999985099, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "representative" }, @@ -318,13 +318,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 125.79999999701977, + "domContentLoadedMedianMs": 128.70000000298023, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 41.100000001490116, - "loadMedianMs": 125.89999999850988, - "longestTaskMaxMs": 174, + "layoutSwitchMedianMs": 40.5, + "loadMedianMs": 128.80000000447035, + "longestTaskMaxMs": 185, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -333,14 +333,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 206030, "requestCount": 88, - "scriptRawBytes": 4063609, + "scriptRawBytes": 4096156, "scriptRequestCount": 83, - "totalRawBytes": 4334310 + "totalRawBytes": 4372407 }, - "routeReadyMedianMs": 1431.3999999985099, + "routeReadyMedianMs": 1412.2000000029802, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -356,13 +356,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 126.89999999850988, + "domContentLoadedMedianMs": 129.10000000149012, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 45.399999998509884, - "loadMedianMs": 127.09999999403954, - "longestTaskMaxMs": 170, + "layoutSwitchMedianMs": 48.70000000298023, + "loadMedianMs": 129.30000000447035, + "longestTaskMaxMs": 191, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -371,14 +371,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 206030, "requestCount": 88, - "scriptRawBytes": 4063609, + "scriptRawBytes": 4096156, "scriptRequestCount": 83, - "totalRawBytes": 4334310 + "totalRawBytes": 4372407 }, - "routeReadyMedianMs": 1396.3999999985099, + "routeReadyMedianMs": 1463.8000000044703, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" } @@ -652,6 +652,7 @@ "source:src/platform/ui/SegmentedControl.tsx", "source:src/platform/ui/Select.tsx", "source:src/platform/ui/Slider.tsx", + "source:src/platform/ui/SliderNumberField.tsx", "source:src/platform/ui/StatusWidgetChip.tsx", "source:src/platform/ui/Tabs.tsx", "source:src/platform/ui/ToggleDot.tsx", @@ -1200,6 +1201,7 @@ "source:src/features/workflow/core/document.ts", "source:src/features/workflow/core/fields.ts", "source:src/features/workflow/core/forLoops.ts", + "source:src/features/workflow/core/formFieldSettings.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/libraryTags.ts", "source:src/features/workflow/core/modelRequirements.ts", @@ -1575,6 +1577,7 @@ "source:src/workbench/image-actions/galleryOptimisticRollback.ts", "source:src/workbench/image-actions/imageRecall.ts", "source:src/workbench/image-actions/index.ts", + "source:src/workbench/image-actions/mediaUsage.ts", "source:src/workbench/image-actions/recallParametersBridge.ts", "source:src/workbench/image-actions/useDeletionConfirmation.tsx", "source:src/workbench/image-actions/useFindGalleryItem.ts", @@ -2288,6 +2291,7 @@ "source:src/features/workflow/core/document.ts", "source:src/features/workflow/core/fields.ts", "source:src/features/workflow/core/forLoops.ts", + "source:src/features/workflow/core/formFieldSettings.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/layerWorkflow.ts", "source:src/features/workflow/core/libraryTags.ts", @@ -2673,6 +2677,7 @@ "source:src/workbench/image-actions/galleryOptimisticRollback.ts", "source:src/workbench/image-actions/imageRecall.ts", "source:src/workbench/image-actions/index.ts", + "source:src/workbench/image-actions/mediaUsage.ts", "source:src/workbench/image-actions/recallParametersBridge.ts", "source:src/workbench/image-actions/useDeletionConfirmation.tsx", "source:src/workbench/image-actions/useFindGalleryItem.ts", @@ -3482,6 +3487,7 @@ "source:src/features/queue/data/events.ts", "source:src/features/queue/data/generationDevicesStore.ts", "source:src/features/queue/data/httpRealtimeQueueBackend.ts", + "source:src/features/queue/data/invocationCacheQueries.ts", "source:src/features/queue/data/itemProgressStore.ts", "source:src/features/queue/data/mappers.ts", "source:src/features/queue/data/progressImageStore.ts", @@ -3501,13 +3507,16 @@ "source:src/features/queue/runtime/receiptAcknowledgements.ts", "source:src/features/queue/runtime/utilityGraph.ts", "source:src/features/queue/ui/CancelQueueItemButton.tsx", + "source:src/features/queue/ui/InvocationCacheFooter.tsx", "source:src/features/queue/ui/ModelCacheFooter.tsx", "source:src/features/queue/ui/NowNextSection.tsx", "source:src/features/queue/ui/QueueDataRuntime.tsx", "source:src/features/queue/ui/QueueFilterTabs.tsx", + "source:src/features/queue/ui/QueueFooter.tsx", "source:src/features/queue/ui/QueueHeaderActions.tsx", "source:src/features/queue/ui/QueueHeaderLabel.tsx", "source:src/features/queue/ui/QueueHeaderMenu.tsx", + "source:src/features/queue/ui/QueueItemDetailActions.tsx", "source:src/features/queue/ui/QueueItemDetails.tsx", "source:src/features/queue/ui/QueueItemRow.tsx", "source:src/features/queue/ui/QueueItemThumbnail.tsx", @@ -3517,6 +3526,7 @@ "source:src/features/queue/ui/QueueUiContext.tsx", "source:src/features/queue/ui/QueueWidgetView.tsx", "source:src/features/queue/ui/RecentSection.tsx", + "source:src/features/queue/ui/RetryQueueItemButton.tsx", "source:src/features/queue/ui/currentBatchItems.ts", "source:src/features/queue/ui/formatDuration.ts", "source:src/features/queue/ui/index.ts", @@ -3552,6 +3562,7 @@ "source:src/features/workflow/core/document.ts", "source:src/features/workflow/core/fields.ts", "source:src/features/workflow/core/forLoops.ts", + "source:src/features/workflow/core/formFieldSettings.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/graphLayout.ts", "source:src/features/workflow/core/graphToDocument.ts", @@ -3628,13 +3639,16 @@ "source:src/features/workflow/ui/library/useWorkflowPublication.ts", "source:src/features/workflow/ui/linear/FormBuilderTab.tsx", "source:src/features/workflow/ui/linear/LinearFormView.tsx", + "source:src/features/workflow/ui/linear/LinkifiedText.tsx", "source:src/features/workflow/ui/linear/NodeFieldControl.tsx", + "source:src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx", "source:src/features/workflow/ui/linear/NodeInspector.tsx", "source:src/features/workflow/ui/linear/WorkflowDetailsTab.tsx", "source:src/features/workflow/ui/linear/WorkflowJsonTab.tsx", "source:src/features/workflow/ui/linear/WorkflowLinearPanel.tsx", "source:src/features/workflow/ui/linear/formBuilderDnd.ts", "source:src/features/workflow/ui/linear/formElementMeta.ts", + "source:src/features/workflow/ui/linear/linkify.ts", "source:src/features/workflow/ui/loopLinkage.ts", "source:src/features/workflow/ui/nodeChrome.tsx", "source:src/features/workflow/ui/pendingLibraryWorkflowLoadRuntime.ts", @@ -3976,6 +3990,7 @@ "source:src/workbench/image-actions/galleryOptimisticRollback.ts", "source:src/workbench/image-actions/imageRecall.ts", "source:src/workbench/image-actions/index.ts", + "source:src/workbench/image-actions/mediaUsage.ts", "source:src/workbench/image-actions/recallParametersBridge.ts", "source:src/workbench/image-actions/useDeletionConfirmation.tsx", "source:src/workbench/image-actions/useFindGalleryItem.ts", From 54902fb21569e554ad9ea6825545051b36bdfd4a Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 21:02:45 -0600 Subject: [PATCH 11/35] fix(workflow): drop a manual memo the React Compiler rule rejects The field binding derives the narrowed template directly; the compiler memoizes it. --- .../workflow/ui/linear/NodeFieldControl.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx index 75ea5c43a82..e5b4c1cf2d1 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx @@ -39,16 +39,9 @@ export const useNodeFieldBinding = (element: NodeFieldFormElement, projectGraph: ? (invocationNode.data.dynamicInputTemplates?.[fieldName] ?? templates[invocationNode.data.type]?.inputs[fieldName]) : undefined; const instance = invocationNode?.data.inputs[fieldName]; - // The authored bounds narrow the template once here; validation, shuffle and the control all read the result, - // and the memo keeps its identity stable for the input's own memos while the bag is unchanged. - const { effectiveTemplate, formSettings } = useMemo(() => { - const settings = template ? getNodeFieldFormSettings(element.data.settings, template) : null; - - return { - effectiveTemplate: template ? applyNodeFieldFormSettings(template, settings) : undefined, - formSettings: settings, - }; - }, [element.data.settings, template]); + const formSettings = template ? getNodeFieldFormSettings(element.data.settings, template) : null; + // The authored bounds narrow the template once here; validation, shuffle and the control all read the result. + const effectiveTemplate = template ? applyNodeFieldFormSettings(template, formSettings) : undefined; const groupId = invocationNode && isWorkflowBatchNodeType(invocationNode.data.type) ? getWorkflowBatchGroupId(invocationNode) From f5ba64882f26e31b4a0a8231c19e42e6f7214618 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 22:05:18 -0600 Subject: [PATCH 12/35] fix(workflow): end slider tracks on the step multiples the node accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slider now runs between the first and last multiples of the step inside the bounds, so bounds of 1–100 with a step of 8 offer 8…96 instead of stops validation rejects; shuffle draws from the same bounds. Multiples are judged with a tolerance relative to the quotient so fine authored steps far from zero stay valid, and the form builder warns when the bounds admit no stop. --- .../frontend/webv2/public/locales/en.json | 2 +- .../src/features/workflow/core/fields.test.ts | 28 ++++++ .../src/features/workflow/core/fields.ts | 96 +++++++++++++------ .../workflow/core/formFieldSettings.test.ts | 40 +++++++- .../workflow/core/formFieldSettings.ts | 40 ++++++-- .../WorkflowFieldInput.browser.test.tsx | 17 ++++ .../ui/linear/NodeFieldSettingsPopover.tsx | 18 +++- .../WorkflowLinearPanel.browser.test.tsx | 35 +++++++ 8 files changed, 232 insertions(+), 44 deletions(-) diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 9b91a820509..942ad24883b 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -3352,7 +3352,7 @@ "optionValuePlaceholder": "Value", "removeOption": "Remove option {{index}}", "invertedBounds": "The minimum is above the maximum; the form ignores both until one is corrected.", - "sliderNeedsBounds": "A slider needs a minimum and a maximum; until both are known the form shows an input.", + "sliderNeedsBounds": "A slider needs a minimum and a maximum with at least one step between them; until then the form shows an input.", "step": "Step", "containerEmpty": "Empty container — drag cards here", "dropHere": "Drop here", diff --git a/invokeai/frontend/webv2/src/features/workflow/core/fields.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/fields.test.ts index dd797305848..c4d1b2a51a6 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/fields.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/fields.test.ts @@ -138,6 +138,15 @@ describe('workflow field validation', () => { expect(isWorkflowFieldValueValid(input({ multipleOf: 2, type: single('IntegerField') }), -3)).toBe(false); }); + it('accepts multiples of a fine step far from zero despite float noise', () => { + // 1.001 / 0.001 is 1000.9999999999999; an absolute tolerance on the quotient would reject it. + const fine = input({ maximum: 2, minimum: 1, multipleOf: 0.001, type: single('FloatField') }); + + expect(isWorkflowFieldValueValid(fine, 1.001)).toBe(true); + expect(isWorkflowFieldValueValid(fine, 1.999)).toBe(true); + expect(isWorkflowFieldValueValid(fine, 1.0015)).toBe(false); + }); + it('tells a cleared required number apart from a present but invalid one', () => { const required = input({ minimum: 0, type: single('IntegerField') }); const optional = input({ required: false, type: single('FloatField') }); @@ -459,6 +468,25 @@ describe('getRandomWorkflowFieldValue', () => { expect(getRandomWorkflowFieldValue(steppedExclusive, () => 0.999)).toBe(0.5); }); + it('treats a bound that is a multiple up to float noise the way validation does', () => { + // 1.1 / 0.1 is 11.000000000000002 and 0.3 / 0.1 is 2.9999999999999996; validation accepts both as multiples. + const closed = input({ maximum: 1.1, minimum: 0.3, multipleOf: 0.1, type: single('FloatField') }); + + expect(getRandomWorkflowFieldValue(closed, () => 0)).toBe(0.3); + expect(getRandomWorkflowFieldValue(closed, () => 0.999)).toBe(1.1); + + const open = input({ exclusiveMaximum: 1.1, exclusiveMinimum: 0.3, multipleOf: 0.1, type: single('FloatField') }); + + expect(getRandomWorkflowFieldValue(open, () => 0)).toBe(0.4); + expect(getRandomWorkflowFieldValue(open, () => 0.999)).toBe(1); + expect( + isWorkflowFieldValueValid( + open, + getRandomWorkflowFieldValue(open, () => 0) + ) + ).toBe(true); + }); + it('keeps legacy numeric templates finite when optional constraints are absent', () => { const legacy = input({ exclusiveMaximum: undefined, diff --git a/invokeai/frontend/webv2/src/features/workflow/core/fields.ts b/invokeai/frontend/webv2/src/features/workflow/core/fields.ts index aa28f89ceda..afb921304c6 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/fields.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/fields.ts @@ -83,44 +83,88 @@ const countDecimals = (value: number): number => { return fraction.length; }; -const finiteNumberOrNull = (value: number | null | undefined): number | null => +export const finiteNumberOrNull = (value: number | null | undefined): number | null => typeof value === 'number' && Number.isFinite(value) ? value : null; +/** The integer `value / step` is a multiple at, or null when it is not a multiple. Validation and bounds share it. */ +const exactStepIndex = (value: number, step: number): number | null => { + const quotient = value / step; + const nearest = Math.round(quotient); + // How far the quotient may sit from an integer and still count, relative to its size so float noise scales with + // it: 0.3 / 0.1 is 2.9999…, and 1.001 / 0.001 is 1000.9999…; both are multiples. Evaluated here rather than at + // module scope: a top-level global member access marks this module side-effectful, which keeps the workflow + // barrels that re-export through it from being tree-shaken and pulls their other chunks into the Launchpad. + const tolerance = Number.EPSILON * 100 * Math.max(1, Math.abs(quotient)); + + return Math.abs(quotient - nearest) <= tolerance ? nearest : null; +}; + +const stepIndex = (value: number, step: number, round: (quotient: number) => number): number => + exactStepIndex(value, step) ?? round(value / step); + +export interface WorkflowFieldStepIndexBounds { + /** Index of the first multiple of the step the bounds admit; null while the lower end is open. */ + first: number | null; + /** Index of the last multiple of the step the bounds admit; null while the upper end is open. */ + last: number | null; +} + +/** + * Which multiples of `step` (counted from zero, as validation counts them) lie inside the template's bounds, as the + * indices of the first and last. Shuffle and the slider track both read this, so neither offers a value the node + * would reject: a field stepping by 8 between 1 and 100 admits 8…96, and one stepping by 8 between 1 and 7 none + * (`first` exceeds `last`). + */ +export const getWorkflowFieldStepIndexBounds = ( + template: FieldInputTemplate, + step: number +): WorkflowFieldStepIndexBounds => { + const exclusiveMaximum = finiteNumberOrNull(template.exclusiveMaximum); + const exclusiveMinimum = finiteNumberOrNull(template.exclusiveMinimum); + const maximum = finiteNumberOrNull(template.maximum); + const minimum = finiteNumberOrNull(template.minimum); + + return { + first: + minimum !== null + ? stepIndex(minimum, step, Math.ceil) + : exclusiveMinimum !== null + ? stepIndex(exclusiveMinimum, step, Math.floor) + 1 + : null, + last: + maximum !== null + ? stepIndex(maximum, step, Math.floor) + : exclusiveMaximum !== null + ? stepIndex(exclusiveMaximum, step, Math.ceil) - 1 + : null, + }; +}; + +/** The multiple of `step` at `index`, written at the step's own precision so three tenths reads 0.3. */ +export const toWorkflowFieldStepMultiple = (index: number, step: number): number => + Number((index * step).toFixed(countDecimals(step))); + /** A random value inside the template's bounds, snapped to its step; unbounded ends default to 0…SEED_MAX. */ export const getRandomWorkflowFieldValue = (template: FieldInputTemplate, random = Math.random): number => { const isInteger = template.type.name === 'IntegerField'; const multipleOf = finiteNumberOrNull(template.multipleOf); const step = multipleOf !== null && multipleOf > 0 ? multipleOf : isInteger ? 1 : 0; - const exclusiveMaximum = finiteNumberOrNull(template.exclusiveMaximum); - const exclusiveMinimum = finiteNumberOrNull(template.exclusiveMinimum); - const maximum = finiteNumberOrNull(template.maximum); - const minimum = finiteNumberOrNull(template.minimum); if (step <= 0) { - const min = minimum ?? exclusiveMinimum ?? 0; - const max = maximum ?? exclusiveMaximum ?? SEED_MAX; + const min = finiteNumberOrNull(template.minimum) ?? finiteNumberOrNull(template.exclusiveMinimum) ?? 0; + const max = finiteNumberOrNull(template.maximum) ?? finiteNumberOrNull(template.exclusiveMaximum) ?? SEED_MAX; return min + random() * (max - min); } // Draw among the step multiples that lie inside the bounds (exclusive ends excluded), so the // result is always valid. - const decimals = countDecimals(step); - const first = - minimum !== null - ? Math.ceil(minimum / step) - : exclusiveMinimum !== null - ? Math.floor(exclusiveMinimum / step) + 1 - : 0; - const last = - maximum !== null - ? Math.floor(maximum / step) - : exclusiveMaximum !== null - ? Math.ceil(exclusiveMaximum / step) - 1 - : Math.floor(SEED_MAX / step); + const bounds = getWorkflowFieldStepIndexBounds(template, step); + const first = bounds.first ?? 0; + const last = bounds.last ?? Math.floor(SEED_MAX / step); const index = first + Math.floor(random() * (last - first + 1)); - return Number((Math.min(last, index) * step).toFixed(decimals)); + return toWorkflowFieldStepMultiple(Math.min(last, index), step); }; export const cloneWorkflowFieldDefault = (template: FieldInputTemplate): unknown => @@ -245,15 +289,7 @@ const isNumberFieldValueValid = (template: FieldInputTemplate, value: unknown): const multipleOf = finiteNumberOrNull(template.multipleOf); - if (multipleOf !== null && multipleOf > 0) { - const quotient = value / multipleOf; - - if (Math.abs(quotient - Math.round(quotient)) > Number.EPSILON * 100) { - return false; - } - } - - return true; + return multipleOf === null || multipleOf <= 0 || exactStepIndex(value, multipleOf) !== null; }; const isStringFieldValueValid = (template: FieldInputTemplate, value: unknown): boolean => { diff --git a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts index 9745e5c1208..a6b961738f8 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts @@ -221,9 +221,47 @@ describe('getNodeFieldSliderRange', () => { expect( getNodeFieldSliderRange(template('FloatField', { exclusiveMaximum: 1, exclusiveMinimum: 0, multipleOf: 0.1 })) ).toEqual({ max: 0.9, min: 0.1, step: 0.1 }); + expect(getNodeFieldSliderRange(template('FloatField', { exclusiveMaximum: 1, exclusiveMinimum: 0 }))).toEqual({ + max: 0.99, + min: 0.01, + step: 0.01, + }); + }); + + it('ends the track on the step multiples validation accepts, not on the bounds', () => { + // Every stop must pass `isWorkflowFieldValueValid`, which counts multiples from zero. + expect(getNodeFieldSliderRange(template('IntegerField', { maximum: 100, minimum: 1, multipleOf: 8 }))).toEqual({ + max: 96, + min: 8, + step: 8, + }); + expect(getNodeFieldSliderRange(template('IntegerField', { maximum: 9.5, minimum: 1.5 }))).toEqual({ + max: 9, + min: 2, + step: 1, + }); + expect(getNodeFieldSliderRange(template('FloatField', { maximum: 0.95, minimum: 0.05, multipleOf: 0.1 }))).toEqual({ + max: 0.9, + min: 0.1, + step: 0.1, + }); + }); + + it('keeps a bound that is itself a multiple despite float noise, and steps past an exclusive one', () => { + // 1.1 / 0.1 and 0.3 / 0.1 are not integers in floating point; validation tolerates that and so must the track. + expect(getNodeFieldSliderRange(template('FloatField', { maximum: 1.1, minimum: 0.3, multipleOf: 0.1 }))).toEqual({ + max: 1.1, + min: 0.3, + step: 0.1, + }); + expect( + getNodeFieldSliderRange(template('FloatField', { exclusiveMaximum: 1.1, exclusiveMinimum: 0.3, multipleOf: 0.1 })) + ).toEqual({ max: 1, min: 0.4, step: 0.1 }); }); - it('has no range while an end is open', () => { + it('has no range while an end is open or no stop would be valid', () => { expect(getNodeFieldSliderRange(template('IntegerField', { minimum: 0 }))).toBeNull(); + expect(getNodeFieldSliderRange(template('IntegerField', { maximum: 7, minimum: 1, multipleOf: 8 }))).toBeNull(); + expect(getNodeFieldSliderRange(template('IntegerField', { maximum: 2.9, minimum: 2.1 }))).toBeNull(); }); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts index 39b477a9a56..0e4870691a8 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts @@ -1,5 +1,7 @@ import type { FieldInputTemplate, WorkflowForm } from './types'; +import { finiteNumberOrNull, getWorkflowFieldStepIndexBounds, toWorkflowFieldStepMultiple } from './fields'; + /** * Per-element control settings an author gives a form field: which control renders it and, for numbers, the * bounds the form offers. They are stored in the element's legacy settings bag (`NodeFieldFormElement.data.settings`) @@ -230,21 +232,43 @@ export interface NodeFieldSliderRange { step: number; } +// The track step of a float field that accepts any value in range, as in v6. +const FLOAT_SLIDER_STEP = 0.01; + /** - * The range a slider can show for `template` (already narrowed by `applyNodeFieldFormSettings`), or null when an - * end is unbounded: a slider over an open range has no meaningful track, so the host keeps the number input. + * The range a slider can show for `template` (already narrowed by `applyNodeFieldFormSettings`), or null when it + * has no track: an end is unbounded, or no stop would pass validation. The host then keeps the number input. + * + * Validation accepts only multiples of the step counted from zero (every integer, for an integer field without + * one), so the track runs between the first and last multiples inside the bounds rather than the bounds + * themselves; a slider stepping by 8 between 1 and 100 offers 8…96, not 1…97. */ export const getNodeFieldSliderRange = (template: FieldInputTemplate): NodeFieldSliderRange | null => { - const isInteger = template.type.name === 'IntegerField'; - const step = template.multipleOf !== null && template.multipleOf > 0 ? template.multipleOf : isInteger ? 1 : 0.01; - const min = template.minimum ?? (template.exclusiveMinimum !== null ? template.exclusiveMinimum + step : null); - const max = template.maximum ?? (template.exclusiveMaximum !== null ? template.exclusiveMaximum - step : null); + const multipleOf = finiteNumberOrNull(template.multipleOf); + const step = multipleOf !== null && multipleOf > 0 ? multipleOf : template.type.name === 'IntegerField' ? 1 : null; + + if (step !== null) { + const { first, last } = getWorkflowFieldStepIndexBounds(template, step); + + if (first === null || last === null || first > last) { + return null; + } + + return { max: toWorkflowFieldStepMultiple(last, step), min: toWorkflowFieldStepMultiple(first, step), step }; + } + + const exclusiveMinimum = finiteNumberOrNull(template.exclusiveMinimum); + const exclusiveMaximum = finiteNumberOrNull(template.exclusiveMaximum); + const min = + finiteNumberOrNull(template.minimum) ?? (exclusiveMinimum !== null ? exclusiveMinimum + FLOAT_SLIDER_STEP : null); + const max = + finiteNumberOrNull(template.maximum) ?? (exclusiveMaximum !== null ? exclusiveMaximum - FLOAT_SLIDER_STEP : null); - if (min === null || max === null || !Number.isFinite(min) || !Number.isFinite(max) || min > max) { + if (min === null || max === null || min > max) { return null; } - return { max, min, step }; + return { max, min, step: FLOAT_SLIDER_STEP }; }; /** diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.browser.test.tsx index 85923120300..42ff9c4c9c1 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.browser.test.tsx @@ -411,6 +411,23 @@ describe('WorkflowFieldInput authored form controls', () => { expect(host.querySelector('[role="slider"]')).toBeNull(); expect(host.querySelector('input[type="number"]')?.value).toBe('8'); }); + + it('ends the slider track on the step multiples the node accepts', async () => { + // Bounds 1–100 with a step of 8 admit 8…96; a track from 1 would offer stops such as 49 that validation rejects. + const misaligned = fullTemplate('IntegerField', { maximum: 100, minimum: 1, multipleOf: 8 }); + await renderWithSettings(misaligned, 16, vi.fn(), { component: 'slider', kind: 'number' }); + + const slider = host.querySelector('[role="slider"]')!; + expect(slider.getAttribute('aria-valuemin')).toBe('8'); + expect(slider.getAttribute('aria-valuemax')).toBe('96'); + + await renderWithSettings(fullTemplate('IntegerField', { maximum: 7, minimum: 1, multipleOf: 8 }), 0, vi.fn(), { + component: 'slider', + kind: 'number', + }); + expect(host.querySelector('[role="slider"]')).toBeNull(); + expect(host.querySelector('input[type="number"]')).not.toBeNull(); + }); }); describe('WorkflowFieldInput typed enums', () => { diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx index d63bd9b0b3a..fd41bb1da6f 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx @@ -1,4 +1,4 @@ -import type { NodeFieldFormElement, ProjectGraphState } from '@features/workflow/contracts'; +import type { FieldInputTemplate, NodeFieldFormElement, ProjectGraphState } from '@features/workflow/contracts'; import type { NodeFieldFormSettings, NodeFieldNumberSettings, @@ -11,7 +11,9 @@ import { isSeedInputField } from '@features/workflow/graph'; import { useFocusedDraft } from '@features/workflow/ui/fields/NumericInput'; import { useProjectGraphCommands } from '@features/workflow/ui/useProjectGraphCommands'; import { + applyNodeFieldFormSettings, getDefaultNodeFieldFormSettings, + getNodeFieldSliderRange, hasInvertedNodeFieldBounds, hasNodeFieldFormSettings, serializeNodeFieldFormSettings, @@ -78,10 +80,12 @@ const BoundInput = ({ const NumberSettingsBody = ({ onChange, settings, + template, templateBounds, }: { onChange: (settings: NodeFieldFormSettings) => void; settings: NodeFieldNumberSettings; + template: FieldInputTemplate; templateBounds: { max: string; min: string; step: string }; }) => { const { t } = useTranslation(); @@ -104,10 +108,11 @@ const NumberSettingsBody = ({ [onChange, settings] ); const inverted = hasInvertedNodeFieldBounds(settings); + // The form falls back to an input whenever the narrowed template yields no track, so say so here. const needsBounds = settings.component !== 'number-input' && - ((settings.min === undefined && templateBounds.min === '') || - (settings.max === undefined && templateBounds.max === '')); + !inverted && + getNodeFieldSliderRange(applyNodeFieldFormSettings(template, settings)) === null; return ( @@ -347,7 +352,12 @@ export const NodeFieldSettingsPopover = ({ {t('widgets.workflow.formBuilder.fieldSettingsHint')} {settings.kind === 'number' ? ( - + ) : ( )} diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx index 70e651af951..4266ea9c5f2 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx @@ -827,4 +827,39 @@ describe('Form builder field settings', () => { expect(storedSettings()).toMatchObject({ max: 5, min: 1 }); expect(document.body.textContent).not.toContain(i18n.t('widgets.workflow.formBuilder.invertedBounds')); }); + + it('warns that a slider has no track until its bounds admit a step multiple', async () => { + const { storedSettings } = await renderBuilder(); + + await userEvent.click( + host.querySelector(`[aria-label="${i18n.t('widgets.workflow.formBuilder.fieldSettings')}"]`)! + ); + + const sliderOption = Array.from(document.querySelectorAll('[data-part="item"]')).find( + (item) => item.textContent === i18n.t('widgets.workflow.formBuilder.controlSlider') + )!; + const boundInput = (key: 'maximum' | 'minimum' | 'step') => + document.querySelector(`input[aria-label="${i18n.t(`widgets.workflow.formBuilder.${key}`)}"]`)!; + // Typed into the focused element: a locator for "Step" also matches the exposed "Steps" field by label. + const fillBound = async (key: 'maximum' | 'minimum' | 'step', value: string) => { + boundInput(key).focus(); + await userEvent.keyboard(`{Control>}a{/Control}${value}`); + }; + const hint = i18n.t('widgets.workflow.formBuilder.sliderNeedsBounds'); + + await userEvent.click(sliderOption); + expect(storedSettings()).toMatchObject({ component: 'slider' }); + // The integer template has no bounds of its own. + expect(document.body.textContent).toContain(hint); + + await fillBound('minimum', '1'); + await fillBound('maximum', '7'); + await fillBound('step', '8'); + // 1…7 holds no multiple of 8, so the form would still show an input. + expect(document.body.textContent).toContain(hint); + + await fillBound('maximum', '8'); + expect(storedSettings()).toMatchObject({ max: 8, min: 1, step: 8 }); + expect(document.body.textContent).not.toContain(hint); + }); }); From 7f4f06ccd5c0b5091d81b21464ce92bfb1ec6f9d Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 16:14:25 -0600 Subject: [PATCH 13/35] fix(queue): keep Invoke Front through run recovery A run restored from the recovery journal before the server admitted it keeps its front-of-queue placement. The test-only snapshot action no longer carries prepend; the Generate, Canvas and Workflow submit paths are covered instead. --- .../04.Image Generation/01.introduction.mdx | 2 +- .../webv2/src/features/queue/runtime.test.ts | 12 ++++ .../src/workbench/invocationSubmit.test.ts | 11 +++- .../queueRunJournal.browser.test.ts | 4 +- .../queue-integration/queueRunRestoration.ts | 2 + .../invoke/prepareCanvasInvocation.test.ts | 12 ++++ .../src/workbench/workbenchState.test.ts | 57 +++++++++++++++---- .../webv2/src/workbench/workbenchState.ts | 19 +------ 8 files changed, 88 insertions(+), 31 deletions(-) diff --git a/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx b/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx index 986d8d10d94..07ae0e3957f 100644 --- a/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx +++ b/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx @@ -49,7 +49,7 @@ If a required component is missing, **Invoke** is disabled and its tooltip names -Hover over **Invoke** to see what will be queued, for example *1 prompt × 4 iterations → 4 generations*. When Invoke is disabled, the same tooltip lists what is stopping it. To run a batch ahead of the work you already have queued, press **Ctrl+Shift+Enter** (**Cmd+Shift+Enter** on macOS) or choose **Invoke (Front of Queue)** from the command palette; the batch still waits its turn behind other users' jobs. +Hover over **Invoke** to see what will be queued, for example *1 prompt × 4 iterations → 4 generations*. When Invoke is disabled, the same tooltip lists what is stopping it. To run a batch ahead of the work you already have queued, press **Ctrl+Shift+Enter** (**Cmd+Shift+Enter** on macOS) or choose **Invoke (Front of Queue)** from the command palette. When several people share a server, the default round-robin queue still alternates between users, so the batch only moves ahead of your own jobs; a server set to first-in, first-out (`session_queue_mode: FIFO`) runs it ahead of everyone's pending jobs. The **Queue** panel lists recent jobs. Cancel a pending, waiting or running job from its row, and retry a failed or canceled job from the session the server saved for it, independently of your current settings. Expand a row to cancel its whole batch while other batches keep running, or to open **View JSON**, which shows the graph the server actually ran, its results and the embedded workflow. The panel menu clears failed items, clears every finished item while keeping queued and running work, or clears the whole queue. Administrators also see the **Invocation Cache** beneath the queue, next to the model cache, where cached node outputs can be cleared, disabled or re-enabled. diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts index 8539d59132a..c1fd726727b 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts @@ -146,6 +146,18 @@ describe('queue runtime', () => { kind: 'generate', request: { prepend: true }, }); + + const workflow = createPendingQueueItem(); + workflow.snapshot = { + ...workflow.snapshot, + backendSubmission: { batchCount: 1, graph: { edges: [], id: 'workflow-graph', nodes: {} }, kind: 'workflow' }, + prepend: true, + }; + + expect(createQueueItemBackendSubmission({ id: 'project-1' }, workflow)).toMatchObject({ + kind: 'workflow', + request: { prepend: true }, + }); }); it('replays items queued before seed modes with the step their random toggle implied', () => { diff --git a/invokeai/frontend/webv2/src/workbench/invocationSubmit.test.ts b/invokeai/frontend/webv2/src/workbench/invocationSubmit.test.ts index c51c116f5e7..089a97b8645 100644 --- a/invokeai/frontend/webv2/src/workbench/invocationSubmit.test.ts +++ b/invokeai/frontend/webv2/src/workbench/invocationSubmit.test.ts @@ -305,7 +305,7 @@ describe('submitResolvedInvocation with a random dynamic prompt', () => { const drawn = { positivePrompts: ['a green cat'], positivePromptsSampleSeed: 7 }; - const submitDrawn = async (sourceId: 'canvas' | 'generate') => { + const submitDrawn = async (sourceId: 'canvas' | 'generate', prepend?: boolean) => { const model: MainModelConfig = { base: 'sd-1', key: 'main', name: 'Main', type: 'main' }; const initial = createInitialWorkbenchState(); const state = workbenchReducer( @@ -347,6 +347,7 @@ describe('submitResolvedInvocation with a random dynamic prompt', () => { models: undefined, owner: captureAccountScope(), prepareCanvasInvocation, + ...(prepend === undefined ? {} : { prepend }), project, route, }); @@ -367,4 +368,12 @@ describe('submitResolvedInvocation with a random dynamic prompt', () => { expect(submitResolved).not.toHaveBeenCalled(); expect(prepareCanvasInvocation).toHaveBeenCalledWith(expect.objectContaining({ expansion: drawn })); }); + + it('keeps Invoke Front through prompt expansion to the Generate dispatch and the Canvas preparation', async () => { + const generate = await submitDrawn('generate', true); + const canvas = await submitDrawn('canvas', true); + + expect(generate.submitResolved.mock.calls[0]?.[0]).toMatchObject({ prepend: true }); + expect(canvas.prepareCanvasInvocation).toHaveBeenCalledWith(expect.objectContaining({ prepend: true })); + }); }); diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts index c22f24675a6..f93c26e2680 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts @@ -227,7 +227,8 @@ describe('IndexedDB queue run journal', () => { const { journal } = await createJournal(); const store = createWorkbenchStore(); const projectId = store.getState().activeProjectId; - const item = createItem({ backendBatchId: 'batch-1', backendItemIds: [11], status: 'running' }); + const baseItem = createItem({ backendBatchId: 'batch-1', backendItemIds: [11], status: 'running' }); + const item = { ...baseItem, snapshot: { ...baseItem.snapshot, prepend: true } }; await journal.record(projectId, item); const loaded = await journal.listForProject(projectId); @@ -245,6 +246,7 @@ describe('IndexedDB queue run journal', () => { snapshot: expect.objectContaining({ canvas: item.snapshot.canvas, graph: { id: 'graph-1', label: 'Graph' }, + prepend: true, }), status: 'running', }), diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunRestoration.ts b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunRestoration.ts index 515e1c3e594..be72a669187 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunRestoration.ts +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunRestoration.ts @@ -154,6 +154,8 @@ export const normalizeRestoredQueueItem = (value: unknown): WorkbenchQueueItem | : { positivePrompt: snapshot.presentation.positivePrompt }), width: snapshot.presentation.width, }, + // Invoke Front must survive a reload before admission, or the replayed enqueue appends instead. + ...(snapshot.prepend === true ? { prepend: true } : {}), ...(recall ? { recall } : {}), ...(snapshot.resultNodeIds === undefined ? {} : { resultNodeIds: [...snapshot.resultNodeIds] }), sourceId: snapshot.sourceId, diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts index d157fd3f2f4..d27c6ab9252 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts @@ -506,6 +506,18 @@ describe('runCanvasInvocation', () => { expect(submitted[0]!.graph.backendGraph!.nodes.canvas_output!.is_intermediate).toBe(false); }); + it('carries Invoke Front to the queued Canvas snapshot only when asked', async () => { + const appended = makeHarness({ document: makeDoc([]) }); + const front = makeHarness({ document: makeDoc([]) }); + front.deps.prepend = true; + + await runCanvasInvocation(appended.deps); + await runCanvasInvocation(front.deps); + + expect(appended.submittedGraphs()[0]).not.toHaveProperty('prepend'); + expect(front.submittedGraphs()[0]?.prepend).toBe(true); + }); + it('marks the output intermediate for a Canvas destination (staging)', async () => { const harness = makeHarness({ destination: 'canvas', document: makeDoc([]) }); diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts index 996e2024cea..5759f1fcf60 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts @@ -438,20 +438,55 @@ const primeGenerate = ( overrides: Partial = {} ) => workbenchReducer(state, { type: 'setGenerateSettings', values: createGenerateValues(overrides) }); -const submitGenerate = (state: WorkbenchState, prepend?: boolean) => - workbenchReducer(state, { - backendSupportsCancellation: true, - ...(prepend === undefined ? {} : { prepend }), - type: 'submitInvocationSnapshot', - }); +const submitGenerate = (state: WorkbenchState) => + workbenchReducer(state, { backendSupportsCancellation: true, type: 'submitInvocationSnapshot' }); describe('Invoke Front', () => { - it('records front priority on the queued snapshot only when the command asks for it', () => { - const appended = submitGenerate(primeGenerate()); - const prepended = submitGenerate(primeGenerate(), true); + const getQueuedSnapshot = (state: WorkbenchState) => getActiveProject(state).queue.items[0]?.snapshot; + + it('records front priority on resolved submissions only when the command asks for it', () => { + const submitResolved = (prepend?: boolean) => { + const state = primeGenerate(); + + return workbenchReducer(state, { + backendSupportsCancellation: true, + ...(prepend === undefined ? {} : { prepend }), + projectId: state.activeProjectId, + route: { destination: 'gallery', destinationLocked: false, sourceId: 'generate', sourceLocked: false }, + type: 'submitResolvedInvocationSnapshot', + }); + }; + + expect(getQueuedSnapshot(submitResolved())).not.toHaveProperty('prepend'); + expect(getQueuedSnapshot(submitResolved(true))?.prepend).toBe(true); + }); + + it('records front priority on prepared Canvas submissions', () => { + const state = primeGenerate(); + const queued = workbenchReducer(state, { + backendSupportsCancellation: true, + canvas: structuredClone(getActiveProject(state).canvas), + destination: 'canvas', + generate: { + negativePromptNodeId: 'negative_prompt', + positivePromptNodeId: 'positive_prompt', + seedNodeId: 'seed', + values: createGenerateValues({ seed: 1, seedMode: 'fixed' }), + }, + graph: { + edges: [], + id: 'canvas-graph', + label: 'Canvas', + nodes: [], + updatedAt: '2026-06-09T00:00:00.000Z', + version: 1, + }, + prepend: true, + projectId: state.activeProjectId, + type: 'submitCanvasInvocationSnapshot', + }); - expect(getActiveProject(appended).queue.items[0]?.snapshot).not.toHaveProperty('prepend'); - expect(getActiveProject(prepended).queue.items[0]?.snapshot.prepend).toBe(true); + expect(getQueuedSnapshot(queued)?.prepend).toBe(true); }); }); diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.ts index 82ed9167466..8b988830955 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.ts @@ -385,13 +385,7 @@ type WorkbenchReducerAction = } | { type: 'undoWorkflowChange'; projectId?: string; workflowId?: string } | { type: 'redoWorkflowChange'; projectId?: string; workflowId?: string } - | { - type: 'submitInvocationSnapshot'; - backendSupportsCancellation: boolean; - models?: readonly ModelConfig[]; - /** Invoke Front: the backend runs the batch ahead of the user's other pending work. */ - prepend?: boolean; - } + | { type: 'submitInvocationSnapshot'; backendSupportsCancellation: boolean; models?: readonly ModelConfig[] } | ({ type: 'submitResolvedInvocationSnapshot'; backendSupportsCancellation: boolean; @@ -4793,16 +4787,7 @@ export const __workbenchReducerInternal = ( return withEnqueueNotification( state, updateActiveProject(state, (project) => - submitInvocationSnapshot( - project, - action.backendSupportsCancellation, - undefined, - action.models, - undefined, - undefined, - undefined, - action.prepend === true - ) + submitInvocationSnapshot(project, action.backendSupportsCancellation, undefined, action.models) ), state.activeProjectId ); From 8a5d4fd38d3072df5a70a504c32daff61bec7064 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 16:14:25 -0600 Subject: [PATCH 14/35] fix(queue): explain a cache without capacity and an unreadable history limit With node_cache_size 0 the invocation cache controls are disabled and say why; cache commands stay silent for an account that changed meanwhile. An unknown queue history limit offers Retry instead of an editable "keep all" field, and the editor is localized. --- .../frontend/webv2/public/locales/en.json | 13 ++ .../queue/data/generationDevicesStore.test.ts | 29 ++++ .../queue/data/generationDevicesStore.ts | 28 ++-- .../queue/data/invocationCacheQueries.ts | 25 ++- .../src/features/queue/data/serverApi.ts | 7 +- .../ui/InvocationCacheFooter.browser.test.tsx | 145 ++++++++++++++++++ .../queue/ui/InvocationCacheFooter.tsx | 31 +++- .../MaxQueueHistorySettings.browser.test.tsx | 136 ++++++++++++++++ .../settings/MaxQueueHistorySettings.tsx | 68 +++++--- 9 files changed, 433 insertions(+), 49 deletions(-) create mode 100644 invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.browser.test.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/settings/MaxQueueHistorySettings.browser.test.tsx diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 942ad24883b..68198dcc102 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -1834,6 +1834,17 @@ "saveFailed": "Could not save the vocabulary.", "loadFailed": "Could not load the vocabulary." }, + "maxQueueHistory": { + "adminOnly": "Only an administrator can change how much queue history the server keeps.", + "description": "Finished, failed and canceled items kept when InvokeAI starts. Leave blank to keep everything, or enter 0 to clear finished history on every start.", + "inputLabel": "Queue history limit", + "invalid": "Enter a whole number of items, or leave the field blank.", + "keepAll": "Keep all", + "modified": "Queue history", + "readFailed": "Could not read the server's queue history setting.", + "restartRequired": "Restart InvokeAI for changes to take effect.", + "saveFailed": "Could not save the queue history limit." + }, "databaseMaintenance": { "compactDatabase": "Compact database", "confirmTitle": "Run database VACUUM?", @@ -2911,6 +2922,8 @@ "label": "Invocation Cache", "loading": "Loading…", "misses": "Misses", + "noCapacity": "The server keeps no invocation cache. Set node_cache_size above 0 in invokeai.yaml and restart to use it.", + "off": "Off", "sizeOfMax": "{{size}} / {{max}} cached", "unavailable": "Unavailable" }, diff --git a/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.test.ts b/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.test.ts index 2dd94fba60f..a0f55085900 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.test.ts @@ -106,6 +106,26 @@ describe('generationDevicesStore', () => { ); await refreshGenerationDevices(); expect(getGenerationDevicesSnapshot().maxQueueHistory).toBeUndefined(); + + mocks.apiFetchJson.mockImplementation((url: string) => + url.includes('generation_device_options') + ? Promise.resolve(options) + : Promise.resolve({ config: { generation_devices: 'auto', max_queue_history: -3 }, set_fields: [] }) + ); + await refreshGenerationDevices(); + expect(getGenerationDevicesSnapshot().maxQueueHistory).toBeUndefined(); + }); + + it('still reads the queue history limit when the device listing fails', async () => { + mocks.apiFetchJson.mockImplementation((url: string) => + url.includes('generation_device_options') + ? Promise.reject(new Error('500')) + : Promise.resolve({ config: { generation_devices: 'auto', max_queue_history: 12 }, set_fields: [] }) + ); + + await refreshGenerationDevices(); + + expect(getGenerationDevicesSnapshot()).toMatchObject({ loadState: 'error', maxQueueHistory: 12 }); }); it('PATCHes only the queue history limit so the device selection is left untouched', async () => { @@ -128,5 +148,14 @@ describe('generationDevicesStore', () => { const lastInit = mocks.apiFetchJson.mock.lastCall?.[1] as RequestInit | undefined; expect(JSON.parse(String(lastInit?.body))).toEqual({ max_queue_history: null }); expect(getGenerationDevicesSnapshot().maxQueueHistory).toBeNull(); + + // The server's answer is what is stored, even when it differs from the request. + mocks.apiFetchJson.mockResolvedValue({ config: { generation_devices: ['cuda:1'], max_queue_history: null } }); + await updateMaxQueueHistory(40); + expect(getGenerationDevicesSnapshot().maxQueueHistory).toBeNull(); + + mocks.apiFetchJson.mockResolvedValue({ detail: 'unexpected' }); + await updateMaxQueueHistory(40); + expect(getGenerationDevicesSnapshot().maxQueueHistory).toBeUndefined(); }); }); diff --git a/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.ts b/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.ts index 35c0d659dd2..ff4bf29d4dc 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/generationDevicesStore.ts @@ -68,15 +68,14 @@ interface RuntimeConfigResponse { const readGenerationDevices = (response: RuntimeConfigResponse | null): GenerationDevicesSetting | null => response?.config?.generation_devices ?? null; -/** Distinguishes "the server keeps everything" (`null`) from "the config was not readable" (`undefined`). */ +/** + * Distinguishes "the server keeps everything" (`null`) from "the config was not readable" (`undefined`). A value the + * server should never send is unreadable too: shown as "keep all" it would invite saving over the real limit. + */ const readMaxQueueHistory = (response: RuntimeConfigResponse | null): MaxQueueHistorySetting | undefined => { - if (!response?.config) { - return undefined; - } + const value = response?.config?.max_queue_history; - const value = response.config.max_queue_history; - - return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null; + return value === null || (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) ? value : undefined; }; /** @@ -94,9 +93,13 @@ export const refreshGenerationDevices = (): Promise => { store.patchSnapshot({ loadState: 'loading' }); } + // The runtime config is read on its own: a failed device listing must not hide the queue history setting. + const runtimeConfigRead = apiFetchJson('/api/v1/app/runtime_config', { + signal: owner.signal, + }).catch(() => null); const refresh = Promise.all([ apiFetchJson('/api/v1/app/generation_device_options', { signal: owner.signal }), - apiFetchJson('/api/v1/app/runtime_config', { signal: owner.signal }).catch(() => null), + runtimeConfigRead, ]) .then(([options, runtimeConfig]) => { if (!isAccountScopeCurrent(owner)) { @@ -111,7 +114,8 @@ export const refreshGenerationDevices = (): Promise => { setting: readGenerationDevices(runtimeConfig), }); }) - .catch((error: unknown) => { + .catch(async (error: unknown) => { + const runtimeConfig = await runtimeConfigRead; if (!isAccountScopeCurrent(owner)) { return; } @@ -119,6 +123,7 @@ export const refreshGenerationDevices = (): Promise => { store.patchSnapshot({ error: getApiErrorMessage(error, 'Failed to load generation devices'), loadState: 'error', + maxQueueHistory: readMaxQueueHistory(runtimeConfig), }); }) .finally(() => { @@ -149,7 +154,8 @@ export const updateGenerationDevices = async (setting: GenerationDevicesSetting) /** * Sends only `max_queue_history`, so the PATCH leaves generation devices alone; `null` clears the limit. Takes - * effect at the next startup, when the server prunes its history. + * effect at the next startup, when the server prunes its history. The stored value is what the server answers, not + * what was asked; an unreadable answer leaves the setting unknown. */ export const updateMaxQueueHistory = async (value: MaxQueueHistorySetting): Promise => { const owner = captureAccountScope(); @@ -162,7 +168,7 @@ export const updateMaxQueueHistory = async (value: MaxQueueHistorySetting): Prom assertAccountScopeCurrent(owner); - store.patchSnapshot({ error: null, maxQueueHistory: readMaxQueueHistory(runtimeConfig) ?? value }); + store.patchSnapshot({ maxQueueHistory: readMaxQueueHistory(runtimeConfig) }); }; export const getGenerationDevicesSnapshot = (): GenerationDevicesSnapshot => store.getSnapshot(); diff --git a/invokeai/frontend/webv2/src/features/queue/data/invocationCacheQueries.ts b/invokeai/frontend/webv2/src/features/queue/data/invocationCacheQueries.ts index 8f4a9ea1e7f..891b176b145 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/invocationCacheQueries.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/invocationCacheQueries.ts @@ -1,4 +1,8 @@ -import { assertAccountScopeCurrent, captureAccountScope } from '@platform/state/accountLifecycle'; +import { + assertAccountScopeCurrent, + captureAccountScope, + isAccountScopeCurrent, +} from '@platform/state/accountLifecycle'; import { queryOptions, type QueryClient } from '@tanstack/react-query'; import type { InvocationCacheStatusDTO } from './serverTypes'; @@ -46,13 +50,26 @@ export const invocationCacheStatusOptions = () => }); })(); +/** Resolves false when the account changed meanwhile: the outcome no longer belongs to anyone who could see it. */ export const runInvocationCacheCommand = async ( queryClient: QueryClient, command: 'clear' | 'disable' | 'enable' -): Promise => { +): Promise => { const owner = captureAccountScope(); - await (command === 'clear' ? clearInvocationCache() : setInvocationCacheEnabled(command === 'enable')); - assertAccountScopeCurrent(owner); + try { + await (command === 'clear' + ? clearInvocationCache(owner.signal) + : setInvocationCacheEnabled(command === 'enable', owner.signal)); + } catch (error) { + if (!isAccountScopeCurrent(owner)) { + return false; + } + throw error; + } + if (!isAccountScopeCurrent(owner)) { + return false; + } await queryClient.invalidateQueries({ queryKey: invocationCacheKey }); + return true; }; diff --git a/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts b/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts index 481ccd6c4f5..e4f764f858b 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts @@ -265,10 +265,11 @@ const INVOCATION_CACHE_URL = '/api/v1/app/invocation_cache'; export const getInvocationCacheStatus = (signal?: AbortSignal): Promise => apiFetchJson(`${INVOCATION_CACHE_URL}/status`, { signal }); -export const clearInvocationCache = (): Promise => apiFetchJson(INVOCATION_CACHE_URL, { method: 'DELETE' }); +export const clearInvocationCache = (signal?: AbortSignal): Promise => + apiFetchJson(INVOCATION_CACHE_URL, { method: 'DELETE', signal }); -export const setInvocationCacheEnabled = (enabled: boolean): Promise => - apiFetchJson(`${INVOCATION_CACHE_URL}/${enabled ? 'enable' : 'disable'}`, { method: 'PUT' }); +export const setInvocationCacheEnabled = (enabled: boolean, signal?: AbortSignal): Promise => + apiFetchJson(`${INVOCATION_CACHE_URL}/${enabled ? 'enable' : 'disable'}`, { method: 'PUT', signal }); export const pauseQueueProcessor = (): Promise => apiFetchJson(buildQueueUrl('processor/pause'), { method: 'PUT' }); diff --git a/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.browser.test.tsx b/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.browser.test.tsx new file mode 100644 index 00000000000..64226707352 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.browser.test.tsx @@ -0,0 +1,145 @@ +import type * as ServerApi from '@features/queue/data/serverApi'; +/* eslint-disable react-perf/jsx-no-jsx-as-prop, react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-object-as-prop */ +import type { InvocationCacheStatusDTO } from '@features/queue/data/serverTypes'; + +import { ChakraProvider } from '@chakra-ui/react'; +import { queryClient } from '@platform/query/client'; +import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { 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 { InvocationCacheFooter } from './InvocationCacheFooter'; +import { QueueUiProvider, type QueueUiAdapter } from './QueueUiContext'; + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +const server = vi.hoisted(() => ({ + clearInvocationCache: vi.fn<(signal?: AbortSignal) => Promise>(), + getInvocationCacheStatus: vi.fn<(signal?: AbortSignal) => Promise>(), + setInvocationCacheEnabled: vi.fn<(enabled: boolean, signal?: AbortSignal) => Promise>(), +})); +vi.mock('@features/queue/data/serverApi', async (importOriginal) => ({ + ...(await importOriginal()), + ...server, +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const status = (overrides: Partial = {}): InvocationCacheStatusDTO => ({ + enabled: true, + hits: 3, + max_size: 512, + misses: 1, + size: 4, + ...overrides, +}); + +const notify = { error: vi.fn(), info: vi.fn(), success: vi.fn() }; +const adapter: QueueUiAdapter = { + ItemActions: () => null, + activeProjectId: null, + canManageInvocationCache: true, + canManageItem: () => true, + canManageProcessor: true, + canViewItemDetails: () => true, + isConnected: true, + notify, + openQueue: () => undefined, + queueJobsScope: 'all', +}; + +let host: HTMLDivElement; +let root: Root; + +beforeEach(() => { + queryClient.clear(); + vi.clearAllMocks(); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root.unmount()); + host.remove(); +}); + +const render = async () => { + await act(() => + root.render( + + + + + + + + ) + ); + await vi.waitFor(() => expect(host.textContent).not.toContain('widgets.queue.invocationCache.loading')); + await act(() => host.querySelector('button[aria-expanded]')!.click()); +}; + +const button = (label: string) => + [...host.querySelectorAll('button')].find((candidate) => candidate.textContent?.includes(label))!; + +describe('InvocationCacheFooter', () => { + it('disables the cache controls and says why when the server keeps no cache', async () => { + server.getInvocationCacheStatus.mockResolvedValue(status({ enabled: false, max_size: 0, size: 0 })); + + await render(); + + expect(host.textContent).toContain('widgets.queue.invocationCache.off'); + expect(host.textContent).toContain('widgets.queue.invocationCache.noCapacity'); + expect(button('widgets.queue.invocationCache.clear').disabled).toBe(true); + expect(button('widgets.queue.invocationCache.enable').disabled).toBe(true); + for (const label of ['widgets.queue.invocationCache.clear', 'widgets.queue.invocationCache.enable']) { + const description = document.getElementById(button(label).getAttribute('aria-describedby') ?? ''); + expect(description?.textContent).toBe('widgets.queue.invocationCache.noCapacity'); + } + }); + + it('disables a running cache, refreshes its status and reports a failed clear', async () => { + server.getInvocationCacheStatus.mockResolvedValue(status()); + server.setInvocationCacheEnabled.mockResolvedValue(null); + server.clearInvocationCache.mockRejectedValue(new Error('Server offline')); + + await render(); + expect(host.textContent).toContain('widgets.queue.invocationCache.sizeOfMax'); + + server.getInvocationCacheStatus.mockResolvedValue(status({ enabled: false })); + await act(() => button('widgets.queue.invocationCache.disable').click()); + + await vi.waitFor(() => expect(button('widgets.queue.invocationCache.enable')).toBeDefined()); + expect(server.setInvocationCacheEnabled).toHaveBeenCalledWith(false, expect.any(AbortSignal)); + expect(notify.success).toHaveBeenCalledWith('widgets.queue.invocationCache.disableDone'); + + await act(() => button('widgets.queue.invocationCache.clear').click()); + + await vi.waitFor(() => + expect(notify.error).toHaveBeenCalledWith('widgets.queue.invocationCache.clearFailed', 'Server offline') + ); + }); + + it('reports nothing for a command whose account signed out while it ran', async () => { + server.getInvocationCacheStatus.mockResolvedValue(status()); + server.clearInvocationCache.mockImplementation( + (signal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason)); + }) + ); + + await render(); + await act(() => button('widgets.queue.invocationCache.clear').click()); + await act(() => { + accountLifecycle.invalidate(); + }); + + await vi.waitFor(() => expect(button('widgets.queue.invocationCache.clear').disabled).toBe(false)); + expect(notify.error).not.toHaveBeenCalled(); + expect(notify.success).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.tsx b/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.tsx index cc04985fd9e..689d8e60af5 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.tsx @@ -5,7 +5,7 @@ import { getApiErrorMessage } from '@platform/transport/http'; import { Button } from '@platform/ui/Button'; import { useQuery } from '@tanstack/react-query'; import { ChevronUpIcon, LayersIcon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react'; -import { useCallback, useState } from 'react'; +import { useCallback, useId, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueueUi } from './QueueUiContext'; @@ -40,8 +40,9 @@ export const InvocationCacheFooter = () => { setBusy(command); try { - await runInvocationCacheCommand(queryClient, command); - notify.success(t(`widgets.queue.invocationCache.${command}Done`)); + if (await runInvocationCacheCommand(queryClient, command)) { + notify.success(t(`widgets.queue.invocationCache.${command}Done`)); + } } catch (error) { notify.error( t(`widgets.queue.invocationCache.${command}Failed`), @@ -55,13 +56,20 @@ export const InvocationCacheFooter = () => { ); const onClear = useCallback(() => run('clear'), [run]); const onToggleEnabled = useCallback(() => run(status?.enabled ? 'disable' : 'enable'), [run, status?.enabled]); + // With no capacity the server ignores every command, so none is offered; only its configuration can turn it on. + const hasNoCapacity = status?.maxSize === 0; + const noCapacityId = useId(); + const commandDescription = hasNoCapacity ? noCapacityId : undefined; + const canCommand = status !== null && !hasNoCapacity && busy === null; const summary = query.isError ? t('widgets.queue.invocationCache.unavailable') : status - ? status.enabled - ? t('widgets.queue.invocationCache.sizeOfMax', { max: status.maxSize, size: status.size }) - : t('widgets.queue.invocationCache.disabled') + ? hasNoCapacity + ? t('widgets.queue.invocationCache.off') + : status.enabled + ? t('widgets.queue.invocationCache.sizeOfMax', { max: status.maxSize, size: status.size }) + : t('widgets.queue.invocationCache.disabled') : t('widgets.queue.invocationCache.loading'); return ( @@ -85,9 +93,15 @@ export const InvocationCacheFooter = () => { )} + {hasNoCapacity ? ( + + {t('widgets.queue.invocationCache.noCapacity')} + + ) : null} + + {restartNotice} + ); } return ( - Finished, failed and canceled items kept when InvokeAI starts. Leave blank to keep everything, or enter 0 to - clear finished history on every start. + {t('settings.maxQueueHistory.description')} - {typeof maxQueueHistory === 'number' ? : null} + {typeof maxQueueHistory === 'number' ? ( + + ) : null} {parsed === 'invalid' ? ( - Enter a whole number of items, or leave the field blank. + {t('settings.maxQueueHistory.invalid')} ) : null} {saveError ? ( - + {saveError} ) : null} - {didChange ? ( - - Restart InvokeAI for changes to take effect. - - ) : null} + {restartNotice} ); }; From 9eafee425bebef8692462a991d9170e68f1d970e Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 16:14:25 -0600 Subject: [PATCH 15/35] perf(queue): stop rereading finished details and sessions to choose what to clear Queue refreshes skip open details of finished items, and scoped Clear Finished and Clear Failed choose roots from item summaries. The mock backend serves item summaries and prunes like the server, keeping finished children of active roots. --- .../frontend/webv2/scripts/mock-backend.mjs | 53 ++++++++++++-- .../src/features/queue/data/queries.test.ts | 22 +++++- .../webv2/src/features/queue/data/queries.ts | 16 ++++- .../src/features/queue/data/serverApi.test.ts | 30 +++++++- .../src/features/queue/data/serverApi.ts | 72 ++++++++++++------- .../features/queue/ui/QueueDataRuntime.tsx | 3 +- .../src/features/queue/ui/queueDataStore.ts | 5 +- 7 files changed, 155 insertions(+), 46 deletions(-) diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs index 4f9952dbd01..4fa5307b4a3 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs @@ -1213,6 +1213,39 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { scopedItems.filter((item) => ids.has(item.item_id)) ); } + if (action === 'item_summaries_by_ids') { + const body = await readJsonBody(request); + const ids = Array.isArray(body.item_ids) ? body.item_ids : []; + const itemsById = new Map(scopedItems.map((item) => [item.item_id, item])); + + return json( + 200, + ids + .map((itemId) => itemsById.get(itemId)) + .filter((item) => item !== undefined) + // SessionQueueItemSummary: an item without its session, workflow or lineage beyond the parent. + .map((item) => + Object.fromEntries( + [ + 'item_id', + 'created_at', + 'status', + 'device', + 'started_at', + 'completed_at', + 'origin', + 'destination', + 'batch_id', + 'user_id', + 'user_display_name', + 'user_email', + 'field_values', + 'parent_item_id', + ].map((key) => [key, item[key] ?? null]) + ) + ) + ); + } if (action === 'item_ids' || action === 'list') { const descending = url.searchParams.get('order_dir')?.toUpperCase() !== 'ASC'; const itemIds = scopedItems.map((item) => item.item_id).sort((left, right) => left - right); @@ -1236,15 +1269,21 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { return json(200, { deleted: true }); } if (action === 'prune' && method === 'PUT') { - let deleted = 0; + // Like the backend: a finished child stays while its root is still active. + const isTerminal = (item) => ['completed', 'failed', 'canceled'].includes(item?.status); + const prunable = [...state.queueItems.values()].filter( + (item) => + isTerminal(item) && + (item.root_item_id === null || + item.root_item_id === undefined || + !state.queueItems.has(item.root_item_id) || + isTerminal(state.queueItems.get(item.root_item_id))) + ); - for (const item of state.queueItems.values()) { - if (item.status === 'completed' || item.status === 'failed' || item.status === 'canceled') { - state.queueItems.delete(item.item_id); - deleted += 1; - } + for (const item of prunable) { + state.queueItems.delete(item.item_id); } - return json(200, { deleted }); + return json(200, { deleted: prunable.length }); } if (action === 'cancel_by_batch_ids' && method === 'PUT') { const body = await readJsonBody(request); diff --git a/invokeai/frontend/webv2/src/features/queue/data/queries.test.ts b/invokeai/frontend/webv2/src/features/queue/data/queries.test.ts index 9126fdda17f..cc8f7b31dd5 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/queries.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/queries.test.ts @@ -1,4 +1,4 @@ -import type { QueueBackendPort } from '@features/queue/core/types'; +import type { QueueBackendPort, QueueItemDetailReadModel } from '@features/queue/core/types'; import { accountLifecycle } from '@platform/state/accountLifecycle'; import { QueryClient } from '@tanstack/react-query'; @@ -6,7 +6,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { queueBackend } from './httpRealtimeQueueBackend'; import { - invalidateQueueReadModels, + invalidateQueueQueries, + queueItemDetailOptions, QUEUE_RECENT_WINDOW, queueKeys, queueReadModelOptions, @@ -93,12 +94,27 @@ describe('queue queries', () => { await client.fetchQuery(queueReadModelOptions(backend, scope)); await client.fetchQuery(queueStatusOptions(backend, {})); - await invalidateQueueReadModels(client); + await invalidateQueueQueries(client); expect(client.getQueryState(queueKeys.readModel(scope))?.isInvalidated).toBe(true); expect(client.getQueryState(queueKeys.status({}))?.isInvalidated).toBe(true); }); + it("re-reads an open item's details only while the item can still change", async () => { + const backend = createBackend(); + const client = new QueryClient(); + vi.mocked(backend.readItemDetail).mockImplementation((itemId) => + Promise.resolve({ status: itemId === 1 ? 'completed' : 'in_progress' } as QueueItemDetailReadModel) + ); + + await client.fetchQuery(queueItemDetailOptions(backend, 1)); + await client.fetchQuery(queueItemDetailOptions(backend, 2)); + await invalidateQueueQueries(client); + + expect(client.getQueryState(queueKeys.itemDetail(1))?.isInvalidated).toBe(false); + expect(client.getQueryState(queueKeys.itemDetail(2))?.isInvalidated).toBe(true); + }); + it('does not continue or publish a read after its account epoch expires', async () => { accountLifecycle.activate('user-a'); const backend = createBackend(); diff --git a/invokeai/frontend/webv2/src/features/queue/data/queries.ts b/invokeai/frontend/webv2/src/features/queue/data/queries.ts index ee7223adf27..bde291d74fa 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/queries.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/queries.ts @@ -6,6 +6,7 @@ import type { QueueStatusReadModel, } from '@features/queue/core/types'; +import { isTerminalBackendStatus } from '@features/queue/data/events'; import { assertAccountScopeCurrent, captureAccountScope } from '@platform/state/accountLifecycle'; import { queryOptions, type QueryClient } from '@tanstack/react-query'; @@ -90,7 +91,16 @@ export const queueItemDetailOptions = (backend: QueueBackendPort, itemId: number }); })(); -/** Coalesced by QueryClient; only active queue observers refetch. */ -export const invalidateQueueReadModels = async (queryClient: QueryClient): Promise => { - await queryClient.invalidateQueries({ queryKey: queueKeys.all }); +/** + * Refresh queue reads after queue activity; coalesced by QueryClient, and only active observers refetch. A finished + * item's execution data no longer changes, so its open details are not read again on every queue event; a details + * view opened again reads them afresh. + */ +export const invalidateQueueQueries = async (queryClient: QueryClient): Promise => { + await queryClient.invalidateQueries({ + predicate: ({ queryKey, state }) => + queryKey[0] === queueKeys.all[0] && + (queryKey[1] !== 'item-detail' || + !isTerminalBackendStatus((state.data as QueueItemDetailReadModel | undefined)?.status ?? 'pending')), + }); }; diff --git a/invokeai/frontend/webv2/src/features/queue/data/serverApi.test.ts b/invokeai/frontend/webv2/src/features/queue/data/serverApi.test.ts index 0d012c7ff51..e534aedf8a2 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/serverApi.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/serverApi.test.ts @@ -118,7 +118,7 @@ describe('scoped clears', () => { if (url.includes('/item_ids')) { return Promise.resolve({ item_ids: scopeIds, total_count: scopeIds.length }); } - if (url.endsWith('/items_by_ids')) { + if (url.endsWith('/item_summaries_by_ids')) { const { item_ids: itemIds } = JSON.parse(String(init?.body)) as { item_ids: number[] }; return Promise.resolve(itemIds.map((itemId) => ({ item_id: itemId, status: 'failed' }))); } @@ -189,7 +189,7 @@ describe('clearFinishedQueueItems', () => { if (url.includes('item_ids')) { return Promise.resolve({ item_ids: [5, 4, 3, 2, 1] }); } - if (url.endsWith('items_by_ids')) { + if (url.endsWith('item_summaries_by_ids')) { return Promise.resolve([ { item_id: 7, parent_item_id: 5, root_item_id: 5, status: 'completed' }, { item_id: 6, parent_item_id: 3, root_item_id: 3, status: 'completed' }, @@ -215,5 +215,31 @@ describe('clearFinishedQueueItems', () => { '/api/v1/queue/default/i/3', ]); expect(requestedUrls()).not.toContain('/api/v1/queue/default/prune'); + // Choosing what to delete needs status and lineage alone, never the items' sessions. + expect(requestedUrls()).not.toContain('/api/v1/queue/default/items_by_ids'); + }); + + it('clears a failed workflow-call chain through its root alone', async () => { + transport.apiFetchJson.mockImplementation((url: string) => { + if (url.includes('item_ids')) { + return Promise.resolve({ item_ids: [3, 2, 1] }); + } + if (url.endsWith('item_summaries_by_ids')) { + return Promise.resolve([ + { item_id: 3, parent_item_id: 1, status: 'failed' }, + { item_id: 2, status: 'completed' }, + { item_id: 1, status: 'failed' }, + ]); + } + return Promise.resolve({}); + }); + + await clearFailedQueueItems({ originPrefix: 'webv2:p:a:' }); + + expect( + transport.apiFetchJson.mock.calls + .filter((call) => (call[1] as RequestInit | undefined)?.method === 'DELETE') + .map((call) => String(call[0])) + ).toEqual(['/api/v1/queue/default/i/1']); }); }); diff --git a/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts b/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts index e4f764f858b..f96cfe660e1 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts @@ -8,7 +8,7 @@ import type { RetryItemsResultDTO, } from '@features/queue/data/serverTypes'; -import { assertAccountScopeCurrent, captureAccountScope } from '@platform/state/accountLifecycle'; +import { assertAccountScopeCurrent, captureAccountScope, type AccountScope } from '@platform/state/accountLifecycle'; import { apiFetchJson } from '@platform/transport/http'; const QUEUE_ID = 'default'; @@ -100,20 +100,52 @@ export const getQueueItemIds = ( { signal } ); -/** Hydrates items in id order, one bounded request per page of ids. */ -export const getQueueItemsByIds = async (itemIds: number[], signal?: AbortSignal): Promise => { - const items: QueueServerItemDTO[] = []; +/** Reads `action`'s answer for `itemIds` in id order, one bounded request per page of ids. */ +const readQueueItemIdPages = async ( + action: 'item_summaries_by_ids' | 'items_by_ids', + itemIds: number[], + signal?: AbortSignal +): Promise => { + const results: T[] = []; for (let start = 0; start < itemIds.length; start += QUEUE_ITEM_IDS_PER_REQUEST) { - const page = await apiFetchJson(buildQueueUrl('items_by_ids'), { + const page = await apiFetchJson(buildQueueUrl(action), { body: JSON.stringify({ item_ids: itemIds.slice(start, start + QUEUE_ITEM_IDS_PER_REQUEST) }), method: 'POST', signal, }); - items.push(...page); + results.push(...page); } - return items; + return results; +}; + +/** Hydrates items in id order. */ +export const getQueueItemsByIds = (itemIds: number[], signal?: AbortSignal): Promise => + readQueueItemIdPages('items_by_ids', itemIds, signal); + +type QueueItemSummaryDTO = Pick; + +/** The roots in `scope` whose status `include` accepts; deleting a root removes its whole workflow-call chain. */ +const readScopedRootIds = async ( + scope: QueueQueryScope, + include: (status: QueueServerItemDTO['status']) => boolean, + owner: AccountScope +): Promise => { + const idsResult = await getQueueItemIds('desc', scope, owner.signal); + + assertAccountScopeCurrent(owner); + // Just status and lineage: choosing roots needs no sessions. + const summaries = await readQueueItemIdPages( + 'item_summaries_by_ids', + idsResult.item_ids, + owner.signal + ); + + assertAccountScopeCurrent(owner); + return summaries + .filter((item) => include(item.status) && (item.parent_item_id ?? null) === null) + .map((item) => item.item_id); }; export const clearQueue = (signal?: AbortSignal): Promise => @@ -133,17 +165,14 @@ export const cancelQueueItems = async (itemIds: number[], signal?: AbortSignal): await runConcurrent(itemIds, (itemId) => cancelQueueItem(itemId, signal)); }; +/** + * Deletes failed roots with their workflow-call chains. A child that failed under a root that is still active, or was + * then canceled, stays with its root, so Clear Failed never deletes a running tree. + */ export const clearFailedQueueItems = async (scope: QueueQueryScope = {}): Promise => { const owner = captureAccountScope(); - const idsResult = await getQueueItemIds('desc', scope, owner.signal); - - assertAccountScopeCurrent(owner); - const items = await getQueueItemsByIds(idsResult.item_ids, owner.signal); - assertAccountScopeCurrent(owner); - const failedItemIds = items.filter((item) => item.status === 'failed').map((item) => item.item_id); - - await deleteQueueItems(failedItemIds, owner.signal); + await deleteQueueItems(await readScopedRootIds(scope, (status) => status === 'failed', owner), owner.signal); assertAccountScopeCurrent(owner); }; @@ -165,18 +194,7 @@ export const clearFinishedQueueItems = async (scope: QueueQueryScope = {}): Prom return; } - const idsResult = await getQueueItemIds('desc', scope, owner.signal); - - assertAccountScopeCurrent(owner); - const items = await getQueueItemsByIds(idsResult.item_ids, owner.signal); - - assertAccountScopeCurrent(owner); - await deleteQueueItems( - items - .filter((item) => isTerminalStatus(item.status) && (item.parent_item_id ?? null) === null) - .map((item) => item.item_id), - owner.signal - ); + await deleteQueueItems(await readScopedRootIds(scope, isTerminalStatus, owner), owner.signal); assertAccountScopeCurrent(owner); }; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueDataRuntime.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueDataRuntime.tsx index b83c6883403..ea74729c184 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueDataRuntime.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueDataRuntime.tsx @@ -1,5 +1,6 @@ import { refreshGenerationDevices } from '@features/queue/data/generationDevicesStore'; import { itemProgressStore } from '@features/queue/data/itemProgressStore'; +import { invalidateQueueQueries } from '@features/queue/data/queries'; import { createProductionQueueRealtimeRuntime } from '@features/queue/publicApi'; import { queryClient } from '@platform/query/client'; import { useMountEffect } from '@platform/react/useMountEffect'; @@ -10,7 +11,7 @@ import { clearQueueConfirmation, useQueueConfirmation } from './queueConfirmatio export const attachQueueDataRuntime = (): (() => void) => { const runtime = createProductionQueueRealtimeRuntime({ - invalidate: () => queryClient.invalidateQueries({ queryKey: ['queue'] }), + invalidate: () => invalidateQueueQueries(queryClient), progress: itemProgressStore, refreshModelCache: refreshModelCacheStats, }); diff --git a/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts b/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts index bfbeb07c8bf..fe8102486fd 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts +++ b/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts @@ -5,6 +5,7 @@ import type { QueueQueryScope, } from '@features/queue/core/types'; +import { invalidateQueueQueries } from '@features/queue/data/queries'; import { getQueueItemDetailOptions, getQueueReadModelOptions } from '@features/queue/publicApi'; import { queryClient } from '@platform/query/client'; import { useQuery } from '@tanstack/react-query'; @@ -43,9 +44,7 @@ const useQueueReadModel = () => { }; /** Invalidate queue data; TanStack Query deduplicates concurrent callers. */ -export const refreshQueue = async (): Promise => { - await queryClient.invalidateQueries({ queryKey: ['queue'] }); -}; +export const refreshQueue = (): Promise => invalidateQueueQueries(queryClient); /** Imperative prefetch for non-React composition roots. */ export const ensureQueueLoaded = (scope: QueueQueryScope = {}): void => { From 98bd97909dbd289d00ed1ed1a7ddf558a906a9c4 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 16:14:25 -0600 Subject: [PATCH 16/35] fix(queue): report a batch with nothing left to cancel and announce detail load failures Cancel Batch says when every item had already finished and reports nothing to an account that changed meanwhile. View JSON's load failure is announced and words its own fallback instead of a hardcoded message. --- .../frontend/webv2/public/locales/en.json | 2 + .../QueueItemDetailActions.browser.test.tsx | 162 ++++++++++++++++++ .../queue/ui/QueueItemDetailActions.tsx | 27 ++- .../src/features/queue/ui/queueDataStore.ts | 3 +- .../QueueItemActions.browser.test.tsx | 101 +++++++++++ .../queue-integration/QueueItemActions.tsx | 16 +- 6 files changed, 295 insertions(+), 16 deletions(-) create mode 100644 invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.browser.test.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.browser.test.tsx diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 68198dcc102..6fed5d7662f 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -2852,6 +2852,8 @@ "batchCancellationRequested": "Batch cancellation requested", "batchCanceledCount_one": "{{count}} queue item was canceled.", "batchCanceledCount_other": "{{count}} queue items were canceled.", + "batchNothingToCancel": "Nothing left to cancel", + "batchNothingToCancelDescription": "Every item in this batch had already finished.", "cancelAllExceptCurrent": "Cancel all except current item", "cancelBatch": "Cancel batch", "cancelBatchConfirmationBody": "This cancels every unfinished item in this batch. Other batches keep running.", diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.browser.test.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.browser.test.tsx new file mode 100644 index 00000000000..d02a5b0db01 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.browser.test.tsx @@ -0,0 +1,162 @@ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ +import type { QueueItemReadModel } from '@features/queue/core/types'; + +import { ChakraProvider } from '@chakra-ui/react'; +import { accountLifecycle } from '@platform/state/accountLifecycle'; +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 type * as QueueDataStore from './queueDataStore'; + +import { clearQueueConfirmation, getQueueConfirmation } from './queueConfirmationStore'; +import { QueueItemDetailActions } from './QueueItemDetailActions'; +import { QueueUiProvider, type QueueUiAdapter } from './QueueUiContext'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string, values?: { count?: number }) => `${key}${values?.count ?? ''}` }), +})); +const commands = vi.hoisted(() => ({ cancelBatch: vi.fn<(batchId: string) => Promise>() })); +vi.mock('@features/queue/publicApi', () => ({ + getQueueItemDetailOptions: (itemId: number) => ({ queryFn: () => null, queryKey: ['queue', 'item-detail', itemId] }), + getQueueReadModelOptions: () => ({ queryFn: () => null, queryKey: ['queue'] }), + queueCommands: commands, +})); +const refreshQueue = vi.hoisted(() => vi.fn(() => Promise.resolve())); +vi.mock('./queueDataStore', async (importOriginal) => ({ + ...(await importOriginal()), + refreshQueue, +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const item = { batchId: 'batch-7', id: 7, status: 'completed' } as QueueItemReadModel; +const notify = { error: vi.fn(), info: vi.fn(), success: vi.fn() }; +const adapter = (canManage: boolean): QueueUiAdapter => ({ + ItemActions: () => null, + activeProjectId: null, + canManageInvocationCache: false, + canManageItem: () => canManage, + canManageProcessor: true, + canViewItemDetails: () => true, + isConnected: true, + notify, + openQueue: () => undefined, + queueJobsScope: 'all', +}); + +let host: HTMLDivElement; +let root: Root; + +beforeEach(() => { + vi.clearAllMocks(); + clearQueueConfirmation(); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root.unmount()); + host.remove(); +}); + +const render = async (canManage = true) => { + await act(() => + root.render( + + + + + + ) + ); +}; + +const requestCancelBatch = async () => { + await act(() => host.querySelector('button')!.click()); + return getQueueConfirmation(); +}; + +describe('QueueItemDetailActions', () => { + it('cancels the batch of a finished row only once the user confirms', async () => { + commands.cancelBatch.mockResolvedValue(2); + await render(); + + expect((await requestCancelBatch())?.title).toBe('widgets.queue.cancelBatchTitle'); + clearQueueConfirmation(); + expect(commands.cancelBatch).not.toHaveBeenCalled(); + + await act(() => requestCancelBatch().then((confirmation) => confirmation?.onConfirm())); + + expect(commands.cancelBatch).toHaveBeenCalledWith('batch-7'); + expect(notify.success).toHaveBeenCalledWith( + 'widgets.queue.batchCancellationRequested', + 'widgets.queue.batchCanceledCount2' + ); + }); + + it('says there was nothing left to cancel instead of reporting zero cancellations', async () => { + commands.cancelBatch.mockResolvedValue(0); + await render(); + + await act(() => requestCancelBatch().then((confirmation) => confirmation?.onConfirm())); + + expect(notify.info).toHaveBeenCalledWith( + 'widgets.queue.batchNothingToCancel', + 'widgets.queue.batchNothingToCancelDescription' + ); + expect(notify.success).not.toHaveBeenCalled(); + }); + + it('reports a failed cancellation, but not to an account that signed in meanwhile', async () => { + commands.cancelBatch.mockRejectedValueOnce(new Error('Server offline')); + await render(); + + await act(() => requestCancelBatch().then((confirmation) => confirmation?.onConfirm())); + expect(notify.error).toHaveBeenCalledWith('widgets.queue.failedToCancelBatch', 'Server offline'); + + let rejectCancel: (reason: Error) => void = () => undefined; + commands.cancelBatch.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectCancel = reject; + }) + ); + const confirmation = await requestCancelBatch(); + const pending = act(() => confirmation?.onConfirm()); + accountLifecycle.invalidate(); + rejectCancel(new Error('Aborted')); + await pending; + + expect(notify.error).toHaveBeenCalledTimes(1); + }); + + it('reports nothing for a cancellation that finished after the account changed', async () => { + let resolveCancel: (canceled: number) => void = () => undefined; + commands.cancelBatch.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCancel = resolve; + }) + ); + await render(); + + const confirmation = await requestCancelBatch(); + const pending = act(() => confirmation?.onConfirm()); + accountLifecycle.invalidate(); + resolveCancel(3); + await pending; + + expect(refreshQueue).not.toHaveBeenCalled(); + expect(notify.success).not.toHaveBeenCalled(); + expect(notify.info).not.toHaveBeenCalled(); + }); + + it('offers nothing to a viewer who cannot manage the item', async () => { + await render(false); + + expect(host.querySelector('button')).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.tsx index f09bd20060d..0c4758e6807 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.tsx @@ -2,6 +2,7 @@ import type { QueueItemReadModel } from '@features/queue/core/types'; import { Icon } from '@chakra-ui/react'; import { queueCommands } from '@features/queue/publicApi'; +import { captureAccountScope, isAccountScopeCurrent } from '@platform/state/accountLifecycle'; import { getApiErrorMessage } from '@platform/transport/http'; import { Button } from '@platform/ui/Button'; import { LayersIcon } from 'lucide-react'; @@ -22,20 +23,30 @@ export const QueueItemDetailActions = ({ item }: { item: QueueItemReadModel }) = const [busy, setBusy] = useState(false); const cancelBatch = useCallback(async () => { + const owner = captureAccountScope(); setBusy(true); try { const canceled = await queueCommands.cancelBatch(item.batchId); + if (!isAccountScopeCurrent(owner)) { + return; + } await refreshQueue(); - notify.success( - t('widgets.queue.batchCancellationRequested'), - t('widgets.queue.batchCanceledCount', { count: canceled }) - ); + if (canceled === 0) { + notify.info(t('widgets.queue.batchNothingToCancel'), t('widgets.queue.batchNothingToCancelDescription')); + } else { + notify.success( + t('widgets.queue.batchCancellationRequested'), + t('widgets.queue.batchCanceledCount', { count: canceled }) + ); + } } catch (error) { - notify.error( - t('widgets.queue.failedToCancelBatch'), - getApiErrorMessage(error, t('widgets.queue.couldNotCancelBatch')) - ); + if (isAccountScopeCurrent(owner)) { + notify.error( + t('widgets.queue.failedToCancelBatch'), + getApiErrorMessage(error, t('widgets.queue.couldNotCancelBatch')) + ); + } } finally { setBusy(false); } diff --git a/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts b/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts index fe8102486fd..81925f8ff3f 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts +++ b/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts @@ -99,7 +99,8 @@ export const useQueueItemDetail = (itemId: number, enabled: boolean): QueueItemD if (query.isError) { return { detail: null, - error: query.error instanceof Error ? query.error.message : 'Failed to load queue item', + // The view words the failure; only a reason the server gave is worth adding to it. + error: query.error instanceof Error ? query.error.message : null, loadState: 'error', retry, }; diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.browser.test.tsx new file mode 100644 index 00000000000..3a6f8fde943 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.browser.test.tsx @@ -0,0 +1,101 @@ +import type { QueueItemReadModel } from '@features/queue/contracts'; +import type * as QueuePublicApi from '@features/queue/publicApi'; +import type { QueueItemDetailReadModel } from '@features/queue/react'; + +import { ChakraProvider } from '@chakra-ui/react'; +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 { QueueItemActions } from './QueueItemActions'; + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +const readDetail = vi.hoisted(() => vi.fn<(itemId: number) => Promise>()); +vi.mock('@features/queue/publicApi', async (importOriginal) => ({ + ...(await importOriginal()), + getQueueItemDetailOptions: (itemId: number) => ({ + queryFn: () => readDetail(itemId), + queryKey: ['queue', 'item-detail', itemId], + }), +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const item = { batchId: 'batch-9', id: 9, status: 'failed' } as QueueItemReadModel; +const detail = (workflow: Record | null): QueueItemDetailReadModel => ({ + errorMessage: 'Out of memory', + errorTraceback: 'Traceback…', + errorType: 'OutOfMemoryError', + graph: { id: 'graph-9' }, + id: 9, + priority: 0, + results: {}, + sessionId: 'session-9', + status: 'failed', + workflow, +}); + +let host: HTMLDivElement; +let root: Root; + +beforeEach(() => { + readDetail.mockReset(); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root.unmount()); + host.remove(); +}); + +const render = async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + await act(() => + root.render( + + + + + + ) + ); +}; + +const button = (label: string) => + [...document.querySelectorAll('button')].find((candidate) => candidate.textContent?.includes(label)); +const dialogText = () => document.querySelector('[role="dialog"]')?.textContent ?? ''; + +describe('QueueItemActions View JSON', () => { + it('reads the saved execution data only when opened, and offers a retry when the read fails', async () => { + readDetail.mockRejectedValueOnce(new Error('Queue item 9 not found')); + await render(); + + expect(readDetail).not.toHaveBeenCalled(); + + await act(() => button('common.viewJson')!.click()); + + await vi.waitFor(() => expect(dialogText()).toContain('widgets.queue.detailLoadFailed')); + expect(document.querySelector('[role="dialog"] [role="alert"]')?.textContent).toContain('Queue item 9 not found'); + + readDetail.mockResolvedValueOnce(detail(null)); + await act(() => button('common.retry')!.click()); + + await vi.waitFor(() => expect(dialogText()).toContain('widgets.queue.detailViews.graph')); + expect(dialogText()).not.toContain('widgets.queue.detailViews.workflow'); + expect(readDetail).toHaveBeenCalledTimes(2); + }); + + it('adds a Workflow view when the batch embedded one', async () => { + readDetail.mockResolvedValue(detail({ name: 'Upscale' })); + await render(); + + await act(() => button('common.viewJson')!.click()); + + await vi.waitFor(() => expect(dialogText()).toContain('widgets.queue.detailViews.workflow')); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx b/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx index b4de69bedc5..06c44074363 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx @@ -44,14 +44,16 @@ const QueueItemDetailBody = ({ item, open }: { item: QueueItemReadModel; open: b if (loadState === 'error') { return ( - - {t('widgets.queue.detailLoadFailed')} - - {error ? ( - - {error} + + + {t('widgets.queue.detailLoadFailed')} - ) : null} + {error ? ( + + {error} + + ) : null} + From 6cf20a351b95b26601ce891f0e5e0efa19cfec48 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 16:14:25 -0600 Subject: [PATCH 17/35] feat(queue): return retried runs to where they were headed Retry results and queue_items_retried events pair each retried item with its rerun, scoped to its owner. The run that owns a retried item reopens around the rerun, so Canvas staging and Gallery boards receive its results; reruns no open run adopts reach the Gallery or the current Canvas. Utility jobs are no longer offered Retry, and the mock backend mirrors the retry and cancel contracts. --- .../04.Image Generation/01.introduction.mdx | 2 +- invokeai/app/api/sockets.py | 26 +- invokeai/app/services/events/events_common.py | 5 + .../session_queue/session_queue_common.py | 8 + .../session_queue/session_queue_default.py | 20 +- invokeai/frontend/api/openapi.json | 42 ++- invokeai/frontend/api/schema.ts | 23 ++ .../scripts/mock-backend-fixtures.test.mjs | 41 ++- .../frontend/webv2/scripts/mock-backend.mjs | 45 ++- .../src/features/queue/core/historyTypes.ts | 2 + .../features/queue/core/submissionRules.ts | 15 + .../webv2/src/features/queue/data/events.ts | 2 + .../webv2/src/features/queue/index.ts | 1 + .../webv2/src/features/queue/runtime.test.ts | 344 ++++++++++++++++++ .../webv2/src/features/queue/runtime.ts | 208 ++++++++++- .../queue/ui/QueueItemRow.browser.test.tsx | 2 +- .../features/queue/ui/queueOwnership.test.ts | 2 + .../src/features/queue/ui/queueOwnership.ts | 12 +- .../queue-integration/queueRunRestoration.ts | 4 + .../src/workbench/workbenchState.test.ts | 194 ++++++++++ .../webv2/src/workbench/workbenchState.ts | 135 ++++++- .../webv2/src/workbench/workbenchStore.ts | 2 + .../routers/test_multiuser_authorization.py | 17 +- .../test_session_queue_workflow_call_retry.py | 31 ++ 24 files changed, 1121 insertions(+), 62 deletions(-) diff --git a/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx b/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx index 07ae0e3957f..63bb1451a3d 100644 --- a/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx +++ b/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx @@ -51,7 +51,7 @@ If a required component is missing, **Invoke** is disabled and its tooltip names Hover over **Invoke** to see what will be queued, for example *1 prompt × 4 iterations → 4 generations*. When Invoke is disabled, the same tooltip lists what is stopping it. To run a batch ahead of the work you already have queued, press **Ctrl+Shift+Enter** (**Cmd+Shift+Enter** on macOS) or choose **Invoke (Front of Queue)** from the command palette. When several people share a server, the default round-robin queue still alternates between users, so the batch only moves ahead of your own jobs; a server set to first-in, first-out (`session_queue_mode: FIFO`) runs it ahead of everyone's pending jobs. -The **Queue** panel lists recent jobs. Cancel a pending, waiting or running job from its row, and retry a failed or canceled job from the session the server saved for it, independently of your current settings. Expand a row to cancel its whole batch while other batches keep running, or to open **View JSON**, which shows the graph the server actually ran, its results and the embedded workflow. The panel menu clears failed items, clears every finished item while keeping queued and running work, or clears the whole queue. Administrators also see the **Invocation Cache** beneath the queue, next to the model cache, where cached node outputs can be cleared, disabled or re-enabled. +The **Queue** panel lists recent jobs. Cancel a pending, waiting or running job from its row, and retry a failed or canceled job from the session the server saved for it, independently of your current settings. A retried job's results go where the original was headed while the tab that sent it is still open: back to its place in the Canvas staging area, or to the Gallery board it was sent to. This also holds when you retry several failed items of one batch one after another. Once that tab is gone, for example after a reload, or once its Canvas was replaced with a new document, Canvas results are staged at the current bounding box of any tab with the project open, and Gallery results appear in the Gallery, on **Uncategorized** unless the job itself saved them to a board. Expand a row to cancel its whole batch while other batches keep running, or to open **View JSON**, which shows the graph the server actually ran, its results and the embedded workflow. The panel menu clears failed items, clears every finished item while keeping queued and running work, or clears the whole queue. Administrators also see the **Invocation Cache** beneath the queue, next to the model cache, where cached node outputs can be cleared, disabled or re-enabled. ## Routing images to the Gallery or the Canvas diff --git a/invokeai/app/api/sockets.py b/invokeai/app/api/sockets.py index 4ddbf95408e..9446216f8e9 100644 --- a/invokeai/app/api/sockets.py +++ b/invokeai/app/api/sockets.py @@ -920,16 +920,24 @@ async def _emit_bulk_queue_item_event( """ item_ids_by_user: dict[str, list[int]] = getattr(event_data, item_ids_by_user_field) admin_sids = self._admin_sids() + + def scoped(item_ids: list[int], user_ids: list[str]) -> dict[str, Any]: + update: dict[str, Any] = { + item_ids_field: item_ids, + "user_ids": user_ids, + item_ids_by_user_field: dict.fromkeys(user_ids, item_ids), + } + if isinstance(event_data, QueueItemsRetriedEvent): + # A retry's new item ids are as private as the retried ids they are paired with. + owned_item_ids = set(item_ids) + update["retried_items"] = [ + item for item in event_data.retried_items if item.source_item_id in owned_item_ids + ] + return update + for user_id in event_data.user_ids: user_room = f"user:{user_id}" - owner_item_ids = item_ids_by_user.get(user_id, []) - owner_event_data = event_data.model_copy( - update={ - item_ids_field: owner_item_ids, - "user_ids": [user_id], - item_ids_by_user_field: {user_id: owner_item_ids}, - } - ) + owner_event_data = event_data.model_copy(update=scoped(item_ids_by_user.get(user_id, []), [user_id])) await self._sio.emit( event=event_name, data=owner_event_data.model_dump(mode="json"), @@ -938,7 +946,7 @@ async def _emit_bulk_queue_item_event( ) await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room="admin") - sanitized = event_data.model_copy(update={item_ids_field: [], "user_ids": [], item_ids_by_user_field: {}}) + sanitized = event_data.model_copy(update=scoped([], [])) await self._sio.emit( event=event_name, data=sanitized.model_dump(mode="json"), diff --git a/invokeai/app/services/events/events_common.py b/invokeai/app/services/events/events_common.py index 1f5ff2712ea..c1605cc9aab 100644 --- a/invokeai/app/services/events/events_common.py +++ b/invokeai/app/services/events/events_common.py @@ -13,6 +13,7 @@ QUEUE_ITEM_STATUS, BatchStatus, EnqueueBatchResult, + RetriedQueueItem, RetryItemsResult, SessionQueueItem, SessionQueueStatus, @@ -384,6 +385,9 @@ class QueueItemsRetriedEvent(QueueEventBase): retried_item_ids_by_user: dict[str, list[int]] = Field( description="The retried root queue item IDs keyed by owner user ID." ) + retried_items: list[RetriedQueueItem] = Field( + default_factory=list, description="Each retried root queue item with the queue item that re-runs it" + ) @classmethod def build( @@ -394,6 +398,7 @@ def build( retried_item_ids=retry_result.retried_item_ids, user_ids=user_ids, retried_item_ids_by_user=retried_item_ids_by_user, + retried_items=retry_result.retried_items, ) diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py index ef749015f53..d6eca6e5a75 100644 --- a/invokeai/app/services/session_queue/session_queue_common.py +++ b/invokeai/app/services/session_queue/session_queue_common.py @@ -536,9 +536,17 @@ def validate_result(self) -> "EnqueueBatchReceipt": return self +class RetriedQueueItem(BaseModel): + source_item_id: int = Field(description="The ID of the retried root queue item") + item_id: int = Field(description="The ID of the queue item that re-runs it") + + class RetryItemsResult(BaseModel): queue_id: str = Field(description="The ID of the queue") retried_item_ids: list[int] = Field(description="The IDs of the queue items that were retried") + retried_items: list[RetriedQueueItem] = Field( + default_factory=list, description="Each retried root queue item with the queue item that re-runs it" + ) class ClearResult(BaseModel): diff --git a/invokeai/app/services/session_queue/session_queue_default.py b/invokeai/app/services/session_queue/session_queue_default.py index 65c3b6fa006..94b05fb9c74 100644 --- a/invokeai/app/services/session_queue/session_queue_default.py +++ b/invokeai/app/services/session_queue/session_queue_default.py @@ -30,6 +30,7 @@ ItemIdsResult, NodeFieldValue, PruneResult, + RetriedQueueItem, RetryItemsResult, SessionQueueCountsByDestination, SessionQueueItem, @@ -46,7 +47,7 @@ from invokeai.app.services.shared.database.database import Database from invokeai.app.services.shared.database.queries import Queries from invokeai.app.services.shared.database.queries.locks import DatabaseLock -from invokeai.app.services.shared.database.queries.session_queue import Scope, SettledReceipt +from invokeai.app.services.shared.database.queries.session_queue import ITEM_COLUMNS, Scope, SettledReceipt from invokeai.app.services.shared.execution_state_migration import dump_execution_state from invokeai.app.services.shared.graph import Graph, GraphExecutionState from invokeai.app.services.shared.pagination import CursorPaginatedResults, SQLiteDirection @@ -1151,15 +1152,16 @@ def read(item_id: int) -> Optional[SessionQueueItem]: clones = self._queries.run(survey, read_only=True) - def admit(q: Queries) -> Optional[list[tuple[int, str, ValueToInsertTuple]]]: - """The clones the queue had room for, inserted; None when it is full.""" + def admit(q: Queries) -> Optional[list[tuple[int, str, int]]]: + """The clones the queue had room for, inserted one by one for their ids; None when it is full.""" q.locks.acquire(*_ADMISSION, also_shared=_PROTECTING_MEDIA) max_new_queue_items = max_queue_size - q.session_queue.pending_count(queue_id) if max_new_queue_items <= 0: return None - admitted = clones[:max_new_queue_items] - q.session_queue.insert_items([values for _, _, values in admitted]) - return admitted + return [ + (root_item_id, user_id, q.session_queue.insert_item(dict(zip(ITEM_COLUMNS, values, strict=True)))) + for root_item_id, user_id, values in clones[:max_new_queue_items] + ] admitted = self._queries.run(admit) if admitted is None: @@ -1168,7 +1170,11 @@ def admit(q: Queries) -> Optional[list[tuple[int, str, ValueToInsertTuple]]]: for root_item_id, user_id, _ in admitted: retried_item_ids_by_user.setdefault(user_id, []).append(root_item_id) retry_result = RetryItemsResult( - queue_id=queue_id, retried_item_ids=[root_item_id for root_item_id, _, _ in admitted] + queue_id=queue_id, + retried_item_ids=[root_item_id for root_item_id, _, _ in admitted], + retried_items=[ + RetriedQueueItem(source_item_id=root_item_id, item_id=item_id) for root_item_id, _, item_id in admitted + ], ) self.__invoker.services.events.emit_queue_items_retried( retry_result, diff --git a/invokeai/frontend/api/openapi.json b/invokeai/frontend/api/openapi.json index 06c65b30c3d..d4b746ae05f 100644 --- a/invokeai/frontend/api/openapi.json +++ b/invokeai/frontend/api/openapi.json @@ -91862,9 +91862,24 @@ "description": "The retried root queue item IDs keyed by owner user ID.", "title": "Retried Item Ids By User", "type": "object" + }, + "retried_items": { + "description": "Each retried root queue item with the queue item that re-runs it", + "items": { + "$ref": "#/components/schemas/RetriedQueueItem" + }, + "title": "Retried Items", + "type": "array" } }, - "required": ["timestamp", "queue_id", "retried_item_ids", "user_ids", "retried_item_ids_by_user"], + "required": [ + "timestamp", + "queue_id", + "retried_item_ids", + "user_ids", + "retried_item_ids_by_user", + "retried_items" + ], "title": "QueueItemsRetriedEvent", "type": "object" }, @@ -96141,6 +96156,23 @@ "title": "ResourceOrigin", "description": "The origin of a resource (eg image).\n\n- INTERNAL: The resource was created by the application.\n- EXTERNAL: The resource was not created by the application.\nThis may be a user-initiated upload, or an internal application upload (eg Canvas init image)." }, + "RetriedQueueItem": { + "properties": { + "source_item_id": { + "type": "integer", + "title": "Source Item Id", + "description": "The ID of the retried root queue item" + }, + "item_id": { + "type": "integer", + "title": "Item Id", + "description": "The ID of the queue item that re-runs it" + } + }, + "type": "object", + "required": ["source_item_id", "item_id"], + "title": "RetriedQueueItem" + }, "RetryItemsResult": { "properties": { "queue_id": { @@ -96155,6 +96187,14 @@ "type": "array", "title": "Retried Item Ids", "description": "The IDs of the queue items that were retried" + }, + "retried_items": { + "items": { + "$ref": "#/components/schemas/RetriedQueueItem" + }, + "type": "array", + "title": "Retried Items", + "description": "Each retried root queue item with the queue item that re-runs it" } }, "type": "object", diff --git a/invokeai/frontend/api/schema.ts b/invokeai/frontend/api/schema.ts index f4c062c0dbc..a7cb12ef253 100644 --- a/invokeai/frontend/api/schema.ts +++ b/invokeai/frontend/api/schema.ts @@ -39429,6 +39429,11 @@ export type components = { retried_item_ids_by_user: { [key: string]: number[]; }; + /** + * Retried Items + * @description Each retried root queue item with the queue item that re-runs it + */ + retried_items: components["schemas"]["RetriedQueueItem"][]; }; /** * Qwen35EncoderField @@ -41819,6 +41824,19 @@ export type components = { * @enum {string} */ ResourceOrigin: "internal" | "external"; + /** RetriedQueueItem */ + RetriedQueueItem: { + /** + * Source Item Id + * @description The ID of the retried root queue item + */ + source_item_id: number; + /** + * Item Id + * @description The ID of the queue item that re-runs it + */ + item_id: number; + }; /** RetryItemsResult */ RetryItemsResult: { /** @@ -41831,6 +41849,11 @@ export type components = { * @description The IDs of the queue items that were retried */ retried_item_ids: number[]; + /** + * Retried Items + * @description Each retried root queue item with the queue item that re-runs it + */ + retried_items?: components["schemas"]["RetriedQueueItem"][]; }; /** * Round Float diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs index c4f4945ddf7..888629fd64a 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs @@ -951,15 +951,46 @@ test('queue retry clones failed or canceled roots once, cancel-by-batch spares o assert.ok(failed && pending && completed, 'the representative queue has failed, pending and completed items'); + const summaries = await getJson(backend, '/api/v1/queue/default/item_summaries_by_ids', { + body: JSON.stringify({ item_ids: [completed.item_id, 999_999, failed.item_id] }), + method: 'POST', + }); + // In the requested order, unknown ids skipped, with the lineage and status a scoped clear chooses roots by. + assert.deepEqual( + summaries.map(({ item_id, parent_item_id, status }) => ({ item_id, parent_item_id, status })), + [ + { item_id: completed.item_id, parent_item_id: null, status: 'completed' }, + { item_id: failed.item_id, parent_item_id: null, status: 'failed' }, + ] + ); + assert.equal('session' in summaries[0], false); + const retried = await getJson(backend, '/api/v1/queue/default/retry_items_by_id', { body: JSON.stringify([failed.item_id, failed.item_id, completed.item_id]), method: 'PUT', }); + const reruns = (await getJson(backend, '/api/v1/queue/default/list_all')).filter( + (item) => item.retried_from_item_id === failed.item_id + ); - assert.equal(retried.retried_item_ids.length, 1); - const clone = await getJson(backend, `/api/v1/queue/default/i/${retried.retried_item_ids[0]}`); + // Like the backend, the result names the retried root, not its rerun; the pairing names the rerun. + assert.deepEqual(retried.retried_item_ids, [failed.item_id]); + assert.equal(reruns.length, 1); + const [clone] = reruns; + assert.deepEqual(retried.retried_items, [{ item_id: clone.item_id, source_item_id: failed.item_id }]); assert.equal(clone.status, 'pending'); - assert.equal(clone.retried_from_item_id, failed.item_id); + assert.notEqual(clone.session_id, failed.session_id); + assert.deepEqual(clone.session.results, {}); + + // Retrying the rerun pairs the new rerun with it while the lineage still names the original. + await getJson(backend, `/api/v1/queue/default/i/${clone.item_id}/cancel`, { method: 'PUT' }); + const again = await getJson(backend, '/api/v1/queue/default/retry_items_by_id', { + body: JSON.stringify([clone.item_id]), + method: 'PUT', + }); + assert.deepEqual(again.retried_item_ids, [clone.item_id]); + const secondRerun = await getJson(backend, `/api/v1/queue/default/i/${again.retried_items[0].item_id}`); + assert.equal(secondRerun.retried_from_item_id, failed.item_id); const canceled = await getJson(backend, '/api/v1/queue/default/cancel_by_batch_ids', { body: JSON.stringify({ batch_ids: [pending.batch_id] }), @@ -968,8 +999,8 @@ test('queue retry clones failed or canceled roots once, cancel-by-batch spares o assert.ok(canceled.canceled >= 1); assert.equal((await getJson(backend, `/api/v1/queue/default/i/${pending.item_id}`)).status, 'canceled'); - if (clone.batch_id !== pending.batch_id) { - assert.equal((await getJson(backend, `/api/v1/queue/default/i/${clone.item_id}`)).status, 'pending'); + if (secondRerun.batch_id !== pending.batch_id) { + assert.equal((await getJson(backend, `/api/v1/queue/default/i/${secondRerun.item_id}`)).status, 'pending'); } const pruned = await getJson(backend, '/api/v1/queue/default/prune', { method: 'PUT' }); diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs index 4fa5307b4a3..7eb2099e7ec 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs @@ -1184,6 +1184,28 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { } } + const queueItemCancelMatch = /^\/api\/v1\/queue\/[^/]+\/i\/(\d+)\/cancel$/.exec(path); + if (queueItemCancelMatch && method === 'PUT') { + const item = state.queueItems.get(Number(queueItemCancelMatch[1])); + + if (!item) { + return json(404, { detail: 'Queue item not found' }); + } + // Like the backend, canceling any item of a workflow-call chain cancels the whole chain. + const rootId = item.root_item_id ?? item.item_id; + for (const chainItem of state.queueItems.values()) { + if ( + (chainItem.root_item_id ?? chainItem.item_id) === rootId && + ['pending', 'waiting', 'in_progress'].includes(chainItem.status) + ) { + chainItem.status = 'canceled'; + chainItem.completed_at = timestamp(state); + chainItem.updated_at = chainItem.completed_at; + } + } + return json(200, item); + } + const queueMatch = /^\/api\/v1\/queue\/[^/]+\/(.+)$/.exec(path); if (queueMatch) { const action = queueMatch[1]; @@ -1301,17 +1323,18 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { return json(200, { canceled }); } if (action === 'retry_items_by_id' && method === 'PUT') { - // Like the backend: only failed or canceled roots are cloned, once each, as new pending items. + // Like the backend: a failed or canceled item retries its failed or canceled root, once, as a new pending + // item with a fresh session; the result names the retried roots and pairs each with its rerun. const itemIds = await readJsonBody(request, []); - const retriedItemIds = []; + const retriedItems = []; const seenRoots = new Set(); + const isRetryable = (item) => ['failed', 'canceled'].includes(item?.status); for (const itemId of Array.isArray(itemIds) ? itemIds : []) { const item = state.queueItems.get(itemId); - const rootId = item?.root_item_id ?? item?.item_id; - const root = rootId === undefined ? undefined : state.queueItems.get(rootId); + const root = state.queueItems.get(item?.root_item_id ?? item?.item_id); - if (!root || seenRoots.has(root.item_id) || !['failed', 'canceled'].includes(root.status)) { + if (!isRetryable(item) || !isRetryable(root) || seenRoots.has(root.item_id)) { continue; } seenRoots.add(root.item_id); @@ -1327,14 +1350,20 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { error_traceback: null, error_type: null, item_id: nextId, - retried_from_item_id: root.item_id, + retried_from_item_id: root.retried_from_item_id ?? root.item_id, + session: { prepared_source_mapping: {}, results: {} }, + session_id: `${root.session_id}-retry-${nextId}`, started_at: null, status: 'pending', updated_at: now, }); - retriedItemIds.push(nextId); + retriedItems.push({ item_id: nextId, source_item_id: root.item_id }); } - return json(200, { queue_id: 'default', retried_item_ids: retriedItemIds }); + return json(200, { + queue_id: 'default', + retried_item_ids: retriedItems.map((item) => item.source_item_id), + retried_items: retriedItems, + }); } return json(200, null); diff --git a/invokeai/frontend/webv2/src/features/queue/core/historyTypes.ts b/invokeai/frontend/webv2/src/features/queue/core/historyTypes.ts index d109793e0a4..eb85fe37158 100644 --- a/invokeai/frontend/webv2/src/features/queue/core/historyTypes.ts +++ b/invokeai/frontend/webv2/src/features/queue/core/historyTypes.ts @@ -36,6 +36,8 @@ export interface QueueItem { backendItemIds?: number[]; completedBackendItemIds?: number[]; cancelledBackendItemIds?: number[]; + /** Backend items a retry reopened this run away from; retrying another of them later still returns here. */ + retriedBackendItemIds?: number[]; backendBatchId?: string; error?: string; resultImages?: QueueResultImage[]; diff --git a/invokeai/frontend/webv2/src/features/queue/core/submissionRules.ts b/invokeai/frontend/webv2/src/features/queue/core/submissionRules.ts index a4b5ee82e1c..6e005c186f7 100644 --- a/invokeai/frontend/webv2/src/features/queue/core/submissionRules.ts +++ b/invokeai/frontend/webv2/src/features/queue/core/submissionRules.ts @@ -1,6 +1,21 @@ import type { QueueItem } from '@features/queue/core/historyTypes'; import type { QueueSourceId } from '@features/queue/core/types'; +/** The graph nodes whose outputs are a source's results; Workflow runs deliver every image they produce. */ +export const QUEUE_SOURCE_RESULT_NODE_IDS: Readonly>> = { + canvas: ['canvas_output'], + generate: ['canvas_output'], + upscale: ['upscale_output'], + video: ['video_output'], +}; + +/** The snapshot fields that limit a source's queued run to its result nodes. */ +export const getQueueSourceResultSelection = (sourceId: QueueSourceId): { resultNodeIds?: string[] } => { + const resultNodeIds = QUEUE_SOURCE_RESULT_NODE_IDS[sourceId]; + + return resultNodeIds ? { resultNodeIds: [...resultNodeIds] } : {}; +}; + /** Source kinds whose pending queue items the runtime enqueues to the backend. */ export const BACKEND_SUBMITTABLE_SOURCE_IDS = [ 'generate', diff --git a/invokeai/frontend/webv2/src/features/queue/data/events.ts b/invokeai/frontend/webv2/src/features/queue/data/events.ts index 1554fc30cbc..45aed671243 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/events.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/events.ts @@ -63,6 +63,8 @@ export interface QueueItemsRetriedEvent { queue_id: string; retried_item_ids: number[]; retried_item_ids_by_user: Record; + /** Each retried root paired with the item that re-runs it; absent from servers that predate the pairing. */ + retried_items?: { item_id: number; source_item_id: number }[]; timestamp: number; user_ids: string[]; } diff --git a/invokeai/frontend/webv2/src/features/queue/index.ts b/invokeai/frontend/webv2/src/features/queue/index.ts index 77dd39b0d64..586ee6ca307 100644 --- a/invokeai/frontend/webv2/src/features/queue/index.ts +++ b/invokeai/frontend/webv2/src/features/queue/index.ts @@ -40,6 +40,7 @@ export { } from './core/historySummary'; export { BACKEND_SUBMITTABLE_SOURCE_IDS, + getQueueSourceResultSelection, isBackendSubmittableSourceId, shouldSubmitPendingQueueItem, } from './core/submissionRules'; diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts index c1fd726727b..538400df863 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts @@ -6,10 +6,12 @@ import type { QueueResultVideo, } from '@features/queue/core/types'; import type { BackendConnectionStatus } from '@platform/transport/types'; +import type { WorkbenchQueueItem } from '@workbench/queueHistoryContracts'; import { activeProgressTargetStore } from '@features/queue/data/activeProgressTargetStore'; import { buildQueueItemOrigin } from '@features/queue/data/events'; import { ApiError } from '@platform/transport/http'; +import { createInitialWorkbenchState, type WorkbenchAction, workbenchReducer } from '@workbench/workbenchState.testing'; import { describe, expect, it, vi } from 'vitest'; import { @@ -91,11 +93,13 @@ const createTestCommands = (overrides: Partial = {}): Queu setLocalRecoveryState: vi.fn(), recordError: vi.fn(), refreshBackendData: vi.fn(), + reopenForRetry: vi.fn(), restoreFromJournal: vi.fn(), routePartialResults: vi.fn(), routeResults: vi.fn(), setConnectionStatus: vi.fn(), setStatus: vi.fn(), + stageRetriedCanvasResults: vi.fn(), ...overrides, }); @@ -373,11 +377,13 @@ describe('queue runtime', () => { setLocalRecoveryState: vi.fn(), recordError: vi.fn(), refreshBackendData: vi.fn(), + reopenForRetry: vi.fn(), restoreFromJournal: vi.fn(), routePartialResults: vi.fn(), routeResults: vi.fn(), setConnectionStatus: vi.fn(), setStatus: vi.fn(), + stageRetriedCanvasResults: vi.fn(), }; const runtime = createQueueRuntime({ backend, @@ -2329,11 +2335,13 @@ describe('queue runtime video board routing', () => { setLocalRecoveryState: vi.fn(), recordError: vi.fn(), refreshBackendData: vi.fn(), + reopenForRetry: vi.fn(), restoreFromJournal: vi.fn(), routePartialResults: vi.fn(), routeResults: vi.fn(), setConnectionStatus: vi.fn(), setStatus: vi.fn(), + stageRetriedCanvasResults: vi.fn(), }; const destinations = { addImagesToGalleryBoard: vi.fn(), @@ -2772,3 +2780,339 @@ describe('queue runtime video board routing', () => { runtime.dispose(); }); }); + +describe('queue runtime retries', () => { + const createRetryHarness = (queueItem: QueueItem, backendOverrides: Partial = {}) => { + const project = { id: 'project-1', queue: { items: [queueItem] } }; + const listeners = new Set<() => void>(); + const notify = () => listeners.forEach((listener) => listener()); + const handlers = new Map void>>(); + const backend = createTestBackend({ + on: vi.fn((event: string, handler: (payload: never) => void) => { + const eventHandlers = handlers.get(event) ?? new Set(); + eventHandlers.add(handler); + handlers.set(event, eventHandlers); + return () => eventHandlers.delete(handler); + }), + ...backendOverrides, + }); + const commands = createTestCommands({ + routeResults: vi.fn(() => { + queueItem.status = 'completed'; + notify(); + }), + }); + const runtime = createQueueRuntime({ + ...runtimeServices, + backend, + history: { + commands, + getSnapshot: () => ({ connectionStatus: 'connected', isHydrated: true, projects: [project] }), + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + }); + const emit = (event: string, payload: unknown) => + handlers.get(event)?.forEach((handler) => handler(payload as never)); + + return { backend, commands, emit, notify, project, runtime }; + }; + + const retriedEvent = (pairs: { item_id: number; source_item_id: number }[]) => ({ + queue_id: 'default', + retried_item_ids: pairs.map((pair) => pair.source_item_id), + retried_item_ids_by_user: {}, + retried_items: pairs, + timestamp: 0, + user_ids: [], + }); + + const completedEvent = (itemId: number, origin: string, destination: string) => ({ + batch_id: 'batch', + completed_at: null, + created_at: '2026-07-17T00:00:00.000Z', + destination, + error_message: null, + error_traceback: null, + error_type: null, + item_id: itemId, + origin, + queue_id: 'default', + session_id: `session-${itemId}`, + started_at: null, + status: 'completed', + status_sequence: null, + timestamp: 0, + updated_at: '2026-07-17T00:00:00.000Z', + user_id: 'user', + }); + + it('reopens the run that owns a retried item and routes its rerun to the run destination', async () => { + const queueItem: QueueItem = { + ...createPendingQueueItem(), + backendBatchId: 'batch', + backendItemIds: [41], + error: 'Out of memory', + status: 'failed', + }; + const rerunImage = { imageName: 'rerun.png' } as QueueResultImage; + const getResultImages = vi.fn().mockResolvedValue([rerunImage]); + const { commands, emit, notify, project, runtime } = createRetryHarness(queueItem, { + getItem: vi.fn().mockResolvedValue({ + batchId: 'batch', + id: 99, + origin: buildQueueItemOrigin(queueItem.id, 'project-1'), + status: 'completed', + }), + getResultImages, + }); + vi.mocked(commands.reopenForRetry).mockImplementation(({ retriedItems }) => { + queueItem.backendItemIds = retriedItems.map(({ itemId }) => itemId); + queueItem.status = 'running'; + delete queueItem.error; + notify(); + }); + + runtime.start(); + emit('queue_items_retried', retriedEvent([{ item_id: 99, source_item_id: 41 }])); + + await vi.waitFor(() => { + expect(commands.routeResults).toHaveBeenCalledWith( + expect.objectContaining({ images: [rerunImage], projectId: project.id, queueItemId: queueItem.id }) + ); + }); + expect(commands.reopenForRetry).toHaveBeenCalledWith({ + projectId: project.id, + queueItemId: queueItem.id, + retriedItems: [{ itemId: 99, sourceItemId: 41 }], + }); + expect(getResultImages).toHaveBeenCalledWith(99, queueItem.id, queueItem.snapshot.submittedAt, { + resultNodeIds: ['canvas_output'], + }); + expect(commands.stageRetriedCanvasResults).not.toHaveBeenCalled(); + + await runtime.dispose(); + }); + + it('stages reruns through the workbench at the run bbox, including a later retry of an item the first left', async () => { + let state = createInitialWorkbenchState(); + const projectId = state.activeProjectId; + const { canvas } = state.projects.find((project) => project.id === projectId)!; + const submittedBbox = { ...canvas.document.bbox }; + const queueItem: WorkbenchQueueItem = { + ...createPendingQueueItem(), + backendItemIds: [41, 42], + snapshot: { + ...createPendingQueueItem().snapshot, + canvas: { + document: { bbox: submittedBbox, height: canvas.document.height, width: canvas.document.width }, + documentRevision: canvas.documentRevision, + }, + }, + status: 'failed', + }; + state = { + ...state, + projects: state.projects.map((project) => + project.id === projectId ? { ...project, queue: { items: [queueItem] } } : project + ), + }; + const listeners = new Set<() => void>(); + const dispatch = (action: WorkbenchAction): void => { + state = workbenchReducer(state, action); + listeners.forEach((listener) => listener()); + }; + dispatch({ + mutation: { bbox: { ...submittedBbox, x: submittedBbox.x + 64 }, type: 'setCanvasBbox' }, + projectId, + type: 'applyCanvasProjectMutation', + }); + const handlers = new Map void>>(); + const backend = createTestBackend({ + getItem: vi.fn((id: number) => + Promise.resolve({ + batchId: 'batch', + id, + origin: buildQueueItemOrigin(queueItem.id, projectId), + status: 'completed' as const, + }) + ), + getResultImages: vi.fn((id: number) => + Promise.resolve([ + { + height: 64, + imageName: `rerun-${id}.png`, + imageUrl: `/rerun-${id}.png`, + queuedAt: queueItem.snapshot.submittedAt, + sourceQueueItemId: queueItem.id, + thumbnailUrl: `/rerun-${id}.png`, + width: 64, + }, + ]) + ), + on: vi.fn((event: string, handler: (payload: never) => void) => { + const eventHandlers = handlers.get(event) ?? new Set(); + eventHandlers.add(handler); + handlers.set(event, eventHandlers); + return () => eventHandlers.delete(handler); + }), + }); + const commands = createTestCommands({ + markBackendCancelled: (payload) => dispatch({ ...payload, type: 'markQueueItemBackendCancelled' }), + markBackendSubmitted: (payload) => dispatch({ ...payload, type: 'markQueueItemBackendSubmitted' }), + reopenForRetry: (payload) => dispatch({ ...payload, type: 'reopenQueueItemForRetry' }), + routePartialResults: (payload) => dispatch({ ...payload, type: 'routeQueueItemPartialResults' }), + routeResults: (payload) => dispatch({ ...payload, type: 'routeQueueItemResults' }), + setStatus: (payload) => dispatch({ ...payload, type: 'setQueueItemStatus' }), + stageRetriedCanvasResults: vi.fn(), + }); + const runtime = createQueueRuntime({ + ...runtimeServices, + backend, + history: { + commands, + getSnapshot: () => ({ connectionStatus: 'connected', isHydrated: true, projects: state.projects }), + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + }); + const emitRetried = (itemId: number, sourceItemId: number) => + handlers + .get('queue_items_retried') + ?.forEach((handler) => handler(retriedEvent([{ item_id: itemId, source_item_id: sourceItemId }]) as never)); + const getRun = () => state.projects.find((project) => project.id === projectId)!.queue.items[0]!; + const getStaged = () => + state.projects.find((project) => project.id === projectId)!.canvas.stagingArea.pendingImages; + + runtime.start(); + emitRetried(99, 41); + await vi.waitFor(() => expect(getRun().status).toBe('completed')); + emitRetried(100, 42); + await vi.waitFor(() => expect(getStaged().map((image) => image.imageName)).toContain('rerun-100.png')); + + expect(getStaged()).toEqual([ + expect.objectContaining({ + imageName: 'rerun-99.png', + placement: expect.objectContaining({ x: submittedBbox.x }), + }), + expect.objectContaining({ + imageName: 'rerun-100.png', + placement: expect.objectContaining({ x: submittedBbox.x }), + }), + ]); + expect(commands.stageRetriedCanvasResults).not.toHaveBeenCalled(); + + await runtime.dispose(); + }); + + it("stages what another tab's Workflow rerun produced, and nothing for a rerun that failed", async () => { + const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [7], status: 'completed' }; + const image = (imageName: string, createdAt: string, isIntermediate = false) => + ({ createdAt, imageName, isIntermediate }) as QueueResultImage; + const produced = image('produced.png', '2026-07-17T00:00:05.000Z'); + const getResultImages = vi.fn((_id: number, _queueItemId: string, _queuedAt: string, options?: object) => + Promise.resolve( + options + ? [] + : [ + produced, + image('echoed-input.png', '2026-07-16T00:00:00.000Z'), + image('preview.png', '2026-07-17T00:00:04.000Z', true), + ] + ) + ); + const { commands, emit, project, runtime } = createRetryHarness(queueItem, { getResultImages }); + const otherTabOrigin = buildQueueItemOrigin('run-in-another-tab', project.id); + + runtime.start(); + emit( + 'queue_items_retried', + retriedEvent([ + { item_id: 99, source_item_id: 41 }, + { item_id: 100, source_item_id: 42 }, + ]) + ); + emit('queue_item_status_changed', { ...completedEvent(100, otherTabOrigin, 'canvas'), status: 'failed' }); + emit('queue_item_status_changed', completedEvent(99, otherTabOrigin, 'canvas')); + + await vi.waitFor(() => { + expect(commands.stageRetriedCanvasResults).toHaveBeenCalledWith({ + backendItemId: 99, + images: [produced], + projectId: project.id, + queueItemId: 'run-in-another-tab', + }); + }); + expect(getResultImages).toHaveBeenNthCalledWith(1, 99, 'run-in-another-tab', '2026-07-17T00:00:00.000Z', { + resultNodeIds: ['canvas_output', 'upscale_output'], + }); + expect(commands.stageRetriedCanvasResults).toHaveBeenCalledTimes(1); + + await runtime.dispose(); + }); + + it("adds a Gallery rerun its run could not adopt to that run's board", async () => { + // Still running from an earlier retry, so this rerun of its other failed item cannot reopen it. + const queueItem: QueueItem = { + ...createPendingQueueItem(), + backendItemIds: [99], + retriedBackendItemIds: [41, 42], + snapshot: { ...createPendingQueueItem().snapshot, destination: 'gallery', galleryBoardId: 'board-1' }, + status: 'running', + }; + const getResultImages = vi.fn().mockResolvedValue([{ imageName: 'rerun.png' } as QueueResultImage]); + const { commands, emit, project, runtime } = createRetryHarness(queueItem, { getResultImages }); + + runtime.start(); + emit('queue_items_retried', retriedEvent([{ item_id: 100, source_item_id: 42 }])); + emit('queue_item_status_changed', completedEvent(100, buildQueueItemOrigin(queueItem.id, project.id), 'gallery')); + + await vi.waitFor(() => expect(commands.refreshBackendData).toHaveBeenCalled()); + expect(commands.reopenForRetry).toHaveBeenCalledWith(expect.objectContaining({ queueItemId: queueItem.id })); + expect(runtimeServices.destinations.addImagesToGalleryBoard).toHaveBeenCalledWith('board-1', ['rerun.png']); + + await runtime.dispose(); + }); + + it('delivers reruns no run adopted to the Gallery or the current Canvas, and nothing else', async () => { + // The owning run refuses to reopen (its Canvas document was replaced); the other rerun belongs to another tab. + const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [41], status: 'failed' }; + const rerunImage = { imageName: 'rerun.png' } as QueueResultImage; + const getResultImages = vi.fn().mockResolvedValue([rerunImage]); + const { commands, emit, project, runtime } = createRetryHarness(queueItem, { getResultImages }); + const canvasOrigin = buildQueueItemOrigin(queueItem.id, project.id); + + runtime.start(); + emit( + 'queue_items_retried', + retriedEvent([ + { item_id: 99, source_item_id: 41 }, + { item_id: 100, source_item_id: 42 }, + ]) + ); + emit('queue_item_status_changed', completedEvent(99, canvasOrigin, 'canvas')); + emit('queue_item_status_changed', completedEvent(100, buildQueueItemOrigin('other-run', project.id), 'gallery')); + emit('queue_item_status_changed', completedEvent(101, canvasOrigin, 'canvas')); + + await vi.waitFor(() => { + expect(commands.stageRetriedCanvasResults).toHaveBeenCalledWith({ + backendItemId: 99, + images: [rerunImage], + projectId: project.id, + queueItemId: queueItem.id, + }); + }); + expect(getResultImages).toHaveBeenCalledTimes(1); + expect(getResultImages).toHaveBeenCalledWith(99, queueItem.id, '2026-07-17T00:00:00.000Z', { + resultNodeIds: ['canvas_output'], + }); + expect(commands.refreshBackendData).toHaveBeenCalledTimes(1); + expect(queueItem.status).toBe('failed'); + + await runtime.dispose(); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.ts b/invokeai/frontend/webv2/src/features/queue/runtime.ts index 37480af96b8..35aac4a9f4d 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.ts @@ -19,7 +19,15 @@ import { MAX_QUEUE_BATCH_ITEMS, type QueueWorkflowSeed, } from '@features/queue/core/promptBatch'; -import { shouldSubmitPendingQueueItem } from '@features/queue/core/submissionRules'; +import { QUEUE_SOURCE_RESULT_NODE_IDS, shouldSubmitPendingQueueItem } from '@features/queue/core/submissionRules'; +import { + isTerminalBackendStatus, + isUtilityQueueItemOrigin, + parseQueueItemOrigin, + parseQueueItemOriginProjectId, + type QueueItemStatusChangedEvent, + type QueueItemsRetriedEvent, +} from '@features/queue/data/events'; import { progressImageStore } from '@features/queue/data/progressImageStore'; import { createQueueCoordinator, @@ -33,6 +41,7 @@ import { } from '@features/queue/runtime/coordinator'; import { mapWithConcurrency } from '@platform/core/concurrency'; import { captureAccountScope, isAccountScopeCurrent } from '@platform/state/accountLifecycle'; +import { normalizeServerTimestamp } from '@platform/time/serverTimestamp'; import { ApiError, getApiErrorMessage } from '@platform/transport/http'; export interface QueueResultDestinationPort { @@ -86,6 +95,12 @@ export interface QueueHistoryCommands { }): void; recordError(payload: { area: string; message: string; namespace: 'queue'; projectId?: string }): void; refreshBackendData(): void; + /** Reopen a finished run around the backend items re-running its failed or canceled ones; refused runs stay. */ + reopenForRetry(payload: { + projectId: string; + queueItemId: string; + retriedItems: { itemId: number; sourceItemId: number }[]; + }): void; restoreFromJournal(payload: { items: unknown[]; projectId: string }): void; routePartialResults(payload: { backendItemId: number; @@ -101,6 +116,13 @@ export interface QueueHistoryCommands { videos: QueueResultVideo[]; }): void; setConnectionStatus(payload: { error?: string; status: BackendConnectionStatus }): void; + /** Stage a retried Canvas run's results at the current bbox when no open run could adopt it. */ + stageRetriedCanvasResults(payload: { + backendItemId: number; + images: QueueResultImage[]; + projectId: string; + queueItemId: string; + }): void; setStatus(payload: { error?: string; notify?: boolean; @@ -354,6 +376,11 @@ const getRouteTarget = (history: QueueHistoryPort, localQueueItemId: string) => const getRunKey = (projectId: string, queueItemId: string): string => JSON.stringify([projectId, queueItemId]); const QUEUE_RUNTIME_CONCURRENCY = 16; +/** + * Reruns no open run adopted, remembered until they finish so their results still reach the Gallery or Canvas; one + * retry can re-run up to the backend's default `max_queue_size`. + */ +const UNADOPTED_RERUN_LIMIT = 10_000; interface RunAttempt { generation: number; @@ -996,19 +1023,21 @@ export const createQueueRuntime = ({ } }; - /** - * Remove input passthroughs and requested intermediates before routing; echoed source media must not move boards - * on every run. - */ - const deliverVisibleImages = async ( - queueItem: QueueItem, - allImages: QueueResultImage[] - ): Promise => { + /** Drop input passthroughs and requested intermediates; echoed source media must not move boards on every run. */ + const selectVisibleImages = (queueItem: QueueItem, allImages: QueueResultImage[]): QueueResultImage[] => { const inputMedia = collectGraphInputMediaNames(getQueueItemCompiledGraph(queueItem)); const producedImages = allImages.filter((image) => !inputMedia.imageNames.has(image.imageName)); - const images = queueItem.snapshot.filterIntermediateResults + + return queueItem.snapshot.filterIntermediateResults ? producedImages.filter((image) => !image.isIntermediate) : producedImages; + }; + + const deliverVisibleImages = async ( + queueItem: QueueItem, + allImages: QueueResultImage[] + ): Promise => { + const images = selectVisibleImages(queueItem, allImages); await addImagesToDestination(queueItem, images); @@ -1250,6 +1279,163 @@ export const createQueueRuntime = ({ { backend: coordinatorBackend, modelLoads, nodeExecution } ); + /** Backend ids of reruns no run in this tab adopted. */ + const unadoptedReruns = new Set(); + const rememberUnadoptedRerun = (itemId: number): void => { + unadoptedReruns.add(itemId); + for (const oldest of unadoptedReruns) { + if (unadoptedReruns.size <= UNADOPTED_RERUN_LIMIT) { + break; + } + unadoptedReruns.delete(oldest); + } + }; + + /** + * A retry, from any tab or client, re-runs failed or canceled backend items under new ids. The open run that owns + * the retried items, or once owned them before an earlier retry, reopens around the reruns, so its captured + * destination receives their results; reconciliation then tracks them like any resumed run. + */ + const handleItemsRetried = (event: QueueItemsRetriedEvent): void => { + if (!isActive() || !event.retried_items?.length) { + return; + } + + const runsByBackendItemId = new Map(); + for (const project of history.getSnapshot().projects) { + for (const queueItem of project.queue.items) { + for (const backendItemId of [...(queueItem.backendItemIds ?? []), ...(queueItem.retriedBackendItemIds ?? [])]) { + runsByBackendItemId.set(backendItemId, { projectId: project.id, queueItemId: queueItem.id }); + } + } + } + + const retriedItemsByRun = new Map< + string, + { projectId: string; queueItemId: string; retriedItems: { itemId: number; sourceItemId: number }[] } + >(); + for (const { item_id: itemId, source_item_id: sourceItemId } of event.retried_items) { + const owner = runsByBackendItemId.get(sourceItemId); + + if (!owner) { + rememberUnadoptedRerun(itemId); + continue; + } + const key = getRunKey(owner.projectId, owner.queueItemId); + const run = retriedItemsByRun.get(key) ?? { ...owner, retriedItems: [] }; + run.retriedItems.push({ itemId, sourceItemId }); + retriedItemsByRun.set(key, run); + } + + for (const [key, run] of retriedItemsByRun) { + commands.reopenForRetry(run); + const reopened = getRouteTarget(history, run.queueItemId)?.queueItem; + let isAdopted = false; + + for (const { itemId } of run.retriedItems) { + if (reopened?.status === 'running' && reopened.backendItemIds?.includes(itemId)) { + isAdopted = true; + } else { + rememberUnadoptedRerun(itemId); + } + } + if (isAdopted) { + // A settle still waiting on the journal belongs to the finished run; it must not remove the reopened record. + pendingJournalSettles.delete(key); + pendingReconcileRunKeys.add(key); + } + } + synchronize(); + }; + + /** + * The images a rerun no run adopted produced. Its run, when this tab still holds it, says which nodes are results; + * otherwise the product sources' result nodes are tried before every image the rerun created, as for a Workflow. + */ + const readUnadoptedRerunImages = async ( + event: QueueItemStatusChangedEvent, + queueItemId: string, + run: QueueItem | undefined + ): Promise => { + const read = (options?: QueueResultImageOptions) => + runResultRead(() => backend.getResultImages(event.item_id, queueItemId, event.created_at, options)); + + if (run) { + return selectVisibleImages(run, await read(getQueueItemResultImageOptions(run))); + } + + const productResults = await read({ + resultNodeIds: [ + ...(QUEUE_SOURCE_RESULT_NODE_IDS.generate ?? []), + ...(QUEUE_SOURCE_RESULT_NODE_IDS.upscale ?? []), + ], + }); + if (productResults.length > 0) { + return productResults; + } + // Images older than the rerun are inputs its graph echoed back, not results. + const queuedAt = Date.parse(normalizeServerTimestamp(event.created_at)); + return (await read()).filter( + (image) => !image.isIntermediate && (!image.createdAt || Date.parse(image.createdAt) >= queuedAt) + ); + }; + + /** + * Deliver a rerun no open run adopted the way its destination would see any untracked result: the Gallery refreshes + * (onto its run's board when this tab still holds that run), and a Canvas project open here stages it. + */ + const handleUnadoptedRerunSettled = (event: QueueItemStatusChangedEvent): void => { + if (!isActive() || !isTerminalBackendStatus(event.status) || !unadoptedReruns.delete(event.item_id)) { + return; + } + if (event.status !== 'completed' || isUtilityQueueItemOrigin(event.origin)) { + return; + } + + const projectId = parseQueueItemOriginProjectId(event.origin); + const queueItemId = parseQueueItemOrigin(event.origin); + const target = queueItemId ? getRouteTarget(history, queueItemId) : undefined; + const run = target?.project.id === projectId ? target?.queueItem : undefined; + const isProjectOpen = history.getSnapshot().projects.some((project) => project.id === projectId); + const recordError = (error: unknown): void => { + if (isActive()) { + commands.recordError({ + area: 'queue-results', + message: toErrorMessage(error), + namespace: 'queue', + ...(projectId ? { projectId } : {}), + }); + } + }; + + if (event.destination === 'gallery') { + if (!run || !queueItemId) { + commands.refreshBackendData(); + return; + } + void readUnadoptedRerunImages(event, queueItemId, run) + .then((images) => addImagesToDestination(run, images)) + .catch(recordError) + .finally(() => { + if (isActive()) { + commands.refreshBackendData(); + } + }); + return; + } + if (event.destination !== 'canvas' || !projectId || !queueItemId || !isProjectOpen) { + return; + } + + void readUnadoptedRerunImages(event, queueItemId, run) + .then((images) => { + if (isActive() && images.length > 0) { + commands.stageRetriedCanvasResults({ backendItemId: event.item_id, images, projectId, queueItemId }); + } + }) + .catch(recordError); + }; + const submitQueueItem = async ( project: QueueHistoryProject, queueItem: QueueItem, @@ -1938,6 +2124,8 @@ export const createQueueRuntime = ({ ensureTemplatesLoaded(); detachers.push( history.subscribe(synchronize), + backend.on('queue_items_retried', handleItemsRetried), + backend.on('queue_item_status_changed', handleUnadoptedRerunSettled), backend.onConnectionChange((status, error) => { if (!isActive()) { return; diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.browser.test.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.browser.test.tsx index fe711a521ed..b15c7bf05f2 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.browser.test.tsx @@ -15,7 +15,7 @@ import { QueueUiProvider, type QueueUiAdapter } from './QueueUiContext'; vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); const commands = vi.hoisted(() => ({ cancelItem: vi.fn(() => Promise.resolve()), - retryItems: vi.fn((itemIds: number[]) => Promise.resolve(itemIds.map((id) => id + 100))), + retryItems: vi.fn((itemIds: number[]) => Promise.resolve(itemIds)), })); vi.mock('@features/queue/publicApi', () => ({ diff --git a/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.test.ts b/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.test.ts index 78516613af2..2fca64c6b7d 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.test.ts @@ -1,5 +1,6 @@ import type { QueueItemReadModel } from '@features/queue/core/types'; +import { buildUtilityQueueItemOrigin } from '@features/queue/data/events'; import { describe, expect, it } from 'vitest'; import { getQueueItemAccess, isCancellableQueueItem, isRetryableQueueItem } from './queueOwnership'; @@ -48,5 +49,6 @@ describe('queue item row actions', () => { expect(isRetryableQueueItem({ parentItemId: 3, status: 'failed' })).toBe(false); expect(isRetryableQueueItem({ parentItemId: null, status: 'completed' })).toBe(false); expect(isRetryableQueueItem({ parentItemId: null, status: 'pending' })).toBe(false); + expect(isRetryableQueueItem({ origin: buildUtilityQueueItemOrigin('thumbnail'), status: 'failed' })).toBe(false); }); }); diff --git a/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.ts b/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.ts index 5667dc22a6f..51772e1025d 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.ts +++ b/invokeai/frontend/webv2/src/features/queue/ui/queueOwnership.ts @@ -1,5 +1,7 @@ import type { QueueItemReadModel } from '@features/queue/core/types'; +import { isUtilityQueueItemOrigin } from '@features/queue/data/events'; + export interface QueueViewer { currentUserId: string | null; isAdmin: boolean; @@ -20,10 +22,14 @@ export const getQueueItemAccess = (item: QueueItemReadModel, viewer: QueueViewer canViewDetails: item.userId !== 'redacted', }); -/** Only failed or canceled roots retry: the backend clones the root session, so a child offers nothing extra. */ -export const isRetryableQueueItem = (item: Pick): boolean => +/** + * Only failed or canceled roots retry: the backend clones the root session, so a child offers nothing extra. Utility + * work (canvas filters, layer workflows) awaits its result in the caller that enqueued it, which a rerun cannot reach. + */ +export const isRetryableQueueItem = (item: Pick): boolean => (item.status === 'failed' || item.status === 'canceled') && - (item.parentItemId === null || item.parentItemId === undefined); + (item.parentItemId === null || item.parentItemId === undefined) && + !isUtilityQueueItemOrigin(item.origin); /** Pending, waiting (a workflow-call parent) and running items can all be canceled. */ export const isCancellableQueueItem = (item: Pick): boolean => diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunRestoration.ts b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunRestoration.ts index be72a669187..8a10e3c3f1d 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunRestoration.ts +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunRestoration.ts @@ -128,6 +128,10 @@ export const normalizeRestoredQueueItem = (value: unknown): WorkbenchQueueItem | cancellable: value.cancellable, ...(completedBackendItemIds === undefined ? {} : { completedBackendItemIds: [...completedBackendItemIds] }), id: value.id, + // Lineage only steers later retries back here; a malformed list just loses that. + ...(isBackendItemIdList(value.retriedBackendItemIds) + ? { retriedBackendItemIds: [...value.retriedBackendItemIds] } + : {}), // Keeps a restored run from reselecting videos it already showed before the reload. ...(Array.isArray(value.resultVideoNames) && value.resultVideoNames.every((name) => typeof name === 'string') ? { resultVideoNames: [...value.resultVideoNames] } diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts index 5759f1fcf60..77fa7e076f3 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts @@ -2286,6 +2286,200 @@ describe('workbenchReducer Phase 5 generation flow', () => { expect(activeProject.queue.items).toEqual([]); }); + it('reopens a failed Canvas run around its rerun and stages the rerun at the submitted bbox', () => { + let state = submitGenerate(primeGenerate()); + const project = getActiveProject(state); + const queueItem = project.queue.items[0]!; + const submittedBbox = queueItem.snapshot.canvas.document.bbox; + + state = workbenchReducer(state, { + backendItemIds: [41], + projectId: project.id, + queueItemId: queueItem.id, + type: 'markQueueItemBackendSubmitted', + }); + state = workbenchReducer(state, { + error: 'Out of memory', + notify: false, + projectId: project.id, + queueItemId: queueItem.id, + status: 'failed', + type: 'setQueueItemStatus', + }); + state = workbenchReducer(state, { + bbox: { ...submittedBbox, x: submittedBbox.x + 64 }, + type: 'setCanvasBbox', + }); + state = workbenchReducer(state, { + projectId: project.id, + queueItemId: queueItem.id, + // A rerun of another run's item is not this run's to adopt. + retriedItems: [ + { itemId: 99, sourceItemId: 41 }, + { itemId: 100, sourceItemId: 7 }, + ], + type: 'reopenQueueItemForRetry', + }); + + const reopened = getProject(state, project.id).queue.items[0]; + expect(reopened).toEqual(expect.objectContaining({ backendItemIds: [99], status: 'running' })); + expect(reopened).not.toHaveProperty('error'); + + state = workbenchReducer(state, { + images: [createImage('rerun.png', queueItem.id)], + projectId: project.id, + queueItemId: queueItem.id, + type: 'routeQueueItemResults', + }); + + const { stagingArea } = getProject(state, project.id).canvas; + expect(stagingArea.pendingImageIds).toEqual(['rerun.png']); + expect(stagingArea.pendingImages[0]).toEqual( + expect.objectContaining({ + placement: expect.objectContaining({ x: submittedBbox.x, y: submittedBbox.y }), + sourceBackendItemId: 99, + }) + ); + }); + + it('leaves a run finished when it is still active or its Canvas document was replaced', () => { + let state = submitGenerate(primeGenerate()); + const project = getActiveProject(state); + const queueItem = project.queue.items[0]!; + const reopen = (current: WorkbenchState) => + workbenchReducer(current, { + projectId: project.id, + queueItemId: queueItem.id, + retriedItems: [{ itemId: 99, sourceItemId: 41 }], + type: 'reopenQueueItemForRetry', + }); + + state = workbenchReducer(state, { + backendItemIds: [41], + projectId: project.id, + queueItemId: queueItem.id, + type: 'markQueueItemBackendSubmitted', + }); + expect(reopen(state)).toBe(state); + + state = workbenchReducer(state, { + notify: false, + projectId: project.id, + queueItemId: queueItem.id, + status: 'failed', + type: 'setQueueItemStatus', + }); + state = workbenchReducer(state, { document: createEmptyCanvasDocument(), type: 'replaceCanvasDocument' }); + expect(reopen(state)).toBe(state); + }); + + it('returns a later retry of another item the run left behind to that run, keeping its earlier results', () => { + let state = submitGenerate(primeGenerate()); + const project = getActiveProject(state); + const queueItem = project.queue.items[0]!; + const reopen = (current: WorkbenchState, itemId: number, sourceItemId: number) => + workbenchReducer(current, { + projectId: project.id, + queueItemId: queueItem.id, + retriedItems: [{ itemId, sourceItemId }], + type: 'reopenQueueItemForRetry', + }); + const finish = (current: WorkbenchState, imageName: string) => + workbenchReducer(current, { + images: [createImage(imageName, queueItem.id)], + projectId: project.id, + queueItemId: queueItem.id, + type: 'routeQueueItemResults', + }); + + state = workbenchReducer(state, { + backendItemIds: [40, 41, 42], + projectId: project.id, + queueItemId: queueItem.id, + type: 'markQueueItemBackendSubmitted', + }); + state = workbenchReducer(state, { + backendItemId: 40, + images: [createImage('first.png', queueItem.id)], + projectId: project.id, + queueItemId: queueItem.id, + type: 'routeQueueItemPartialResults', + }); + state = workbenchReducer(state, { + notify: false, + projectId: project.id, + queueItemId: queueItem.id, + status: 'failed', + type: 'setQueueItemStatus', + }); + + state = finish(reopen(state, 99, 41), 'rerun-41.png'); + expect(getProject(state, project.id).queue.items[0]).toEqual( + expect.objectContaining({ + resultImages: [ + expect.objectContaining({ imageName: 'first.png' }), + expect.objectContaining({ imageName: 'rerun-41.png' }), + ], + status: 'completed', + }) + ); + + state = reopen(state, 100, 42); + expect(getProject(state, project.id).queue.items[0]).toEqual( + expect.objectContaining({ backendItemIds: [100], status: 'running' }) + ); + }); + + it('stages a rerun its active run could not adopt where that run stages', () => { + let state = submitGenerate(primeGenerate()); + const project = getActiveProject(state); + const queueItem = project.queue.items[0]!; + const submittedBbox = queueItem.snapshot.canvas.document.bbox; + + state = workbenchReducer(state, { + bbox: { ...submittedBbox, x: submittedBbox.x + 64 }, + type: 'setCanvasBbox', + }); + state = workbenchReducer(state, { + backendItemId: 100, + images: [createImage('rerun.png', queueItem.id)], + projectId: project.id, + queueItemId: queueItem.id, + type: 'stageRetriedCanvasResults', + }); + + expect(getCanvas(state).stagingArea.pendingImages[0]).toEqual( + expect.objectContaining({ placement: expect.objectContaining({ x: submittedBbox.x, y: submittedBbox.y }) }) + ); + }); + + it('stages a rerun no run adopted at the current bbox', () => { + let state = primeGenerate(); + const projectId = state.activeProjectId; + const initialBbox = getCanvas(state).document.bbox; + + state = workbenchReducer(state, { bbox: { ...initialBbox, x: initialBbox.x + 64 }, type: 'setCanvasBbox' }); + const currentBbox = getCanvas(state).document.bbox; + expect(currentBbox.x).not.toBe(initialBbox.x); + state = workbenchReducer(state, { + backendItemId: 99, + images: [createImage('rerun.png', 'run-from-another-tab')], + projectId, + queueItemId: 'run-from-another-tab', + type: 'stageRetriedCanvasResults', + }); + + const { stagingArea } = getCanvas(state); + expect(stagingArea.pendingImageIds).toEqual(['rerun.png']); + expect(stagingArea.isVisible).toBe(true); + expect(stagingArea.pendingImages[0]).toEqual( + expect.objectContaining({ + placement: expect.objectContaining({ x: currentBbox.x, y: currentBbox.y }), + sourceBackendItemId: 99, + }) + ); + }); + it("keeps the selected workflow candidate when auto-switch is 'off'", () => { let state = createInitialWorkbenchState(); const projectId = state.activeProjectId; diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.ts index 8b988830955..da0a8c0d3a0 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.ts @@ -150,7 +150,7 @@ import { sanitizeBatchCount, syncGenerateWidgetValuesWithModels, } from '@features/generation/settings'; -import { MAX_QUEUE_BATCH_ITEMS } from '@features/queue'; +import { getQueueSourceResultSelection, MAX_QUEUE_BATCH_ITEMS } from '@features/queue'; import { getGenerationDevicesSnapshot, resolveRandDeviceMetadata } from '@features/queue/devices'; import { clearDeletedUpscaleInput, @@ -439,6 +439,19 @@ type WorkbenchReducerAction = videos?: GeneratedVideoContract[]; } | { type: 'restoreQueueItemsFromJournal'; projectId: string; items: unknown[] } + | { + type: 'reopenQueueItemForRetry'; + projectId: string; + queueItemId: string; + retriedItems: { itemId: number; sourceItemId: number }[]; + } + | { + type: 'stageRetriedCanvasResults'; + projectId: string; + queueItemId: string; + backendItemId: number; + images: GeneratedImageContract[]; + } | { type: 'appendCanvasStagingCandidate'; projectId: string; candidate: CanvasStagingCandidateContract } | { type: 'selectGalleryItem'; @@ -971,15 +984,49 @@ const stageCanvasResultImages = ( ): Project => { const queueItem = project.queue.items.find((item) => item.id === queueItemId); - if ( - images.length === 0 || - !queueItem || - queueItem.snapshot.canvas.documentRevision !== project.canvas.documentRevision - ) { + if (!queueItem || queueItem.snapshot.canvas.documentRevision !== project.canvas.documentRevision) { + return project; + } + + return stageCanvasImages( + project, + queueItemId, + queueItem.snapshot.canvas.document, + images, + sourceBackendItemIds, + previousSelectedSlot + ); +}; + +/** + * Where a rerun no run adopted stages: where its run would have while that document is still open, else at the current + * bbox. + */ +const getRetriedCanvasResultDocument = ( + project: Project, + queueItemId: string +): Pick => { + const queueItem = project.queue.items.find((item) => item.id === queueItemId); + + return queueItem?.snapshot.destination === 'canvas' && + queueItem.snapshot.canvas.documentRevision === project.canvas.documentRevision + ? queueItem.snapshot.canvas.document + : project.canvas.document; +}; + +/** Stage images at `queueDocument`'s bbox: the submitted document's, or the current one for a run nobody captured. */ +const stageCanvasImages = ( + project: Project, + queueItemId: string, + queueDocument: Pick, + images: GeneratedImageContract[], + sourceBackendItemIds?: readonly (number | undefined)[], + previousSelectedSlot?: CanvasStagingSlot +): Project => { + if (images.length === 0) { return project; } - const queueDocument = queueItem.snapshot.canvas.document; const { bbox } = queueDocument; const incomingImages = images.map((image, index) => normalizeStagingCandidate( @@ -3180,6 +3227,50 @@ const mergeImageResults = ( return [...existing, ...incomingImages.filter((image) => !existingNames.has(image.imageName))]; }; +/** + * Reopen a finished run around the backend items that re-run its failed or canceled ones, so its captured destination + * receives their results. A run already active, or a Canvas run whose document was since replaced, stays finished. + */ +const reopenQueueItemForRetry = ( + project: Project, + queueItemId: string, + retriedItems: readonly { itemId: number; sourceItemId: number }[] +): Project => { + const queueItem = project.queue.items.find((item) => item.id === queueItemId); + const ownedItemIds = new Set([...(queueItem?.backendItemIds ?? []), ...(queueItem?.retriedBackendItemIds ?? [])]); + const itemIds = retriedItems.filter(({ sourceItemId }) => ownedItemIds.has(sourceItemId)).map(({ itemId }) => itemId); + + if ( + !queueItem || + itemIds.length === 0 || + queueItem.cancellationPending === true || + (queueItem.status !== 'completed' && queueItem.status !== 'failed' && queueItem.status !== 'cancelled') || + (queueItem.snapshot.destination === 'canvas' && + queueItem.snapshot.canvas.documentRevision !== project.canvas.documentRevision) + ) { + return project; + } + + return updateQueueItem(project, queueItemId, (item) => { + const { + cancelledBackendItemIds: _cancelled, + completedBackendItemIds: _completed, + error: _error, + localRecoveryState: _localRecoveryState, + ...reopened + } = item; + + // The run tracks only the reruns from here; it remembers the items it left so that retrying another of them + // later still returns its results here. + return { + ...reopened, + backendItemIds: itemIds, + retriedBackendItemIds: [...ownedItemIds], + status: 'running', + }; + }); +}; + const mergeBackendItemId = (ids: number[] | undefined, backendItemId: number): number[] => ids?.includes(backendItemId) ? ids : [...(ids ?? []), backendItemId]; @@ -3396,7 +3487,8 @@ const routeQueueItemResults = ( completedBackendItemIds: item.backendItemIds ? item.backendItemIds.filter((backendItemId) => !item.cancelledBackendItemIds?.includes(backendItemId)) : item.completedBackendItemIds, - resultImages: images, + // A run a retry reopened keeps the results its other items delivered before. + resultImages: item.retriedBackendItemIds ? mergeImageResults(item.resultImages, images) : images, status: 'completed', })); @@ -3602,13 +3694,7 @@ const enqueueCompiledSnapshot = ( width: presentationDimensions.width, }, sourceId: route.sourceId, - ...(route.sourceId === 'generate' || route.sourceId === 'canvas' - ? { resultNodeIds: ['canvas_output'] } - : route.sourceId === 'upscale' - ? { resultNodeIds: ['upscale_output'] } - : route.sourceId === 'video' - ? { resultNodeIds: ['video_output'] } - : {}), + ...getQueueSourceResultSelection(route.sourceId), submittedAt, ...(recall ? { recall } : {}), ...(prepend ? { prepend: true } : {}), @@ -4996,6 +5082,25 @@ export const __workbenchReducerInternal = ( : { ...project, queue: { items: [...liveItems, ...restoredItems] } }; }); } + case 'reopenQueueItemForRetry': { + return updateProjectById(state, action.projectId, (project) => + reopenQueueItemForRetry(project, action.queueItemId, action.retriedItems) + ); + } + case 'stageRetriedCanvasResults': { + return updateProjectById(state, action.projectId, (project) => + clampCanvasStagingSelection( + stageCanvasImages( + project, + action.queueItemId, + getRetriedCanvasResultDocument(project, action.queueItemId), + action.images, + action.images.map(() => action.backendItemId), + getSelectedCanvasStagingSlot(project) + ) + ) + ); + } case 'appendCanvasStagingCandidate': { return updateProjectById(state, action.projectId, (project) => appendCanvasStagingCandidate(project, action.candidate) diff --git a/invokeai/frontend/webv2/src/workbench/workbenchStore.ts b/invokeai/frontend/webv2/src/workbench/workbenchStore.ts index d1e89b9641e..8c998829eca 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchStore.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchStore.ts @@ -464,7 +464,9 @@ const createCommands = ( setLocalRecoveryState: command('setQueueItemLocalRecoveryState'), routePartialResults: command('routeQueueItemPartialResults'), routeResults: command('routeQueueItemResults'), + reopenForRetry: command('reopenQueueItemForRetry'), restoreFromJournal: command('restoreQueueItemsFromJournal'), + stageRetriedCanvasResults: command('stageRetriedCanvasResults'), setConnectionStatus: command('setBackendConnectionStatus'), setStatus: command('setQueueItemStatus'), }, diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index cf12b881833..76f32f43065 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -3094,10 +3094,17 @@ def test_queue_items_retried_payload_is_filtered_per_owner_room(self, socketio: from unittest.mock import AsyncMock from invokeai.app.services.events.events_common import QueueItemsRetriedEvent - from invokeai.app.services.session_queue.session_queue_common import RetryItemsResult + from invokeai.app.services.session_queue.session_queue_common import RetriedQueueItem, RetryItemsResult event = QueueItemsRetriedEvent.build( - RetryItemsResult(queue_id="default", retried_item_ids=[10, 20]), + RetryItemsResult( + queue_id="default", + retried_item_ids=[10, 20], + retried_items=[ + RetriedQueueItem(source_item_id=10, item_id=30), + RetriedQueueItem(source_item_id=20, item_id=31), + ], + ), user_ids=["owner-123", "owner-456"], retried_item_ids_by_user={"owner-123": [10], "owner-456": [20]}, ) @@ -3123,6 +3130,12 @@ def test_queue_items_retried_payload_is_filtered_per_owner_room(self, socketio: assert admin_calls[0].kwargs["data"]["retried_item_ids"] == [10, 20] assert admin_calls[0].kwargs["data"]["user_ids"] == ["owner-123", "owner-456"] assert admin_calls[0].kwargs["data"]["retried_item_ids_by_user"] == {"owner-123": [10], "owner-456": [20]} + # The new item ids follow the retried ids they pair with: each owner sees only its own reruns. + assert owner_123_calls[0].kwargs["data"]["retried_items"] == [{"source_item_id": 10, "item_id": 30}] + assert owner_456_calls[0].kwargs["data"]["retried_items"] == [{"source_item_id": 20, "item_id": 31}] + assert len(admin_calls[0].kwargs["data"]["retried_items"]) == 2 + sanitized_calls = [call for call in mock_emit.call_args_list if call.kwargs.get("room") == "default"] + assert sanitized_calls[0].kwargs["data"]["retried_items"] == [] def test_queue_items_canceled_routed_privately_per_owner(self, socketio: Any) -> None: """The FULL queue_items_canceled event, which carries owners' item ids, must reach only the diff --git a/tests/app/services/session_queue/test_session_queue_workflow_call_retry.py b/tests/app/services/session_queue/test_session_queue_workflow_call_retry.py index 120287f5715..1b0120c48d1 100644 --- a/tests/app/services/session_queue/test_session_queue_workflow_call_retry.py +++ b/tests/app/services/session_queue/test_session_queue_workflow_call_retry.py @@ -6,6 +6,7 @@ from invokeai.app.services.events.events_common import QueueItemsRetriedEvent from invokeai.app.services.invoker import Invoker +from invokeai.app.services.session_queue.session_queue_common import RetriedQueueItem from invokeai.app.services.session_queue.session_queue_default import SessionQueue from invokeai.app.services.shared.database.database import Database from invokeai.app.services.shared.graph import Graph, GraphExecutionState @@ -112,12 +113,32 @@ def test_retry_items_by_id_retries_root_once_for_child_chain_item( assert retried_items[0].workflow_call_id is None assert retried_items[0].parent_item_id is None assert retried_items[0].root_item_id is None + assert retry_result.retried_items == [ + RetriedQueueItem(source_item_id=root_item_id, item_id=retried_items[0].item_id) + ] retry_events = [event for event in event_bus.events if isinstance(event, QueueItemsRetriedEvent)] assert len(retry_events) == 1 assert retry_events[0].retried_item_ids == [root_item_id] assert retry_events[0].user_ids == ["user-1"] assert retry_events[0].retried_item_ids_by_user == {"user-1": [root_item_id]} + assert retry_events[0].retried_items == retry_result.retried_items + + +def test_retrying_a_retry_pairs_it_with_its_own_rerun_and_keeps_the_original_lineage( + session_queue: SessionQueue, +) -> None: + original_item_id = _insert_queue_item( + session_queue, session=GraphExecutionState(graph=Graph()), user_id="user-1", status="failed" + ) + first = session_queue.retry_items_by_id("default", [original_item_id]).retried_items[0].item_id + session_queue.cancel_queue_item(first) + + second = session_queue.retry_items_by_id("default", [first]).retried_items + + assert [item.source_item_id for item in second] == [first] + rerun = session_queue.get_queue_item(second[0].item_id) + assert (rerun.status, rerun.retried_from_item_id) == ("pending", original_item_id) def test_retry_items_by_id_emits_unique_owner_ids_for_multiple_roots( @@ -133,6 +154,15 @@ def test_retry_items_by_id_emits_unique_owner_ids_for_multiple_roots( retry_result = session_queue.retry_items_by_id("default", [first_root_item_id, second_root_item_id]) assert retry_result.retried_item_ids == [first_root_item_id, second_root_item_id] + reruns = { + rerun.retried_from_item_id: rerun.item_id + for rerun in session_queue.list_all_queue_items("default") + if rerun.retried_from_item_id is not None + } + assert [(item.source_item_id, item.item_id) for item in retry_result.retried_items] == [ + (first_root_item_id, reruns[first_root_item_id]), + (second_root_item_id, reruns[second_root_item_id]), + ] retry_events = [event for event in event_bus.events if isinstance(event, QueueItemsRetriedEvent)] assert len(retry_events) == 1 @@ -141,6 +171,7 @@ def test_retry_items_by_id_emits_unique_owner_ids_for_multiple_roots( "user-1": [first_root_item_id], "user-2": [second_root_item_id], } + assert retry_events[0].retried_items == retry_result.retried_items def test_retried_items_inherit_the_project_of_the_root( From 4ec22ca61140c99c99ae0ef4196bd5add8924d65 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 16:14:25 -0600 Subject: [PATCH 18/35] fix(workflow): ignore form bounds beyond the node and warn on every control Authored bounds past the node's own limits are ignored like inverted ones, and a bounds or step problem is announced on every control, including number inputs. Shuffle hides when no step value is valid, a one-stop slider shows as a number input, unknown settings survive edits, and Arrange is unit-tested. --- .../frontend/webv2/public/locales/en.json | 3 + .../src/features/workflow/core/fields.test.ts | 6 +- .../src/features/workflow/core/fields.ts | 21 +++- .../workflow/core/formFieldSettings.test.ts | 83 ++++++++++++++ .../workflow/core/formFieldSettings.ts | 101 +++++++++++++++--- .../workflow/core/graphLayout.test.ts | 64 ++++++++++- .../src/features/workflow/core/graphLayout.ts | 69 ++++++++++++ .../workflow/ui/editor/WorkflowEditorView.tsx | 51 ++------- .../workflow/ui/fields/WorkflowFieldInput.tsx | 6 +- .../workflow/ui/linear/NodeFieldControl.tsx | 4 +- .../ui/linear/NodeFieldSettingsPopover.tsx | 40 +++---- .../WorkflowLinearPanel.browser.test.tsx | 86 +++++++++++++-- 12 files changed, 446 insertions(+), 88 deletions(-) diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 6fed5d7662f..4c7a43ea1d2 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -3368,6 +3368,9 @@ "removeOption": "Remove option {{index}}", "invertedBounds": "The minimum is above the maximum; the form ignores both until one is corrected.", "sliderNeedsBounds": "A slider needs a minimum and a maximum with at least one step between them; until then the form shows an input.", + "boundsOutsideNode": "A bound lies beyond the node's own limits and would leave no valid value; the form ignores it until it is corrected.", + "noValidValue": "No multiple of the step lies between the bounds, so no value is valid and Invoke stays blocked. Widen the bounds or change the step.", + "unnamedOption": "Option {{index}}", "step": "Step", "containerEmpty": "Empty container — drag cards here", "dropHere": "Drop here", diff --git a/invokeai/frontend/webv2/src/features/workflow/core/fields.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/fields.test.ts index c4d1b2a51a6..ddbfd3afbb7 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/fields.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/fields.test.ts @@ -503,10 +503,14 @@ describe('getRandomWorkflowFieldValue', () => { expect(isWorkflowFieldValueValid(legacy, value)).toBe(true); }); - it('shuffles only direct numeric fields', () => { + it('shuffles only direct numeric fields with a value in range', () => { expect(isShuffleableField(input({ type: single('IntegerField') }))).toBe(true); expect(isShuffleableField(input({ type: single('FloatField') }))).toBe(true); expect(isShuffleableField(input({ input: 'connection', type: single('IntegerField') }))).toBe(false); expect(isShuffleableField(input())).toBe(false); + // No multiple of 8 lies between 1 and 7, so any value a shuffle wrote would be invalid. + expect(isShuffleableField(input({ maximum: 7, minimum: 1, multipleOf: 8, type: single('IntegerField') }))).toBe( + false + ); }); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/fields.ts b/invokeai/frontend/webv2/src/features/workflow/core/fields.ts index afb921304c6..b370fee2dba 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/fields.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/fields.ts @@ -72,10 +72,12 @@ export const isDirectInputField = (template: FieldInputTemplate): boolean => export const isExposableField = (template: FieldInputTemplate): boolean => isDirectInputField(template); /** Scalar numeric fields whose linear-form element can show a randomize button. */ +/** Direct numeric fields whose bounds admit at least one value; shuffling any other would write an invalid one. */ export const isShuffleableField = (template: FieldInputTemplate): boolean => (template.type.name === 'IntegerField' || template.type.name === 'FloatField') && template.type.cardinality !== 'COLLECTION' && - isDirectInputField(template); + isDirectInputField(template) && + admitsWorkflowFieldStepValue(template); const countDecimals = (value: number): number => { const [, fraction = ''] = String(value).split('.'); @@ -140,6 +142,23 @@ export const getWorkflowFieldStepIndexBounds = ( }; }; +/** + * False when the bounds admit no multiple of the field's step (every integer, for an integer field without one), so + * no value passes validation: a field stepping by 8 between 1 and 7. Fields without a step admit any value in range. + */ +export const admitsWorkflowFieldStepValue = (template: FieldInputTemplate): boolean => { + const multipleOf = finiteNumberOrNull(template.multipleOf); + const step = multipleOf !== null && multipleOf > 0 ? multipleOf : template.type.name === 'IntegerField' ? 1 : null; + + if (step === null) { + return true; + } + + const { first, last } = getWorkflowFieldStepIndexBounds(template, step); + + return first === null || last === null || first <= last; +}; + /** The multiple of `step` at `index`, written at the step's own precision so three tenths reads 0.3. */ export const toWorkflowFieldStepMultiple = (index: number, step: number): number => Number((index * step).toFixed(countDecimals(step))); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts index a6b961738f8..6a1ecfbcfcb 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts @@ -6,6 +6,7 @@ import { applyNodeFieldFormSettings, getDefaultNodeFieldFormSettings, getNodeFieldFormSettings, + getNodeFieldSettingsIssue, getNodeFieldSliderRange, serializeNodeFieldFormSettings, } from './formFieldSettings'; @@ -160,6 +161,32 @@ describe('serializeNodeFieldFormSettings', () => { ) ).toEqual({ component: 'input', type: 'string-field-config' }); }); + + it('keeps keys of the stored bag it does not own and drops owned ones the edit cleared', () => { + expect( + serializeNodeFieldFormSettings({ component: 'slider', kind: 'number', max: 8 }, template('IntegerField'), false, { + component: 'slider', + max: 8, + min: 2, + showShuffle: true, + sliderMarks: [2, 8], + type: 'integer-field-config', + }) + ).toEqual({ component: 'slider', max: 8, showShuffle: false, sliderMarks: [2, 8], type: 'integer-field-config' }); + expect( + serializeNodeFieldFormSettings( + { component: 'input', kind: 'string', options: [] }, + template('StringField'), + false, + { + component: 'dropdown', + options: [{ label: 'A', value: 'a' }], + placeholder: 'Pick one', + type: 'string-field-config', + } + ) + ).toEqual({ component: 'input', placeholder: 'Pick one', type: 'string-field-config' }); + }); }); describe('applyNodeFieldFormSettings', () => { @@ -203,6 +230,26 @@ describe('applyNodeFieldFormSettings', () => { ).toMatchObject({ maximum: 100, minimum: 0, multipleOf: 5 }); }); + it("ignores a bound beyond the template's other end, which would leave no valid value", () => { + const closed = template('IntegerField', { maximum: 10, minimum: 0 }); + + expect(applyNodeFieldFormSettings(closed, { component: 'slider', kind: 'number', min: 20 })).toBe(closed); + expect(applyNodeFieldFormSettings(closed, { component: 'slider', kind: 'number', max: -5 })).toBe(closed); + // Either end may be reached exactly, which leaves a single valid value. + expect(applyNodeFieldFormSettings(closed, { component: 'slider', kind: 'number', min: 10 })).toMatchObject({ + maximum: 10, + minimum: 10, + }); + // An exclusive end admits values up to, not at, the bound. + const open = template('FloatField', { exclusiveMaximum: 10, exclusiveMinimum: 0 }); + + expect(applyNodeFieldFormSettings(open, { component: 'slider', kind: 'number', min: 10 })).toBe(open); + expect(applyNodeFieldFormSettings(open, { component: 'slider', kind: 'number', max: 0 })).toBe(open); + expect(applyNodeFieldFormSettings(open, { component: 'slider', kind: 'number', max: 9.5, min: 0.5 })).toMatchObject( + { exclusiveMaximum: null, exclusiveMinimum: null, maximum: 9.5, minimum: 0.5 } + ); + }); + it('returns the template itself for string settings and unchanged bounds', () => { const base = template('StringField'); @@ -264,4 +311,40 @@ describe('getNodeFieldSliderRange', () => { expect(getNodeFieldSliderRange(template('IntegerField', { maximum: 7, minimum: 1, multipleOf: 8 }))).toBeNull(); expect(getNodeFieldSliderRange(template('IntegerField', { maximum: 2.9, minimum: 2.1 }))).toBeNull(); }); + + it('has no range when the bounds admit a single stop, which a track could not move between', () => { + expect(getNodeFieldSliderRange(template('IntegerField', { maximum: 8, minimum: 8 }))).toBeNull(); + expect(getNodeFieldSliderRange(template('IntegerField', { maximum: 12, minimum: 5, multipleOf: 8 }))).toBeNull(); + expect(getNodeFieldSliderRange(template('FloatField', { maximum: 0.5, minimum: 0.5 }))).toBeNull(); + }); +}); + +describe('getNodeFieldSettingsIssue', () => { + const closed = template('IntegerField', { maximum: 100, minimum: 0 }); + + it('names the problem the author has to fix first', () => { + expect(getNodeFieldSettingsIssue(closed, { component: 'number-input', kind: 'number', max: 5, min: 10 })).toBe( + 'inverted' + ); + expect(getNodeFieldSettingsIssue(closed, { component: 'number-input', kind: 'number', min: 200 })).toBe( + 'outOfRange' + ); + // Bounds and step are each valid, but no multiple of 8 lies between 1 and 7: every value fails validation. + expect( + getNodeFieldSettingsIssue(closed, { component: 'number-input', kind: 'number', max: 7, min: 1, step: 8 }) + ).toBe('noValue'); + expect(getNodeFieldSettingsIssue(closed, { component: 'slider', kind: 'number', max: 8, min: 8 })).toBe('noTrack'); + }); + + it('reports nothing for settings the form honours, including an input on an open-ended field', () => { + expect(getNodeFieldSettingsIssue(closed, { component: 'slider', kind: 'number', max: 50, min: 10 })).toBeNull(); + // Bounds that do not tighten the template are not applied, but they leave the range intact. + expect(getNodeFieldSettingsIssue(closed, { component: 'slider', kind: 'number', max: 500, min: -5 })).toBeNull(); + expect( + getNodeFieldSettingsIssue(template('IntegerField', { minimum: 0 }), { component: 'number-input', kind: 'number' }) + ).toBeNull(); + expect( + getNodeFieldSettingsIssue(template('StringField'), { component: 'dropdown', kind: 'string', options: [] }) + ).toBeNull(); + }); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts index 0e4870691a8..f5f37d260be 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts @@ -1,6 +1,11 @@ import type { FieldInputTemplate, WorkflowForm } from './types'; -import { finiteNumberOrNull, getWorkflowFieldStepIndexBounds, toWorkflowFieldStepMultiple } from './fields'; +import { + admitsWorkflowFieldStepValue, + finiteNumberOrNull, + getWorkflowFieldStepIndexBounds, + toWorkflowFieldStepMultiple, +} from './fields'; /** * Per-element control settings an author gives a form field: which control renders it and, for numbers, the @@ -19,7 +24,10 @@ export interface NodeFieldStringOption { export interface NodeFieldNumberSettings { kind: 'number'; component: NumberFieldComponent; - /** Authored bounds and step; each applies only where it tightens the invocation template's own constraint. */ + /** + * Authored bounds and step; each applies only where it tightens the invocation template's own constraint without + * leaving its range. + */ min?: number; max?: number; step?: number; @@ -134,25 +142,34 @@ export const getNodeFieldFormSettings = ( }; /** Authored bounds that cannot form a range; the form ignores both until the author fixes one. */ -export const hasInvertedNodeFieldBounds = (settings: NodeFieldFormSettings | null): boolean => +const hasInvertedNodeFieldBounds = (settings: NodeFieldFormSettings | null): boolean => settings?.kind === 'number' && settings.min !== undefined && settings.max !== undefined && settings.min > settings.max; +// The bag keys these settings own; a cleared one is left out rather than carried over from the stored bag. +const OWNED_SETTINGS_KEYS = new Set(['component', 'max', 'min', 'options', 'showShuffle', 'step', 'type']); + /** * The settings bag to store for `settings` on a field of `template`, in the legacy shape: tagged with the field - * kind, and carrying `showShuffle` on number fields as the v6 editor does. + * kind, and carrying `showShuffle` on number fields as the v6 editor does. Keys of `stored` these settings do not + * own (another editor's) are kept. */ export const serializeNodeFieldFormSettings = ( settings: NodeFieldFormSettings, template: FieldInputTemplate, - showShuffle: boolean + showShuffle: boolean, + stored?: Record ): Record => { const type = isSettingsFieldTypeName(template.type.name) ? LEGACY_SETTINGS_TYPES[template.type.name] : undefined; + const unowned = stored + ? Object.fromEntries(Object.entries(stored).filter(([key]) => !OWNED_SETTINGS_KEYS.has(key))) + : {}; if (settings.kind === 'string') { return { + ...unowned, component: settings.component, type, ...(settings.component === 'dropdown' ? { options: settings.options.map((option) => ({ ...option })) } : {}), @@ -160,6 +177,7 @@ export const serializeNodeFieldFormSettings = ( } return { + ...unowned, component: settings.component, showShuffle, type, @@ -175,11 +193,28 @@ const isMultipleOf = (step: number, base: number): boolean => { return Math.abs(ratio - Math.round(ratio)) < 1e-9; }; +// Whether an authored bound lies inside the template's opposite end. One beyond it (a minimum above the template's +// maximum) would leave no value in range, so the form ignores it as it does an inverted pair. +const isMinimumInsideTemplate = (template: FieldInputTemplate, min: number): boolean => { + const maximum = finiteNumberOrNull(template.maximum); + const exclusiveMaximum = finiteNumberOrNull(template.exclusiveMaximum); + + return maximum !== null ? min <= maximum : exclusiveMaximum === null || min < exclusiveMaximum; +}; + +const isMaximumInsideTemplate = (template: FieldInputTemplate, max: number): boolean => { + const minimum = finiteNumberOrNull(template.minimum); + const exclusiveMinimum = finiteNumberOrNull(template.exclusiveMinimum); + + return minimum !== null ? max >= minimum : exclusiveMinimum === null || max > exclusiveMinimum; +}; + /** - * The template narrowed by the authored number settings: a bound applies only inside the template's own range (and - * an inverted pair not at all), and a step only when the template has none or the step keeps every value a multiple - * of the template's. Validation, shuffle and the control all read this template, so the form can never offer a - * value the node would reject. + * The template narrowed by the authored number settings: a bound applies only where it tightens the template's own + * end and stays inside its other one (an inverted pair not at all), and a step only when the template has none or + * the step keeps every value a multiple of the template's. Validation, shuffle and the control all read this + * template, so the form never offers a value the node would reject. Ignored settings stay stored for the author to + * correct; `getNodeFieldSettingsIssue` says why. */ export const applyNodeFieldFormSettings = ( template: FieldInputTemplate, @@ -195,7 +230,7 @@ export const applyNodeFieldFormSettings = ( next = next === template ? { ...template, ...fields } : { ...next, ...fields }; }; - if (settings.min !== undefined && !inverted) { + if (settings.min !== undefined && !inverted && isMinimumInsideTemplate(template, settings.min)) { if (template.minimum !== null) { if (settings.min > template.minimum) { patch({ minimum: settings.min }); @@ -205,7 +240,7 @@ export const applyNodeFieldFormSettings = ( } } - if (settings.max !== undefined && !inverted) { + if (settings.max !== undefined && !inverted && isMaximumInsideTemplate(template, settings.max)) { if (template.maximum !== null) { if (settings.max < template.maximum) { patch({ maximum: settings.max }); @@ -237,7 +272,8 @@ const FLOAT_SLIDER_STEP = 0.01; /** * The range a slider can show for `template` (already narrowed by `applyNodeFieldFormSettings`), or null when it - * has no track: an end is unbounded, or no stop would pass validation. The host then keeps the number input. + * has no track: an end is unbounded, or fewer than two stops would pass validation. The host then keeps the number + * input. * * Validation accepts only multiples of the step counted from zero (every integer, for an integer field without * one), so the track runs between the first and last multiples inside the bounds rather than the bounds @@ -250,7 +286,7 @@ export const getNodeFieldSliderRange = (template: FieldInputTemplate): NodeField if (step !== null) { const { first, last } = getWorkflowFieldStepIndexBounds(template, step); - if (first === null || last === null || first > last) { + if (first === null || last === null || first >= last) { return null; } @@ -264,13 +300,50 @@ export const getNodeFieldSliderRange = (template: FieldInputTemplate): NodeField const max = finiteNumberOrNull(template.maximum) ?? (exclusiveMaximum !== null ? exclusiveMaximum - FLOAT_SLIDER_STEP : null); - if (min === null || max === null || min > max) { + if (min === null || max === null || min >= max) { return null; } return { max, min, step: FLOAT_SLIDER_STEP }; }; +export type NodeFieldSettingsIssue = 'inverted' | 'outOfRange' | 'noValue' | 'noTrack'; + +/** + * Why the form cannot honour authored number settings as written, in the order an author would fix them; null when + * it can. `inverted` and `outOfRange` bounds are ignored by `applyNodeFieldFormSettings`; with `noValue` no value + * passes validation, so Invoke stays blocked; with `noTrack` a slider control falls back to the number input. + */ +export const getNodeFieldSettingsIssue = ( + template: FieldInputTemplate, + settings: NodeFieldFormSettings | null +): NodeFieldSettingsIssue | null => { + if (settings?.kind !== 'number') { + return null; + } + + if (hasInvertedNodeFieldBounds(settings)) { + return 'inverted'; + } + + if ( + (settings.min !== undefined && !isMinimumInsideTemplate(template, settings.min)) || + (settings.max !== undefined && !isMaximumInsideTemplate(template, settings.max)) + ) { + return 'outOfRange'; + } + + const effectiveTemplate = applyNodeFieldFormSettings(template, settings); + + if (!admitsWorkflowFieldStepValue(effectiveTemplate)) { + return 'noValue'; + } + + return settings.component !== 'number-input' && getNodeFieldSliderRange(effectiveTemplate) === null + ? 'noTrack' + : null; +}; + /** * The template a node field is validated against: the invocation template narrowed by the settings of the form * element exposing it, or the template itself when the field is not on the form. Invoke readiness and the builder's diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.test.ts index 4666511df6c..36b0e3c9153 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.test.ts @@ -2,7 +2,14 @@ import { describe, expect, it } from 'vitest'; import type { LayoutGraphEdge, LayoutGraphNode } from './graphLayout'; -import { getLayeredPositions, getNodeDepths, getSizedLayeredPositions, getTopologicalOrder } from './graphLayout'; +import { + getArrangedLayout, + getLayeredPositions, + getNodeDepths, + getSizedLayeredPositions, + getTopologicalOrder, + UNMEASURED_ARRANGE_SIZE, +} from './graphLayout'; // Diamond: a -> b, a -> c, b -> d, c -> d const DIAMOND_NODES: LayoutGraphNode[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }]; @@ -162,3 +169,58 @@ describe('getSizedLayeredPositions', () => { }); }); }); + +describe('getArrangedLayout', () => { + const sizes = (entries: Record) => new Map(Object.entries(entries)); + + it('arranges only the selection, anchored at its top-left corner, and leaves other nodes alone', () => { + const nodes = [ + { id: 'a', position: { x: 400, y: 300 } }, + { id: 'b', position: { x: 900, y: 200 } }, + { id: 'outside', position: { x: 0, y: 0 } }, + ]; + const edges = [ + { source: 'a', target: 'b', type: 'default' }, + { source: 'outside', target: 'a', type: 'default' }, + ]; + const { bounds, positions } = getArrangedLayout( + nodes, + edges, + new Set(['a', 'b']), + sizes({ a: { height: 50, width: 100 }, b: { height: 60, width: 120 } }) + ); + + expect(positions).toEqual({ a: { x: 400, y: 200 }, b: { x: 580, y: 200 } }); + expect(bounds).toEqual({ height: 60, width: 300, x: 400, y: 200 }); + }); + + it('ranks by data edges only, so a loop linkage does not fold the body onto its head', () => { + const nodes = [ + { id: 'head', position: { x: 0, y: 0 } }, + { id: 'body', position: { x: 0, y: 0 } }, + ]; + const edges = [ + { source: 'head', target: 'body', type: 'default' }, + { source: 'body', target: 'head', type: 'loop_linkage' }, + ]; + const { positions } = getArrangedLayout( + nodes, + edges, + null, + sizes({ body: { height: 50, width: 100 }, head: { height: 50, width: 100 } }) + ); + + expect(positions.head!.x).toBeLessThan(positions.body!.x); + }); + + it('sizes a node the view has not measured at a typical node size', () => { + const { bounds } = getArrangedLayout( + [{ id: 'a', position: { x: 10, y: 20 } }], + [], + null, + new Map([['a', { height: 0, width: 0 }]]) + ); + + expect(bounds).toEqual({ ...UNMEASURED_ARRANGE_SIZE, x: 10, y: 20 }); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts index 7ef7312d8b0..eaf5db7e850 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphLayout.ts @@ -264,3 +264,72 @@ export const getSizedLayeredPositions = ( return positions; }; + +export interface ArrangeNode extends LayoutGraphNode { + position: { x: number; y: number }; +} + +export interface ArrangeEdge { + source: string; + target: string; + type: string; +} + +export interface ArrangedLayout { + /** The arranged nodes' footprint, for the view to fit before the rendered positions catch up. */ + bounds: { height: number; width: number; x: number; y: number }; + positions: Record; +} + +/** The size assumed for a node the view has not measured yet (a large graph renders only what is visible). */ +export const UNMEASURED_ARRANGE_SIZE = { height: 160, width: 288 } as const; + +/** + * The editor's Arrange command: lays out `selectedIds` (every node when null) with `getSizedLayeredPositions`, + * anchored at the arranged nodes' current top-left corner so the result stays where the author was working. Nodes + * outside the selection keep their positions, and edges to them do not rank the arranged ones. Loop linkages are + * ignored too: they point backwards, and ranking by them would fold a loop body onto its own head. + */ +export const getArrangedLayout = ( + nodes: readonly ArrangeNode[], + edges: readonly ArrangeEdge[], + selectedIds: ReadonlySet | null, + measuredSizes: ReadonlyMap +): ArrangedLayout => { + const targets = nodes.filter((node) => !selectedIds || selectedIds.has(node.id)); + const origin = targets.reduce( + (corner, node) => ({ x: Math.min(corner.x, node.position.x), y: Math.min(corner.y, node.position.y) }), + { x: Infinity, y: Infinity } + ); + const sized = targets.map((node) => { + const measured = measuredSizes.get(node.id); + + return { + height: measured?.height || UNMEASURED_ARRANGE_SIZE.height, + id: node.id, + width: measured?.width || UNMEASURED_ARRANGE_SIZE.width, + }; + }); + const layoutEdges = edges + .filter((edge) => edge.type !== 'loop_linkage') + .map((edge) => ({ sourceNodeId: edge.source, targetNodeId: edge.target })); + const positions = getSizedLayeredPositions(sized, layoutEdges, { origin }); + const rect = sized.reduce( + (current, node) => { + const position = positions[node.id]!; + + return { + maxX: Math.max(current.maxX, position.x + node.width), + maxY: Math.max(current.maxY, position.y + node.height), + minX: Math.min(current.minX, position.x), + minY: Math.min(current.minY, position.y), + }; + }, + { maxX: -Infinity, maxY: -Infinity, minX: Infinity, minY: Infinity } + ); + + return { + bounds: { height: rect.maxY - rect.minY, width: rect.maxX - rect.minX, x: rect.minX, y: rect.minY }, + positions, + }; +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/WorkflowEditorView.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/editor/WorkflowEditorView.tsx index e68e73ef35e..a9a0f2bbb58 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/WorkflowEditorView.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/WorkflowEditorView.tsx @@ -2,7 +2,7 @@ import type { ProjectGraphState, WorkflowEdge as WorkflowDocumentEdge, XYPositio import type { WorkflowPerfSource, WorkflowRuntimeApi } from '@features/workflow/ui/contracts'; import { Box, Flex, HStack, Spinner, Stack, Text } from '@chakra-ui/react'; -import { getSizedLayeredPositions } from '@features/workflow/core/graphLayout'; +import { getArrangedLayout } from '@features/workflow/core/graphLayout'; import '@xyflow/react/dist/style.css'; import { ensureInvocationTemplatesLoaded, useInvocationTemplatesSelector } from '@features/workflow/react'; import { FlowMiniMap, flowThemeCss, getFlowColorMode } from '@features/workflow/ui/graph-preview'; @@ -101,9 +101,6 @@ import { WORKFLOW_HOTKEYS } from './workflowHotkeys'; import { WorkflowSelectionRequestRuntime } from './WorkflowSelectionRequestRuntime'; import { getWorkflowViewport, getWorkflowViewportKey, setWorkflowViewport } from './workflowViewportStore'; -// A node xyflow has not measured yet (a large graph renders only what is visible) counts at a typical node size. -const UNMEASURED_ARRANGE_SIZE = { height: 160, width: 288 }; - const nodeTypes: NodeTypes = { connector: ConnectorFlowNode, current_image: CurrentImageFlowNode, @@ -1227,50 +1224,16 @@ const WorkflowFlow = ({ runtime }: { runtime: WorkflowRuntimeApi }) => { return; } - const targetIds = arrangeScope === 'selection' ? new Set(selectedNodeIds) : null; - const measuredById = new Map(flowInstance.getNodes().map((node) => [node.id, node.measured])); - const targets = projectGraph.nodes.filter((node) => !targetIds || targetIds.has(node.id)); - const origin = targets.reduce( - (corner, node) => ({ x: Math.min(corner.x, node.position.x), y: Math.min(corner.y, node.position.y) }), - { x: Infinity, y: Infinity } + const { bounds, positions } = getArrangedLayout( + projectGraph.nodes, + projectGraph.edges, + arrangeScope === 'selection' ? new Set(selectedNodeIds) : null, + new Map(flowInstance.getNodes().map((node) => [node.id, node.measured])) ); - const sized = targets.map((node) => { - const measured = measuredById.get(node.id); - - return { - height: measured?.height || UNMEASURED_ARRANGE_SIZE.height, - id: node.id, - width: measured?.width || UNMEASURED_ARRANGE_SIZE.width, - }; - }); - const sizedIds = new Set(sized.map((node) => node.id)); - // Loop linkages point backwards; ranking by them would fold a loop body onto its own head. - const edges = projectGraph.edges - .filter((edge) => edge.type !== 'loop_linkage' && sizedIds.has(edge.source) && sizedIds.has(edge.target)) - .map((edge) => ({ sourceNodeId: edge.source, targetNodeId: edge.target })); - const positions = getSizedLayeredPositions(sized, edges, { origin }); editGraph({ positions, type: 'setNodePositions' }); - // The flow shows the new positions after the model rebuilds; the bounds are known now, so fit them directly. - const bounds = sized.reduce( - (rect, node) => { - const position = positions[node.id]!; - - return { - maxX: Math.max(rect.maxX, position.x + node.width), - maxY: Math.max(rect.maxY, position.y + node.height), - minX: Math.min(rect.minX, position.x), - minY: Math.min(rect.minY, position.y), - }; - }, - { maxX: -Infinity, maxY: -Infinity, minX: Infinity, minY: Infinity } - ); - - void flowInstance.fitBounds( - { height: bounds.maxY - bounds.minY, width: bounds.maxX - bounds.minX, x: bounds.minX, y: bounds.minY }, - { duration: reduceMotion ? 0 : 300 } - ); + void flowInstance.fitBounds(bounds, { duration: reduceMotion ? 0 : 300 }); }, [arrangeScope, editGraph, flowInstance, projectGraph.edges, projectGraph.nodes, reduceMotion, selectedNodeIds]); if (isPreparing) { diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx index 45e806a5d3b..376836ee3bf 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx @@ -179,6 +179,7 @@ const MEDIA_WELL_INVALID_PROPS = { type ScalarInputProps = WorkflowFieldInputProps & { ariaLabel?: string }; const StringInput = ({ ariaLabel, formSettings, id, invalid, onChange, template, value }: ScalarInputProps) => { + const { t } = useTranslation(); const [draft, setDraft, clearDraft] = useFocusedDraft(); const text = draft ?? (typeof value === 'string' ? value : ''); const onTextChange = useCallback( @@ -194,11 +195,12 @@ const StringInput = ({ ariaLabel, formSettings, id, invalid, onChange, template, () => stringSettings?.component === 'dropdown' ? stringSettings.options.map((option, index) => ({ - label: option.label || option.value || `Option ${index + 1}`, + label: + option.label || option.value || t('widgets.workflow.formBuilder.unnamedOption', { index: index + 1 }), value: option.value, })) : [], - [stringSettings] + [stringSettings, t] ); if (component === 'dropdown') { diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx index e5b4c1cf2d1..dd28d9b13e3 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldControl.tsx @@ -118,11 +118,13 @@ export const NodeFieldControl = ({ (seedMode: SeedMode) => editGraph({ fieldName, nodeId, seedMode, type: 'setFieldSeedMode' }), [editGraph, fieldName, nodeId] ); + // Read the narrowed template: authored bounds and step can leave no value to shuffle to. const showsShuffle = element.data.showShuffle && !!template && + !!effectiveTemplate && !isConnected && - isShuffleableField(template) && + isShuffleableField(effectiveTemplate) && !isSeedInputField(template); const onShuffleClick = useCallback(() => { if (effectiveTemplate) { diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx index fd41bb1da6f..1acff643281 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx @@ -2,6 +2,7 @@ import type { FieldInputTemplate, NodeFieldFormElement, ProjectGraphState } from import type { NodeFieldFormSettings, NodeFieldNumberSettings, + NodeFieldSettingsIssue, NodeFieldStringOption, NodeFieldStringSettings, } from '@features/workflow/utility'; @@ -11,10 +12,8 @@ import { isSeedInputField } from '@features/workflow/graph'; import { useFocusedDraft } from '@features/workflow/ui/fields/NumericInput'; import { useProjectGraphCommands } from '@features/workflow/ui/useProjectGraphCommands'; import { - applyNodeFieldFormSettings, getDefaultNodeFieldFormSettings, - getNodeFieldSliderRange, - hasInvertedNodeFieldBounds, + getNodeFieldSettingsIssue, hasNodeFieldFormSettings, serializeNodeFieldFormSettings, } from '@features/workflow/utility'; @@ -23,13 +22,20 @@ import { PopoverContent } from '@platform/ui/Popover'; import { SegmentedControl } from '@platform/ui/SegmentedControl'; import { Tooltip, useTooltipTriggerIds } from '@platform/ui/Tooltip'; import { PlusIcon, SlidersHorizontalIcon, XIcon } from 'lucide-react'; -import { useCallback, useMemo, type ChangeEvent } from 'react'; +import { useCallback, useId, useMemo, type ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useNodeFieldBinding } from './NodeFieldControl'; const POPOVER_POSITIONING = { placement: 'bottom-end' } as const; +const SETTINGS_ISSUE_KEYS = { + inverted: 'widgets.workflow.formBuilder.invertedBounds', + noTrack: 'widgets.workflow.formBuilder.sliderNeedsBounds', + noValue: 'widgets.workflow.formBuilder.noValidValue', + outOfRange: 'widgets.workflow.formBuilder.boundsOutsideNode', +} as const satisfies Record; + const toOptionalNumber = (raw: string): number | undefined => { const parsed = Number(raw); @@ -38,11 +44,13 @@ const toOptionalNumber = (raw: string): number | undefined => { /** One authored bound: the text stays as typed while focused; a blank or unparseable entry clears the bound. */ const BoundInput = ({ + describedBy, label, onChange, placeholder, value, }: { + describedBy: string; label: string; onChange: (value: number | undefined) => void; placeholder: string; @@ -63,6 +71,7 @@ const BoundInput = ({ {label} onChange({ ...settings, step: step !== undefined && step > 0 ? step : undefined }), [onChange, settings] ); - const inverted = hasInvertedNodeFieldBounds(settings); - // The form falls back to an input whenever the narrowed template yields no track, so say so here. - const needsBounds = - settings.component !== 'number-input' && - !inverted && - getNodeFieldSliderRange(applyNodeFieldFormSettings(template, settings)) === null; + const issue = getNodeFieldSettingsIssue(template, settings); + const hintId = useId(); return ( @@ -125,30 +130,29 @@ const NumberSettingsBody = ({ /> - - {inverted - ? t('widgets.workflow.formBuilder.invertedBounds') - : needsBounds - ? t('widgets.workflow.formBuilder.sliderNeedsBounds') - : t('widgets.workflow.formBuilder.boundsHint')} + + {t(issue ? SETTINGS_ISSUE_KEYS[issue] : 'widgets.workflow.formBuilder.boundsHint')} ); @@ -297,12 +301,12 @@ export const NodeFieldSettingsPopover = ({ if (template) { editGraph({ elementId: element.id, - settings: serializeNodeFieldFormSettings(settings, template, element.data.showShuffle), + settings: serializeNodeFieldFormSettings(settings, template, element.data.showShuffle, element.data.settings), type: 'setNodeFieldSettings', }); } }, - [editGraph, element.data.showShuffle, element.id, template] + [editGraph, element.data.settings, element.data.showShuffle, element.id, template] ); const templateBounds = useMemo( () => diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx index 4266ea9c5f2..38b0fba15f8 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx @@ -747,12 +747,12 @@ describe('Form builder field settings', () => { host.remove(); }); - const renderBuilder = async () => { + const renderBuilder = async (fieldName = 'steps') => { const node = buildInvocationNode(entryTemplate, { x: 0, y: 0 }); let latest = projectGraphReducer(createProjectGraph('form-settings-test'), { node, type: 'addNode' }); latest = projectGraphReducer(latest, { - fieldIdentifier: { fieldName: 'steps', nodeId: node.id }, + fieldIdentifier: { fieldName, nodeId: node.id }, type: 'exposeField', }); @@ -853,13 +853,87 @@ describe('Form builder field settings', () => { expect(document.body.textContent).toContain(hint); await fillBound('minimum', '1'); - await fillBound('maximum', '7'); + await fillBound('maximum', '8'); await fillBound('step', '8'); - // 1…7 holds no multiple of 8, so the form would still show an input. + // 1…8 holds the single multiple 8: a value, but nothing for a track to move between. expect(document.body.textContent).toContain(hint); - await fillBound('maximum', '8'); - expect(storedSettings()).toMatchObject({ max: 8, min: 1, step: 8 }); + await fillBound('maximum', '16'); + expect(storedSettings()).toMatchObject({ max: 16, min: 1, step: 8 }); expect(document.body.textContent).not.toContain(hint); }); + + it('warns a number input too when its bounds leave no multiple of the step', async () => { + const { storedSettings } = await renderBuilder(); + + await userEvent.click( + host.querySelector(`[aria-label="${i18n.t('widgets.workflow.formBuilder.fieldSettings')}"]`)! + ); + + const fillBound = async (key: 'maximum' | 'minimum' | 'step', value: string) => { + document + .querySelector(`input[aria-label="${i18n.t(`widgets.workflow.formBuilder.${key}`)}"]`)! + .focus(); + await userEvent.keyboard(`{Control>}a{/Control}${value}`); + }; + const warning = i18n.t('widgets.workflow.formBuilder.noValidValue'); + + await fillBound('minimum', '1'); + await fillBound('maximum', '7'); + await fillBound('step', '8'); + // 1…7 holds no multiple of 8, so every value fails validation whichever control shows the field. + expect(storedSettings()).toMatchObject({ component: 'number-input', max: 7, min: 1, step: 8 }); + expect(document.body.textContent).toContain(warning); + // Announced as it appears, and read with the bound being edited. + const description = document.getElementById(document.activeElement?.getAttribute('aria-describedby') ?? ''); + expect(description?.textContent).toBe(warning); + expect(description?.getAttribute('aria-live')).toBe('polite'); + + await fillBound('maximum', '8'); + expect(document.body.textContent).not.toContain(warning); + }); + + it('authors dropdown options and offers exactly those on the form', async () => { + const { storedSettings } = await renderBuilder('prompt'); + const formBuilder = (key: string, index?: number) => i18n.t(`widgets.workflow.formBuilder.${key}`, { index }); + const byLabel = (label: string) => document.querySelector(`[aria-label="${label}"]`)!; + + await userEvent.click(host.querySelector(`[aria-label="${formBuilder('fieldSettings')}"]`)!); + await userEvent.click( + Array.from(document.querySelectorAll('[data-part="item"]')).find( + (item) => item.textContent === formBuilder('controlDropdown') + )! + ); + expect(document.body.textContent).toContain(formBuilder('noOptions')); + + await userEvent.click( + Array.from(document.querySelectorAll('button')).find((button) => button.textContent === formBuilder('addOption'))! + ); + await userEvent.click( + Array.from(document.querySelectorAll('button')).find((button) => button.textContent === formBuilder('addOption'))! + ); + await userEvent.fill(byLabel(formBuilder('optionLabel', 1)), 'Fast'); + await userEvent.fill(byLabel(formBuilder('optionValue', 1)), 'fast'); + await userEvent.fill(byLabel(formBuilder('optionLabel', 2)), 'Slow'); + await userEvent.fill(byLabel(formBuilder('optionValue', 2)), 'slow'); + expect(storedSettings()).toMatchObject({ + component: 'dropdown', + options: [ + { label: 'Fast', value: 'fast' }, + { label: 'Slow', value: 'slow' }, + ], + }); + + await userEvent.click(byLabel(formBuilder('removeOption', 1))); + expect(storedSettings()).toMatchObject({ options: [{ label: 'Slow', value: 'slow' }] }); + expect(byLabel(formBuilder('optionLabel', 1)).value).toBe('Slow'); + + await userEvent.keyboard('{Escape}'); + await act(() => userEvent.click(host.querySelector('[data-scope="select"][data-part="trigger"]')!)); + expect( + Array.from(document.querySelectorAll('[data-scope="select"][data-part="item"]')).map( + (item) => item.textContent + ) + ).toEqual(['Slow']); + }); }); From 907712aaf7e555a800f04397a921637ed90818a0 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 16:14:25 -0600 Subject: [PATCH 19/35] fix(gallery): fall back by name for reference-image models and tidy deletion and media actions Recall matches reference-image adapters by name, base and type when their hash is gone, as legacy recall did; other models stay hash-strict. Deleted recorded references keep the current ones, the routine deletion opt-out hides when already off, and the context menu reads embedded workflows once. --- .../Users Guide/06.Gallery/introduction.mdx | 4 +- .../frontend/webv2/public/locales/en.json | 1 + .../webv2/src/workbench/hotkeys/catalog.ts | 12 +++- .../workbench/hotkeys/firstPartyCommands.ts | 2 +- .../ImageContextMenu.browser.test.tsx | 59 ++++++++++++----- .../image-actions/ImageContextMenu.tsx | 66 ++++++++----------- .../image-actions/executeImageRecall.test.ts | 43 ++++++++++++ .../image-actions/executeImageRecall.ts | 8 +++ .../image-actions/imageRecall.test.ts | 53 +++++++++++++++ .../workbench/image-actions/imageRecall.ts | 61 +++++++++++++---- .../useDeletionConfirmation.browser.test.tsx | 30 ++++++++- .../image-actions/useDeletionConfirmation.tsx | 34 ++++++---- .../CustomSettingsEditors.browser.test.tsx | 19 ++++-- 13 files changed, 296 insertions(+), 96 deletions(-) diff --git a/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx b/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx index 8f509325ccc..cf5b666bb01 100644 --- a/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx +++ b/docs/src/content/docs/Users Guide/06.Gallery/introduction.mdx @@ -148,9 +148,9 @@ Drag items onto a board in the board list; if the list is hidden, it opens while ### Deleting media -Press **Delete** (or **Backspace**), choose **Delete Image**, **Delete Video** or **Delete Selection** from the menu, or use the delete button in the selection bar. Deleting permanently removes the items from every board and from disk, and cannot be undone. A confirmation asks first. Tick **Don't ask me again** and confirm to stop asking (cancelling leaves it on); you can turn it back on, or off, with **Confirm image deletion** in Settings under Gallery. +Press **Delete** (or **Backspace**), choose **Delete Image**, **Delete Video** or **Delete Selection** from the menu, or use the delete button in the selection bar. Deleting permanently removes the items from every board and from disk, and cannot be undone. A confirmation asks first. Tick **Don't ask me again** and confirm to stop asking for ordinary deletions (cancelling leaves it on); you can turn it back on, or off, with **Confirm image deletion** in Settings under Gallery. -Deleting does not check whether an image is still used elsewhere, for example on a canvas or as a reference image, so check before you delete media you are still working with. If deleting is interrupted, InvokeAI cleans up any leftover files the next time it starts. +When the open project still uses something you are deleting, for example as a canvas layer, a reference image, an upscale or video input, or in a workflow, the confirmation always appears and lists where, even if you turned confirmations off. Only the open project's current work is checked: other projects, undo history and canvas snapshots are not, so check before you delete media you still need there. If deleting is interrupted, InvokeAI cleans up any leftover files the next time it starts. ## Related pages diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 4c7a43ea1d2..ddcb6d7eb82 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -4218,6 +4218,7 @@ "editorUnavailable": "The workflow editor is not available in this layout.", "failed": "Could not load the image's workflow", "failedVideo": "Could not load the video's workflow", + "label": "Load Workflow", "loadedLabel": "Loaded “{{name}}” from image", "loadedLabelVideo": "Loaded “{{name}}” from video", "missing": "This image has no workflow embedded.", diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts index c33806f5366..2b90fb28731 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts @@ -130,7 +130,13 @@ const formatHotkeyTitle = (id: string): string => .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); -const hotkey = (category: HotkeyCategory, id: string, defaultKeys: string[]): HotkeyDefinition => { +/** `title` overrides the one derived from `id` where the command was renamed but its id kept for saved bindings. */ +const hotkey = ( + category: HotkeyCategory, + id: string, + defaultKeys: string[], + title = formatHotkeyTitle(id) +): HotkeyDefinition => { const commandId = `${category}.${id}`; return { @@ -142,7 +148,7 @@ const hotkey = (category: HotkeyCategory, id: string, defaultKeys: string[]): Ho implemented: implemented.has(commandId), preventDefault: true, scope: categoryScopes[category], - title: formatHotkeyTitle(id), + title, }; }; @@ -291,7 +297,7 @@ export const firstPartyHotkeyCatalog: HotkeyDefinition[] = [ hotkey('viewer', 'recallPrompts', ['p']), hotkey('viewer', 'remix', ['r']), hotkey('viewer', 'useSize', ['d']), - hotkey('viewer', 'toggleMetadata', ['i']), + hotkey('viewer', 'toggleMetadata', ['i'], 'Toggle Details'), hotkey('gallery', 'selectAllOnPage', ['mod+a']), hotkey('gallery', 'clearSelection', ['esc']), hotkey('gallery', 'galleryNavUp', ['up']), diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts index df689976655..e209dfb4bca 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts @@ -482,7 +482,7 @@ export const useRegisterFirstPartyCommands = () => { commandApi.register({ handler: () => loadSelectedItemWorkflow(), id: LOAD_WORKFLOW_COMMAND_ID, - title: 'Load workflow from image', + title: 'Load workflow from image or video', }), ]; 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 66f7f231459..906e01ae47d 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 @@ -3,6 +3,7 @@ import type { GalleryItemContextMenuTarget } from '@features/gallery/react'; /* oxlint-disable react-perf/jsx-no-new-function-as-prop */ import { ChakraProvider } from '@chakra-ui/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { system } from '@theme/system'; import { act, useState } from 'react'; import { createRoot, type Root } from 'react-dom/client'; @@ -14,6 +15,7 @@ import { ImageContextMenu, type ImageContextMenuTarget } from './ImageContextMen import { EMPTY_IMAGE_RECALL_CAPABILITIES } from './imageRecall'; const NO_BOARDS: [] = []; +const LOAD_WORKFLOW = 'widgets.gallery.itemActions.loadWorkflow.label'; const findGalleryItem = vi.fn(); @@ -165,9 +167,11 @@ const renderMenu = async (actions: ImageActions, images: GalleryImage[]) => { await interact(() => { root?.render( - - - + + + + + ); }); }; @@ -205,15 +209,17 @@ const renderItemMenu = async ( await interact(() => { root?.render( - - - + + + + + ); }); }; @@ -271,8 +277,8 @@ describe('ImageContextMenu load workflow', () => { ); await renderMenu(actions, [image('made-by-workflow.png')]); - await vi.waitFor(() => expect(getMenuItem('Load Workflow').getAttribute('aria-disabled')).not.toBe('true')); - await interact(() => getMenuItem('Load Workflow').click()); + await vi.waitFor(() => expect(getMenuItem(LOAD_WORKFLOW).getAttribute('aria-disabled')).not.toBe('true')); + await interact(() => getMenuItem(LOAD_WORKFLOW).click()); expect(actions.loadImageWorkflow).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ imageName: 'made-by-workflow.png' }) @@ -284,7 +290,7 @@ describe('ImageContextMenu load workflow', () => { await renderMenu(actions, [image('plain.png')]); await vi.waitFor(() => expect(actions.getImageRecallCapabilities).toHaveBeenCalled()); - expect(getMenuItem('Load Workflow').getAttribute('aria-disabled')).toBe('true'); + expect(getMenuItem(LOAD_WORKFLOW).getAttribute('aria-disabled')).toBe('true'); expect(actions.loadImageWorkflow).not.toHaveBeenCalled(); }); }); @@ -454,18 +460,35 @@ describe('ImageContextMenu video placement', () => { actions.hasVideoWorkflow = vi.fn(() => Promise.resolve(true)); await renderVideoMenu(actions, video); - await vi.waitFor(() => expect(getMenuItem('Load Workflow').getAttribute('aria-disabled')).not.toBe('true')); - await interact(() => getMenuItem('Load Workflow').click()); + await vi.waitFor(() => expect(getMenuItem(LOAD_WORKFLOW).getAttribute('aria-disabled')).not.toBe('true')); + await interact(() => getMenuItem(LOAD_WORKFLOW).click()); expect(actions.loadVideoWorkflow).toHaveBeenCalledExactlyOnceWith(video); }); + it('aborts the video capability reads when the menu closes before they answer', async () => { + const actions = createActions(vi.fn()); + const pending = () => new Promise(() => {}); + actions.getVideoRecallCapabilities = vi.fn(pending); + actions.hasVideoWorkflow = vi.fn(pending); + await renderVideoMenu(actions, item('video', 'slow.mp4')); + await vi.waitFor(() => expect(actions.hasVideoWorkflow).toHaveBeenCalled()); + + await interact(() => root?.unmount()); + + const signals = [ + vi.mocked(actions.getVideoRecallCapabilities).mock.calls[0]?.[1], + vi.mocked(actions.hasVideoWorkflow).mock.calls[0]?.[1], + ]; + expect(signals.map((signal) => signal?.aborted)).toEqual([true, true]); + }); + it('keeps Load Workflow disabled for a video without one', async () => { const actions = createActions(vi.fn()); await renderVideoMenu(actions, item('video', 'plain.mp4')); await vi.waitFor(() => expect(actions.hasVideoWorkflow).toHaveBeenCalled()); - expect(getMenuItem('Load Workflow').getAttribute('aria-disabled')).toBe('true'); + expect(getMenuItem(LOAD_WORKFLOW).getAttribute('aria-disabled')).toBe('true'); expect(actions.loadVideoWorkflow).not.toHaveBeenCalled(); }); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx index c24234ca355..4ac2f7d9c58 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx @@ -11,8 +11,10 @@ import { toGalleryItemKey, } from '@features/gallery'; import { getGalleryBoardDestinationGroups, getGalleryBoardLabel } from '@features/gallery/contracts'; +import { captureAccountScope } from '@platform/state/accountLifecycle'; import { MenuContent, MenuIconItem } from '@platform/ui'; import { MiddleTruncate } from '@platform/ui/MiddleTruncate'; +import { useQuery } from '@tanstack/react-query'; import { useFindGalleryItem } from '@workbench/image-actions/useFindGalleryItem'; import { useProjectLibrarySelector } from '@workbench/projects/library'; import { useOpenWorkbenchWidget } from '@workbench/useOpenWorkbenchWidget'; @@ -332,45 +334,28 @@ const SingleItemMenuItems = ({ [actions, itemRef] ); const handleDelete = useCallback(() => onRequestDeletion([itemRef]), [itemRef, onRequestDeletion]); - const [videoCapabilities, setVideoCapabilities] = useState(EMPTY_VIDEO_ITEM_CAPABILITIES); - const { recall: videoRecallCapabilities, workflow: hasVideoWorkflow } = videoCapabilities; - const [isLoadingVideoRecall, setIsLoadingVideoRecall] = useState(false); - const videoItemName = item.kind === 'video' ? item.name : null; - const getVideoRecallCapabilities = actions.getVideoRecallCapabilities; - const getHasVideoWorkflow = actions.hasVideoWorkflow; - - useEffect(() => { - if (!videoItemName || item.kind !== 'video') { - return; - } + const owner = captureAccountScope(); + const videoItem = item.kind === 'video' ? item : null; + // The record answers whether a workflow is embedded only when the listing did not say. + const videoCapabilitiesQuery = useQuery({ + enabled: videoItem !== null, + queryKey: ['gallery', 'item-menu', 'video-capabilities', owner.epoch, videoItem?.name ?? null], + queryFn: async ({ signal }): Promise => { + if (!videoItem) { + return EMPTY_VIDEO_ITEM_CAPABILITIES; + } - let isCancelled = false; - const videoItem = item; + const [recall, workflow] = await Promise.all([ + actions.getVideoRecallCapabilities(videoItem, signal), + actions.hasVideoWorkflow(videoItem, signal), + ]); - setVideoCapabilities(EMPTY_VIDEO_ITEM_CAPABILITIES); - setIsLoadingVideoRecall(true); - Promise.all([getVideoRecallCapabilities(videoItem), getHasVideoWorkflow(videoItem)]) - .then(([recall, workflow]) => { - if (!isCancelled) { - setVideoCapabilities({ recall, workflow }); - } - }) - .catch(() => { - if (!isCancelled) { - setVideoCapabilities(EMPTY_VIDEO_ITEM_CAPABILITIES); - } - }) - .finally(() => { - if (!isCancelled) { - setIsLoadingVideoRecall(false); - } - }); - - return () => { - isCancelled = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by name; the item identity churns per render - }, [getHasVideoWorkflow, getVideoRecallCapabilities, videoItemName]); + return { recall, workflow }; + }, + }); + const isLoadingVideoRecall = videoCapabilitiesQuery.isPending; + const { recall: videoRecallCapabilities, workflow: hasVideoWorkflow } = + videoCapabilitiesQuery.data ?? EMPTY_VIDEO_ITEM_CAPABILITIES; const handleLoadVideoWorkflow = useCallback(() => { if (item.kind === 'video') { void actions.loadVideoWorkflow(item); @@ -396,12 +381,14 @@ const SingleItemMenuItems = ({ }, [actions, item]); const handleUseAsReferenceVideo = useCallback(() => { if (item.kind === 'video') { + // eslint-disable-next-line react/hooks -- `useAsReferenceVideo` is a plain action method; the compiler reads its name as a hook. actions.useAsReferenceVideo(item); } }, [actions, item]); const makeConditioningClipHandler = useCallback( (role: VideoConditioningRole) => () => { if (item.kind === 'video') { + // eslint-disable-next-line react/hooks -- `useAsConditioningClip` is a plain action method; the compiler reads its name as a hook. actions.useAsConditioningClip(item, role); } }, @@ -440,7 +427,7 @@ const SingleItemMenuItems = ({ @@ -757,6 +744,7 @@ const SingleImageMenuItems = ({ onRequestDeletion: (imageNames: string[]) => void; recallCapabilities: ImageRecallCapabilities; }) => { + const { t } = useTranslation(); const handleMove = useCallback( (boardId: string) => void actions.moveImagesToBoard([image.imageName], boardId), [actions, image.imageName] @@ -807,7 +795,7 @@ const SingleImageMenuItems = ({ diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts index 3c776c880e4..b319e46ea09 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts @@ -423,6 +423,49 @@ describe('executeImageRecall', () => { ); }); + it('keeps the current references when every recorded reference was deleted', async () => { + const { commands, setSettings } = createCommands(); + const current = { + config: { + image: { original: { image: { height: 64, image_name: 'current.png', width: 64 } } }, + model: null, + type: 'ip_adapter', + }, + id: 'current', + isEnabled: true, + }; + + galleryApi.galleryImages.metadata.mockResolvedValue({ + positive_prompt: 'recalled prompt', + ref_images: [ + { + config: { image: { height: 64, image_name: 'deleted.png', width: 64 }, type: 'qwen_image_reference_image' }, + id: 'deleted', + isEnabled: true, + }, + ], + }); + galleryApi.galleryImages.resolveMany.mockResolvedValue([]); + + await executeImageRecall({ + t, + commands, + generateValues: { modelKey: model.key, referenceImages: [current] }, + image, + kind: 'all', + models: [model], + projectId: 'project-1', + }); + + expect(setSettings).toHaveBeenCalledWith( + expect.objectContaining({ + positivePrompt: 'recalled prompt', + referenceImages: [expect.objectContaining({ id: 'current' })], + }), + 'project-1' + ); + }); + it('does not reuse or commit image metadata from an expired account epoch', async () => { const oldCommands = createCommands(); let resolveOldMetadata: ((value: unknown) => void) | undefined; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts index 2928c778064..913d5e46ba6 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts @@ -11,6 +11,7 @@ import type { TFunction } from 'i18next'; import { galleryImages, galleryVideos } from '@features/gallery'; import { + getCompatibleReferenceImages, getDefaultGenerateSettings, getEffectiveReferenceImage, hasArchitectureCapabilities, @@ -302,7 +303,14 @@ export const executeImageRecall = async ({ result.values.referenceImages = await filterAvailableReferenceImages(result.values.referenceImages, owner.signal); assertAccountScopeCurrent(owner); + // Every recorded reference was deleted: keep the current ones, fitted to the recalled model as an unrecorded list + // would be, rather than clearing them as if the record listed none. if (result.values.referenceImages.length === 0) { + result.values.referenceImages = getCompatibleReferenceImages( + currentGenerateValues.referenceImages, + result.values.model, + models + ); result.fields = result.fields.filter((field) => field !== 'referenceImages'); } } diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts index d8253b63ad3..34a8658b758 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts @@ -1451,5 +1451,58 @@ describe('legacy records', () => { expect(result?.values.vae?.key).toBe(vae.key); expect(result?.fields).toEqual(expect.arrayContaining(['components', 'vae'])); }); + + describe('reference-image adapters', () => { + const adapter: ComponentModelConfig = { + base: 'sdxl', + hash: 'blake3:adapter-new', + key: 'adapter-new', + name: 'Adapter', + type: 'ip_adapter', + }; + const recallAdapter = (installed: ComponentModelConfig[]) => + recall( + { + model: { key: sdxlModel.key }, + ref_images: [ + { + config: { + image: { height: 512, image_name: 'reference.png', width: 512 }, + model: { + base: 'sdxl', + hash: 'blake3:adapter-old', + key: 'adapter-old', + name: 'Adapter', + type: 'ip_adapter', + }, + type: 'ip_adapter', + }, + id: 'reference', + isEnabled: true, + }, + ], + }, + {}, + [sdxlModel], + [sdxlModel, ...installed] + )?.values.referenceImages[0]?.config; + + it('resolves a reinstalled adapter by its recorded hash', () => { + const rehashed = { ...adapter, hash: 'blake3:adapter-old', key: 'adapter-rehashed' }; + + expect(recallAdapter([adapter, rehashed])).toMatchObject({ model: { key: rehashed.key } }); + }); + + // Unlike the main model, legacy web falls back to the name when an adapter's hash matches nothing. + it('falls back to name, base and type when the recorded hash matches nothing', () => { + expect(recallAdapter([adapter, { ...adapter, key: 'lora-named-adapter', type: 'lora' }])).toMatchObject({ + model: { key: adapter.key }, + }); + }); + + it('leaves the adapter unset when the name matches more than one installed adapter', () => { + expect(recallAdapter([adapter, { ...adapter, key: 'adapter-twin' }])).toMatchObject({ model: null }); + }); + }); }); }); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts index cc1ba19564a..82597d678bd 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts @@ -548,12 +548,17 @@ const toRecordedModelRef = (value: unknown): RecordedModelRef | null => { /** * Resolve the install-local key first, then a portable identifier: the content hash when one was recorded, otherwise - * name and base (and type, when recorded). A recorded hash never falls back to a same-named file, and a portable - * identifier counts only when exactly one installed model matches it. + * name and base (and type, when recorded). A portable identifier counts only when exactly one installed model matches + * it. + * + * By default a recorded hash never falls back to a same-named file, so the main model, VAE, components and concepts + * restore only the exact weights that ran. `hashMiss: 'matchName'` lets a hash that matches nothing fall through to + * name and base, as legacy web does for reference-image adapters after a reinstall changes their hash. */ const resolveRecordedModel = ( ref: RecordedModelRef, - models: readonly T[] + models: readonly T[], + hashMiss: 'refuse' | 'matchName' = 'refuse' ): T | 'ambiguous' | null => { const byKey = ref.key ? models.find((model) => model.key === ref.key) : undefined; @@ -561,13 +566,15 @@ const resolveRecordedModel = ( return byKey; } - const matches = ref.hash - ? models.filter((model) => model.hash === ref.hash) - : ref.name && ref.base - ? models.filter( - (model) => model.name === ref.name && model.base === ref.base && (!ref.type || model.type === ref.type) - ) - : []; + const byHash = ref.hash ? models.filter((model) => model.hash === ref.hash) : []; + const matches = + byHash.length > 0 || (ref.hash && hashMiss === 'refuse') + ? byHash + : ref.name && ref.base + ? models.filter( + (model) => model.name === ref.name && model.base === ref.base && (!ref.type || model.type === ref.type) + ) + : []; return matches.length > 1 ? 'ambiguous' : (matches[0] ?? null); }; @@ -690,6 +697,34 @@ export const getMetadataReferenceImages = (metadata: unknown): GenerateReference return legacy.length > 0 ? legacy : null; }; +/** + * Point each recorded adapter at the installed model it names, or clear it so Generate asks for one: a reinstall gives + * the adapter a new key, and a stale key would pass validation and fail only on the server. + */ +const resolveReferenceImageModels = ( + referenceImages: GenerateReferenceImage[], + models: readonly ComponentModelConfig[] +): GenerateReferenceImage[] => + referenceImages.map((referenceImage) => { + const { config } = referenceImage; + + if ((config.type !== 'ip_adapter' && config.type !== 'flux_redux') || !config.model) { + return referenceImage; + } + + const recordedType = config.model.type; + const ref = toRecordedModelRef(config.model); + const installed = ref + ? resolveRecordedModel( + ref, + models.filter((model) => model.type === recordedType), + 'matchName' + ) + : null; + + return { ...referenceImage, config: { ...config, model: installed === 'ambiguous' ? null : installed } }; + }); + export const withDimensions = ( values: GenerateWidgetValues, size: Partial> @@ -886,7 +921,11 @@ export const buildImageRecallSettings = ({ // metadata model was not recalled (e.g. uninstalled), `values.model` is // still the current one and incompatible configs are re-targeted or dropped. const recordedReferenceImages = getMetadataReferenceImages(metadata); - const referenceImages = getCompatibleReferenceImages(recordedReferenceImages ?? [], values.model, models); + const referenceImages = getCompatibleReferenceImages( + resolveReferenceImageModels(recordedReferenceImages ?? [], models), + values.model, + models + ); // A record of no references clears the current ones; a record whose references do not fit leaves them. if (referenceImages.length > 0 || (recordedReferenceImages?.length === 0 && values.referenceImages.length > 0)) { diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.browser.test.tsx index da4b77bd497..a3ce718f20c 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.browser.test.tsx @@ -148,11 +148,14 @@ describe('useDeletionConfirmation', () => { it('lists where the project still uses the items, with the count each surface loses', async () => { const executeDeletion = vi.fn(async () => {}); + const consoleError = vi.spyOn(console, 'error'); await act(() => { void confirmationRef.current!.requestDeletionConfirmation(mixedRefs, executeDeletion, undefined, [ { names: ['image.png'], surface: 'canvasRaster' }, { label: 'Upscale pass', names: ['image.png', 'video.mp4'], surface: 'workflow' }, + // Workflow names are not unique. + { label: 'Upscale pass', names: ['video.mp4'], surface: 'workflow' }, ]); }); @@ -163,13 +166,36 @@ describe('useDeletionConfirmation', () => { 'Still used in this project:' ); const rows = Array.from(group?.querySelectorAll('li') ?? []).map((row) => row.textContent); - expect(rows).toEqual(['Canvas raster layers · 1 selected item', 'Workflow “Upscale pass” · 2 selected items']); + expect(rows).toEqual([ + 'Canvas raster layers · 1 selected item', + 'Workflow “Upscale pass” · 2 selected items', + 'Workflow “Upscale pass” · 1 selected item', + ]); + expect(consoleError).not.toHaveBeenCalledWith(expect.stringContaining('same key'), expect.anything()); expect(dialog?.textContent).toContain('The affected work keeps pointing at media that will no longer exist.'); - // The opt-out stays available: it governs routine deletions, not this forced confirmation. + // With routine confirmations on, the opt-out still means something for later deletions. expect(skipCheckbox()).not.toBeNull(); await clickButton('Delete'); expect(executeDeletion).toHaveBeenCalledOnce(); + consoleError.mockRestore(); + }); + + it('offers no opt-out when routine confirmations are already off and the media is in use', async () => { + await patchWorkbenchPreferences({ confirmImageDeletion: false }); + + await act(() => { + void confirmationRef.current!.requestDeletionConfirmation( + imageRefs, + vi.fn(async () => {}), + undefined, + [{ names: ['image.png'], surface: 'canvasRaster' }] + ); + }); + + expect(openDialog()?.textContent).toContain('Still used in this project:'); + expect(skipCheckbox()).toBeNull(); + expect(openDialog()?.textContent).not.toContain('You can turn this confirmation back on in Settings.'); }); it('shows no usage section for a deletion nothing depends on', async () => { diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.tsx index 8f65d809e57..d6ae0a90fbf 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useDeletionConfirmation.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from 'react'; import { Checkbox, List, Stack, Text } from '@chakra-ui/react'; import { useMountEffect } from '@platform/react/useMountEffect'; import { ConfirmDialog } from '@platform/ui/ConfirmDialog'; -import { patchWorkbenchPreferences } from '@workbench/settings/store'; +import { patchWorkbenchPreferences, useWorkbenchPreferenceSelector } from '@workbench/settings/store'; import { useCallback, useId, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -40,6 +40,9 @@ export const useDeletionConfirmation = (): { const deletionInFlightRef = useRef(false); // Read only when deletion is confirmed, so ticking it and then cancelling changes nothing. const [skipFutureConfirmations, setSkipFutureConfirmations] = useState(false); + // With routine confirmations already off, the dialog only appears because the media is in use, which the opt-out + // does not cover. + const canSkipFutureConfirmations = useWorkbenchPreferenceSelector((preferences) => preferences.confirmImageDeletion); const skipHintId = useId(); const usageHeadingId = useId(); @@ -124,7 +127,8 @@ export const useDeletionConfirmation = (): { {usage.map((entry, index) => ( - + // Captured when the dialog opens and never reordered; labels such as workflow names can repeat. + {t(`widgets.gallery.deleteInUse.surface.${entry.surface}`, { name: entry.label })} {' · '} @@ -138,18 +142,20 @@ export const useDeletionConfirmation = (): { ) : null} - - - - - - - {t('widgets.gallery.deleteConfirmSkip')} - - - {t('widgets.gallery.deleteConfirmSkipHint')} - - + {canSkipFutureConfirmations ? ( + + + + + + + {t('widgets.gallery.deleteConfirmSkip')} + + + {t('widgets.gallery.deleteConfirmSkipHint')} + + + ) : null} } confirmLabel={t('widgets.gallery.deleteConfirmLabel')} diff --git a/invokeai/frontend/webv2/src/workbench/settings/CustomSettingsEditors.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/settings/CustomSettingsEditors.browser.test.tsx index 1376ecda749..392dd594283 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/CustomSettingsEditors.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/settings/CustomSettingsEditors.browser.test.tsx @@ -92,7 +92,7 @@ describe('custom setting modified indicators', () => { expect(modifiedIndicators()).toHaveLength(0); }); - it('lists shortcuts under category headers and says so when a search matches nothing', async () => { + it('lists shortcuts under category headers, finds them by title, and says so when a search matches nothing', async () => { host.style.cssText = 'display:flex;flex-direction:column;height:600px;width:640px;'; await render(hotkeysSettings.fields[0]!); await expect.poll(() => host.textContent).toContain('Open Command Palette'); @@ -106,12 +106,19 @@ describe('custom setting modified indicators', () => { expect(host.querySelector('[data-list-pinned-header]')?.textContent).toContain('hotkeys.categories.app'); const search = host.querySelector('input[aria-label="hotkeys.searchPlaceholder"]')!; + const typeSearch = (value: string) => + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!; + setter.call(search, value); + search.dispatchEvent(new Event('input', { bubbles: true })); + }); - await act(() => { - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!; - setter.call(search, 'no shortcut is called this'); - search.dispatchEvent(new Event('input', { bubbles: true })); - }); + // The viewer's metadata toggle opens the panel the Preview calls Details, so it is found by that name. + await typeSearch('details'); + await expect.poll(() => host.textContent).toContain('Toggle Details'); + expect(host.textContent).not.toContain('Open Command Palette'); + + await typeSearch('no shortcut is called this'); await expect.poll(() => host.textContent).toContain('hotkeys.noMatches'); expect(host.querySelector('[role="list"][aria-label="hotkeys.bindings"]')).toBeNull(); }); From af781700ef6479bd4b286d0022c6dc870b5c1bd0 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 16:54:11 -0600 Subject: [PATCH 20/35] fix(queue): add a video rerun its active run cannot adopt to that run's board A second failed item of a video batch retried while the first one's rerun still runs now reaches the run's captured Gallery board instead of staying Uncategorized, as an adopted rerun's video would. --- .../webv2/src/features/queue/runtime.test.ts | 33 +++++++++++++++++++ .../webv2/src/features/queue/runtime.ts | 9 +++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts index 538400df863..b262437109b 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts @@ -3078,6 +3078,39 @@ describe('queue runtime retries', () => { await runtime.dispose(); }); + it("adds a video rerun its active run could not adopt to that run's board", async () => { + // A second failed item of a video batch, retried while the first item's rerun still runs. + const queueItem: QueueItem = { + ...createPendingQueueItem(), + backendItemIds: [99], + retriedBackendItemIds: [41, 42], + snapshot: { + ...createPendingQueueItem().snapshot, + destination: 'gallery', + galleryBoardId: 'board-1', + resultNodeIds: ['video_output'], + sourceId: 'video', + }, + status: 'running', + }; + const getResultVideoNames = vi.fn().mockResolvedValue(['rerun.mp4']); + const getResultVideos = vi.fn().mockResolvedValue([{ videoName: 'rerun.mp4' } as QueueResultVideo]); + const { commands, emit, project, runtime } = createRetryHarness(queueItem, { + getResultVideoNames, + getResultVideos, + }); + + runtime.start(); + emit('queue_items_retried', retriedEvent([{ item_id: 100, source_item_id: 42 }])); + emit('queue_item_status_changed', completedEvent(100, buildQueueItemOrigin(queueItem.id, project.id), 'gallery')); + + await vi.waitFor(() => expect(commands.refreshBackendData).toHaveBeenCalled()); + expect(getResultVideoNames).toHaveBeenCalledWith(100, { resultNodeIds: ['video_output'] }); + expect(runtimeServices.destinations.addVideosToGalleryBoard).toHaveBeenCalledWith('board-1', ['rerun.mp4']); + + await runtime.dispose(); + }); + it('delivers reruns no run adopted to the Gallery or the current Canvas, and nothing else', async () => { // The owning run refuses to reopen (its Canvas document was replaced); the other rerun belongs to another tab. const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [41], status: 'failed' }; diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.ts b/invokeai/frontend/webv2/src/features/queue/runtime.ts index 35aac4a9f4d..4f6d0e7d73b 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.ts @@ -1409,12 +1409,15 @@ export const createQueueRuntime = ({ }; if (event.destination === 'gallery') { - if (!run || !queueItemId) { + if (!run || !target || !queueItemId) { commands.refreshBackendData(); return; } - void readUnadoptedRerunImages(event, queueItemId, run) - .then((images) => addImagesToDestination(run, images)) + // Video graphs leave their output unboarded, so videos need the same board routing as an adopted rerun's. + void Promise.all([ + readUnadoptedRerunImages(event, queueItemId, run).then((images) => addImagesToDestination(run, images)), + deliverResultVideos(target.project.id, run, [event.item_id]), + ]) .catch(recordError) .finally(() => { if (isActive()) { From 66f8cd869f9f258e409253fc3ebb88ea9f711289 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 16:54:11 -0600 Subject: [PATCH 21/35] fix(workflow): keep the precision of steps written in exponent form Step multiples are rounded to the decimals a step like 1e-7 actually has, so its slider track and shuffle no longer collapse to 0. --- .../webv2/src/features/workflow/core/fields.test.ts | 4 ++++ .../webv2/src/features/workflow/core/fields.ts | 7 +++++-- .../features/workflow/core/formFieldSettings.test.ts | 12 ++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/workflow/core/fields.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/fields.test.ts index ddbfd3afbb7..db8f90f146c 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/fields.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/fields.test.ts @@ -445,6 +445,10 @@ describe('getRandomWorkflowFieldValue', () => { const decimal = input({ maximum: 1, minimum: 0, multipleOf: 0.1, type: single('FloatField') }); expect(getRandomWorkflowFieldValue(decimal, () => 0.3)).toBe(0.3); + + const tiny = input({ maximum: 9e-7, minimum: 1e-7, multipleOf: 1e-7, type: single('FloatField') }); + + expect(getRandomWorkflowFieldValue(tiny, () => 0.5)).toBe(5e-7); }); it('respects exclusive bounds for integers and keeps floats unrounded', () => { diff --git a/invokeai/frontend/webv2/src/features/workflow/core/fields.ts b/invokeai/frontend/webv2/src/features/workflow/core/fields.ts index b370fee2dba..59b3f26c495 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/fields.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/fields.ts @@ -79,10 +79,13 @@ export const isShuffleableField = (template: FieldInputTemplate): boolean => isDirectInputField(template) && admitsWorkflowFieldStepValue(template); +/** Digits after the point in `value`'s shortest form, counting those its exponent implies: 1.5e-7 has eight. */ const countDecimals = (value: number): number => { - const [, fraction = ''] = String(value).split('.'); + const [mantissa = '', exponent = '0'] = String(value).split('e'); + const [, fraction = ''] = mantissa.split('.'); - return fraction.length; + // `toFixed` accepts at most 100 digits. + return Math.min(100, Math.max(0, fraction.length - Number(exponent))); }; export const finiteNumberOrNull = (value: number | null | undefined): number | null => diff --git a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts index 6a1ecfbcfcb..148a2fb4817 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts @@ -294,6 +294,18 @@ describe('getNodeFieldSliderRange', () => { }); }); + it('keeps the precision of a step written in exponent form', () => { + // String(1e-7) is "1e-7": rounding to its digits after the point alone would collapse every stop to 0. + expect(getNodeFieldSliderRange(template('FloatField', { maximum: 9e-7, minimum: 1e-7, multipleOf: 1e-7 }))).toEqual( + { max: 9e-7, min: 1e-7, step: 1e-7 } + ); + expect(getNodeFieldSliderRange(template('FloatField', { maximum: 3e-7, minimum: 0, multipleOf: 1.5e-7 }))).toEqual({ + max: 3e-7, + min: 0, + step: 1.5e-7, + }); + }); + it('keeps a bound that is itself a multiple despite float noise, and steps past an exclusive one', () => { // 1.1 / 0.1 and 0.3 / 0.1 are not integers in floating point; validation tolerates that and so must the track. expect(getNodeFieldSliderRange(template('FloatField', { maximum: 1.1, minimum: 0.3, multipleOf: 0.1 }))).toEqual({ From f1c7d03a4e864b2c61b1ba2cf068377a718c900e Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 17:00:44 -0600 Subject: [PATCH 22/35] fix(queue): stage a rerun no run adopted in one tab only Staging lands in the synced project document, so a tab now stages an unadopted Canvas rerun only while it holds the run's lock; a tab still tracking the run keeps it, and other tabs skip it. --- .../04.Image Generation/01.introduction.mdx | 2 +- .../webv2/src/features/queue/runtime.test.ts | 53 ++++++++++++++++++- .../webv2/src/features/queue/runtime.ts | 45 ++++++++++++++-- 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx b/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx index 63bb1451a3d..3c4d4425f31 100644 --- a/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx +++ b/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx @@ -51,7 +51,7 @@ If a required component is missing, **Invoke** is disabled and its tooltip names Hover over **Invoke** to see what will be queued, for example *1 prompt × 4 iterations → 4 generations*. When Invoke is disabled, the same tooltip lists what is stopping it. To run a batch ahead of the work you already have queued, press **Ctrl+Shift+Enter** (**Cmd+Shift+Enter** on macOS) or choose **Invoke (Front of Queue)** from the command palette. When several people share a server, the default round-robin queue still alternates between users, so the batch only moves ahead of your own jobs; a server set to first-in, first-out (`session_queue_mode: FIFO`) runs it ahead of everyone's pending jobs. -The **Queue** panel lists recent jobs. Cancel a pending, waiting or running job from its row, and retry a failed or canceled job from the session the server saved for it, independently of your current settings. A retried job's results go where the original was headed while the tab that sent it is still open: back to its place in the Canvas staging area, or to the Gallery board it was sent to. This also holds when you retry several failed items of one batch one after another. Once that tab is gone, for example after a reload, or once its Canvas was replaced with a new document, Canvas results are staged at the current bounding box of any tab with the project open, and Gallery results appear in the Gallery, on **Uncategorized** unless the job itself saved them to a board. Expand a row to cancel its whole batch while other batches keep running, or to open **View JSON**, which shows the graph the server actually ran, its results and the embedded workflow. The panel menu clears failed items, clears every finished item while keeping queued and running work, or clears the whole queue. Administrators also see the **Invocation Cache** beneath the queue, next to the model cache, where cached node outputs can be cleared, disabled or re-enabled. +The **Queue** panel lists recent jobs. Cancel a pending, waiting or running job from its row, and retry a failed or canceled job from the session the server saved for it, independently of your current settings. A retried job's results go where the original was headed while the tab that sent it is still open: back to its place in the Canvas staging area, or to the Gallery board it was sent to. This also holds when you retry several failed items of one batch one after another. Once that tab is gone, for example after a reload, or once its Canvas was replaced with a new document, Canvas results are staged once, at the current bounding box, by a tab with the project open, and Gallery results appear in the Gallery, on **Uncategorized** unless the job itself saved them to a board. Expand a row to cancel its whole batch while other batches keep running, or to open **View JSON**, which shows the graph the server actually ran, its results and the embedded workflow. The panel menu clears failed items, clears every finished item while keeping queued and running work, or clears the whole queue. Administrators also see the **Invocation Cache** beneath the queue, next to the model cache, where cached node outputs can be cleared, disabled or re-enabled. ## Routing images to the Gallery or the Canvas diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts index b262437109b..e5f8d7f307f 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts @@ -2782,7 +2782,11 @@ describe('queue runtime video board routing', () => { }); describe('queue runtime retries', () => { - const createRetryHarness = (queueItem: QueueItem, backendOverrides: Partial = {}) => { + const createRetryHarness = ( + queueItem: QueueItem, + backendOverrides: Partial = {}, + services: { locks?: QueueRunLockPort } = {} + ) => { const project = { id: 'project-1', queue: { items: [queueItem] } }; const listeners = new Set<() => void>(); const notify = () => listeners.forEach((listener) => listener()); @@ -2804,6 +2808,7 @@ describe('queue runtime retries', () => { }); const runtime = createQueueRuntime({ ...runtimeServices, + ...services, backend, history: { commands, @@ -3111,6 +3116,52 @@ describe('queue runtime retries', () => { await runtime.dispose(); }); + it('leaves a Canvas rerun to the tab tracking its run, and stages it here only while holding the run lock', async () => { + const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [7], status: 'completed' }; + const getResultImages = vi.fn().mockResolvedValue([{ imageName: 'rerun.png' } as QueueResultImage]); + const release = vi.fn().mockResolvedValue(undefined); + // Another tab tracks the run behind rerun 99; nobody tracks the one behind rerun 100 any more. + const acquire = vi.fn((_projectId: string, queueItemId: string) => + Promise.resolve( + queueItemId === 'tracked-elsewhere' + ? ({ kind: 'contended' } as const) + : ({ kind: 'acquired', release } as const) + ) + ); + const { commands, emit, project, runtime } = createRetryHarness( + queueItem, + { getResultImages }, + { locks: { acquire } } + ); + + runtime.start(); + emit( + 'queue_items_retried', + retriedEvent([ + { item_id: 99, source_item_id: 41 }, + { item_id: 100, source_item_id: 42 }, + ]) + ); + emit( + 'queue_item_status_changed', + completedEvent(99, buildQueueItemOrigin('tracked-elsewhere', project.id), 'canvas') + ); + emit('queue_item_status_changed', completedEvent(100, buildQueueItemOrigin('run-gone', project.id), 'canvas')); + + await vi.waitFor(() => expect(release).toHaveBeenCalledTimes(1)); + expect(commands.stageRetriedCanvasResults).toHaveBeenCalledTimes(1); + expect(commands.stageRetriedCanvasResults).toHaveBeenCalledWith( + expect.objectContaining({ backendItemId: 100, queueItemId: 'run-gone' }) + ); + expect(getResultImages).toHaveBeenCalledTimes(1); + // Released only after staging, so a tab that sees the same completion meanwhile finds the lock taken. + expect(release.mock.invocationCallOrder[0]).toBeGreaterThan( + vi.mocked(commands.stageRetriedCanvasResults).mock.invocationCallOrder[0]! + ); + + await runtime.dispose(); + }); + it('delivers reruns no run adopted to the Gallery or the current Canvas, and nothing else', async () => { // The owning run refuses to reopen (its Canvas document was replaced); the other rerun belongs to another tab. const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [41], status: 'failed' }; diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.ts b/invokeai/frontend/webv2/src/features/queue/runtime.ts index 4f6d0e7d73b..0a983e08abe 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.ts @@ -1380,9 +1380,36 @@ export const createQueueRuntime = ({ ); }; + /** + * Elect the one tab that stages a Canvas rerun no run adopted: staging lands in the synced project document, so a + * second tab staging it would duplicate it. A tab that holds the run stages it; otherwise the run's lock decides, + * kept until staging is done, and while the tab tracking the run is alive it holds that lock and other tabs skip. + * Resolves to the claim's release, or null when another tab has it. Tabs in other browsers cannot be coordinated. + */ + const claimUnadoptedRerunStaging = async ( + projectId: string, + queueItemId: string + ): Promise<(() => Promise) | null> => { + const key = getRunKey(projectId, queueItemId); + if (!locks || ownedLocks.has(key)) { + return () => Promise.resolve(); + } + // This tab's own claim on the run is still being decided; competing with it would leave the rerun to no tab. + const ownRequest = lockRequests.get(key); + if (ownRequest) { + return (await ownRequest) ? () => Promise.resolve() : null; + } + const lock = await locks.acquire(projectId, queueItemId); + if (lock.kind === 'acquired') { + return () => lock.release(); + } + // Without browser locks no tab coordinates; staging here is what every tab does then. + return lock.kind === 'unavailable' ? () => Promise.resolve() : null; + }; + /** * Deliver a rerun no open run adopted the way its destination would see any untracked result: the Gallery refreshes - * (onto its run's board when this tab still holds that run), and a Canvas project open here stages it. + * (onto its run's board when this tab still holds that run), and one tab with its Canvas project open stages it. */ const handleUnadoptedRerunSettled = (event: QueueItemStatusChangedEvent): void => { if (!isActive() || !isTerminalBackendStatus(event.status) || !unadoptedReruns.delete(event.item_id)) { @@ -1430,10 +1457,18 @@ export const createQueueRuntime = ({ return; } - void readUnadoptedRerunImages(event, queueItemId, run) - .then((images) => { - if (isActive() && images.length > 0) { - commands.stageRetriedCanvasResults({ backendItemId: event.item_id, images, projectId, queueItemId }); + void claimUnadoptedRerunStaging(projectId, queueItemId) + .then(async (release) => { + if (!release) { + return; + } + try { + const images = await readUnadoptedRerunImages(event, queueItemId, run); + if (isActive() && images.length > 0) { + commands.stageRetriedCanvasResults({ backendItemId: event.item_id, images, projectId, queueItemId }); + } + } finally { + await release().catch(() => undefined); } }) .catch(recordError); From 517642488e5285eaccead98b4d087f018ce74e1b Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 17:24:31 -0600 Subject: [PATCH 23/35] fix(queue): keep the staging selection when a retry reopens a run Reopening regroups the run's staging slots under its rerun; the selection now stays on the candidate it showed instead of landing on another run's placeholder. --- .../src/workbench/workbenchState.test.ts | 67 +++++++++++++++++++ .../webv2/src/workbench/workbenchState.ts | 11 ++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts index 77fa7e076f3..03a59dd60d4 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts @@ -2342,6 +2342,73 @@ describe('workbenchReducer Phase 5 generation flow', () => { ); }); + it('keeps the staged candidate selected when a retry reopens an earlier run', () => { + let state = submitGenerate(primeGenerate()); + const projectId = state.activeProjectId; + const runA = getProject(state, projectId).queue.items[0]!; + state = workbenchReducer(state, { mode: 'off', type: 'setCanvasStagingAutoSwitch' }); + state = workbenchReducer(state, { + backendItemIds: [41, 42], + projectId, + queueItemId: runA.id, + type: 'markQueueItemBackendSubmitted', + }); + state = workbenchReducer(state, { + backendItemId: 41, + images: [createImage('a1.png', runA.id)], + projectId, + queueItemId: runA.id, + type: 'routeQueueItemPartialResults', + }); + state = workbenchReducer(state, { + notify: false, + projectId, + queueItemId: runA.id, + status: 'failed', + type: 'setQueueItemStatus', + }); + state = submitGenerate(state); + const runB = getProject(state, projectId).queue.items.find((item) => item.id !== runA.id)!; + state = workbenchReducer(state, { + backendItemIds: [51], + projectId, + queueItemId: runB.id, + type: 'markQueueItemBackendSubmitted', + }); + state = workbenchReducer(state, { + images: [createImage('b1.png', runB.id), createImage('b2.png', runB.id)], + projectId, + queueItemId: runB.id, + type: 'routeQueueItemResults', + }); + const selectedImageName = () => { + const project = getProject(state, projectId); + const slot = getCanvasStagingSlots(project.canvas, project.queue.items)[ + project.canvas.stagingArea.selectedImageIndex + ]; + return slot?.kind === 'candidate' ? slot.candidate.imageName : slot?.kind; + }; + const slotsBefore = getCanvasStagingSlots( + getProject(state, projectId).canvas, + getProject(state, projectId).queue.items + ); + state = workbenchReducer(state, { + imageIndex: slotsBefore.findIndex((slot) => slot.kind === 'candidate' && slot.candidate.imageName === 'b2.png'), + type: 'setStagedImageIndex', + }); + expect(selectedImageName()).toBe('b2.png'); + + state = workbenchReducer(state, { + projectId, + queueItemId: runA.id, + retriedItems: [{ itemId: 99, sourceItemId: 42 }], + type: 'reopenQueueItemForRetry', + }); + + expect(getProject(state, projectId).queue.items.find((item) => item.id === runA.id)?.status).toBe('running'); + expect(selectedImageName()).toBe('b2.png'); + }); + it('leaves a run finished when it is still active or its Canvas document was replaced', () => { let state = submitGenerate(primeGenerate()); const project = getActiveProject(state); diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.ts index da0a8c0d3a0..0b401540fec 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.ts @@ -5083,9 +5083,14 @@ export const __workbenchReducerInternal = ( }); } case 'reopenQueueItemForRetry': { - return updateProjectById(state, action.projectId, (project) => - reopenQueueItemForRetry(project, action.queueItemId, action.retriedItems) - ); + return updateProjectById(state, action.projectId, (project) => { + const reopened = reopenQueueItemForRetry(project, action.queueItemId, action.retriedItems); + + // The reopened run's slots regroup under its rerun, so keep the selection on the candidate it showed. + return reopened === project + ? project + : clampCanvasStagingSelection(reopened, getSelectedCanvasStagingSlot(project)); + }); } case 'stageRetriedCanvasResults': { return updateProjectById(state, action.projectId, (project) => From 1d7398e1ee7b8f010c296b68d37cff950f25cde9 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 17:24:31 -0600 Subject: [PATCH 24/35] fix(queue): adopt a retry from its own response and ignore other users' retries The retrying tab replays its response's pairing to the retry handler, so adoption no longer hangs on the socket event; the handler skips pairs it has already seen. On multiuser servers the runtime keeps only the viewer's own pairs, so admin tabs stop filing every user's reruns. --- .../webv2/src/app/QueueRuntimeAdapter.tsx | 4 ++ .../data/httpRealtimeQueueBackend.test.ts | 43 +++++++++++++++++++ .../queue/data/httpRealtimeQueueBackend.ts | 37 +++++++++++++++- .../src/features/queue/data/serverTypes.ts | 2 + .../webv2/src/features/queue/publicApi.ts | 3 ++ .../webv2/src/features/queue/runtime.test.ts | 40 ++++++++++++++++- .../webv2/src/features/queue/runtime.ts | 18 ++++++++ 7 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.test.ts diff --git a/invokeai/frontend/webv2/src/app/QueueRuntimeAdapter.tsx b/invokeai/frontend/webv2/src/app/QueueRuntimeAdapter.tsx index c16b1cf2e91..d8f6ccb0b1c 100644 --- a/invokeai/frontend/webv2/src/app/QueueRuntimeAdapter.tsx +++ b/invokeai/frontend/webv2/src/app/QueueRuntimeAdapter.tsx @@ -1,4 +1,5 @@ import { invalidateGallery } from '@features/gallery/queries'; +import { getAuthSession } from '@features/identity'; import { modelLoadActivitySink } from '@features/models'; import { nodeExecutionStore } from '@features/nodes'; import { createProductionQueueRuntime, createProductionQueueReceiptAcknowledgements } from '@features/queue'; @@ -29,6 +30,8 @@ export const QueueRuntimeAdapter = () => { useMountEffect(() => { const owner = captureAccountScope(); + // Changing users remounts the workbench route, so the session read here stays the runtime's viewer. + const session = getAuthSession(); let isDisposed = false; let journal: QueueRunJournal | undefined; let recallCache: QueueRecallCache | undefined; @@ -149,6 +152,7 @@ export const QueueRuntimeAdapter = () => { locks: createQueueRunLockPort(owner.storageSuffix), modelLoads: modelLoadActivitySink, nodeExecution: nodeExecutionStore, + viewerUserId: session.multiuserEnabled ? (session.user?.user_id ?? null) : null, }); runtime.start(); }; diff --git a/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.test.ts b/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.test.ts new file mode 100644 index 00000000000..a83a64a9062 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const server = vi.hoisted(() => ({ retryItemsById: vi.fn() })); +vi.mock('./serverApi', async (importOriginal) => ({ ...(await importOriginal()), ...server })); + +import { queueBackend } from './httpRealtimeQueueBackend'; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('queueBackend retries', () => { + it("hands the tab's own retry answer to retry listeners, so adoption does not hang on the socket", async () => { + const pairs = [{ item_id: 99, source_item_id: 41 }]; + server.retryItemsById.mockResolvedValue({ queue_id: 'default', retried_item_ids: [41], retried_items: pairs }); + const onRetried = vi.fn(); + const onOther = vi.fn(); + const detach = queueBackend.on('queue_items_retried', onRetried); + const detachOther = queueBackend.on('queue_item_status_changed', onOther); + + await expect(queueBackend.retryItems([41])).resolves.toEqual([41]); + expect(onRetried).toHaveBeenCalledWith( + expect.objectContaining({ queue_id: 'default', retried_items: pairs, user_ids: [] }) + ); + expect(onOther).not.toHaveBeenCalled(); + + detach(); + detachOther(); + await queueBackend.retryItems([41]); + expect(onRetried).toHaveBeenCalledTimes(1); + }); + + it('announces nothing for a server that does not pair retries', async () => { + server.retryItemsById.mockResolvedValue({ queue_id: 'default', retried_item_ids: [41] }); + const onRetried = vi.fn(); + const detach = queueBackend.on('queue_items_retried', onRetried); + + await queueBackend.retryItems([41]); + + expect(onRetried).not.toHaveBeenCalled(); + detach(); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.ts b/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.ts index 69c07bf572d..759173ce1c1 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.ts @@ -1,4 +1,5 @@ import type { QueueBackendPort } from '@features/queue/core/types'; +import type { QueueItemsRetriedEvent } from '@features/queue/data/events'; import { socketHub } from '@platform/transport/socketHub'; @@ -40,6 +41,12 @@ import { getResultVideos, } from './submissionApi'; +/** + * This tab's own retry answers, delivered like the socket's `queue_items_retried` so a rerun is adopted even when + * the socket misses that event; handlers ignore the pairs they have already seen. + */ +const ownRetryHandlers = new Set<(payload: never) => void>(); + /** Production adapter for the queue backend port. */ export const queueBackend: QueueBackendPort = { acknowledgeEnqueue: acknowledgeQueueEnqueue, @@ -65,7 +72,17 @@ export const queueBackend: QueueBackendPort = { getResultVideoNames, getResultVideos, listItems: async () => (await listAllQueueItems()).map(mapQueueBackendItemDTO), - on: socketHub.on, + on: (event, handler) => { + const detachSocket = socketHub.on(event, handler); + if (event !== 'queue_items_retried') { + return detachSocket; + } + ownRetryHandlers.add(handler); + return () => { + detachSocket(); + ownRetryHandlers.delete(handler); + }; + }, onConnectionChange: socketHub.onConnectionChange, pauseProcessor: async () => { await pauseQueueProcessor(); @@ -86,7 +103,23 @@ export const queueBackend: QueueBackendPort = { return item ? mapQueueItemDTO(item) : null; }, readStatus: async (scope, signal) => mapQueueStatusDTO(await getQueueStatus(scope, signal)), - retryItems: async (itemIds) => (await retryItemsById(itemIds)).retried_item_ids, + retryItems: async (itemIds) => { + const result = await retryItemsById(itemIds); + if (result.retried_items?.length) { + const event: QueueItemsRetriedEvent = { + queue_id: result.queue_id, + retried_item_ids: result.retried_item_ids, + retried_item_ids_by_user: {}, + retried_items: result.retried_items, + timestamp: Date.now(), + user_ids: [], + }; + for (const handler of ownRetryHandlers) { + handler(event as never); + } + } + return result.retried_item_ids; + }, resumeProcessor: async () => { await resumeQueueProcessor(); }, diff --git a/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts b/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts index 1d059a8ed64..b6c3812f65f 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts @@ -42,6 +42,8 @@ export interface QueueServerItemDTO { export interface RetryItemsResultDTO { queue_id: string; retried_item_ids: number[]; + /** Each retried root paired with the item that re-runs it; absent from servers that predate the pairing. */ + retried_items?: { item_id: number; source_item_id: number }[]; } export interface CancelByBatchIdsResultDTO { diff --git a/invokeai/frontend/webv2/src/features/queue/publicApi.ts b/invokeai/frontend/webv2/src/features/queue/publicApi.ts index cca3bc63c40..ab5587cb25c 100644 --- a/invokeai/frontend/webv2/src/features/queue/publicApi.ts +++ b/invokeai/frontend/webv2/src/features/queue/publicApi.ts @@ -68,6 +68,7 @@ export const createProductionQueueRuntime = ({ locks, modelLoads, nodeExecution, + viewerUserId, }: { destinations: QueueResultDestinationPort; ensureProjectPersisted?(projectId: string): Promise<'ready' | 'refused' | 'retry'>; @@ -77,6 +78,7 @@ export const createProductionQueueRuntime = ({ locks?: QueueRunLockPort; modelLoads: QueueModelLoadPort; nodeExecution: QueueNodeExecutionPort; + viewerUserId: string | null; }): QueueRuntime => createQueueRuntime({ backend: queueBackend, @@ -88,4 +90,5 @@ export const createProductionQueueRuntime = ({ locks, modelLoads, nodeExecution, + viewerUserId, }); diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts index e5f8d7f307f..812a9b8c8ce 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts @@ -2785,7 +2785,7 @@ describe('queue runtime retries', () => { const createRetryHarness = ( queueItem: QueueItem, backendOverrides: Partial = {}, - services: { locks?: QueueRunLockPort } = {} + services: { locks?: QueueRunLockPort; viewerUserId?: string | null } = {} ) => { const project = { id: 'project-1', queue: { items: [queueItem] } }; const listeners = new Set<() => void>(); @@ -3071,6 +3071,8 @@ describe('queue runtime retries', () => { }; const getResultImages = vi.fn().mockResolvedValue([{ imageName: 'rerun.png' } as QueueResultImage]); const { commands, emit, project, runtime } = createRetryHarness(queueItem, { getResultImages }); + // The destination port is shared by every test in this file. + vi.mocked(runtimeServices.destinations.addImagesToGalleryBoard).mockClear(); runtime.start(); emit('queue_items_retried', retriedEvent([{ item_id: 100, source_item_id: 42 }])); @@ -3104,6 +3106,8 @@ describe('queue runtime retries', () => { getResultVideoNames, getResultVideos, }); + // The destination port is shared by every test in this file. + vi.mocked(runtimeServices.destinations.addVideosToGalleryBoard).mockClear(); runtime.start(); emit('queue_items_retried', retriedEvent([{ item_id: 100, source_item_id: 42 }])); @@ -3162,6 +3166,40 @@ describe('queue runtime retries', () => { await runtime.dispose(); }); + it("handles a retry once when both the tab's own answer and the socket deliver it, and ignores others' retries", async () => { + const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [41], status: 'failed' }; + const { commands, emit, notify, project, runtime } = createRetryHarness(queueItem, {}, { viewerUserId: 'me' }); + vi.mocked(commands.reopenForRetry).mockImplementation(({ retriedItems }) => { + queueItem.retriedBackendItemIds = [...(queueItem.backendItemIds ?? [])]; + queueItem.backendItemIds = retriedItems.map(({ itemId }) => itemId); + queueItem.status = 'running'; + notify(); + }); + const ownAnswer = retriedEvent([{ item_id: 99, source_item_id: 41 }]); + + runtime.start(); + emit('queue_items_retried', ownAnswer); + // The socket copy an admin receives: the viewer's pair again, plus another user's. + emit('queue_items_retried', { + ...retriedEvent([ + { item_id: 99, source_item_id: 41 }, + { item_id: 200, source_item_id: 70 }, + ]), + retried_item_ids_by_user: { me: [41], other: [70] }, + user_ids: ['me', 'other'], + }); + emit( + 'queue_item_status_changed', + completedEvent(200, buildQueueItemOrigin('their-run', 'their-project'), 'gallery') + ); + + expect(commands.reopenForRetry).toHaveBeenCalledTimes(1); + expect(commands.refreshBackendData).not.toHaveBeenCalled(); + expect(project.queue.items[0]?.backendItemIds).toEqual([99]); + + await runtime.dispose(); + }); + it('delivers reruns no run adopted to the Gallery or the current Canvas, and nothing else', async () => { // The owning run refuses to reopen (its Canvas document was replaced); the other rerun belongs to another tab. const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [41], status: 'failed' }; diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.ts b/invokeai/frontend/webv2/src/features/queue/runtime.ts index 0a983e08abe..8811de7656c 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.ts @@ -409,6 +409,7 @@ export const createQueueRuntime = ({ locks, modelLoads, nodeExecution, + viewerUserId = null, }: { backend: QueueBackendPort; destinations: QueueResultDestinationPort; @@ -419,6 +420,8 @@ export const createQueueRuntime = ({ locks?: QueueRunLockPort; modelLoads: QueueModelLoadPort; nodeExecution: QueueNodeExecutionPort; + /** The signed-in user on a multiuser server; null in single-user mode, where every item is the viewer's. */ + viewerUserId?: string | null; }): QueueRuntime => { const owner = captureAccountScope(); const commands = history.commands; @@ -1302,19 +1305,34 @@ export const createQueueRuntime = ({ } const runsByBackendItemId = new Map(); + const trackedItemIds = new Set(); for (const project of history.getSnapshot().projects) { for (const queueItem of project.queue.items) { for (const backendItemId of [...(queueItem.backendItemIds ?? []), ...(queueItem.retriedBackendItemIds ?? [])]) { runsByBackendItemId.set(backendItemId, { projectId: project.id, queueItemId: queueItem.id }); } + for (const backendItemId of queueItem.backendItemIds ?? []) { + trackedItemIds.add(backendItemId); + } } } + // An admin's socket carries every user's retries; only the viewer's own can belong to runs or projects here. + const viewerSourceIds = + viewerUserId && event.user_ids.length > 0 ? new Set(event.retried_item_ids_by_user[viewerUserId] ?? []) : null; const retriedItemsByRun = new Map< string, { projectId: string; queueItemId: string; retriedItems: { itemId: number; sourceItemId: number }[] } >(); for (const { item_id: itemId, source_item_id: sourceItemId } of event.retried_items) { + // The retrying tab sees its pairs twice, from its own response and from the socket; the first one handles them. + if ( + (viewerSourceIds && !viewerSourceIds.has(sourceItemId)) || + trackedItemIds.has(itemId) || + unadoptedReruns.has(itemId) + ) { + continue; + } const owner = runsByBackendItemId.get(sourceItemId); if (!owner) { From 859598403f5a41e97f5180d00ef2e599f3d90c76 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 17:24:31 -0600 Subject: [PATCH 25/35] fix(queue): say where a closed project's Canvas retry is delivered Retrying a Canvas job whose project is not open in this tab still works, since a tab with it open stages the result; the confirmation now says so, and that nothing shows it when the project is open nowhere. --- .../04.Image Generation/01.introduction.mdx | 2 +- .../frontend/webv2/public/locales/en.json | 1 + .../frontend/webv2/src/app/QueueUiAdapter.tsx | 7 ++++ .../ui/InvocationCacheFooter.browser.test.tsx | 1 + .../QueueItemDetailActions.browser.test.tsx | 1 + .../queue/ui/QueueItemRow.browser.test.tsx | 34 +++++++++++++++++++ .../src/features/queue/ui/QueueItemRow.tsx | 9 +++-- .../src/features/queue/ui/QueueUiContext.tsx | 2 ++ .../queue/ui/RetryQueueItemButton.tsx | 25 +++++++++++--- 9 files changed, 75 insertions(+), 7 deletions(-) diff --git a/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx b/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx index 3c4d4425f31..7cd6c0fd3ef 100644 --- a/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx +++ b/docs/src/content/docs/Users Guide/04.Image Generation/01.introduction.mdx @@ -51,7 +51,7 @@ If a required component is missing, **Invoke** is disabled and its tooltip names Hover over **Invoke** to see what will be queued, for example *1 prompt × 4 iterations → 4 generations*. When Invoke is disabled, the same tooltip lists what is stopping it. To run a batch ahead of the work you already have queued, press **Ctrl+Shift+Enter** (**Cmd+Shift+Enter** on macOS) or choose **Invoke (Front of Queue)** from the command palette. When several people share a server, the default round-robin queue still alternates between users, so the batch only moves ahead of your own jobs; a server set to first-in, first-out (`session_queue_mode: FIFO`) runs it ahead of everyone's pending jobs. -The **Queue** panel lists recent jobs. Cancel a pending, waiting or running job from its row, and retry a failed or canceled job from the session the server saved for it, independently of your current settings. A retried job's results go where the original was headed while the tab that sent it is still open: back to its place in the Canvas staging area, or to the Gallery board it was sent to. This also holds when you retry several failed items of one batch one after another. Once that tab is gone, for example after a reload, or once its Canvas was replaced with a new document, Canvas results are staged once, at the current bounding box, by a tab with the project open, and Gallery results appear in the Gallery, on **Uncategorized** unless the job itself saved them to a board. Expand a row to cancel its whole batch while other batches keep running, or to open **View JSON**, which shows the graph the server actually ran, its results and the embedded workflow. The panel menu clears failed items, clears every finished item while keeping queued and running work, or clears the whole queue. Administrators also see the **Invocation Cache** beneath the queue, next to the model cache, where cached node outputs can be cleared, disabled or re-enabled. +The **Queue** panel lists recent jobs. Cancel a pending, waiting or running job from its row, and retry a failed or canceled job from the session the server saved for it, independently of your current settings. A retried job's results go where the original was headed while the tab that sent it is still open: back to its place in the Canvas staging area, or to the Gallery board it was sent to. This also holds when you retry several failed items of one batch one after another. A retried Canvas job is staged in a tab where its project is open; if that project is open nowhere, its result does not appear. Once that tab is gone, for example after a reload, or once its Canvas was replaced with a new document, Canvas results are staged once, at the current bounding box, by a tab with the project open, and Gallery results appear in the Gallery, on **Uncategorized** unless the job itself saved them to a board. Expand a row to cancel its whole batch while other batches keep running, or to open **View JSON**, which shows the graph the server actually ran, its results and the embedded workflow. The panel menu clears failed items, clears every finished item while keeping queued and running work, or clears the whole queue. Administrators also see the **Invocation Cache** beneath the queue, next to the model cache, where cached node outputs can be cleared, disabled or re-enabled. ## Routing images to the Gallery or the Canvas diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index ddcb6d7eb82..ae9544458ec 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -2973,6 +2973,7 @@ "retryNotQueuedDescription": "Queue item #{{id}} is not a failed or canceled job the server can retry.", "retryQueued": "Retry queued", "retryQueuedDescription": "Queue item #{{id}} was queued again from its saved session.", + "retryQueuedProjectClosedDescription": "Queue item #{{id}} was queued again. Its result is staged on its project's Canvas in a tab where that project is open; with none open, it will not appear.", "sendToCanvas": "Send to canvas", "sendToCanvasComingSoon": "Sending queue results to the canvas is coming soon.", "settingsRecalled": "Settings recalled", diff --git a/invokeai/frontend/webv2/src/app/QueueUiAdapter.tsx b/invokeai/frontend/webv2/src/app/QueueUiAdapter.tsx index 652e3ae3609..2687793861a 100644 --- a/invokeai/frontend/webv2/src/app/QueueUiAdapter.tsx +++ b/invokeai/frontend/webv2/src/app/QueueUiAdapter.tsx @@ -1,5 +1,6 @@ import { useAuthSession, useCapabilities } from '@features/identity'; import { getQueueItemAccess, QueueUiProvider, type QueueUiAdapter } from '@features/queue/react'; +import { areArraysEqual } from '@platform/state/selectors'; import { useWorkbenchPreferences } from '@workbench/settings/store'; import { useNotify } from '@workbench/useNotify'; import { useOpenWorkbenchWidget } from '@workbench/useOpenWorkbenchWidget'; @@ -17,6 +18,10 @@ export const QueueUiAdapterProvider = ({ children }: { children: ReactNode }) => const { queueJobsScope } = useWorkbenchPreferences(); const openWorkbenchWidget = useOpenWorkbenchWidget(); const isConnected = useWorkbenchSelector((snapshot) => snapshot.backendConnection.status === 'connected'); + const openProjectIds = useWorkbenchSelector( + (snapshot) => snapshot.projects.map((project) => project.id), + areArraysEqual + ); const adapter = useMemo(() => { const viewer = { currentUserId: session.user?.user_id ?? null, @@ -32,6 +37,7 @@ export const QueueUiAdapterProvider = ({ children }: { children: ReactNode }) => canManageItem: (item) => getQueueItemAccess(item, viewer).canManage, canViewItemDetails: (item) => getQueueItemAccess(item, viewer).canViewDetails, isConnected, + isProjectOpen: (projectId) => openProjectIds.includes(projectId), notify, openQueue: () => openWorkbenchWidget('queue'), preloadItemActions: () => void importQueueItemActions(), @@ -43,6 +49,7 @@ export const QueueUiAdapterProvider = ({ children }: { children: ReactNode }) => canManageModels, isConnected, notify, + openProjectIds, openWorkbenchWidget, queueJobsScope, session, diff --git a/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.browser.test.tsx b/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.browser.test.tsx index 64226707352..f26ef2a774e 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.browser.test.tsx @@ -45,6 +45,7 @@ const adapter: QueueUiAdapter = { canManageProcessor: true, canViewItemDetails: () => true, isConnected: true, + isProjectOpen: () => true, notify, openQueue: () => undefined, queueJobsScope: 'all', diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.browser.test.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.browser.test.tsx index d02a5b0db01..f7ebb61cf9f 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemDetailActions.browser.test.tsx @@ -41,6 +41,7 @@ const adapter = (canManage: boolean): QueueUiAdapter => ({ canManageProcessor: true, canViewItemDetails: () => true, isConnected: true, + isProjectOpen: () => true, notify, openQueue: () => undefined, queueJobsScope: 'all', diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.browser.test.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.browser.test.tsx index b15c7bf05f2..86be433478a 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.browser.test.tsx @@ -2,6 +2,7 @@ import type { QueueItemReadModel } from '@features/queue/core/types'; import { ChakraProvider } from '@chakra-ui/react'; +import { buildQueueItemOrigin } from '@features/queue/data/events'; import { system } from '@theme/system'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; @@ -54,6 +55,7 @@ const adapter = (overrides: Partial = {}): QueueUiAdapter => ({ canManageProcessor: true, canViewItemDetails: () => true, isConnected: true, + isProjectOpen: () => true, notify: { error: () => undefined, info: () => undefined, success: () => undefined }, openQueue: () => undefined, preloadItemActions: () => undefined, @@ -179,6 +181,38 @@ describe('QueueItemRow', () => { expect(success).toHaveBeenCalledWith('widgets.queue.retryQueued', 'widgets.queue.retryQueuedDescription'); }); + it('retries a Canvas job of a project closed here, and says another tab with it open receives the result', async () => { + const success = vi.fn(); + const canvasJob = (id: number, projectId: string) => + item({ destination: 'canvas', id, origin: buildQueueItemOrigin('run-1', projectId), status: 'failed' }); + const retry = async (queueItem: QueueItemReadModel) => { + await act(() => root.unmount()); + root = createRoot(host); + await render( + adapter({ + isProjectOpen: (projectId) => projectId === 'open-project', + notify: { error: () => undefined, info: () => undefined, success }, + }), + queueItem + ); + await act(async () => { + host.querySelector('button[aria-label="widgets.queue.retryItem"]')!.click(); + await Promise.resolve(); + }); + }; + commands.retryItems.mockClear(); + + await retry(canvasJob(15, 'closed-project')); + expect(commands.retryItems).toHaveBeenCalledWith([15]); + expect(success).toHaveBeenLastCalledWith( + 'widgets.queue.retryQueued', + 'widgets.queue.retryQueuedProjectClosedDescription' + ); + + await retry(canvasJob(16, 'open-project')); + expect(success).toHaveBeenLastCalledWith('widgets.queue.retryQueued', 'widgets.queue.retryQueuedDescription'); + }); + it('hides retry on a failed workflow-call child and on rows the viewer cannot manage', async () => { await render(adapter(), item({ id: 13, parentItemId: 5, status: 'failed' })); expect(host.querySelector('button[aria-label="widgets.queue.retryItem"]')).toBeNull(); diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.tsx index 847148ca444..fb5474ea32c 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueItemRow.tsx @@ -3,6 +3,7 @@ import type { QueueItemReadModel } from '@features/queue/core/types'; import { Box, HStack, Icon, Text } from '@chakra-ui/react'; import { extractGenerationMeta, getResultImageName } from '@features/queue/core/generationMeta'; +import { parseQueueItemOriginProjectId } from '@features/queue/data/events'; import { useItemProgress } from '@features/queue/data/itemProgressStore'; import { ListItem } from '@platform/ui/list/ListItem'; import { ChevronRightIcon } from 'lucide-react'; @@ -27,7 +28,7 @@ const CHEVRON_OPEN = { transform: 'rotate(90deg)' } as const; export const QueueItemRow = memo( ({ item, revealRequest }: { item: QueueItemReadModel; revealRequest?: QueueItemRevealRequest | null }) => { const { t } = useTranslation(); - const { canManageItem, canViewItemDetails, preloadItemActions } = useQueueUi(); + const { canManageItem, canViewItemDetails, isProjectOpen, preloadItemActions } = useQueueUi(); const [expanded, setExpanded] = useState(false); const toggle = useCallback(() => setExpanded((open) => !open), []); // Warm the actions chunk on intent so the first expand has nothing to wait for. @@ -41,6 +42,10 @@ export const QueueItemRow = memo( const canManage = canManageItem(item); const isCancellable = isCancellableQueueItem(item) && canManage; const isRetryable = isRetryableQueueItem(item) && canManage; + const originProjectId = parseQueueItemOriginProjectId(item.origin); + // A Canvas rerun is staged by a tab with its project open, which need not be this one (or this user's). + const isRetryForProjectClosedHere = + item.destination === 'canvas' && originProjectId !== null && !isProjectOpen(originProjectId); const canExpand = canViewItemDetails(item); const consumeReveal = useCallback( @@ -78,7 +83,7 @@ export const QueueItemRow = memo( isCancellable ? ( ) : isRetryable ? ( - + ) : undefined } description={ diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueUiContext.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueUiContext.tsx index e561fd4d8fa..53a14bee09c 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueUiContext.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueUiContext.tsx @@ -18,6 +18,8 @@ export interface QueueUiAdapter { canManageItem(item: QueueItemReadModel): boolean; canViewItemDetails(item: QueueItemReadModel): boolean; isConnected: boolean; + /** Whether `projectId` is open in this tab; a Canvas rerun is staged only into a project open here. */ + isProjectOpen(projectId: string): boolean; notify: QueueUiNotificationPort; openQueue(): void; /** diff --git a/invokeai/frontend/webv2/src/features/queue/ui/RetryQueueItemButton.tsx b/invokeai/frontend/webv2/src/features/queue/ui/RetryQueueItemButton.tsx index 74209d67689..812a0b582da 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/RetryQueueItemButton.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/RetryQueueItemButton.tsx @@ -12,8 +12,17 @@ import { useTranslation } from 'react-i18next'; import { refreshQueue } from './queueDataStore'; import { useQueueUi } from './QueueUiContext'; -/** Re-queues a failed or canceled root item from its saved session; the backend reports which ids it took. */ -export const RetryQueueItemButton = ({ itemId }: { itemId: number }) => { +/** + * Re-queues a failed or canceled root item from its saved session; the backend reports which ids it took. A Canvas + * job whose project is closed here is staged by another tab, so the confirmation says where its result goes. + */ +export const RetryQueueItemButton = ({ + isProjectClosedHere = false, + itemId, +}: { + isProjectClosedHere?: boolean; + itemId: number; +}) => { const { t } = useTranslation(); const { notify } = useQueueUi(); const [busy, setBusy] = useState(false); @@ -29,7 +38,15 @@ export const RetryQueueItemButton = ({ itemId }: { itemId: number }) => { if (retriedItemIds.length === 0) { notify.info(t('widgets.queue.retryNotQueued'), t('widgets.queue.retryNotQueuedDescription', { id: itemId })); } else { - notify.success(t('widgets.queue.retryQueued'), t('widgets.queue.retryQueuedDescription', { id: itemId })); + notify.success( + t('widgets.queue.retryQueued'), + t( + isProjectClosedHere + ? 'widgets.queue.retryQueuedProjectClosedDescription' + : 'widgets.queue.retryQueuedDescription', + { id: itemId } + ) + ); } } catch (error) { notify.error(t('widgets.queue.retryFailed'), getApiErrorMessage(error, t('widgets.queue.couldNotRetryItem'))); @@ -37,7 +54,7 @@ export const RetryQueueItemButton = ({ itemId }: { itemId: number }) => { setBusy(false); } }, - [itemId, notify, t] + [isProjectClosedHere, itemId, notify, t] ); return ( From 407cd56b53d7a7a0fbc13c66f5e00dc2a6e3a2b6 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 17:24:31 -0600 Subject: [PATCH 26/35] fix(queue): keep focus on retried reads A successful Retry moves focus to the restored View JSON tab or queue history field, and a retry in flight keeps its button mounted, so focus never falls to the page. --- .../src/features/queue/ui/queueDataStore.ts | 13 ++++++++++-- .../QueueItemActions.browser.test.tsx | 7 ++++++- .../queue-integration/QueueItemActions.tsx | 20 +++++++++++++++---- .../MaxQueueHistorySettings.browser.test.tsx | 6 +++++- .../settings/MaxQueueHistorySettings.tsx | 15 ++++++++++++-- 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts b/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts index 81925f8ff3f..1919824facc 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts +++ b/invokeai/frontend/webv2/src/features/queue/ui/queueDataStore.ts @@ -85,6 +85,8 @@ export interface QueueItemDetailState { error: string | null; loadState: 'idle' | 'loading' | 'loaded' | 'error'; retry: () => void; + /** A retry of a failed read is in flight; the failure stays shown until it settles. */ + retrying: boolean; } /** One item's saved execution data, fetched only while `enabled` (a details dialog is open). */ @@ -93,7 +95,7 @@ export const useQueueItemDetail = (itemId: number, enabled: boolean): QueueItemD const retry = query.refetch; if (!enabled) { - return { detail: null, error: null, loadState: 'idle', retry }; + return { detail: null, error: null, loadState: 'idle', retry, retrying: false }; } if (query.isError) { @@ -103,8 +105,15 @@ export const useQueueItemDetail = (itemId: number, enabled: boolean): QueueItemD error: query.error instanceof Error ? query.error.message : null, loadState: 'error', retry, + retrying: query.isFetching, }; } - return { detail: query.data ?? null, error: null, loadState: query.isPending ? 'loading' : 'loaded', retry }; + return { + detail: query.data ?? null, + error: null, + loadState: query.isPending ? 'loading' : 'loaded', + retry, + retrying: false, + }; }; diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.browser.test.tsx index 3a6f8fde943..1acc1ae5775 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.browser.test.tsx @@ -83,9 +83,14 @@ describe('QueueItemActions View JSON', () => { expect(document.querySelector('[role="dialog"] [role="alert"]')?.textContent).toContain('Queue item 9 not found'); readDetail.mockResolvedValueOnce(detail(null)); - await act(() => button('common.retry')!.click()); + const retry = button('common.retry')!; + retry.focus(); + await act(() => retry.click()); await vi.waitFor(() => expect(dialogText()).toContain('widgets.queue.detailViews.graph')); + // The Retry button is gone; focus lands on the view now shown instead of falling to the page. + expect(document.activeElement?.getAttribute('role')).toBe('tab'); + expect(document.activeElement?.getAttribute('aria-selected')).toBe('true'); expect(dialogText()).not.toContain('widgets.queue.detailViews.workflow'); expect(readDetail).toHaveBeenCalledTimes(2); }); diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx b/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx index 06c44074363..a3096221ef1 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx @@ -9,7 +9,7 @@ import { JsonPreview } from '@platform/ui/JsonPreview'; import { Tabs } from '@platform/ui/Tabs'; import { useNotify } from '@workbench/useNotify'; import { FileTextIcon, WandSparklesIcon } from 'lucide-react'; -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; type DetailView = 'graph' | 'item' | 'results' | 'workflow'; @@ -37,9 +37,21 @@ const getDetailValue = (item: QueueItemReadModel, detail: QueueItemDetailReadMod /** The saved execution data, read when the dialog opens so a failed or outputless job can still be inspected. */ const QueueItemDetailBody = ({ item, open }: { item: QueueItemReadModel; open: boolean }) => { const { t } = useTranslation(); - const { detail, error, loadState, retry } = useQueueItemDetail(item.id, open); + const { detail, error, loadState, retry, retrying } = useQueueItemDetail(item.id, open); const [view, setView] = useState('item'); const onViewChange = useCallback((details: { value: string }) => setView(details.value as DetailView), []); + // A successful retry replaces the focused Retry button with the views; focus follows onto the selected one. + const focusAfterRetryRef = useRef(false); + const onRetry = useCallback(() => { + focusAfterRetryRef.current = true; + retry(); + }, [retry]); + const focusRestoredViews = useCallback((node: HTMLDivElement | null) => { + if (node && focusAfterRetryRef.current) { + focusAfterRetryRef.current = false; + node.querySelector('[role="tab"][aria-selected="true"]')?.focus(); + } + }, []); if (loadState === 'error') { return ( @@ -54,7 +66,7 @@ const QueueItemDetailBody = ({ item, open }: { item: QueueItemReadModel; open: b ) : null} - @@ -77,7 +89,7 @@ const QueueItemDetailBody = ({ item, open }: { item: QueueItemReadModel; open: b return ( - + {views.map((candidate) => ( {t(`widgets.queue.detailViews.${candidate}`)} diff --git a/invokeai/frontend/webv2/src/workbench/settings/MaxQueueHistorySettings.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/settings/MaxQueueHistorySettings.browser.test.tsx index 07f49a49f4a..93706b957f9 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/MaxQueueHistorySettings.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/settings/MaxQueueHistorySettings.browser.test.tsx @@ -108,10 +108,14 @@ describe('MaxQueueHistorySettings', () => { mocks.snapshot = { ...mocks.snapshot!, error: null, loadState: 'loaded', maxQueueHistory: 25 }; return Promise.resolve(); }); - await act(() => button('Retry').click()); + const retry = button('Retry'); + retry.focus(); + await act(() => retry.click()); await render({ maxQueueHistory: mocks.snapshot!.maxQueueHistory }); expect(limitInput()?.value).toBe('25'); + // The Retry button is gone; focus lands on the restored field instead of falling to the page. + expect(document.activeElement).toBe(limitInput()); }); it('still asks for a restart when the server accepted a limit but its answer was unreadable', async () => { diff --git a/invokeai/frontend/webv2/src/workbench/settings/MaxQueueHistorySettings.tsx b/invokeai/frontend/webv2/src/workbench/settings/MaxQueueHistorySettings.tsx index a5fe211707f..636fe348daa 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/MaxQueueHistorySettings.tsx +++ b/invokeai/frontend/webv2/src/workbench/settings/MaxQueueHistorySettings.tsx @@ -6,7 +6,7 @@ import { refreshGenerationDevices, updateMaxQueueHistory, useGenerationDevices } import { useMountEffect } from '@platform/react/useMountEffect'; import { Button } from '@platform/ui/Button'; import { ModifiedSettingIndicator } from '@platform/ui/settings/ModifiedSettingIndicator'; -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; const LIMIT_PATTERN = /^\d+$/; @@ -39,11 +39,20 @@ export const MaxQueueHistorySettings = () => { }); const [retrying, setRetrying] = useState(false); + // A successful retry replaces the focused Retry button with the field; focus follows onto it. + const focusAfterRetryRef = useRef(false); const onRetry = useCallback(async () => { + focusAfterRetryRef.current = true; setRetrying(true); await refreshGenerationDevices(); setRetrying(false); }, []); + const focusRestoredInput = useCallback((node: HTMLInputElement | null) => { + if (node && focusAfterRetryRef.current) { + focusAfterRetryRef.current = false; + node.focus(); + } + }, []); const savedText = formatLimit(maxQueueHistory); const text = draft ?? savedText; @@ -77,7 +86,8 @@ export const MaxQueueHistorySettings = () => { [isDirty, parsed, t] ); - if (loadState === 'loading' || loadState === 'idle') { + // A retry keeps its own button (showing progress) mounted, so focus stays on it if the read fails again. + if ((loadState === 'loading' || loadState === 'idle') && !retrying) { return ; } @@ -120,6 +130,7 @@ export const MaxQueueHistorySettings = () => { Date: Mon, 12 Oct 2026 17:24:31 -0600 Subject: [PATCH 27/35] fix(workflow): warn when an authored step is ignored A fractional step on an integer field, or one that is not a multiple of the node's own step, now shows why the form ignores it. --- .../frontend/webv2/public/locales/en.json | 1 + .../src/features/workflow/core/fields.ts | 1 - .../workflow/core/formFieldSettings.test.ts | 9 ++++++++ .../workflow/core/formFieldSettings.ts | 22 ++++++++++++------- .../ui/linear/NodeFieldSettingsPopover.tsx | 1 + 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index ae9544458ec..03206caf5c3 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -3370,6 +3370,7 @@ "invertedBounds": "The minimum is above the maximum; the form ignores both until one is corrected.", "sliderNeedsBounds": "A slider needs a minimum and a maximum with at least one step between them; until then the form shows an input.", "boundsOutsideNode": "A bound lies beyond the node's own limits and would leave no valid value; the form ignores it until it is corrected.", + "stepOutsideNode": "The node accepts only whole steps for whole numbers, and only multiples of its own step; the form ignores this step until it is corrected.", "noValidValue": "No multiple of the step lies between the bounds, so no value is valid and Invoke stays blocked. Widen the bounds or change the step.", "unnamedOption": "Option {{index}}", "step": "Step", diff --git a/invokeai/frontend/webv2/src/features/workflow/core/fields.ts b/invokeai/frontend/webv2/src/features/workflow/core/fields.ts index 59b3f26c495..11bfa7d7920 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/fields.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/fields.ts @@ -71,7 +71,6 @@ export const isDirectInputField = (template: FieldInputTemplate): boolean => /** A field can be exposed to the Linear UI when it can be edited directly. */ export const isExposableField = (template: FieldInputTemplate): boolean => isDirectInputField(template); -/** Scalar numeric fields whose linear-form element can show a randomize button. */ /** Direct numeric fields whose bounds admit at least one value; shuffling any other would write an invalid one. */ export const isShuffleableField = (template: FieldInputTemplate): boolean => (template.type.name === 'IntegerField' || template.type.name === 'FloatField') && diff --git a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts index 148a2fb4817..7aa8fee2c6a 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.test.ts @@ -341,6 +341,15 @@ describe('getNodeFieldSettingsIssue', () => { expect(getNodeFieldSettingsIssue(closed, { component: 'number-input', kind: 'number', min: 200 })).toBe( 'outOfRange' ); + // A fractional step on an integer field, or one off the node's own step, is ignored rather than applied. + expect(getNodeFieldSettingsIssue(closed, { component: 'number-input', kind: 'number', step: 2.5 })).toBe( + 'stepIgnored' + ); + const halves = template('FloatField', { maximum: 10, minimum: 0, multipleOf: 0.5 }); + expect(getNodeFieldSettingsIssue(halves, { component: 'number-input', kind: 'number', step: 0.3 })).toBe( + 'stepIgnored' + ); + expect(getNodeFieldSettingsIssue(halves, { component: 'number-input', kind: 'number', step: 1.5 })).toBeNull(); // Bounds and step are each valid, but no multiple of 8 lies between 1 and 7: every value fails validation. expect( getNodeFieldSettingsIssue(closed, { component: 'number-input', kind: 'number', max: 7, min: 1, step: 8 }) diff --git a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts index f5f37d260be..e57f1a9ab12 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/formFieldSettings.ts @@ -209,6 +209,11 @@ const isMaximumInsideTemplate = (template: FieldInputTemplate, max: number): boo return minimum !== null ? max >= minimum : exclusiveMinimum === null || max > exclusiveMinimum; }; +/** A whole step for an integer field, and a multiple of the node's own step when it has one. */ +const isStepAcceptedByTemplate = (template: FieldInputTemplate, step: number): boolean => + (template.type.name !== 'IntegerField' || Number.isInteger(step)) && + (template.multipleOf === null || template.multipleOf <= 0 || isMultipleOf(step, template.multipleOf)); + /** * The template narrowed by the authored number settings: a bound applies only where it tightens the template's own * end and stays inside its other one (an inverted pair not at all), and a step only when the template has none or @@ -250,11 +255,7 @@ export const applyNodeFieldFormSettings = ( } } - if ( - settings.step !== undefined && - (template.type.name !== 'IntegerField' || Number.isInteger(settings.step)) && - (template.multipleOf === null || template.multipleOf <= 0 || isMultipleOf(settings.step, template.multipleOf)) - ) { + if (settings.step !== undefined && isStepAcceptedByTemplate(template, settings.step)) { patch({ multipleOf: settings.step }); } @@ -307,12 +308,13 @@ export const getNodeFieldSliderRange = (template: FieldInputTemplate): NodeField return { max, min, step: FLOAT_SLIDER_STEP }; }; -export type NodeFieldSettingsIssue = 'inverted' | 'outOfRange' | 'noValue' | 'noTrack'; +export type NodeFieldSettingsIssue = 'inverted' | 'outOfRange' | 'stepIgnored' | 'noValue' | 'noTrack'; /** * Why the form cannot honour authored number settings as written, in the order an author would fix them; null when - * it can. `inverted` and `outOfRange` bounds are ignored by `applyNodeFieldFormSettings`; with `noValue` no value - * passes validation, so Invoke stays blocked; with `noTrack` a slider control falls back to the number input. + * it can. `inverted` and `outOfRange` bounds and a `stepIgnored` step are ignored by `applyNodeFieldFormSettings`; + * with `noValue` no value passes validation, so Invoke stays blocked; with `noTrack` a slider control falls back to + * the number input. */ export const getNodeFieldSettingsIssue = ( template: FieldInputTemplate, @@ -333,6 +335,10 @@ export const getNodeFieldSettingsIssue = ( return 'outOfRange'; } + if (settings.step !== undefined && !isStepAcceptedByTemplate(template, settings.step)) { + return 'stepIgnored'; + } + const effectiveTemplate = applyNodeFieldFormSettings(template, settings); if (!admitsWorkflowFieldStepValue(effectiveTemplate)) { diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx index 1acff643281..84375276d3d 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/NodeFieldSettingsPopover.tsx @@ -34,6 +34,7 @@ const SETTINGS_ISSUE_KEYS = { noTrack: 'widgets.workflow.formBuilder.sliderNeedsBounds', noValue: 'widgets.workflow.formBuilder.noValidValue', outOfRange: 'widgets.workflow.formBuilder.boundsOutsideNode', + stepIgnored: 'widgets.workflow.formBuilder.stepOutsideNode', } as const satisfies Record; const toOptionalNumber = (raw: string): number | undefined => { From 10b1fe7968a0f7199692d5ed5e1d737b67ab61cb Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 17:24:31 -0600 Subject: [PATCH 28/35] refactor(gallery): key the video menu query under galleryKeys The video context menu's capability read is keyed per account beside the other gallery item queries. --- invokeai/frontend/webv2/src/features/gallery/data/queries.ts | 3 +++ .../webv2/src/workbench/image-actions/ImageContextMenu.tsx | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/webv2/src/features/gallery/data/queries.ts b/invokeai/frontend/webv2/src/features/gallery/data/queries.ts index cc69a85f427..cc4cc14ee8f 100644 --- a/invokeai/frontend/webv2/src/features/gallery/data/queries.ts +++ b/invokeai/frontend/webv2/src/features/gallery/data/queries.ts @@ -201,6 +201,9 @@ export const galleryKeys = { itemNames: (owner: AccountScope, filter: CanonicalGalleryItemsFilter) => [...galleryKeys.itemNamesForAccount(owner), filter] as const, imageIndexAvailability: (owner: AccountScope) => [...galleryKeys.all, 'image-index', getAccountKey(owner)] as const, + /** What a video's context menu can offer (recall, embedded workflow), read from its record when opened. */ + videoMenuCapabilities: (owner: AccountScope, videoName: string | null) => + [...galleryKeys.itemsRoot(), 'menu-capabilities', getAccountKey(owner), videoName] as const, }; const galleryItemNamesOptionsForOwner = (owner: AccountScope, filter: CanonicalGalleryItemsFilter) => diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx index 4ac2f7d9c58..0be50316ed6 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx @@ -11,6 +11,7 @@ import { toGalleryItemKey, } from '@features/gallery'; import { getGalleryBoardDestinationGroups, getGalleryBoardLabel } from '@features/gallery/contracts'; +import { galleryKeys } from '@features/gallery/queries'; import { captureAccountScope } from '@platform/state/accountLifecycle'; import { MenuContent, MenuIconItem } from '@platform/ui'; import { MiddleTruncate } from '@platform/ui/MiddleTruncate'; @@ -339,7 +340,7 @@ const SingleItemMenuItems = ({ // The record answers whether a workflow is embedded only when the listing did not say. const videoCapabilitiesQuery = useQuery({ enabled: videoItem !== null, - queryKey: ['gallery', 'item-menu', 'video-capabilities', owner.epoch, videoItem?.name ?? null], + queryKey: galleryKeys.videoMenuCapabilities(owner, videoItem?.name ?? null), queryFn: async ({ signal }): Promise => { if (!videoItem) { return EMPTY_VIDEO_ITEM_CAPABILITIES; From 3a279386534d827f2bd9c0a07e561b6a776340e9 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 17:24:31 -0600 Subject: [PATCH 29/35] test: cover Invoke Front dispatch, retry lineage journaling, mock chain rules and cache status errors Adds coverage for prepend on the plain submit path, retriedBackendItemIds surviving the recovery journal, the mock prune and cancel-chain rules (through a test-only seeding route), Shuffle visibility, and the cache footer when its status cannot be read. --- .../scripts/mock-backend-fixtures.test.mjs | 43 +++++++++++ .../frontend/webv2/scripts/mock-backend.mjs | 9 +++ .../ui/InvocationCacheFooter.browser.test.tsx | 17 ++++- .../WorkflowLinearPanel.browser.test.tsx | 27 +++++++ .../src/workbench/invocationSubmit.test.ts | 75 +++++++++++++++++++ .../queueRunJournal.browser.test.ts | 32 ++++++++ 6 files changed, 202 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs index 888629fd64a..82cd43937ac 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs @@ -1011,6 +1011,49 @@ test('queue retry clones failed or canceled roots once, cancel-by-batch spares o }); }); +test('queue chain rules: prune keeps finished children of an active root, and canceling a child cancels its chain', async () => { + await withRepresentativeBackend(async (backend) => { + const items = await getJson(backend, '/api/v1/queue/default/list_all'); + const pending = items.find((item) => item.status === 'pending'); + const completed = items.find((item) => item.status === 'completed'); + assert.ok(pending && completed, 'the representative queue has pending and completed items'); + + const nextId = Math.max(...items.map((item) => item.item_id)) + 1; + const chained = (item_id, root, status) => ({ + ...root, + item_id, + parent_item_id: root.item_id, + root_item_id: root.item_id, + status, + }); + const activeRoot = { ...pending, item_id: nextId, parent_item_id: null, root_item_id: null }; + const finishedChildOfActive = chained(nextId + 1, activeRoot, 'completed'); + const waitingChildOfActive = chained(nextId + 2, activeRoot, 'pending'); + const finishedChildOfFinished = chained(nextId + 3, completed, 'completed'); + await getJson(backend, '/__queue_items', { + body: JSON.stringify({ + items: [activeRoot, finishedChildOfActive, waitingChildOfActive, finishedChildOfFinished], + }), + method: 'POST', + }); + + await getJson(backend, '/api/v1/queue/default/prune', { method: 'PUT' }); + const afterPrune = new Set((await getJson(backend, '/api/v1/queue/default/list_all')).map((item) => item.item_id)); + + // Like the backend, a finished child stays while its root is still active; one under a finished root goes. + assert.ok(afterPrune.has(finishedChildOfActive.item_id)); + assert.ok(!afterPrune.has(finishedChildOfFinished.item_id)); + + await getJson(backend, `/api/v1/queue/default/i/${waitingChildOfActive.item_id}/cancel`, { method: 'PUT' }); + const status = async (itemId) => (await getJson(backend, `/api/v1/queue/default/i/${itemId}`)).status; + + assert.equal(await status(activeRoot.item_id), 'canceled'); + assert.equal(await status(waitingChildOfActive.item_id), 'canceled'); + assert.equal(await status(finishedChildOfActive.item_id), 'completed'); + assert.equal(await status(pending.item_id), 'pending'); + }); +}); + test('runtime config PATCH keeps the other writable field and validates the queue history limit', async () => { await withRepresentativeBackend(async (backend) => { await getJson(backend, '/api/v1/app/runtime_config', { diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs index 7eb2099e7ec..4be6e7163d7 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs @@ -793,6 +793,15 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { return json(200, { ok: true, ...getProfileInfo(state) }); } + if (method === 'POST' && path === '/__queue_items') { + // Test seam: workflow-call chains only arise from execution, which the mock does not run. + const body = await readJsonBody(request); + for (const item of Array.isArray(body.items) ? body.items : []) { + state.queueItems.set(item.item_id, clone(item)); + } + return json(200, { ok: true }); + } + if (path === '/__faults') { if (method === 'POST') { const body = await readJsonBody(request); diff --git a/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.browser.test.tsx b/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.browser.test.tsx index f26ef2a774e..0417f7f24d5 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/InvocationCacheFooter.browser.test.tsx @@ -79,7 +79,10 @@ const render = async () => { ) ); - await vi.waitFor(() => expect(host.textContent).not.toContain('widgets.queue.invocationCache.loading')); + // The status read retries once before it reports a failure. + await vi.waitFor(() => expect(host.textContent).not.toContain('widgets.queue.invocationCache.loading'), { + timeout: 5_000, + }); await act(() => host.querySelector('button[aria-expanded]')!.click()); }; @@ -102,6 +105,18 @@ describe('InvocationCacheFooter', () => { } }); + it('says the cache is unavailable, with the reason, and offers no command when its status cannot be read', async () => { + server.getInvocationCacheStatus.mockRejectedValue(new Error('Server offline')); + + await render(); + + expect(host.textContent).toContain('widgets.queue.invocationCache.unavailable'); + expect(host.textContent).toContain('Server offline'); + expect(button('widgets.queue.invocationCache.clear').disabled).toBe(true); + expect(button('widgets.queue.invocationCache.enable').disabled).toBe(true); + expect(server.clearInvocationCache).not.toHaveBeenCalled(); + }); + it('disables a running cache, refreshes its status and reports a failed clear', async () => { server.getInvocationCacheStatus.mockResolvedValue(status()); server.setInvocationCacheEnabled.mockResolvedValue(null); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx index 38b0fba15f8..0dc4db0f549 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/linear/WorkflowLinearPanel.browser.test.tsx @@ -893,6 +893,33 @@ describe('Form builder field settings', () => { expect(document.body.textContent).not.toContain(warning); }); + it('offers Shuffle only while the authored bounds and step leave a value to shuffle to', async () => { + await renderBuilder(); + const shuffleButton = () => + host.querySelector(`button[aria-label="${i18n.t('nodes.shuffleField', { label: 'Steps' })}"]`); + const fillBound = async (key: 'maximum' | 'minimum' | 'step', value: string) => { + document + .querySelector(`input[aria-label="${i18n.t(`widgets.workflow.formBuilder.${key}`)}"]`)! + .focus(); + await userEvent.keyboard(`{Control>}a{/Control}${value}`); + }; + + await userEvent.click(host.querySelector(`[aria-label="${i18n.t('widgets.workflow.formBuilder.showShuffle')}"]`)!); + expect(shuffleButton()).not.toBeNull(); + + await userEvent.click( + host.querySelector(`[aria-label="${i18n.t('widgets.workflow.formBuilder.fieldSettings')}"]`)! + ); + await fillBound('minimum', '1'); + await fillBound('maximum', '7'); + await fillBound('step', '8'); + // 1…7 holds no multiple of 8: a shuffle could only write a value the node rejects. + expect(shuffleButton()).toBeNull(); + + await fillBound('maximum', '8'); + expect(shuffleButton()).not.toBeNull(); + }); + it('authors dropdown options and offers exactly those on the form', async () => { const { storedSettings } = await renderBuilder('prompt'); const formBuilder = (key: string, index?: number) => i18n.t(`widgets.workflow.formBuilder.${key}`, { index }); diff --git a/invokeai/frontend/webv2/src/workbench/invocationSubmit.test.ts b/invokeai/frontend/webv2/src/workbench/invocationSubmit.test.ts index 089a97b8645..8fd7715ecc3 100644 --- a/invokeai/frontend/webv2/src/workbench/invocationSubmit.test.ts +++ b/invokeai/frontend/webv2/src/workbench/invocationSubmit.test.ts @@ -377,3 +377,78 @@ describe('submitResolvedInvocation with a random dynamic prompt', () => { expect(canvas.prepareCanvasInvocation).toHaveBeenCalledWith(expect.objectContaining({ prepend: true })); }); }); + +describe('submitResolvedInvocation without dynamic prompts', () => { + seedArchitectureCapabilities(); + + const submitGenerate = async (prepend?: boolean) => { + const model: MainModelConfig = { base: 'sd-1', key: 'main', name: 'Main', type: 'main' }; + const project = getActiveProject( + workbenchReducer(createInitialWorkbenchState(), { + type: 'setGenerateSettings', + values: { ...getDefaultGenerateSettings(model), model, modelKey: model.key, positivePrompt: 'a cat' }, + }) + ); + const route = resolveInvocationRoute(project, 'global', { + destination: 'gallery', + destinationLocked: false, + sourceId: 'generate', + sourceLocked: false, + }); + const commands = createWorkbenchStore().commands; + const submitResolved = vi.spyOn(commands.generation, 'submitResolved'); + + expect(route.validationReasons).toEqual([]); + + await submitResolvedInvocation({ + commands, + formatControlLayerError, + models: undefined, + owner: captureAccountScope(), + prepareCanvasInvocation: vi.fn(), + ...(prepend === undefined ? {} : { prepend }), + project, + route, + }); + + return submitResolved; + }; + + it('dispatches a plain Generate prompt to the front of the queue only for Invoke Front', async () => { + const front = await submitGenerate(true); + const back = await submitGenerate(); + + expect(front).toHaveBeenCalledTimes(1); + expect(front.mock.calls[0]?.[0]).toMatchObject({ prepend: true }); + // No expansion took this path. + expect(front.mock.calls[0]?.[0]).not.toHaveProperty('positivePrompts'); + expect(back.mock.calls[0]?.[0]).not.toHaveProperty('prepend'); + }); + + it('keeps Invoke Front on a Workflow dispatch', async () => { + generatorsMock.resolveWorkflowGenerators.mockImplementation( + (_client: unknown, pending: Array<{ key: string; nodeId: string }>) => + Promise.resolve({ + errors: [], + resolutions: Object.fromEntries(pending.map((item) => [item.nodeId, { items: ['a cat'], key: item.key }])), + }) + ); + const project = buildProject(); + const commands = createWorkbenchStore().commands; + const submitResolved = vi.spyOn(commands.generation, 'submitResolved'); + + await submitResolvedInvocation({ + commands, + formatControlLayerError, + models: undefined, + owner: captureAccountScope(), + prepareCanvasInvocation: vi.fn(), + prepend: true, + project, + route: resolveInvocationRoute(project, 'global', workflowRoute), + }); + + expect(submitResolved).toHaveBeenCalledTimes(1); + expect(submitResolved.mock.calls[0]?.[0]).toMatchObject({ prepend: true }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts index f93c26e2680..2533f6544e1 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts @@ -253,6 +253,38 @@ describe('IndexedDB queue run journal', () => { ]); }); + it('keeps the items a retry reopened a run away from, and drops a malformed list without losing the run', async () => { + const { journal } = await createJournal(); + const store = createWorkbenchStore(); + const projectId = store.getState().activeProjectId; + const reopened = createItem({ backendItemIds: [99], retriedBackendItemIds: [41, 42], status: 'running' }); + const malformed = createItem({ + backendItemIds: [100], + id: 'queue-item-2', + retriedBackendItemIds: [43, 43], + status: 'running', + }); + await journal.record(projectId, reopened); + await journal.record(projectId, malformed); + + const loaded = await journal.listForProject(projectId); + expect(loaded.kind).toBe('available'); + store.commands.queue.restoreFromJournal({ + items: loaded.kind === 'available' ? loaded.entries.map((entry) => entry.item) : [], + projectId, + }); + + const restored = store.getState().projects[0]?.queue.items ?? []; + expect(restored.find((item) => item.id === reopened.id)).toMatchObject({ + backendItemIds: [99], + retriedBackendItemIds: [41, 42], + status: 'running', + }); + const restoredMalformed = restored.find((item) => item.id === malformed.id); + expect(restoredMalformed).toMatchObject({ backendItemIds: [100], status: 'running' }); + expect(restoredMalformed).not.toHaveProperty('retriedBackendItemIds'); + }); + it('restores newest-first submission order without moving a run when its status changes', async () => { const { journal } = await createJournal(); await journal.record('project-1', createItem({ id: 'queue-item-1' })); From 785379207b8d78192485e5ee2ff0161a21bf09af Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 19:17:34 -0600 Subject: [PATCH 30/35] fix(queue): stage every sibling rerun a tab finishes while staging their run Sibling reruns of one run share this tab's staging claim instead of contending with its own lock. The claim waits for a run lock this tab is still releasing and rechecks its own ownership after each wait. --- .../webv2/src/features/queue/runtime.test.ts | 121 ++++++++++++++++++ .../webv2/src/features/queue/runtime.ts | 97 ++++++++++++-- 2 files changed, 208 insertions(+), 10 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts index 812a9b8c8ce..9da57afdaa6 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts @@ -3166,6 +3166,127 @@ describe('queue runtime retries', () => { await runtime.dispose(); }); + it('stages sibling Canvas reruns that finish while this tab is still staging their run', async () => { + const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [7], status: 'completed' }; + let finishFirstRead!: (images: QueueResultImage[]) => void; + const getResultImages = vi.fn((itemId: number) => + itemId === 99 + ? new Promise((resolve) => { + finishFirstRead = resolve; + }) + : Promise.resolve([{ imageName: `rerun-${itemId}.png` } as QueueResultImage]) + ); + // Web Locks are requested ifAvailable, so a held lock refuses its own tab too. + const held = new Set(); + const release = vi.fn(); + const acquire = vi.fn((projectId: string, queueItemId: string) => { + const name = `${projectId}:${queueItemId}`; + if (held.has(name)) { + return Promise.resolve({ kind: 'contended' } as const); + } + held.add(name); + return Promise.resolve({ + kind: 'acquired', + release: () => { + release(); + held.delete(name); + return Promise.resolve(); + }, + } as const); + }); + const { commands, emit, project, runtime } = createRetryHarness( + queueItem, + { getResultImages }, + { locks: { acquire } } + ); + const origin = buildQueueItemOrigin('run-gone', project.id); + const staged = () => + vi.mocked(commands.stageRetriedCanvasResults).mock.calls.map(([payload]) => payload.backendItemId); + + runtime.start(); + emit( + 'queue_items_retried', + retriedEvent([ + { item_id: 99, source_item_id: 41 }, + { item_id: 100, source_item_id: 42 }, + { item_id: 101, source_item_id: 43 }, + ]) + ); + emit('queue_item_status_changed', completedEvent(99, origin, 'canvas')); + await vi.waitFor(() => expect(getResultImages).toHaveBeenCalledTimes(1)); + emit('queue_item_status_changed', completedEvent(100, origin, 'canvas')); + + finishFirstRead([{ imageName: 'rerun-99.png' } as QueueResultImage]); + + await vi.waitFor(() => expect(release).toHaveBeenCalledTimes(1)); + expect(staged()).toEqual([99, 100]); + expect(acquire).toHaveBeenCalledTimes(1); + + // Once released, a later sibling claims the run again. + emit('queue_item_status_changed', completedEvent(101, origin, 'canvas')); + await vi.waitFor(() => expect(release).toHaveBeenCalledTimes(2)); + expect(staged()).toEqual([99, 100, 101]); + expect(acquire).toHaveBeenCalledTimes(2); + + await runtime.dispose(); + }); + + it('stages a Canvas rerun that finishes while this tab is still freeing the run lock it gave up', async () => { + const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [88], status: 'running' }; + const getResultImages = vi.fn().mockResolvedValue([{ imageName: 'rerun.png' } as QueueResultImage]); + let held = false; + let freeRunLock!: () => void; + const acquire = vi.fn(() => { + if (held) { + return Promise.resolve({ kind: 'contended' } as const); + } + held = true; + const release = + acquire.mock.calls.length === 1 + ? () => + new Promise((resolve) => { + freeRunLock = () => { + held = false; + resolve(); + }; + }) + : () => { + held = false; + return Promise.resolve(); + }; + return Promise.resolve({ kind: 'acquired', release } as const); + }); + const { commands, emit, notify, project, runtime } = createRetryHarness( + queueItem, + { getResultImages }, + { locks: { acquire } } + ); + + runtime.start(); + await vi.waitFor(() => expect(acquire).toHaveBeenCalledTimes(1)); + emit('queue_items_retried', retriedEvent([{ item_id: 100, source_item_id: 42 }])); + // Removing the run gives up its lock; the browser frees it only later. + project.queue.items = []; + notify(); + await vi.waitFor(() => expect(freeRunLock).toBeDefined()); + emit('queue_item_status_changed', completedEvent(100, buildQueueItemOrigin(queueItem.id, project.id), 'canvas')); + // Let the staging claim settle as far as it can before the lock is free. + await new Promise((resolve) => { + setTimeout(resolve); + }); + expect(acquire).toHaveBeenCalledTimes(1); + freeRunLock(); + + await vi.waitFor(() => + expect(commands.stageRetriedCanvasResults).toHaveBeenCalledWith( + expect.objectContaining({ backendItemId: 100, queueItemId: queueItem.id }) + ) + ); + expect(acquire).toHaveBeenCalledTimes(2); + + await runtime.dispose(); + }); + it("handles a retry once when both the tab's own answer and the socket deliver it, and ignores others' retries", async () => { const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [41], status: 'failed' }; const { commands, emit, notify, project, runtime } = createRetryHarness(queueItem, {}, { viewerUserId: 'me' }); diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.ts b/invokeai/frontend/webv2/src/features/queue/runtime.ts index 8811de7656c..6e30dc563cb 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.ts @@ -446,6 +446,8 @@ export const createQueueRuntime = ({ const reportedCancelFailures = new Set(); const ownedLocks = new Map>(); const lockRequests = new Map>(); + /** Run locks this tab gave up whose browser lock is not yet free. */ + const lockReleases = new Map>(); const preparingRunKeys = new Set(); const activeAttempts = new Map(); const ownershipEpochs = new Map(); @@ -646,7 +648,13 @@ export const createQueueRuntime = ({ return; } ownedLocks.delete(key); - void lock.release().catch(() => undefined); + const released = lock.release().catch(() => undefined); + lockReleases.set(key, released); + void released.finally(() => { + if (lockReleases.get(key) === released) { + lockReleases.delete(key); + } + }); }; const releaseRunOwnershipAfterJournal = (projectId: string, queueItemId: string): void => { @@ -1402,20 +1410,30 @@ export const createQueueRuntime = ({ * Elect the one tab that stages a Canvas rerun no run adopted: staging lands in the synced project document, so a * second tab staging it would duplicate it. A tab that holds the run stages it; otherwise the run's lock decides, * kept until staging is done, and while the tab tracking the run is alive it holds that lock and other tabs skip. - * Resolves to the claim's release, or null when another tab has it. Tabs in other browsers cannot be coordinated. + * Tabs in other browsers cannot be coordinated. */ - const claimUnadoptedRerunStaging = async ( + const acquireUnadoptedRerunStaging = async ( projectId: string, queueItemId: string ): Promise<(() => Promise) | null> => { const key = getRunKey(projectId, queueItemId); - if (!locks || ownedLocks.has(key)) { - return () => Promise.resolve(); - } - // This tab's own claim on the run is still being decided; competing with it would leave the rerun to no tab. - const ownRequest = lockRequests.get(key); - if (ownRequest) { - return (await ownRequest) ? () => Promise.resolve() : null; + // Competing with this tab's own lock, while its claim is decided or its release is still freeing it, would leave + // the rerun to no tab; either can end with this tab holding the run or not, so look again after each. + for (;;) { + if (!locks || ownedLocks.has(key)) { + return () => Promise.resolve(); + } + const ownRequest = lockRequests.get(key); + const releasing = lockReleases.get(key); + if (ownRequest) { + if (!(await ownRequest)) { + return null; + } + } else if (releasing) { + await releasing; + } else { + break; + } } const lock = await locks.acquire(projectId, queueItemId); if (lock.kind === 'acquired') { @@ -1425,6 +1443,65 @@ export const createQueueRuntime = ({ return lock.kind === 'unavailable' ? () => Promise.resolve() : null; }; + /** Staging claims this tab holds or is deciding, per run, with the reruns delivering under each. */ + const stagingClaims = new Map< + string, + { claim: Promise<(() => Promise) | null>; holders: number; released?: Promise } + >(); + + /** + * Claim staging for one rerun. Sibling reruns of a run this tab is already staging share its claim, since the lock + * would refuse this tab's second request and leave them to no tab; a claim being released is awaited before the + * next one asks. Resolves to this rerun's release, or null when another tab has the run. + */ + const claimUnadoptedRerunStaging = async ( + projectId: string, + queueItemId: string + ): Promise<(() => Promise) | null> => { + const key = getRunKey(projectId, queueItemId); + const current = stagingClaims.get(key); + const entry = + current && !current.released + ? current + : { + claim: (current?.released ?? Promise.resolve()).then(() => + acquireUnadoptedRerunStaging(projectId, queueItemId) + ), + holders: 0, + }; + stagingClaims.set(key, entry); + entry.holders += 1; + + const leave = (release?: () => Promise): Promise => { + entry.holders -= 1; + if (entry.holders > 0) { + return Promise.resolve(); + } + const released: Promise = (release?.() ?? Promise.resolve()) + .catch(() => undefined) + .finally(() => { + if (stagingClaims.get(key) === entry) { + stagingClaims.delete(key); + } + }); + entry.released = released; + return released; + }; + + let release: (() => Promise) | null; + try { + release = await entry.claim; + } catch (error) { + await leave(); + throw error; + } + if (!release) { + await leave(); + return null; + } + return () => leave(release); + }; + /** * Deliver a rerun no open run adopted the way its destination would see any untracked result: the Gallery refreshes * (onto its run's board when this tab still holds that run), and one tab with its Canvas project open stages it. From ad56c410e4b6c3b13fb13dec581542693c6fce73 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Mon, 12 Oct 2026 22:05:15 -0600 Subject: [PATCH 31/35] fix(queue): claim a rerun's delivery durably so no later tab stages it again Adopting or staging a rerun claims it in the account's workbench metadata store, keyed by item and local run. Staging claims only after its results are read and defers to a tab that tracks the run without taking its lock. --- .../webv2/src/features/queue/runtime.test.ts | 288 +++++++++++------- .../webv2/src/features/queue/runtime.ts | 170 ++++------- .../webv2/src/workbench/projects/README.md | 3 +- .../projects/durableSyncedPersistence.test.ts | 2 + .../queueRunJournal.browser.test.ts | 51 +++- .../queue-integration/queueRunJournal.ts | 47 +++ .../queue-integration/queueRunLocks.test.ts | 16 + .../queue-integration/queueRunLocks.ts | 5 +- 8 files changed, 356 insertions(+), 226 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts index 9da57afdaa6..0f8048204a8 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts @@ -104,12 +104,19 @@ const createTestCommands = (overrides: Partial = {}): Queu }); const createTestJournal = (overrides: Partial = {}): QueueRunJournalPort => ({ + claimRerunDelivery: vi.fn().mockResolvedValue({ kind: 'claimed' }), listForProject: vi.fn().mockResolvedValue({ entries: [], kind: 'available' }), record: vi.fn().mockResolvedValue({ kind: 'stored' }), settle: vi.fn().mockResolvedValue({ kind: 'removed' }), ...overrides, }); +/** Run locks that no other tab holds, around the given acquire. */ +const createTestLocks = (acquire: QueueRunLockPort['acquire']): QueueRunLockPort => ({ + acquire, + isHeld: () => Promise.resolve(false), +}); + const runtimeServices = { destinations: { addImagesToGalleryBoard: vi.fn(), addVideosToGalleryBoard: vi.fn() }, ensureTemplatesLoaded: vi.fn(), @@ -854,6 +861,7 @@ describe('queue runtime', () => { let finishAcquire: (result: { kind: 'acquired'; release(): Promise }) => void = () => undefined; const release = vi.fn().mockResolvedValue(undefined); const locks: QueueRunLockPort = { + isHeld: () => Promise.resolve(false), acquire: vi.fn( () => new Promise((resolve) => { @@ -919,6 +927,7 @@ describe('queue runtime', () => { const release = vi.fn().mockResolvedValue(undefined); let acquisitionCount = 0; const locks: QueueRunLockPort = { + isHeld: () => Promise.resolve(false), acquire: vi.fn(() => { acquisitionCount += 1; if (acquisitionCount > 1) { @@ -1058,7 +1067,7 @@ describe('queue runtime', () => { }, }, journal: createTestJournal({ record }), - locks: { acquire: vi.fn().mockResolvedValue({ kind: 'acquired', release }) }, + locks: createTestLocks(vi.fn().mockResolvedValue({ kind: 'acquired', release })), }); runtime.start(); @@ -1151,7 +1160,7 @@ describe('queue runtime', () => { }, }, journal, - locks: { acquire: vi.fn().mockResolvedValue({ kind: 'acquired', release }) }, + locks: createTestLocks(vi.fn().mockResolvedValue({ kind: 'acquired', release })), }); runtime.start(); @@ -1211,7 +1220,7 @@ describe('queue runtime', () => { }, }, journal: createTestJournal({ settle }), - locks: { acquire: vi.fn().mockResolvedValue({ kind: 'acquired', release }) }, + locks: createTestLocks(vi.fn().mockResolvedValue({ kind: 'acquired', release })), }); runtime.start(); @@ -1666,7 +1675,7 @@ describe('queue runtime', () => { }, }, journal, - locks: { acquire }, + locks: createTestLocks(acquire), }); runtime.start(); @@ -1715,7 +1724,7 @@ describe('queue runtime', () => { subscribe: vi.fn(() => vi.fn()), }, journal: createTestJournal({ record }), - locks: { acquire }, + locks: createTestLocks(acquire), }); runtime.start(); @@ -1931,7 +1940,7 @@ describe('queue runtime', () => { }), }); const acquire = vi.fn().mockResolvedValue({ kind: 'contended' }); - const locks: QueueRunLockPort = { acquire }; + const locks = createTestLocks(acquire); const backend = createTestBackend(); const runtime = createQueueRuntime({ ...runtimeServices, @@ -1999,7 +2008,7 @@ describe('queue runtime', () => { kind: 'available', }), }), - locks: { acquire }, + locks: createTestLocks(acquire), }); runtime.start(); @@ -2019,6 +2028,7 @@ describe('queue runtime', () => { const journal = createTestJournal(); const release = vi.fn().mockResolvedValue(undefined); const locks: QueueRunLockPort = { + isHeld: () => Promise.resolve(false), acquire: vi.fn().mockResolvedValue({ kind: 'acquired', release }), }; const commands = createTestCommands({ @@ -2220,7 +2230,7 @@ describe('queue runtime', () => { }, }, journal, - locks: { acquire: vi.fn().mockResolvedValue({ kind: 'acquired', release }) }, + locks: createTestLocks(vi.fn().mockResolvedValue({ kind: 'acquired', release })), }); runtime.start(); @@ -2785,7 +2795,7 @@ describe('queue runtime retries', () => { const createRetryHarness = ( queueItem: QueueItem, backendOverrides: Partial = {}, - services: { locks?: QueueRunLockPort; viewerUserId?: string | null } = {} + services: { journal?: QueueRunJournalPort; locks?: QueueRunLockPort; viewerUserId?: string | null } = {} ) => { const project = { id: 'project-1', queue: { items: [queueItem] } }; const listeners = new Set<() => void>(); @@ -3120,23 +3130,15 @@ describe('queue runtime retries', () => { await runtime.dispose(); }); - it('leaves a Canvas rerun to the tab tracking its run, and stages it here only while holding the run lock', async () => { + it('leaves a Canvas rerun to the tab tracking its run, without taking that run from it', async () => { const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [7], status: 'completed' }; const getResultImages = vi.fn().mockResolvedValue([{ imageName: 'rerun.png' } as QueueResultImage]); - const release = vi.fn().mockResolvedValue(undefined); // Another tab tracks the run behind rerun 99; nobody tracks the one behind rerun 100 any more. - const acquire = vi.fn((_projectId: string, queueItemId: string) => - Promise.resolve( - queueItemId === 'tracked-elsewhere' - ? ({ kind: 'contended' } as const) - : ({ kind: 'acquired', release } as const) - ) - ); - const { commands, emit, project, runtime } = createRetryHarness( - queueItem, - { getResultImages }, - { locks: { acquire } } - ); + const locks: QueueRunLockPort = { + acquire: vi.fn(), + isHeld: (_projectId, queueItemId) => Promise.resolve(queueItemId === 'tracked-elsewhere'), + }; + const { commands, emit, project, runtime } = createRetryHarness(queueItem, { getResultImages }, { locks }); runtime.start(); emit( @@ -3152,141 +3154,199 @@ describe('queue runtime retries', () => { ); emit('queue_item_status_changed', completedEvent(100, buildQueueItemOrigin('run-gone', project.id), 'canvas')); - await vi.waitFor(() => expect(release).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(commands.stageRetriedCanvasResults).toHaveBeenCalledTimes(1)); + await settleEvents(); expect(commands.stageRetriedCanvasResults).toHaveBeenCalledTimes(1); expect(commands.stageRetriedCanvasResults).toHaveBeenCalledWith( expect.objectContaining({ backendItemId: 100, queueItemId: 'run-gone' }) ); expect(getResultImages).toHaveBeenCalledTimes(1); - // Released only after staging, so a tab that sees the same completion meanwhile finds the lock taken. - expect(release.mock.invocationCallOrder[0]).toBeGreaterThan( - vi.mocked(commands.stageRetriedCanvasResults).mock.invocationCallOrder[0]! - ); + expect(locks.acquire).not.toHaveBeenCalled(); await runtime.dispose(); }); - it('stages sibling Canvas reruns that finish while this tab is still staging their run', async () => { - const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [7], status: 'completed' }; - let finishFirstRead!: (images: QueueResultImage[]) => void; - const getResultImages = vi.fn((itemId: number) => - itemId === 99 - ? new Promise((resolve) => { - finishFirstRead = resolve; - }) - : Promise.resolve([{ imageName: `rerun-${itemId}.png` } as QueueResultImage]) - ); - // Web Locks are requested ifAvailable, so a held lock refuses its own tab too. - const held = new Set(); - const release = vi.fn(); - const acquire = vi.fn((projectId: string, queueItemId: string) => { - const name = `${projectId}:${queueItemId}`; - if (held.has(name)) { - return Promise.resolve({ kind: 'contended' } as const); - } - held.add(name); - return Promise.resolve({ - kind: 'acquired', - release: () => { - release(); - held.delete(name); - return Promise.resolve(); - }, - } as const); + /** The account's journal of rerun delivery claims, shared by every tab of the browser. */ + const createSharedDeliveryJournal = () => { + const claims = new Set(); + return createTestJournal({ + claimRerunDelivery: vi.fn((rerun: { backendItemId: number; projectId: string; queueItemId: string }) => { + const key = JSON.stringify([rerun.backendItemId, rerun.projectId, rerun.queueItemId]); + if (claims.has(key)) { + return Promise.resolve({ kind: 'taken' } as const); + } + claims.add(key); + return Promise.resolve({ kind: 'claimed' } as const); + }), }); - const { commands, emit, project, runtime } = createRetryHarness( - queueItem, + }; + const openCanvasTab = ( + journal: QueueRunJournalPort, + getResultImages: QueueBackendPort['getResultImages'] = vi.fn((itemId: number) => + Promise.resolve([{ imageName: `rerun-${itemId}.png` } as QueueResultImage]) + ) + ) => + createRetryHarness( + { ...createPendingQueueItem(), backendItemIds: [7], status: 'completed' }, { getResultImages }, - { locks: { acquire } } + { journal, locks: createTestLocks(vi.fn()) } ); - const origin = buildQueueItemOrigin('run-gone', project.id); - const staged = () => - vi.mocked(commands.stageRetriedCanvasResults).mock.calls.map(([payload]) => payload.backendItemId); - - runtime.start(); - emit( + const stagedItemIds = (tab: ReturnType) => + vi.mocked(tab.commands.stageRetriedCanvasResults).mock.calls.map(([payload]) => payload.backendItemId); + const emitReruns = (tab: ReturnType, itemIds: number[]) => + tab.emit( 'queue_items_retried', - retriedEvent([ - { item_id: 99, source_item_id: 41 }, - { item_id: 100, source_item_id: 42 }, - { item_id: 101, source_item_id: 43 }, - ]) + retriedEvent(itemIds.map((itemId) => ({ item_id: itemId, source_item_id: itemId + 1_000 }))) ); - emit('queue_item_status_changed', completedEvent(99, origin, 'canvas')); - await vi.waitFor(() => expect(getResultImages).toHaveBeenCalledTimes(1)); - emit('queue_item_status_changed', completedEvent(100, origin, 'canvas')); + const settleEvents = () => + new Promise((resolve) => { + setTimeout(resolve); + }); - finishFirstRead([{ imageName: 'rerun-99.png' } as QueueResultImage]); + it('stages a Canvas rerun once across tabs, however late another tab sees it and after the first tab closed', async () => { + const journal = createSharedDeliveryJournal(); + const first = openCanvasTab(journal); + const second = openCanvasTab(journal); + const completed = (itemId: number) => + completedEvent(itemId, buildQueueItemOrigin('run-gone', first.project.id), 'canvas'); - await vi.waitFor(() => expect(release).toHaveBeenCalledTimes(1)); - expect(staged()).toEqual([99, 100]); - expect(acquire).toHaveBeenCalledTimes(1); + for (const tab of [first, second]) { + tab.runtime.start(); + emitReruns(tab, [99, 100, 101]); + } + first.emit('queue_item_status_changed', completed(99)); + first.emit('queue_item_status_changed', completed(100)); + await vi.waitFor(() => expect(stagedItemIds(first).sort((left, right) => left - right)).toEqual([99, 100])); + await first.runtime.dispose(); + + for (const itemId of [99, 100, 101]) { + second.emit('queue_item_status_changed', completed(itemId)); + } + await vi.waitFor(() => expect(stagedItemIds(second)).toEqual([101])); + await settleEvents(); + expect(stagedItemIds(second)).toEqual([101]); - // Once released, a later sibling claims the run again. - emit('queue_item_status_changed', completedEvent(101, origin, 'canvas')); - await vi.waitFor(() => expect(release).toHaveBeenCalledTimes(2)); - expect(staged()).toEqual([99, 100, 101]); - expect(acquire).toHaveBeenCalledTimes(2); + await second.runtime.dispose(); + }); - await runtime.dispose(); + it('leaves a Canvas rerun to a tab that sees it later when this one could not read its results', async () => { + const journal = createSharedDeliveryJournal(); + const failing = openCanvasTab(journal, vi.fn().mockRejectedValue(new Error('Server restarting'))); + const later = openCanvasTab(journal); + const completed = completedEvent(99, buildQueueItemOrigin('run-gone', failing.project.id), 'canvas'); + + for (const tab of [failing, later]) { + tab.runtime.start(); + emitReruns(tab, [99]); + } + failing.emit('queue_item_status_changed', completed); + await vi.waitFor(() => + expect(failing.commands.recordError).toHaveBeenCalledWith(expect.objectContaining({ area: 'queue-results' })) + ); + later.emit('queue_item_status_changed', completed); + + await vi.waitFor(() => expect(stagedItemIds(later)).toEqual([99])); + expect(stagedItemIds(failing)).toEqual([]); + + await failing.runtime.dispose(); + await later.runtime.dispose(); + }); + + it('stages a Canvas rerun when the delivery journal cannot answer', async () => { + const tab = openCanvasTab( + createTestJournal({ claimRerunDelivery: vi.fn().mockRejectedValue(new Error('IndexedDB closed')) }) + ); + + tab.runtime.start(); + emitReruns(tab, [99]); + tab.emit( + 'queue_item_status_changed', + completedEvent(99, buildQueueItemOrigin('run-gone', tab.project.id), 'canvas') + ); + + await vi.waitFor(() => expect(stagedItemIds(tab)).toEqual([99])); + + await tab.runtime.dispose(); }); it('stages a Canvas rerun that finishes while this tab is still freeing the run lock it gave up', async () => { const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [88], status: 'running' }; - const getResultImages = vi.fn().mockResolvedValue([{ imageName: 'rerun.png' } as QueueResultImage]); let held = false; - let freeRunLock!: () => void; - const acquire = vi.fn(() => { - if (held) { - return Promise.resolve({ kind: 'contended' } as const); - } - held = true; - const release = - acquire.mock.calls.length === 1 - ? () => - new Promise((resolve) => { - freeRunLock = () => { - held = false; - resolve(); - }; - }) - : () => { - held = false; - return Promise.resolve(); - }; - return Promise.resolve({ kind: 'acquired', release } as const); - }); + let freeRunLock: (() => void) | undefined; + const locks: QueueRunLockPort = { + acquire: vi.fn(() => { + held = true; + return Promise.resolve({ + kind: 'acquired', + release: () => + new Promise((resolve) => { + freeRunLock = () => { + held = false; + resolve(); + }; + }), + } as const); + }), + isHeld: () => Promise.resolve(held), + }; const { commands, emit, notify, project, runtime } = createRetryHarness( queueItem, - { getResultImages }, - { locks: { acquire } } + { getResultImages: vi.fn().mockResolvedValue([{ imageName: 'rerun.png' } as QueueResultImage]) }, + { journal: createSharedDeliveryJournal(), locks } ); runtime.start(); - await vi.waitFor(() => expect(acquire).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(held).toBe(true)); emit('queue_items_retried', retriedEvent([{ item_id: 100, source_item_id: 42 }])); // Removing the run gives up its lock; the browser frees it only later. project.queue.items = []; notify(); await vi.waitFor(() => expect(freeRunLock).toBeDefined()); emit('queue_item_status_changed', completedEvent(100, buildQueueItemOrigin(queueItem.id, project.id), 'canvas')); - // Let the staging claim settle as far as it can before the lock is free. - await new Promise((resolve) => { - setTimeout(resolve); - }); - expect(acquire).toHaveBeenCalledTimes(1); - freeRunLock(); await vi.waitFor(() => expect(commands.stageRetriedCanvasResults).toHaveBeenCalledWith( expect.objectContaining({ backendItemId: 100, queueItemId: queueItem.id }) ) ); - expect(acquire).toHaveBeenCalledTimes(2); + expect(held).toBe(true); + freeRunLock?.(); await runtime.dispose(); }); + it('leaves a rerun another tab adopted to that tab, after its run is let go', async () => { + const journal = createSharedDeliveryJournal(); + const failedRun: QueueItem = { ...createPendingQueueItem(), backendItemIds: [41], status: 'failed' }; + const adopting = createRetryHarness(failedRun, {}, { journal }); + vi.mocked(adopting.commands.reopenForRetry).mockImplementation(({ retriedItems }) => { + failedRun.retriedBackendItemIds = [41]; + failedRun.backendItemIds = retriedItems.map(({ itemId }) => itemId); + failedRun.status = 'running'; + adopting.notify(); + }); + // This tab opened the project after the run was gone from it, so it remembers the rerun as unadopted; the + // adopting tab no longer holds the run's lock when the completion reaches this one. + const late = openCanvasTab(journal); + + adopting.runtime.start(); + late.runtime.start(); + for (const tab of [adopting, late]) { + tab.emit('queue_items_retried', retriedEvent([{ item_id: 99, source_item_id: 41 }])); + } + expect(failedRun.backendItemIds).toEqual([99]); + late.emit( + 'queue_item_status_changed', + completedEvent(99, buildQueueItemOrigin(failedRun.id, late.project.id), 'canvas') + ); + await settleEvents(); + + expect(late.commands.stageRetriedCanvasResults).not.toHaveBeenCalled(); + + await adopting.runtime.dispose(); + await late.runtime.dispose(); + }); + it("handles a retry once when both the tab's own answer and the socket deliver it, and ignores others' retries", async () => { const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [41], status: 'failed' }; const { commands, emit, notify, project, runtime } = createRetryHarness(queueItem, {}, { viewerUserId: 'me' }); diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.ts b/invokeai/frontend/webv2/src/features/queue/runtime.ts index 6e30dc563cb..d3432de0682 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.ts @@ -55,6 +55,12 @@ export interface QueueRuntime { } export interface QueueRunJournalPort { + /** Claim delivering a backend rerun of a local run; only the first claim across the account's tabs succeeds. */ + claimRerunDelivery(rerun: { + backendItemId: number; + projectId: string; + queueItemId: string; + }): Promise<{ kind: 'claimed' | 'taken' | 'unavailable' }>; listForProject( projectId: string ): Promise< @@ -72,6 +78,8 @@ export type QueueRunLock = { kind: 'acquired'; release(): Promise } | { ki export interface QueueRunLockPort { acquire(projectId: string, queueItemId: string): Promise; + /** Whether any tab holds the run, asked without requesting it; null when the browser cannot tell. */ + isHeld(projectId: string, queueItemId: string): Promise; } export interface QueueHistoryProject { @@ -446,8 +454,8 @@ export const createQueueRuntime = ({ const reportedCancelFailures = new Set(); const ownedLocks = new Map>(); const lockRequests = new Map>(); - /** Run locks this tab gave up whose browser lock is not yet free. */ - const lockReleases = new Map>(); + /** Runs this tab gave up whose browser lock is not yet free; their lock still reads as held. */ + const releasingRuns = new Map>(); const preparingRunKeys = new Set(); const activeAttempts = new Map(); const ownershipEpochs = new Map(); @@ -649,10 +657,10 @@ export const createQueueRuntime = ({ } ownedLocks.delete(key); const released = lock.release().catch(() => undefined); - lockReleases.set(key, released); + releasingRuns.set(key, released); void released.finally(() => { - if (lockReleases.get(key) === released) { - lockReleases.delete(key); + if (releasingRuns.get(key) === released) { + releasingRuns.delete(key); } }); }; @@ -1302,6 +1310,26 @@ export const createQueueRuntime = ({ } }; + /** + * Claim delivering a rerun in the account's journal, which every tab shares and which outlives this one: the tab that + * adopts or stages a rerun first keeps any tab handling its completion later, run lock released or not, from staging + * it again. Without the journal no tab can coordinate, so each delivers. + */ + const claimRerunDelivery = async (rerun: { + backendItemId: number; + projectId: string; + queueItemId: string; + }): Promise => { + if (!journal) { + return true; + } + try { + return (await journal.claimRerunDelivery(rerun)).kind !== 'taken'; + } catch { + return true; + } + }; + /** * A retry, from any tab or client, re-runs failed or canceled backend items under new ids. The open run that owns * the retried items, or once owned them before an earlier retry, reopens around the reruns, so its captured @@ -1361,6 +1389,10 @@ export const createQueueRuntime = ({ for (const { itemId } of run.retriedItems) { if (reopened?.status === 'running' && reopened.backendItemIds?.includes(itemId)) { isAdopted = true; + // The run delivers it, so a tab that took it for unadopted must not stage it once this one lets the run go. + // A claim already taken would mean another tab staged its completion before this retry reached here, which + // the rerun's execution time rules out; the run delivers regardless. + void claimRerunDelivery({ backendItemId: itemId, projectId: run.projectId, queueItemId: run.queueItemId }); } else { rememberUnadoptedRerun(itemId); } @@ -1407,99 +1439,20 @@ export const createQueueRuntime = ({ }; /** - * Elect the one tab that stages a Canvas rerun no run adopted: staging lands in the synced project document, so a - * second tab staging it would duplicate it. A tab that holds the run stages it; otherwise the run's lock decides, - * kept until staging is done, and while the tab tracking the run is alive it holds that lock and other tabs skip. - * Tabs in other browsers cannot be coordinated. + * Whether another tab tracks the run behind a Canvas rerun no run adopted. That tab knows the run's result nodes, so + * it stages the rerun and this one leaves it; the lock is only looked at, never requested, so that tab is never + * turned away. Tabs that do not track the run are kept apart by the rerun's delivery claim instead. */ - const acquireUnadoptedRerunStaging = async ( - projectId: string, - queueItemId: string - ): Promise<(() => Promise) | null> => { + const isRunTrackedElsewhere = async (projectId: string, queueItemId: string): Promise => { const key = getRunKey(projectId, queueItemId); - // Competing with this tab's own lock, while its claim is decided or its release is still freeing it, would leave - // the rerun to no tab; either can end with this tab holding the run or not, so look again after each. - for (;;) { - if (!locks || ownedLocks.has(key)) { - return () => Promise.resolve(); - } - const ownRequest = lockRequests.get(key); - const releasing = lockReleases.get(key); - if (ownRequest) { - if (!(await ownRequest)) { - return null; - } - } else if (releasing) { - await releasing; - } else { - break; - } - } - const lock = await locks.acquire(projectId, queueItemId); - if (lock.kind === 'acquired') { - return () => lock.release(); + if (!locks || ownedLocks.has(key) || lockRequests.has(key) || releasingRuns.has(key)) { + return false; } - // Without browser locks no tab coordinates; staging here is what every tab does then. - return lock.kind === 'unavailable' ? () => Promise.resolve() : null; - }; - - /** Staging claims this tab holds or is deciding, per run, with the reruns delivering under each. */ - const stagingClaims = new Map< - string, - { claim: Promise<(() => Promise) | null>; holders: number; released?: Promise } - >(); - - /** - * Claim staging for one rerun. Sibling reruns of a run this tab is already staging share its claim, since the lock - * would refuse this tab's second request and leave them to no tab; a claim being released is awaited before the - * next one asks. Resolves to this rerun's release, or null when another tab has the run. - */ - const claimUnadoptedRerunStaging = async ( - projectId: string, - queueItemId: string - ): Promise<(() => Promise) | null> => { - const key = getRunKey(projectId, queueItemId); - const current = stagingClaims.get(key); - const entry = - current && !current.released - ? current - : { - claim: (current?.released ?? Promise.resolve()).then(() => - acquireUnadoptedRerunStaging(projectId, queueItemId) - ), - holders: 0, - }; - stagingClaims.set(key, entry); - entry.holders += 1; - - const leave = (release?: () => Promise): Promise => { - entry.holders -= 1; - if (entry.holders > 0) { - return Promise.resolve(); - } - const released: Promise = (release?.() ?? Promise.resolve()) - .catch(() => undefined) - .finally(() => { - if (stagingClaims.get(key) === entry) { - stagingClaims.delete(key); - } - }); - entry.released = released; - return released; - }; - - let release: (() => Promise) | null; try { - release = await entry.claim; - } catch (error) { - await leave(); - throw error; - } - if (!release) { - await leave(); - return null; + return (await locks.isHeld(projectId, queueItemId)) === true; + } catch { + return false; } - return () => leave(release); }; /** @@ -1552,21 +1505,22 @@ export const createQueueRuntime = ({ return; } - void claimUnadoptedRerunStaging(projectId, queueItemId) - .then(async (release) => { - if (!release) { - return; - } - try { - const images = await readUnadoptedRerunImages(event, queueItemId, run); - if (isActive() && images.length > 0) { - commands.stageRetriedCanvasResults({ backendItemId: event.item_id, images, projectId, queueItemId }); - } - } finally { - await release().catch(() => undefined); - } - }) - .catch(recordError); + const stage = async (): Promise => { + if (await isRunTrackedElsewhere(projectId, queueItemId)) { + return; + } + const images = await readUnadoptedRerunImages(event, queueItemId, run); + // Claimed only with something to stage, so a failed or empty read leaves the rerun to a tab that sees it later. + if ( + images.length > 0 && + isActive() && + (await claimRerunDelivery({ backendItemId: event.item_id, projectId, queueItemId })) && + isActive() + ) { + commands.stageRetriedCanvasResults({ backendItemId: event.item_id, images, projectId, queueItemId }); + } + }; + void stage().catch(recordError); }; const submitQueueItem = async ( diff --git a/invokeai/frontend/webv2/src/workbench/projects/README.md b/invokeai/frontend/webv2/src/workbench/projects/README.md index 71a5ecd08c0..ae37fffbac4 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/README.md +++ b/invokeai/frontend/webv2/src/workbench/projects/README.md @@ -18,7 +18,7 @@ Project links are consumed after a successful open, so reloading restores the sa ## Browser recovery -- Account-owned IndexedDB stores only unacknowledged project drafts, unload journal entries, active queue runs, receipt acknowledgements, and bounded recall values. Clean server documents are not mirrored. The journal has a database of its own, deleted with the workbench database by account cleanup and by clearing workspace data. +- Account-owned IndexedDB stores only unacknowledged project drafts, unload journal entries, active queue runs, receipt acknowledgements, rerun delivery claims, and bounded recall values. Clean server documents are not mirrored. The journal has a database of its own, deleted with the workbench database by account cleanup and by clearing workspace data. - Draft generations fence acknowledgements. Draft writer ownership and cross-tab notifications prevent one editor from overwriting another editor's unacknowledged work. - **Unload journal.** Staging a draft takes fenced read-then-write transactions, which an unloading page never finishes. So when the page is hidden or unloads, before the save starts, each open project whose document is not what the next load would restore (its durably staged draft, or without one the acknowledged server copy) is written as one blind, explicitly committed IndexedDB write. It lives in its own database because Chromium drops such a write at unload while a read-write transaction of the same database is still open, which a stage in flight is. An entry carries the account, project, lineage (an editor session, possibly another tab's that the session blob handed on), the editor session of the page that wrote it, writer, base revision and canvas schema floor, and a draft generation: above everything the writer staged or journaled before, or equal to a stage of the same document still in flight. A writer's generations never restart while its page lives, even when a project is closed and reopened. Each write also deletes the writer's lower generations for the lineage, so there is at most one entry per lineage and writer, however long saves cannot be acknowledged. - **Settled mark.** A lineage's writer claim records the generation through which its writer has settled its journal: everything it acknowledged, discarded, or holds in recovery. The acknowledgement and the draft deletion write it in the same transaction that settles or deletes the draft; a document back to what recovery holds (an undo, say) writes it on its next save, and claims the lineage for the writer if it has none. It is an optional claim field, so claims written earlier read as having settled nothing and the workbench database stays at version 4; a mark of an earlier writer is ignored. It is what tells "never staged" from "acknowledged and moved on" once an acknowledgement has deleted the draft, so an entry whose deletion failed, or that another tab reconciles at the moment of the acknowledgement, can never come back. @@ -27,6 +27,7 @@ Project links are consumed after a successful open, so reloading restores the sa - An entry the draft store cannot take waits. One for a damaged lineage waits for that lineage's cleanup, for at most 7 days, and is then discarded. One refused for lack of storage is applied by a later load only while its lineage is unclaimed; the load that refused it usually claims the lineage when it opens the project, and then the edit is lost. Both report degraded local recovery for that project until its next stage succeeds. An unavailable journal (its database will not open, or reconciliation fails) is logged, not reported: recovery then works exactly as it did without the journal. A journal operation that fails on its own (a deletion whose transaction could not be opened, say) fails alone; the next hidden event journals again. - A writer settles and removes its entries when the server acknowledges a generation at or above them, when the document returns to what recovery holds (on the next save; a hidden event meanwhile also deletes the entry, and the save still writes the mark), and when it deletes the project or discards its draft; a removal that fails leaves the entry settled, and one whose mark fails is retried with the next save. When an older generation is acknowledged, a newer entry is rewritten onto the acknowledged revision, as a newer staged draft is rebased. A journal write that aborts is written again on the next hidden event. Nothing is journaled for a closed session, after the account lifetime ended, for a project being deleted, or beyond 8 MiB of documents per event; larger projects rely on autosave, and the skip is logged. The editor route counts projects with entries as ones that would open, so a new project typed into and reloaded before its first save reopens. - Queue ownership uses Web Locks. Journal writes precede submission; backend receipts make retries safe after lost responses. Accepted IDs must be durable before their receipt can expire. Fresh runs that never reached browser storage use best-effort terminal acknowledgement instead. +- A Canvas rerun no open run adopted is staged by one tab of the browser. The tab tracking its run stages it while it lives (other tabs only check that run's Web Lock, without requesting it); otherwise a tab claims the rerun in the workbench database's metadata store just before staging, once its results are read, and a tab whose claim is already taken skips it, even after the claiming tab closed. A tab that adopts a rerun into its run claims it too, so a tab that took it for unadopted does not stage it after the run is let go. Claims are keyed by the backend item and its local run, so an id the server reuses after its database is reset is a different claim; they are kept 7 days and capped at 10,000, oldest first. Without browser storage, tabs that handle the rerun at the same moment can each stage it; tabs in other browsers are never coordinated. - Completed, failed, and cancelled runs retain exact recall values in an LRU cache, capped at 500 entries / 32 MiB. Eviction removes recall convenience, not project data or active-run recovery. - Projects with journal entries remain reachable even if another tab saved an empty session. Automatic reopening is bounded; remaining entries can be opened, exported as queue-recovery JSON, or explicitly discarded. Discarding local recovery does not cancel backend work. - Browser-storage failures do not block backend project loads or saves. Recovery warnings distinguish degraded local durability from successful server persistence. diff --git a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts index 41f58adfd61..44bb43d5be3 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts @@ -163,6 +163,7 @@ beforeEach(() => { vi.mocked(createAccountOwnedQueueRunJournal).mockResolvedValue({ availability: 'available', acknowledgeReceipt: vi.fn(), + claimRerunDelivery: vi.fn(), listPendingReceipts: vi.fn(), close: vi.fn(), deleteForProject: vi.fn(), @@ -335,6 +336,7 @@ describe('durable project persistence', () => { vi.mocked(createAccountOwnedQueueRunJournal).mockResolvedValueOnce({ availability: 'available', acknowledgeReceipt: vi.fn(), + claimRerunDelivery: vi.fn(), listPendingReceipts: vi.fn(), close: vi.fn(), deleteForProject: vi.fn(), diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts index 2533f6544e1..a094031c837 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts @@ -8,7 +8,7 @@ import { import { createWorkbenchStore } from '@workbench/workbenchStore'; import { afterEach, describe, expect, it } from 'vitest'; -import { createIndexedDbQueueRunJournal } from './queueRunJournal'; +import { createIndexedDbQueueRunJournal, QUEUE_RERUN_DELIVERY_RETENTION_MS } from './queueRunJournal'; const databases: WorkbenchDatabase[] = []; const suffixes = new Set(); @@ -18,7 +18,7 @@ const createJournal = async (options?: Parameters = {}): WorkbenchQueueItem => @@ -128,6 +128,53 @@ describe('IndexedDB queue run journal', () => { }); }); + const rerun = (backendItemId: number, queueItemId = 'run-1') => ({ + backendItemId, + projectId: 'project-1', + queueItemId, + }); + + it('lets only the first claim deliver a rerun, from any connection, until the claim expires', async () => { + let now = 1_000; + const { journal, suffix } = await createJournal({ now: () => now }); + const otherTabDatabase = await openWorkbenchDatabase(suffix); + databases.push(otherTabDatabase); + const otherTab = createIndexedDbQueueRunJournal(otherTabDatabase, { now: () => now }); + + await expect( + Promise.all([journal.claimRerunDelivery(rerun(99)), otherTab.claimRerunDelivery(rerun(99))]).then((results) => + results.map((result) => result.kind).sort() + ) + ).resolves.toEqual(['claimed', 'taken']); + await expect(otherTab.claimRerunDelivery(rerun(100))).resolves.toEqual({ kind: 'claimed' }); + // A server whose database was reset hands the same id to another run's rerun. + await expect(otherTab.claimRerunDelivery(rerun(99, 'run-2'))).resolves.toEqual({ kind: 'claimed' }); + + now += QUEUE_RERUN_DELIVERY_RETENTION_MS; + await expect(journal.claimRerunDelivery(rerun(99))).resolves.toEqual({ kind: 'claimed' }); + }); + + it('keeps rerun delivery claims within the entry budget by dropping the oldest, apart from active runs', async () => { + let now = 1_000; + const { journal } = await createJournal({ maxEntries: 2, now: () => now++ }); + await journal.record('project-1', createItem()); + + for (const backendItemId of [1, 2, 3]) { + await expect(journal.claimRerunDelivery(rerun(backendItemId))).resolves.toEqual({ kind: 'claimed' }); + } + + await expect(journal.claimRerunDelivery(rerun(3))).resolves.toEqual({ kind: 'taken' }); + await expect(journal.claimRerunDelivery(rerun(1))).resolves.toEqual({ kind: 'claimed' }); + await expect(journal.listAll()).resolves.toMatchObject({ entries: [{ queueItemId: 'queue-item-1' }] }); + }); + + it('claims no rerun once closed', async () => { + const { journal } = await createJournal(); + journal.close(); + + await expect(journal.claimRerunDelivery(rerun(99))).resolves.toEqual({ kind: 'unavailable' }); + }); + it('lists distinct projects without decoding their run payloads', async () => { const { database, journal } = await createJournal(); await journal.record('project-1', createItem()); diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.ts b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.ts index e95e7f594df..19d6fc43dfc 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.ts +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.ts @@ -17,6 +17,15 @@ import { normalizeRestoredQueueItem } from '@workbench/queue-integration/queueRu export const QUEUE_RUN_MAX_BYTES = 32 * 1024 * 1024; export const QUEUE_RUN_TOTAL_MAX_BYTES = 128 * 1024 * 1024; export const QUEUE_RUN_MAX_ENTRIES = 10_000; +/** How long a rerun delivery claim is kept: past any tab still handling the rerun's completion, even one long queued. */ +export const QUEUE_RERUN_DELIVERY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; + +/** A backend rerun of a local run; the run names it apart from an id the server reuses after its database is reset. */ +export interface QueueRerunDelivery { + backendItemId: number; + projectId: string; + queueItemId: string; +} export interface QueueRunJournalEntry { /** Untrusted serialized candidate. Normalize it against the current project before restoration. */ @@ -31,11 +40,14 @@ export type QueueRunJournalLoadResult = | { kind: 'unavailable' }; export type QueueRunJournalWriteResult = { kind: 'invalid' | 'quota' | 'stored' | 'too-large' | 'unavailable' }; export type QueueRunJournalSettleResult = { kind: 'removed' | 'unavailable' }; +export type QueueRerunDeliveryClaimResult = { kind: 'claimed' | 'taken' | 'unavailable' }; export interface QueueRunJournal { readonly availability: 'available' | 'unavailable'; close(): void; acknowledgeReceipt(projectId: string, queueItemId: string): Promise; + /** Claim delivering a backend rerun's results; only the first claim across the account's tabs succeeds. */ + claimRerunDelivery(rerun: QueueRerunDelivery): Promise; listPendingReceipts(): Promise< { entries: QueueReceiptAcknowledgement[]; kind: 'available' } | { kind: 'unavailable' } >; @@ -51,6 +63,8 @@ const getByteSize = (value: string): number => new TextEncoder().encode(value).b const QUEUE_RUN_SUBMISSION_SEQUENCE_KEY = 'queueRunSubmissionSequence'; const QUEUE_RUN_TOTAL_BYTES_KEY = 'queueRunTotalBytes'; const QUEUE_RUN_COUNT_KEY = 'queueRunCount'; +// Claims share the metadata store, under a prefix of their own, rather than needing a database upgrade. +const QUEUE_RERUN_DELIVERY_KEY_PREFIX = 'queueRerunDelivery:'; const QUEUE_RUN_STORES = [ WORKBENCH_DATABASE_METADATA_STORE, WORKBENCH_QUEUE_RUN_STORE, @@ -324,6 +338,38 @@ export const createIndexedDbQueueRunJournal = ( return { kind: 'unavailable' }; } }, + async claimRerunDelivery({ backendItemId, projectId, queueItemId }) { + if (!isAvailable()) { + return { kind: 'unavailable' }; + } + try { + const transaction = observeTransaction(database.transaction(WORKBENCH_DATABASE_METADATA_STORE, 'readwrite')); + const metadataStore = transaction.store; + const key = `${QUEUE_RERUN_DELIVERY_KEY_PREFIX}${JSON.stringify([backendItemId, projectId, queueItemId])}`; + const claimedAt = now(); + // One read of every claim, so the store the queue journal writes to is held only briefly. + const claims = await metadataStore.getAll( + IDBKeyRange.bound(QUEUE_RERUN_DELIVERY_KEY_PREFIX, `${QUEUE_RERUN_DELIVERY_KEY_PREFIX}\uffff`) + ); + const isLive = (claim: { value: number }) => + Number.isFinite(claim.value) && claimedAt - claim.value < QUEUE_RERUN_DELIVERY_RETENTION_MS; + const isTaken = claims.some((claim) => claim.key === key && isLive(claim)); + const kept = claims.filter((claim) => claim.key !== key && isLive(claim)); + const stale = claims.filter((claim) => !isLive(claim)); + if (!isTaken) { + kept.sort((left, right) => left.value - right.value); + stale.push(...kept.slice(0, Math.max(0, kept.length - Math.max(0, entryBudget - 1)))); + } + await Promise.all([ + ...stale.map((claim) => metadataStore.delete(claim.key)), + ...(isTaken ? [] : [metadataStore.put({ key, value: claimedAt })]), + ]); + await transaction.done; + return { kind: isTaken ? 'taken' : 'claimed' }; + } catch { + return { kind: 'unavailable' }; + } + }, async deleteForProject(projectId) { if (!isAvailable()) { return { kind: 'unavailable' }; @@ -523,6 +569,7 @@ export const createAccountOwnedQueueRunJournal = async ( return { availability: 'unavailable', acknowledgeReceipt: () => Promise.resolve({ kind: 'unavailable' }), + claimRerunDelivery: () => Promise.resolve({ kind: 'unavailable' }), listPendingReceipts: () => Promise.resolve({ kind: 'unavailable' }), close: () => undefined, deleteForProject: () => Promise.resolve({ kind: 'unavailable' }), diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunLocks.test.ts b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunLocks.test.ts index bb7c7559574..c724af535d0 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunLocks.test.ts +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunLocks.test.ts @@ -18,6 +18,22 @@ describe('queue run locks', () => { expect(acquire.mock.calls[0]?.[0]).not.toBe(acquire.mock.calls[1]?.[0]); }); + it('asks whether a run is held under the name its owner acquires', async () => { + const acquire = vi.fn().mockResolvedValue({ kind: 'contended' }); + const isHeld = vi.fn().mockResolvedValue(true); + const locks = createQueueRunLockPort( + ':account-1', + acquire, + vi.fn().mockResolvedValue({ kind: 'acquired', release: vi.fn() }), + isHeld + ); + + await locks.acquire('project-1', 'item-1'); + await expect(locks.isHeld('project-1', 'item-1')).resolves.toBe(true); + + expect(isHeld).toHaveBeenCalledWith(acquire.mock.calls[0]?.[0]); + }); + it('holds a shared project lease for the lifetime of an acquired run', async () => { const calls: string[] = []; const locks = createQueueRunLockPort( diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunLocks.ts b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunLocks.ts index 2e339fd52ee..197554b0a7f 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunLocks.ts +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunLocks.ts @@ -5,6 +5,7 @@ import { acquireSharedLock, type BrowserLockResult, type ExclusiveLockResult, + isLockHeld, } from '@platform/browser/webLocks'; import { getProjectLifecycleLockName } from '@workbench/projects/projectLifecycleLocks'; @@ -16,8 +17,10 @@ export const getQueueRunLockName = (storageSuffix: string, projectId: string, qu export const createQueueRunLockPort = ( storageSuffix: string, acquireRun: (name: string) => Promise = acquireExclusiveLock, - acquireProject: (name: string) => Promise = acquireSharedLock + acquireProject: (name: string) => Promise = acquireSharedLock, + isHeld: (name: string) => Promise = isLockHeld ): QueueRunLockPort => ({ + isHeld: (projectId, queueItemId) => isHeld(getQueueRunLockName(storageSuffix, projectId, queueItemId)), async acquire(projectId, queueItemId) { const projectLock = await acquireProject(getProjectLifecycleLockName(storageSuffix, projectId)); if (projectLock.kind !== 'acquired') { From b374b39261b6c4261e37f97e4c516f8f661434b5 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Tue, 13 Oct 2026 00:01:14 -0600 Subject: [PATCH 32/35] fix(queue): stage a rerun only where its project is open and never re-adopt a delivered one Recheck that the project is open before claiming a rerun's delivery, and give the claim back if it closes meanwhile. Reserve each rerun before reopening its run; a delivered rerun refuses the reservation, and a refused reopen releases it. --- .../webv2/src/features/queue/index.ts | 2 +- .../webv2/src/features/queue/runtime.test.ts | 143 ++++++++++++++++- .../webv2/src/features/queue/runtime.ts | 145 ++++++++++++------ .../webv2/src/workbench/projects/README.md | 2 +- .../projects/durableSyncedPersistence.test.ts | 2 + .../queueRunJournal.browser.test.ts | 34 ++-- .../queue-integration/queueRunJournal.ts | 46 ++++-- 7 files changed, 298 insertions(+), 76 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/queue/index.ts b/invokeai/frontend/webv2/src/features/queue/index.ts index 586ee6ca307..1081172f95f 100644 --- a/invokeai/frontend/webv2/src/features/queue/index.ts +++ b/invokeai/frontend/webv2/src/features/queue/index.ts @@ -66,5 +66,5 @@ export { getQueueReadModelOptions, queueCommands, } from './publicApi'; -export type { QueueRunLockPort } from './runtime'; +export type { QueueRerunDelivery, QueueRunLockPort } from './runtime'; export { hasPendingWorkflowQueueItem } from './ui/queueViewModel'; diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts index 0f8048204a8..a7b370e150a 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts @@ -20,6 +20,7 @@ import { type QueueHistoryCommands, type QueueRunJournalPort, type QueueRunLock, + type QueueRerunDelivery, type QueueRunLockPort, } from './runtime'; @@ -105,6 +106,7 @@ const createTestCommands = (overrides: Partial = {}): Queu const createTestJournal = (overrides: Partial = {}): QueueRunJournalPort => ({ claimRerunDelivery: vi.fn().mockResolvedValue({ kind: 'claimed' }), + releaseRerunDelivery: vi.fn().mockResolvedValue({ kind: 'removed' }), listForProject: vi.fn().mockResolvedValue({ entries: [], kind: 'available' }), record: vi.fn().mockResolvedValue({ kind: 'stored' }), settle: vi.fn().mockResolvedValue({ kind: 'removed' }), @@ -2798,8 +2800,13 @@ describe('queue runtime retries', () => { services: { journal?: QueueRunJournalPort; locks?: QueueRunLockPort; viewerUserId?: string | null } = {} ) => { const project = { id: 'project-1', queue: { items: [queueItem] } }; + const projects = [project]; const listeners = new Set<() => void>(); const notify = () => listeners.forEach((listener) => listener()); + const closeProject = () => { + projects.splice(0); + notify(); + }; const handlers = new Map void>>(); const backend = createTestBackend({ on: vi.fn((event: string, handler: (payload: never) => void) => { @@ -2822,7 +2829,7 @@ describe('queue runtime retries', () => { backend, history: { commands, - getSnapshot: () => ({ connectionStatus: 'connected', isHydrated: true, projects: [project] }), + getSnapshot: () => ({ connectionStatus: 'connected', isHydrated: true, projects }), subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); @@ -2832,7 +2839,7 @@ describe('queue runtime retries', () => { const emit = (event: string, payload: unknown) => handlers.get(event)?.forEach((handler) => handler(payload as never)); - return { backend, commands, emit, notify, project, runtime }; + return { backend, closeProject, commands, emit, notify, project, runtime }; }; const retriedEvent = (pairs: { item_id: number; source_item_id: number }[]) => ({ @@ -3169,15 +3176,21 @@ describe('queue runtime retries', () => { /** The account's journal of rerun delivery claims, shared by every tab of the browser. */ const createSharedDeliveryJournal = () => { const claims = new Set(); + const keyOf = (rerun: QueueRerunDelivery, kind: 'adoption' | 'delivery') => + JSON.stringify([rerun.backendItemId, rerun.projectId, rerun.queueItemId, kind]); return createTestJournal({ - claimRerunDelivery: vi.fn((rerun: { backendItemId: number; projectId: string; queueItemId: string }) => { - const key = JSON.stringify([rerun.backendItemId, rerun.projectId, rerun.queueItemId]); - if (claims.has(key)) { + claimRerunDelivery: vi.fn((rerun: QueueRerunDelivery, kind: 'adoption' | 'delivery') => { + const refusedBy = kind === 'delivery' ? (['adoption', 'delivery'] as const) : (['delivery'] as const); + if (refusedBy.some((refusing) => claims.has(keyOf(rerun, refusing)))) { return Promise.resolve({ kind: 'taken' } as const); } - claims.add(key); + claims.add(keyOf(rerun, kind)); return Promise.resolve({ kind: 'claimed' } as const); }), + releaseRerunDelivery: vi.fn((rerun: QueueRerunDelivery, kind: 'adoption' | 'delivery') => { + claims.delete(keyOf(rerun, kind)); + return Promise.resolve({ kind: 'removed' }); + }), }); }; const openCanvasTab = ( @@ -3315,6 +3328,120 @@ describe('queue runtime retries', () => { await runtime.dispose(); }); + it('leaves a Canvas rerun to another tab when its project closed here while the results loaded', async () => { + const journal = createSharedDeliveryJournal(); + let finishRead!: (images: QueueResultImage[]) => void; + const closing = openCanvasTab( + journal, + vi.fn( + () => + new Promise((resolve) => { + finishRead = resolve; + }) + ) + ); + const open = openCanvasTab(journal); + const completed = completedEvent(99, buildQueueItemOrigin('run-gone', closing.project.id), 'canvas'); + + for (const tab of [closing, open]) { + tab.runtime.start(); + emitReruns(tab, [99]); + } + closing.emit('queue_item_status_changed', completed); + await vi.waitFor(() => expect(finishRead).toBeDefined()); + closing.closeProject(); + finishRead([{ imageName: 'rerun-99.png' } as QueueResultImage]); + await settleEvents(); + open.emit('queue_item_status_changed', completed); + + await vi.waitFor(() => expect(stagedItemIds(open)).toEqual([99])); + expect(stagedItemIds(closing)).toEqual([]); + + await closing.runtime.dispose(); + await open.runtime.dispose(); + }); + + it('gives back the claim of a rerun whose project closed while it was being claimed', async () => { + const journal = createSharedDeliveryJournal(); + const claim = vi.mocked(journal.claimRerunDelivery).getMockImplementation()!; + const closing = openCanvasTab(journal); + vi.mocked(journal.claimRerunDelivery).mockImplementationOnce((rerun, kind) => { + closing.closeProject(); + return claim(rerun, kind); + }); + const open = openCanvasTab(journal); + const completed = completedEvent(99, buildQueueItemOrigin('run-gone', closing.project.id), 'canvas'); + + for (const tab of [closing, open]) { + tab.runtime.start(); + emitReruns(tab, [99]); + } + closing.emit('queue_item_status_changed', completed); + await vi.waitFor(() => expect(journal.releaseRerunDelivery).toHaveBeenCalled()); + open.emit('queue_item_status_changed', completed); + + await vi.waitFor(() => expect(stagedItemIds(open)).toEqual([99])); + expect(stagedItemIds(closing)).toEqual([]); + + await closing.runtime.dispose(); + await open.runtime.dispose(); + }); + + it('does not adopt a rerun another tab already staged before this one learned of the retry', async () => { + const journal = createSharedDeliveryJournal(); + const failedRun: QueueItem = { ...createPendingQueueItem(), backendItemIds: [41], status: 'failed' }; + const delayed = createRetryHarness(failedRun, {}, { journal }); + const staging = openCanvasTab(journal); + const retried = retriedEvent([{ item_id: 99, source_item_id: 41 }]); + + staging.runtime.start(); + staging.emit('queue_items_retried', retried); + staging.emit( + 'queue_item_status_changed', + completedEvent(99, buildQueueItemOrigin(failedRun.id, staging.project.id), 'canvas') + ); + await vi.waitFor(() => expect(stagedItemIds(staging)).toEqual([99])); + delayed.runtime.start(); + delayed.emit('queue_items_retried', retried); + await settleEvents(); + + expect(delayed.commands.reopenForRetry).not.toHaveBeenCalled(); + expect(failedRun.status).toBe('failed'); + + await delayed.runtime.dispose(); + await staging.runtime.dispose(); + }); + + it('gives back the reservation of a rerun its run refused, so another tab can stage it', async () => { + const journal = createSharedDeliveryJournal(); + // Still running, so the run refuses to reopen around the rerun of its other failed item. + const activeRun: QueueItem = { + ...createPendingQueueItem(), + backendItemIds: [99], + retriedBackendItemIds: [41, 42], + status: 'running', + }; + const refusing = createRetryHarness(activeRun, {}, { journal }); + const other = openCanvasTab(journal); + const retried = retriedEvent([{ item_id: 100, source_item_id: 42 }]); + + refusing.runtime.start(); + refusing.emit('queue_items_retried', retried); + await vi.waitFor(() => expect(refusing.commands.reopenForRetry).toHaveBeenCalled()); + await vi.waitFor(() => expect(journal.releaseRerunDelivery).toHaveBeenCalled()); + other.runtime.start(); + other.emit('queue_items_retried', retried); + other.emit( + 'queue_item_status_changed', + completedEvent(100, buildQueueItemOrigin(activeRun.id, other.project.id), 'canvas') + ); + + await vi.waitFor(() => expect(stagedItemIds(other)).toEqual([100])); + + await refusing.runtime.dispose(); + await other.runtime.dispose(); + }); + it('leaves a rerun another tab adopted to that tab, after its run is let go', async () => { const journal = createSharedDeliveryJournal(); const failedRun: QueueItem = { ...createPendingQueueItem(), backendItemIds: [41], status: 'failed' }; @@ -3334,7 +3461,7 @@ describe('queue runtime retries', () => { for (const tab of [adopting, late]) { tab.emit('queue_items_retried', retriedEvent([{ item_id: 99, source_item_id: 41 }])); } - expect(failedRun.backendItemIds).toEqual([99]); + await vi.waitFor(() => expect(failedRun.backendItemIds).toEqual([99])); late.emit( 'queue_item_status_changed', completedEvent(99, buildQueueItemOrigin(failedRun.id, late.project.id), 'canvas') @@ -3374,9 +3501,9 @@ describe('queue runtime retries', () => { completedEvent(200, buildQueueItemOrigin('their-run', 'their-project'), 'gallery') ); + await vi.waitFor(() => expect(project.queue.items[0]?.backendItemIds).toEqual([99])); expect(commands.reopenForRetry).toHaveBeenCalledTimes(1); expect(commands.refreshBackendData).not.toHaveBeenCalled(); - expect(project.queue.items[0]?.backendItemIds).toEqual([99]); await runtime.dispose(); }); diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.ts b/invokeai/frontend/webv2/src/features/queue/runtime.ts index d3432de0682..67ae76df4d8 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.ts @@ -54,13 +54,25 @@ export interface QueueRuntime { start(): void; } +/** A backend rerun of a local run; the run names it apart from an id the server reuses after its database is reset. */ +export interface QueueRerunDelivery { + backendItemId: number; + projectId: string; + queueItemId: string; +} + export interface QueueRunJournalPort { - /** Claim delivering a backend rerun of a local run; only the first claim across the account's tabs succeeds. */ - claimRerunDelivery(rerun: { - backendItemId: number; - projectId: string; - queueItemId: string; - }): Promise<{ kind: 'claimed' | 'taken' | 'unavailable' }>; + /** + * Claim a backend rerun of a local run for this tab, across the account's tabs: an adoption reserves it for the run + * that reopened around it and is refused only by a delivery; a delivery, made just before staging, is refused by + * either. + */ + claimRerunDelivery( + rerun: QueueRerunDelivery, + kind: 'adoption' | 'delivery' + ): Promise<{ kind: 'claimed' | 'taken' | 'unavailable' }>; + /** Give back a claim whose delivery cannot happen. */ + releaseRerunDelivery(rerun: QueueRerunDelivery, kind: 'adoption' | 'delivery'): Promise; listForProject( projectId: string ): Promise< @@ -1311,24 +1323,73 @@ export const createQueueRuntime = ({ }; /** - * Claim delivering a rerun in the account's journal, which every tab shares and which outlives this one: the tab that - * adopts or stages a rerun first keeps any tab handling its completion later, run lock released or not, from staging - * it again. Without the journal no tab can coordinate, so each delivers. + * Claim a rerun in the account's journal, which every tab shares and which outlives this one, so a rerun one tab + * adopted or staged is never staged again by a tab that handles it later. Without the journal no tab can coordinate, + * so each delivers. */ - const claimRerunDelivery = async (rerun: { - backendItemId: number; - projectId: string; - queueItemId: string; - }): Promise => { + const claimRerunDelivery = async (rerun: QueueRerunDelivery, kind: 'adoption' | 'delivery'): Promise => { if (!journal) { return true; } try { - return (await journal.claimRerunDelivery(rerun)).kind !== 'taken'; + return (await journal.claimRerunDelivery(rerun, kind)).kind !== 'taken'; } catch { return true; } }; + const releaseRerunDelivery = (rerun: QueueRerunDelivery, kind: 'adoption' | 'delivery'): void => { + void journal?.releaseRerunDelivery(rerun, kind).catch(() => undefined); + }; + + /** + * Reopen a run around its reruns once each is reserved for it. A rerun another tab already staged is left alone, + * since adopting it would deliver it twice; a rerun the run refuses gives its reservation back and stays remembered + * as unadopted. + */ + const adoptRetriedItems = async (run: { + projectId: string; + queueItemId: string; + retriedItems: { itemId: number; sourceItemId: number }[]; + }): Promise => { + const toRerun = (itemId: number): QueueRerunDelivery => ({ + backendItemId: itemId, + projectId: run.projectId, + queueItemId: run.queueItemId, + }); + const reservations = await Promise.all( + run.retriedItems.map(async (item) => ({ + isReserved: await claimRerunDelivery(toRerun(item.itemId), 'adoption'), + item, + })) + ); + const retriedItems = reservations.filter(({ isReserved }) => isReserved).map(({ item }) => item); + if (!isActive()) { + retriedItems.forEach(({ itemId }) => releaseRerunDelivery(toRerun(itemId), 'adoption')); + return; + } + if (retriedItems.length === 0) { + return; + } + commands.reopenForRetry({ ...run, retriedItems }); + const reopened = getRouteTarget(history, run.queueItemId)?.queueItem; + let isAdopted = false; + + for (const { itemId } of retriedItems) { + if (reopened?.status === 'running' && reopened.backendItemIds?.includes(itemId)) { + isAdopted = true; + unadoptedReruns.delete(itemId); + } else { + releaseRerunDelivery(toRerun(itemId), 'adoption'); + } + } + if (isAdopted) { + const key = getRunKey(run.projectId, run.queueItemId); + // A settle still waiting on the journal belongs to the finished run; it must not remove the reopened record. + pendingJournalSettles.delete(key); + pendingReconcileRunKeys.add(key); + synchronize(); + } + }; /** * A retry, from any tab or client, re-runs failed or canceled backend items under new ids. The open run that owns @@ -1381,29 +1442,21 @@ export const createQueueRuntime = ({ retriedItemsByRun.set(key, run); } - for (const [key, run] of retriedItemsByRun) { - commands.reopenForRetry(run); - const reopened = getRouteTarget(history, run.queueItemId)?.queueItem; - let isAdopted = false; - - for (const { itemId } of run.retriedItems) { - if (reopened?.status === 'running' && reopened.backendItemIds?.includes(itemId)) { - isAdopted = true; - // The run delivers it, so a tab that took it for unadopted must not stage it once this one lets the run go. - // A claim already taken would mean another tab staged its completion before this retry reached here, which - // the rerun's execution time rules out; the run delivers regardless. - void claimRerunDelivery({ backendItemId: itemId, projectId: run.projectId, queueItemId: run.queueItemId }); - } else { - rememberUnadoptedRerun(itemId); + for (const run of retriedItemsByRun.values()) { + // Remembered until adopted, so a completion arriving while the run's reservation is made is handled like any + // unadopted one; the reservation, or the delivery that beat it, keeps the rerun from being delivered twice. + run.retriedItems.forEach(({ itemId }) => rememberUnadoptedRerun(itemId)); + void adoptRetriedItems(run).catch((error: unknown) => { + if (isActive()) { + commands.recordError({ + area: 'queue-results', + message: toErrorMessage(error), + namespace: 'queue', + projectId: run.projectId, + }); } - } - if (isAdopted) { - // A settle still waiting on the journal belongs to the finished run; it must not remove the reopened record. - pendingJournalSettles.delete(key); - pendingReconcileRunKeys.add(key); - } + }); } - synchronize(); }; /** @@ -1505,20 +1558,24 @@ export const createQueueRuntime = ({ return; } + const rerun: QueueRerunDelivery = { backendItemId: event.item_id, projectId, queueItemId }; + const canStage = (): boolean => + isActive() && history.getSnapshot().projects.some((project) => project.id === projectId); const stage = async (): Promise => { if (await isRunTrackedElsewhere(projectId, queueItemId)) { return; } const images = await readUnadoptedRerunImages(event, queueItemId, run); - // Claimed only with something to stage, so a failed or empty read leaves the rerun to a tab that sees it later. - if ( - images.length > 0 && - isActive() && - (await claimRerunDelivery({ backendItemId: event.item_id, projectId, queueItemId })) && - isActive() - ) { - commands.stageRetriedCanvasResults({ backendItemId: event.item_id, images, projectId, queueItemId }); + // Claimed only when staging can happen, and given back when the project closed during the claim, so a rerun this + // tab cannot stage is left to a tab that can. + if (images.length === 0 || !canStage() || !(await claimRerunDelivery(rerun, 'delivery'))) { + return; + } + if (!canStage()) { + releaseRerunDelivery(rerun, 'delivery'); + return; } + commands.stageRetriedCanvasResults({ backendItemId: event.item_id, images, projectId, queueItemId }); }; void stage().catch(recordError); }; diff --git a/invokeai/frontend/webv2/src/workbench/projects/README.md b/invokeai/frontend/webv2/src/workbench/projects/README.md index ae37fffbac4..0b8b837a5d0 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/README.md +++ b/invokeai/frontend/webv2/src/workbench/projects/README.md @@ -27,7 +27,7 @@ Project links are consumed after a successful open, so reloading restores the sa - An entry the draft store cannot take waits. One for a damaged lineage waits for that lineage's cleanup, for at most 7 days, and is then discarded. One refused for lack of storage is applied by a later load only while its lineage is unclaimed; the load that refused it usually claims the lineage when it opens the project, and then the edit is lost. Both report degraded local recovery for that project until its next stage succeeds. An unavailable journal (its database will not open, or reconciliation fails) is logged, not reported: recovery then works exactly as it did without the journal. A journal operation that fails on its own (a deletion whose transaction could not be opened, say) fails alone; the next hidden event journals again. - A writer settles and removes its entries when the server acknowledges a generation at or above them, when the document returns to what recovery holds (on the next save; a hidden event meanwhile also deletes the entry, and the save still writes the mark), and when it deletes the project or discards its draft; a removal that fails leaves the entry settled, and one whose mark fails is retried with the next save. When an older generation is acknowledged, a newer entry is rewritten onto the acknowledged revision, as a newer staged draft is rebased. A journal write that aborts is written again on the next hidden event. Nothing is journaled for a closed session, after the account lifetime ended, for a project being deleted, or beyond 8 MiB of documents per event; larger projects rely on autosave, and the skip is logged. The editor route counts projects with entries as ones that would open, so a new project typed into and reloaded before its first save reopens. - Queue ownership uses Web Locks. Journal writes precede submission; backend receipts make retries safe after lost responses. Accepted IDs must be durable before their receipt can expire. Fresh runs that never reached browser storage use best-effort terminal acknowledgement instead. -- A Canvas rerun no open run adopted is staged by one tab of the browser. The tab tracking its run stages it while it lives (other tabs only check that run's Web Lock, without requesting it); otherwise a tab claims the rerun in the workbench database's metadata store just before staging, once its results are read, and a tab whose claim is already taken skips it, even after the claiming tab closed. A tab that adopts a rerun into its run claims it too, so a tab that took it for unadopted does not stage it after the run is let go. Claims are keyed by the backend item and its local run, so an id the server reuses after its database is reset is a different claim; they are kept 7 days and capped at 10,000, oldest first. Without browser storage, tabs that handle the rerun at the same moment can each stage it; tabs in other browsers are never coordinated. +- A retried backend item is delivered once per browser through claims in the workbench database's metadata store, which every tab of the account shares and which outlive the tab that wrote them. A tab adopting a rerun into its run first reserves it; a reservation is refused only when another tab already staged the rerun (the late adopter then leaves the run closed), and is given back when the run refuses to reopen. A Canvas rerun no run adopted is staged by the tab tracking its run while it lives (other tabs only check that run's Web Lock, without requesting it); otherwise a tab claims its delivery once the results are read and its project is open, gives the claim back if the project closed meanwhile, and skips a rerun another tab reserved or staged, even after that tab closed. Claims are keyed by the backend item and its local run, so an id the server reuses after its database is reset is a different claim; they are kept 7 days and capped at 10,000, oldest first. A tab that closes in the moment between reserving a rerun and journaling its reopened run leaves a reservation nothing delivers, so that Canvas rerun is not staged. Without browser storage, tabs that handle the rerun at the same moment can each deliver it; tabs in other browsers are never coordinated. - Completed, failed, and cancelled runs retain exact recall values in an LRU cache, capped at 500 entries / 32 MiB. Eviction removes recall convenience, not project data or active-run recovery. - Projects with journal entries remain reachable even if another tab saved an empty session. Automatic reopening is bounded; remaining entries can be opened, exported as queue-recovery JSON, or explicitly discarded. Discarding local recovery does not cancel backend work. - Browser-storage failures do not block backend project loads or saves. Recovery warnings distinguish degraded local durability from successful server persistence. diff --git a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts index 44bb43d5be3..686eb3d7b5d 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts @@ -164,6 +164,7 @@ beforeEach(() => { availability: 'available', acknowledgeReceipt: vi.fn(), claimRerunDelivery: vi.fn(), + releaseRerunDelivery: vi.fn(), listPendingReceipts: vi.fn(), close: vi.fn(), deleteForProject: vi.fn(), @@ -337,6 +338,7 @@ describe('durable project persistence', () => { availability: 'available', acknowledgeReceipt: vi.fn(), claimRerunDelivery: vi.fn(), + releaseRerunDelivery: vi.fn(), listPendingReceipts: vi.fn(), close: vi.fn(), deleteForProject: vi.fn(), diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts index a094031c837..5c5881740ff 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts @@ -142,16 +142,17 @@ describe('IndexedDB queue run journal', () => { const otherTab = createIndexedDbQueueRunJournal(otherTabDatabase, { now: () => now }); await expect( - Promise.all([journal.claimRerunDelivery(rerun(99)), otherTab.claimRerunDelivery(rerun(99))]).then((results) => - results.map((result) => result.kind).sort() - ) + Promise.all([ + journal.claimRerunDelivery(rerun(99), 'delivery'), + otherTab.claimRerunDelivery(rerun(99), 'delivery'), + ]).then((results) => results.map((result) => result.kind).sort()) ).resolves.toEqual(['claimed', 'taken']); - await expect(otherTab.claimRerunDelivery(rerun(100))).resolves.toEqual({ kind: 'claimed' }); + await expect(otherTab.claimRerunDelivery(rerun(100), 'delivery')).resolves.toEqual({ kind: 'claimed' }); // A server whose database was reset hands the same id to another run's rerun. - await expect(otherTab.claimRerunDelivery(rerun(99, 'run-2'))).resolves.toEqual({ kind: 'claimed' }); + await expect(otherTab.claimRerunDelivery(rerun(99, 'run-2'), 'delivery')).resolves.toEqual({ kind: 'claimed' }); now += QUEUE_RERUN_DELIVERY_RETENTION_MS; - await expect(journal.claimRerunDelivery(rerun(99))).resolves.toEqual({ kind: 'claimed' }); + await expect(journal.claimRerunDelivery(rerun(99), 'delivery')).resolves.toEqual({ kind: 'claimed' }); }); it('keeps rerun delivery claims within the entry budget by dropping the oldest, apart from active runs', async () => { @@ -160,19 +161,32 @@ describe('IndexedDB queue run journal', () => { await journal.record('project-1', createItem()); for (const backendItemId of [1, 2, 3]) { - await expect(journal.claimRerunDelivery(rerun(backendItemId))).resolves.toEqual({ kind: 'claimed' }); + await expect(journal.claimRerunDelivery(rerun(backendItemId), 'delivery')).resolves.toEqual({ kind: 'claimed' }); } - await expect(journal.claimRerunDelivery(rerun(3))).resolves.toEqual({ kind: 'taken' }); - await expect(journal.claimRerunDelivery(rerun(1))).resolves.toEqual({ kind: 'claimed' }); + await expect(journal.claimRerunDelivery(rerun(3), 'delivery')).resolves.toEqual({ kind: 'taken' }); + await expect(journal.claimRerunDelivery(rerun(1), 'delivery')).resolves.toEqual({ kind: 'claimed' }); await expect(journal.listAll()).resolves.toMatchObject({ entries: [{ queueItemId: 'queue-item-1' }] }); }); + it('lets an adoption reserve a rerun against staging, and a staged rerun refuse a later adoption', async () => { + const { journal } = await createJournal(); + + await expect(journal.claimRerunDelivery(rerun(99), 'adoption')).resolves.toEqual({ kind: 'claimed' }); + await expect(journal.claimRerunDelivery(rerun(99), 'adoption')).resolves.toEqual({ kind: 'claimed' }); + await expect(journal.claimRerunDelivery(rerun(99), 'delivery')).resolves.toEqual({ kind: 'taken' }); + await expect(journal.releaseRerunDelivery(rerun(99), 'adoption')).resolves.toEqual({ kind: 'removed' }); + await expect(journal.claimRerunDelivery(rerun(99), 'delivery')).resolves.toEqual({ kind: 'claimed' }); + await expect(journal.claimRerunDelivery(rerun(99), 'adoption')).resolves.toEqual({ kind: 'taken' }); + await expect(journal.releaseRerunDelivery(rerun(99), 'delivery')).resolves.toEqual({ kind: 'removed' }); + await expect(journal.claimRerunDelivery(rerun(99), 'delivery')).resolves.toEqual({ kind: 'claimed' }); + }); + it('claims no rerun once closed', async () => { const { journal } = await createJournal(); journal.close(); - await expect(journal.claimRerunDelivery(rerun(99))).resolves.toEqual({ kind: 'unavailable' }); + await expect(journal.claimRerunDelivery(rerun(99), 'delivery')).resolves.toEqual({ kind: 'unavailable' }); }); it('lists distinct projects without decoding their run payloads', async () => { diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.ts b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.ts index 19d6fc43dfc..59324b14e4c 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.ts +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.ts @@ -1,3 +1,4 @@ +import type { QueueRerunDelivery } from '@features/queue'; import type { AccountScope } from '@platform/state/accountLifecycle'; import type { IDBPObjectStore } from 'idb'; @@ -20,13 +21,6 @@ export const QUEUE_RUN_MAX_ENTRIES = 10_000; /** How long a rerun delivery claim is kept: past any tab still handling the rerun's completion, even one long queued. */ export const QUEUE_RERUN_DELIVERY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; -/** A backend rerun of a local run; the run names it apart from an id the server reuses after its database is reset. */ -export interface QueueRerunDelivery { - backendItemId: number; - projectId: string; - queueItemId: string; -} - export interface QueueRunJournalEntry { /** Untrusted serialized candidate. Normalize it against the current project before restoration. */ item: unknown; @@ -41,13 +35,20 @@ export type QueueRunJournalLoadResult = export type QueueRunJournalWriteResult = { kind: 'invalid' | 'quota' | 'stored' | 'too-large' | 'unavailable' }; export type QueueRunJournalSettleResult = { kind: 'removed' | 'unavailable' }; export type QueueRerunDeliveryClaimResult = { kind: 'claimed' | 'taken' | 'unavailable' }; +/** + * An adoption reserves a rerun for the run that reopened around it, which delivers it later; a delivery records that a + * tab staged it. A delivery is refused by either, an adoption only by a delivery. + */ +export type QueueRerunDeliveryKind = 'adoption' | 'delivery'; export interface QueueRunJournal { readonly availability: 'available' | 'unavailable'; close(): void; acknowledgeReceipt(projectId: string, queueItemId: string): Promise; - /** Claim delivering a backend rerun's results; only the first claim across the account's tabs succeeds. */ - claimRerunDelivery(rerun: QueueRerunDelivery): Promise; + /** Claim a backend rerun's delivery for this tab, shared by every tab of the account. */ + claimRerunDelivery(rerun: QueueRerunDelivery, kind: QueueRerunDeliveryKind): Promise; + /** Give back a claim whose delivery cannot happen, so another tab may still deliver the rerun. */ + releaseRerunDelivery(rerun: QueueRerunDelivery, kind: QueueRerunDeliveryKind): Promise; listPendingReceipts(): Promise< { entries: QueueReceiptAcknowledgement[]; kind: 'available' } | { kind: 'unavailable' } >; @@ -65,6 +66,10 @@ const QUEUE_RUN_TOTAL_BYTES_KEY = 'queueRunTotalBytes'; const QUEUE_RUN_COUNT_KEY = 'queueRunCount'; // Claims share the metadata store, under a prefix of their own, rather than needing a database upgrade. const QUEUE_RERUN_DELIVERY_KEY_PREFIX = 'queueRerunDelivery:'; +const getRerunDeliveryKey = ( + { backendItemId, projectId, queueItemId }: QueueRerunDelivery, + kind: QueueRerunDeliveryKind +): string => `${QUEUE_RERUN_DELIVERY_KEY_PREFIX}${JSON.stringify([backendItemId, projectId, queueItemId, kind])}`; const QUEUE_RUN_STORES = [ WORKBENCH_DATABASE_METADATA_STORE, WORKBENCH_QUEUE_RUN_STORE, @@ -338,14 +343,19 @@ export const createIndexedDbQueueRunJournal = ( return { kind: 'unavailable' }; } }, - async claimRerunDelivery({ backendItemId, projectId, queueItemId }) { + async claimRerunDelivery(rerun, kind) { if (!isAvailable()) { return { kind: 'unavailable' }; } try { const transaction = observeTransaction(database.transaction(WORKBENCH_DATABASE_METADATA_STORE, 'readwrite')); const metadataStore = transaction.store; - const key = `${QUEUE_RERUN_DELIVERY_KEY_PREFIX}${JSON.stringify([backendItemId, projectId, queueItemId])}`; + const key = getRerunDeliveryKey(rerun, kind); + const refusedBy = new Set( + kind === 'delivery' + ? [getRerunDeliveryKey(rerun, 'adoption'), getRerunDeliveryKey(rerun, 'delivery')] + : [getRerunDeliveryKey(rerun, 'delivery')] + ); const claimedAt = now(); // One read of every claim, so the store the queue journal writes to is held only briefly. const claims = await metadataStore.getAll( @@ -353,7 +363,7 @@ export const createIndexedDbQueueRunJournal = ( ); const isLive = (claim: { value: number }) => Number.isFinite(claim.value) && claimedAt - claim.value < QUEUE_RERUN_DELIVERY_RETENTION_MS; - const isTaken = claims.some((claim) => claim.key === key && isLive(claim)); + const isTaken = claims.some((claim) => refusedBy.has(claim.key) && isLive(claim)); const kept = claims.filter((claim) => claim.key !== key && isLive(claim)); const stale = claims.filter((claim) => !isLive(claim)); if (!isTaken) { @@ -370,6 +380,17 @@ export const createIndexedDbQueueRunJournal = ( return { kind: 'unavailable' }; } }, + async releaseRerunDelivery(rerun, kind) { + if (!isAvailable()) { + return { kind: 'unavailable' }; + } + try { + await database.delete(WORKBENCH_DATABASE_METADATA_STORE, getRerunDeliveryKey(rerun, kind)); + return { kind: 'removed' }; + } catch { + return { kind: 'unavailable' }; + } + }, async deleteForProject(projectId) { if (!isAvailable()) { return { kind: 'unavailable' }; @@ -570,6 +591,7 @@ export const createAccountOwnedQueueRunJournal = async ( availability: 'unavailable', acknowledgeReceipt: () => Promise.resolve({ kind: 'unavailable' }), claimRerunDelivery: () => Promise.resolve({ kind: 'unavailable' }), + releaseRerunDelivery: () => Promise.resolve({ kind: 'unavailable' }), listPendingReceipts: () => Promise.resolve({ kind: 'unavailable' }), close: () => undefined, deleteForProject: () => Promise.resolve({ kind: 'unavailable' }), From 4320e64e42d255135f7fc56c5d3b27042b8b159c Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Tue, 13 Oct 2026 02:47:41 -0600 Subject: [PATCH 33/35] feat(queue): let browsers claim a queue item's delivery Adds POST /queue/{queue_id}/i/{item_id}/delivery to claim, confirm or release staging a finished item's results, decided by one conditional update per call. Migration 2026_10_13_add_session_queue_delivery adds three nullable columns. --- invokeai/app/api/routers/session_queue.py | 36 ++++ .../session_queue/session_queue_base.py | 23 +++ .../session_queue/session_queue_common.py | 36 +++- .../session_queue/session_queue_default.py | 61 ++++++- .../shared/database/queries/session_queue.py | 92 +++++++++- .../shared/database/schema/session_queue.py | 4 + ...n_2026_10_13_add_session_queue_delivery.py | 33 ++++ invokeai/frontend/api/openapi.json | 117 ++++++++++++ invokeai/frontend/api/schema.ts | 93 ++++++++++ .../test_session_queue_delivery_route.py | 141 +++++++++++++++ .../test_session_queue_delivery.py | 169 ++++++++++++++++++ .../session_queue/test_session_queue_reads.py | 6 +- ...n_2026_10_13_add_session_queue_delivery.py | 65 +++++++ 13 files changed, 866 insertions(+), 10 deletions(-) create mode 100644 invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_13_add_session_queue_delivery.py create mode 100644 tests/app/routers/test_session_queue_delivery_route.py create mode 100644 tests/app/services/session_queue/test_session_queue_delivery.py create mode 100644 tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_13_add_session_queue_delivery.py diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index c64f32623b1..0c1471a1d26 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -29,6 +29,8 @@ EnqueueReceiptLimitError, ItemIdsResult, PruneResult, + QueueItemDeliveryRequest, + QueueItemDeliveryResult, RetryItemsResult, SessionQueueCountsByDestination, SessionQueueItem, @@ -882,6 +884,40 @@ def cancel_queue_item( raise HTTPException(status_code=500, detail=f"Unexpected error while canceling queue item: {e}") +@session_queue_router.post( + "/{queue_id}/i/{item_id}/delivery", + operation_id="claim_queue_item_delivery", + response_model=QueueItemDeliveryResult, +) +def claim_queue_item_delivery( + current_user: CurrentUserOrDefault, + queue_id: str = Path(description="The queue id to perform this operation on"), + item_id: int = Path(description="The queue item whose results are delivered"), + request: QueueItemDeliveryRequest = Body(description="The delivery change"), +) -> QueueItemDeliveryResult: + """Claims, confirms or releases staging a queue item's results, so that only one of its owner's browsers stages + them. A claim is held for a while unless confirmed, released or renewed.""" + session_queue = ApiDependencies.invoker.services.session_queue + try: + # Waiting browsers ask often: authorize by the item's owner alone, without reading its session. + owner = session_queue.get_queue_item_owner(item_id) + if owner.queue_id != queue_id: + raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}") + if owner.user_id != current_user.user_id and not current_user.is_admin: + raise HTTPException(status_code=403, detail=f"You do not have permission to mutate queue item {item_id}") + if request.action == "claim": + return session_queue.claim_queue_item_delivery(item_id, request.claimant) + if request.action == "confirm": + return session_queue.confirm_queue_item_delivery(item_id) + return session_queue.release_queue_item_delivery(item_id, request.claimant) + except SessionQueueItemNotFoundError: + raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Unexpected error while changing queue item delivery: {e}") + + @session_queue_router.get( "/{queue_id}/counts_by_destination", operation_id="counts_by_destination", diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index 20aa4b278aa..e95d0cc9125 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -20,6 +20,8 @@ ItemIdsResult, NodeFieldValue, PruneResult, + QueueItemDeliveryResult, + QueueItemOwner, RetryItemsResult, SessionQueueCountsByDestination, SessionQueueItem, @@ -292,6 +294,27 @@ def get_queue_item_for_api(self, item_id: int) -> SessionQueueItem: """Gets a queue item in the response projection used by API reads.""" return self.get_queue_item(item_id=item_id) + @abstractmethod + def get_queue_item_owner(self, item_id: int) -> QueueItemOwner: + """Gets a queue item's queue and owner without reading its session, for authorizing frequent requests.""" + pass + + @abstractmethod + def claim_queue_item_delivery(self, item_id: int, claimant: str) -> QueueItemDeliveryResult: + """Claims delivering a queue item's results for a claimant: `claimed` unless they were delivered or another + claimant's unexpired claim holds them.""" + pass + + @abstractmethod + def confirm_queue_item_delivery(self, item_id: int) -> QueueItemDeliveryResult: + """Marks a queue item's results delivered, whoever claimed them.""" + pass + + @abstractmethod + def release_queue_item_delivery(self, item_id: int, claimant: str) -> QueueItemDeliveryResult: + """Drops a claimant's claim of a queue item's undelivered results, if it holds one.""" + pass + @abstractmethod def get_queue_item_workflow_json(self, item_id: int) -> str | None: """Gets only the workflow metadata JSON for a queue item, if present.""" diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py index d6eca6e5a75..31fc0ada27c 100644 --- a/invokeai/app/services/session_queue/session_queue_common.py +++ b/invokeai/app/services/session_queue/session_queue_common.py @@ -2,7 +2,7 @@ import json from collections.abc import Mapping from itertools import chain, product -from typing import Any, Generator, Literal, Optional, TypeAlias, Union +from typing import Any, Generator, Literal, NamedTuple, Optional, TypeAlias, Union from pydantic import ( AliasChoices, @@ -549,6 +549,40 @@ class RetryItemsResult(BaseModel): ) +QueueItemDeliveryAction: TypeAlias = Literal["claim", "confirm", "release"] +QueueItemDeliveryStatus: TypeAlias = Literal["claimed", "delivered", "held", "released"] + + +class QueueItemDeliveryRequest(BaseModel): + """A change to who stages a queue item's results, so that only one browser does.""" + + action: QueueItemDeliveryAction = Field( + description="claim: take delivering the results; confirm: mark them delivered; release: give up a claim" + ) + claimant: str = Field( + min_length=1, max_length=128, description="An id the client chooses for the browser tab or run claiming" + ) + + +class QueueItemOwner(NamedTuple): + """The queue a queue item is in and the account that owns it.""" + + queue_id: str + user_id: str + + +class QueueItemDeliveryResult(BaseModel): + """Who stages a queue item's results after a delivery request.""" + + status: QueueItemDeliveryStatus = Field( + description="claimed: the claimant may deliver; delivered: the results were delivered; " + "held: another claimant is delivering them; released: the claimant holds no claim" + ) + retry_after_ms: Optional[int] = Field( + default=None, description="When held, how long until the other claim lapses, in milliseconds" + ) + + class ClearResult(BaseModel): """Result of clearing the session queue""" diff --git a/invokeai/app/services/session_queue/session_queue_default.py b/invokeai/app/services/session_queue/session_queue_default.py index 94b05fb9c74..87e30f11d64 100644 --- a/invokeai/app/services/session_queue/session_queue_default.py +++ b/invokeai/app/services/session_queue/session_queue_default.py @@ -1,7 +1,8 @@ import asyncio import hashlib import json -from datetime import timedelta +import math +from datetime import datetime, timedelta, timezone from typing import Any, Literal, Optional from pydantic_core import to_jsonable_python @@ -30,6 +31,8 @@ ItemIdsResult, NodeFieldValue, PruneResult, + QueueItemDeliveryResult, + QueueItemOwner, RetriedQueueItem, RetryItemsResult, SessionQueueCountsByDestination, @@ -47,7 +50,8 @@ from invokeai.app.services.shared.database.database import Database from invokeai.app.services.shared.database.queries import Queries from invokeai.app.services.shared.database.queries.locks import DatabaseLock -from invokeai.app.services.shared.database.queries.session_queue import ITEM_COLUMNS, Scope, SettledReceipt +from invokeai.app.services.shared.database.queries.session_queue import ITEM_COLUMNS, Delivery, Scope, SettledReceipt +from invokeai.app.services.shared.database.types import timestamp_text from invokeai.app.services.shared.execution_state_migration import dump_execution_state from invokeai.app.services.shared.graph import Graph, GraphExecutionState from invokeai.app.services.shared.pagination import CursorPaginatedResults, SQLiteDirection @@ -67,6 +71,10 @@ _FINISHED = ("completed", "failed", "canceled") +# How long a browser's claim to stage a queue item's results holds it from other browsers, unless renewed: long +# enough to stage them, short enough that a browser that went away holds them up only briefly. +QUEUE_ITEM_DELIVERY_CLAIM_TTL = timedelta(seconds=120) + # Every write that adds items takes the queue's admission lock, so that two of them neither both fit into its last # free places nor both write one enqueue receipt, and shares the media protection: the items' sessions make the media # they name active, which the intermediates cleanup must not delete between its check and its delete. @@ -79,6 +87,19 @@ def _session_json(session: GraphExecutionState) -> str: return json.dumps(dump_execution_state(session), default=to_jsonable_python) +def _held(delivery: Delivery, now: datetime) -> QueueItemDeliveryResult: + """Why a claim was refused: the results were delivered, or another claim holds them until it lapses.""" + if delivery.delivered_at is not None: + return QueueItemDeliveryResult(status="delivered") + retry_after_ms = 1 + # Without a claim it was released after the refusal: the claimant may try again at once. + if delivery.claimant is not None and delivery.claimed_at is not None: + lapses_at = datetime.fromisoformat(delivery.claimed_at).replace(tzinfo=timezone.utc) + remaining = lapses_at + QUEUE_ITEM_DELIVERY_CLAIM_TTL - now + retry_after_ms = max(1, math.ceil(remaining / timedelta(milliseconds=1))) + return QueueItemDeliveryResult(status="held", retry_after_ms=retry_after_ms) + + def _decode_enqueue_receipt(batch_id: str, requested: int, enqueued: int, item_ids_json: str) -> EnqueueBatchReceipt: item_ids = json.loads(item_ids_json) if not isinstance(item_ids, list) or not all(isinstance(item_id, int) for item_id in item_ids): @@ -721,6 +742,42 @@ def get_queue_item_for_api(self, item_id: int) -> SessionQueueItem: def get_queue_item_workflow_json(self, item_id: int) -> str | None: return self._queries.session_queue.workflow(item_id) + def get_queue_item_owner(self, item_id: int) -> QueueItemOwner: + owner = self._queries.session_queue.owner(item_id) + if owner is None: + raise SessionQueueItemNotFoundError(f"No queue item with id {item_id}") + return QueueItemOwner(queue_id=owner.queue_id, user_id=owner.user_id) + + def claim_queue_item_delivery(self, item_id: int, claimant: str) -> QueueItemDeliveryResult: + def claim(q: Queries) -> QueueItemDeliveryResult: + now = datetime.now(timezone.utc) + stale_before = timestamp_text(now - QUEUE_ITEM_DELIVERY_CLAIM_TTL) + if q.session_queue.claim_delivery(item_id, claimant, now=timestamp_text(now), stale_before=stale_before): + return QueueItemDeliveryResult(status="claimed") + delivery = q.session_queue.delivery(item_id) + if delivery is None: + raise SessionQueueItemNotFoundError(f"No queue item with id {item_id}") + return _held(delivery, now) + + return self._queries.run(claim) + + def confirm_queue_item_delivery(self, item_id: int) -> QueueItemDeliveryResult: + def confirm(q: Queries) -> None: + confirmed = q.session_queue.confirm_delivery(item_id, now=timestamp_text(datetime.now(timezone.utc))) + if not confirmed and q.session_queue.delivery(item_id) is None: + raise SessionQueueItemNotFoundError(f"No queue item with id {item_id}") + + self._queries.run(confirm) + return QueueItemDeliveryResult(status="delivered") + + def release_queue_item_delivery(self, item_id: int, claimant: str) -> QueueItemDeliveryResult: + def release(q: Queries) -> None: + if not q.session_queue.release_delivery(item_id, claimant) and q.session_queue.delivery(item_id) is None: + raise SessionQueueItemNotFoundError(f"No queue item with id {item_id}") + + self._queries.run(release) + return QueueItemDeliveryResult(status="released") + def _save_session(self, item_id: int, session: GraphExecutionState, *, only_unfinished: bool) -> bool: session_json = _session_json(session) diff --git a/invokeai/app/services/shared/database/queries/session_queue.py b/invokeai/app/services/shared/database/queries/session_queue.py index f543bf63eb5..e0d29451408 100644 --- a/invokeai/app/services/shared/database/queries/session_queue.py +++ b/invokeai/app/services/shared/database/queries/session_queue.py @@ -1,7 +1,8 @@ """The session queue: the items users enqueued, their workflow-call chains, and the counts the queue's views show. -An item is read as a dict of its row (every column of `session_queue`) with its owner's display name and email, the -form `SessionQueueItem.queue_item_from_dict()` and `SessionQueueItemSummary.queue_item_summary_from_dict()` take. +An item is read as a dict of its row (every column of `session_queue` but its delivery's) with its owner's display name +and email, the form `SessionQueueItem.queue_item_from_dict()` and `SessionQueueItemSummary.queue_item_summary_from_dict()` +take. Its delivery (which browser stages its results) is read on its own. An origin prefix matches as SQLite's `LIKE` always has, ignoring case; its `%` and `_` are taken as written. """ @@ -47,7 +48,10 @@ _Q = session_queue.c _R = session_queue_enqueue_receipts.c -_ITEM = (*session_queue.c, users.c.display_name.label("user_display_name"), users.c.email.label("user_email")) +_DELIVERY_COLUMNS = frozenset({"delivery_claimant", "delivery_claimed_at", "delivered_at"}) +_ROW = tuple(column for column in session_queue.c if column.name not in _DELIVERY_COLUMNS) + +_ITEM = (*_ROW, users.c.display_name.label("user_display_name"), users.c.email.label("user_email")) _ITEM_NAMES = tuple(column.name for column in _ITEM) _ITEMS = session_queue.outerjoin(users, users.c.user_id == _Q.user_id) @@ -343,8 +347,8 @@ class BatchCounts(NamedTuple): _ITEM_EXISTS = select(literal(1)).where(_Q.item_id == bindparam("item_id")) _LOCK_STATUS = select(_Q.status).where(_Q.item_id == bindparam("item_id")).with_for_update() # Locks the item's own row only: on a server a FOR UPDATE over a join would lock the owner's account row as well. -_LOCK_ITEM = select(*session_queue.c).where(_Q.item_id == bindparam("item_id")).with_for_update() -_LOCK_ITEM_NAMES = tuple(column.name for column in session_queue.c) +_LOCK_ITEM = select(*_ROW).where(_Q.item_id == bindparam("item_id")).with_for_update() +_LOCK_ITEM_NAMES = tuple(column.name for column in _ROW) _CLAIM = ( update(session_queue) @@ -508,6 +512,34 @@ def _stamp(status: str) -> Optional[str]: ).where(_R.user_id == bindparam("user_id")) _INSERT_RECEIPT = insert(session_queue_enqueue_receipts) +# Delivery leaves `updated_at` alone: it says nothing about the item that the queue's views show. +_UNDELIVERED = _Q.delivered_at.is_(None) +_CLAIM_DELIVERY = ( + update(session_queue) + .where( + _THE_ITEM, + _UNDELIVERED, + or_( + _Q.delivery_claimant.is_(None), + _Q.delivery_claimant == bindparam("claimant"), + _Q.delivery_claimed_at <= bindparam("stale_before"), + ), + ) + .values(delivery_claimant=bindparam("new_claimant"), delivery_claimed_at=bindparam("now"), updated_at=_Q.updated_at) +) +_CONFIRM_DELIVERY = ( + update(session_queue).where(_THE_ITEM, _UNDELIVERED).values(delivered_at=bindparam("now"), updated_at=_Q.updated_at) +) +_RELEASE_DELIVERY = ( + update(session_queue) + .where(_THE_ITEM, _UNDELIVERED, _Q.delivery_claimant == bindparam("claimant")) + .values(delivery_claimant=None, delivery_claimed_at=None, updated_at=_Q.updated_at) +) +_DELIVERY = select(_Q.delivered_at, _Q.delivery_claimant, _Q.delivery_claimed_at).where( + _Q.item_id == bindparam("item_id") +) +_OWNER = select(_Q.queue_id, _Q.user_id).where(_Q.item_id == bindparam("item_id")) + _ACTIVE_ITEM_IDS = select(_Q.item_id).where(_Q.status.in_([literal(status) for status in ACTIVE_QUEUE_STATUSES])) # History: finished items that no active workflow call still needs. A completed child is recovery state for its active # root until the root ends, and the intermediates cleanup protects the root's media through it. @@ -661,6 +693,19 @@ class Transition(NamedTuple): changed: Optional[dict[str, Any]] +class Delivery(NamedTuple): + """Who stages an item's results: when they were delivered, or who claims delivering them since when.""" + + delivered_at: Optional[str] + claimant: Optional[str] + claimed_at: Optional[str] + + +class ItemOwner(NamedTuple): + queue_id: str + user_id: str + + class SettledReceipt(NamedTuple): payload_hash: str batch_id: str @@ -1010,6 +1055,43 @@ def workflow_call_item_ids(self, conn: Connection, workflow_call_id: str) -> lis for item_id in conn.execute(_WORKFLOW_CALL_ITEMS, {"workflow_call_id": workflow_call_id}).scalars().all() ] + # --- Delivery --------------------------------------------------------------------------------------------------- + + @write + def claim_delivery(self, conn: Connection, item_id: int, claimant: str, *, now: str, stale_before: str) -> bool: + """Claims delivering the item's results for `claimant`, stamped `now`, unless they were delivered or another + claimant holds a claim made after `stale_before`; whether it did. (A claimant's own claim is renewed.)""" + parameters = { + "target_item_id": item_id, + "claimant": claimant, + "new_claimant": claimant, + "now": now, + "stale_before": stale_before, + } + return conn.execute(_CLAIM_DELIVERY, parameters).rowcount != 0 + + @write + def confirm_delivery(self, conn: Connection, item_id: int, *, now: str) -> bool: + """Marks the item's results delivered `now`; whether they were not before.""" + return conn.execute(_CONFIRM_DELIVERY, {"target_item_id": item_id, "now": now}).rowcount != 0 + + @write + def release_delivery(self, conn: Connection, item_id: int, claimant: str) -> bool: + """Drops `claimant`'s claim of the item's undelivered results; whether it held one.""" + return conn.execute(_RELEASE_DELIVERY, {"target_item_id": item_id, "claimant": claimant}).rowcount != 0 + + @read + def owner(self, conn: Connection, item_id: int) -> Optional[ItemOwner]: + """The item's queue and owner, without reading its session; None if it does not exist.""" + row = conn.execute(_OWNER, {"item_id": item_id}).first() + return ItemOwner(row[0], row[1]) if row is not None else None + + @read + def delivery(self, conn: Connection, item_id: int) -> Optional[Delivery]: + """The item's delivery; None if it does not exist.""" + row = conn.execute(_DELIVERY, {"item_id": item_id}).first() + return Delivery(row[0], row[1], row[2]) if row is not None else None + # --- Enqueueing ------------------------------------------------------------------------------------------------- @read diff --git a/invokeai/app/services/shared/database/schema/session_queue.py b/invokeai/app/services/shared/database/schema/session_queue.py index 0f1a48a3944..57f9e997f5c 100644 --- a/invokeai/app/services/shared/database/schema/session_queue.py +++ b/invokeai/app/services/shared/database/schema/session_queue.py @@ -46,6 +46,10 @@ Column("project_id", LongText()), # Counts the writes of `session`, so a writer can tell whether it still holds the latest one. Column("session_revision", BigInt(), nullable=False, server_default=default(0)), + # Which browser stages a retried item's results, so that only one does (2026_10_13_add_session_queue_delivery). + Column("delivery_claimant", LongText()), + Column("delivery_claimed_at", Timestamp()), + Column("delivered_at", Timestamp()), sqlite_autoincrement=True, ) diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_13_add_session_queue_delivery.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_13_add_session_queue_delivery.py new file mode 100644 index 00000000000..d62d996dfb8 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_13_add_session_queue_delivery.py @@ -0,0 +1,33 @@ +"""Record who stages a queue item's results, so that browsers on different devices agree on one. + +A retried item's results are staged into the project it ran for by one browser. A browser claims the delivery +(`delivery_claimant`, `delivery_claimed_at`) before staging and confirms it (`delivered_at`) after; a claim that +is not confirmed lapses, so a browser that went away does not keep the results from every other one. +""" + +from sqlalchemy import Column, inspect + +from invokeai.app.services.shared.database.types import LongText, Timestamp +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import ( + PortableMigration, + PortableMigrationContext, +) + + +def _add_session_queue_delivery(context: PortableMigrationContext) -> None: + existing = {column["name"] for column in inspect(context.conn).get_columns("session_queue")} + for column in ( + Column("delivery_claimant", LongText()), + Column("delivery_claimed_at", Timestamp()), + Column("delivered_at", Timestamp()), + ): + if column.name not in existing: + context.op.add_column("session_queue", column) + + +def build_migration() -> PortableMigration: + return PortableMigration( + id="2026_10_13_add_session_queue_delivery", + depends_on="2026_10_08_add_board_project_membership", + callback=_add_session_queue_delivery, + ) diff --git a/invokeai/frontend/api/openapi.json b/invokeai/frontend/api/openapi.json index d4b746ae05f..59360c15347 100644 --- a/invokeai/frontend/api/openapi.json +++ b/invokeai/frontend/api/openapi.json @@ -13717,6 +13717,76 @@ } } }, + "/api/v1/queue/{queue_id}/i/{item_id}/delivery": { + "post": { + "tags": ["queue"], + "summary": "Claim Queue Item Delivery", + "description": "Claims, confirms or releases staging a queue item's results, so that only one of its owner's browsers stages\nthem. A claim is held for a while unless confirmed, released or renewed.", + "operationId": "claim_queue_item_delivery", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "queue_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The queue id to perform this operation on", + "title": "Queue Id" + }, + "description": "The queue id to perform this operation on" + }, + { + "name": "item_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "description": "The queue item whose results are delivered", + "title": "Item Id" + }, + "description": "The queue item whose results are delivered" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueItemDeliveryRequest", + "description": "The delivery change" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueItemDeliveryResult" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/queue/{queue_id}/counts_by_destination": { "get": { "tags": ["queue"], @@ -91591,6 +91661,53 @@ "title": "QueueClearedEvent", "type": "object" }, + "QueueItemDeliveryRequest": { + "properties": { + "action": { + "type": "string", + "enum": ["claim", "confirm", "release"], + "title": "Action", + "description": "claim: take delivering the results; confirm: mark them delivered; release: give up a claim" + }, + "claimant": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "title": "Claimant", + "description": "An id the client chooses for the browser tab or run claiming" + } + }, + "type": "object", + "required": ["action", "claimant"], + "title": "QueueItemDeliveryRequest", + "description": "A change to who stages a queue item's results, so that only one browser does." + }, + "QueueItemDeliveryResult": { + "properties": { + "status": { + "type": "string", + "enum": ["claimed", "delivered", "held", "released"], + "title": "Status", + "description": "claimed: the claimant may deliver; delivered: the results were delivered; held: another claimant is delivering them; released: the claimant holds no claim" + }, + "retry_after_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Retry After Ms", + "description": "When held, how long until the other claim lapses, in milliseconds" + } + }, + "type": "object", + "required": ["status"], + "title": "QueueItemDeliveryResult", + "description": "Who stages a queue item's results after a delivery request." + }, "QueueItemStatusChangedEvent": { "description": "Event model for queue_item_status_changed", "properties": { diff --git a/invokeai/frontend/api/schema.ts b/invokeai/frontend/api/schema.ts index a7cb12ef253..38065be37b6 100644 --- a/invokeai/frontend/api/schema.ts +++ b/invokeai/frontend/api/schema.ts @@ -3478,6 +3478,27 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/queue/{queue_id}/i/{item_id}/delivery": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Claim Queue Item Delivery + * @description Claims, confirms or releases staging a queue item's results, so that only one of its owner's browsers stages + * them. A claim is held for a while unless confirmed, released or renewed. + */ + post: operations["claim_queue_item_delivery"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/queue/{queue_id}/counts_by_destination": { parameters: { query?: never; @@ -39258,6 +39279,40 @@ export type components = { */ user_id: string | null; }; + /** + * QueueItemDeliveryRequest + * @description A change to who stages a queue item's results, so that only one browser does. + */ + QueueItemDeliveryRequest: { + /** + * Action + * @description claim: take delivering the results; confirm: mark them delivered; release: give up a claim + * @enum {string} + */ + action: "claim" | "confirm" | "release"; + /** + * Claimant + * @description An id the client chooses for the browser tab or run claiming + */ + claimant: string; + }; + /** + * QueueItemDeliveryResult + * @description Who stages a queue item's results after a delivery request. + */ + QueueItemDeliveryResult: { + /** + * Status + * @description claimed: the claimant may deliver; delivered: the results were delivered; held: another claimant is delivering them; released: the claimant holds no claim + * @enum {string} + */ + status: "claimed" | "delivered" | "held" | "released"; + /** + * Retry After Ms + * @description When held, how long until the other claim lapses, in milliseconds + */ + retry_after_ms?: number | null; + }; /** * QueueItemStatusChangedEvent * @description Event model for queue_item_status_changed @@ -59027,6 +59082,44 @@ export interface operations { }; }; }; + claim_queue_item_delivery: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The queue id to perform this operation on */ + queue_id: string; + /** @description The queue item whose results are delivered */ + item_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["QueueItemDeliveryRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QueueItemDeliveryResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; counts_by_destination: { parameters: { query: { diff --git a/tests/app/routers/test_session_queue_delivery_route.py b/tests/app/routers/test_session_queue_delivery_route.py new file mode 100644 index 00000000000..5b7c4d11ffa --- /dev/null +++ b/tests/app/routers/test_session_queue_delivery_route.py @@ -0,0 +1,141 @@ +"""The delivery route lets only the owner of a queue item (or an admin) claim, confirm or release staging its results.""" + +import json +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from pydantic_core import to_jsonable_python +from sqlalchemy import insert + +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.session_queue.session_queue_default import SessionQueue +from invokeai.app.services.shared.database.database import Database +from invokeai.app.services.shared.database.schema.session_queue import session_queue +from invokeai.app.services.shared.execution_state_migration import dump_execution_state +from invokeai.app.services.shared.graph import Graph, GraphExecutionState +from tests.app.routers.conftest import MockApiDependencies, _auth + + +@pytest.fixture +def queue_router( + monkeypatch: pytest.MonkeyPatch, enable_multiuser: Any, mock_invoker: Invoker, mock_sqlite_database: Database +): + mock_invoker.services.session_queue = SessionQueue(mock_sqlite_database) + monkeypatch.setattr("invokeai.app.api.routers.session_queue.ApiDependencies", MockApiDependencies(mock_invoker)) + + +def _item(database: Database, user_id: str, queue_id: str = "default") -> int: + session = GraphExecutionState(graph=Graph()) + values = { + "queue_id": queue_id, + "batch_id": "batch", + "session_id": session.id, + "session": json.dumps(dump_execution_state(session), default=to_jsonable_python), + "status": "completed", + "user_id": user_id, + } + with database.begin(write=True) as conn: + return int(conn.execute(insert(session_queue).values(**values)).inserted_primary_key[0]) + + +def _deliver( + client: TestClient, token: str, item_id: int, action: str, claimant: str = "tab-a", queue_id: str = "default" +): + return client.post( + f"/api/v1/queue/{queue_id}/i/{item_id}/delivery", + json={"action": action, "claimant": claimant}, + headers=_auth(token), + ) + + +def test_the_owner_claims_confirms_and_releases( + queue_router: None, mock_sqlite_database: Database, mock_invoker: Invoker, client: TestClient, user1_token: str +): + user_id = mock_invoker.services.users.get_by_email("user1@test.com").user_id + item_id = _item(mock_sqlite_database, user_id) + + assert _deliver(client, user1_token, item_id, "claim").json() == {"status": "claimed", "retry_after_ms": None} + held = _deliver(client, user1_token, item_id, "claim", claimant="tab-b") + assert held.status_code == 200 + assert held.json()["status"] == "held" and 0 < held.json()["retry_after_ms"] <= 120_000 + assert _deliver(client, user1_token, item_id, "release").json()["status"] == "released" + assert _deliver(client, user1_token, item_id, "claim", claimant="tab-b").json()["status"] == "claimed" + assert _deliver(client, user1_token, item_id, "confirm", claimant="tab-c").json()["status"] == "delivered" + assert _deliver(client, user1_token, item_id, "claim").json()["status"] == "delivered" + + +def test_another_users_item_is_refused_and_an_admins_request_allowed( + queue_router: None, + mock_sqlite_database: Database, + mock_invoker: Invoker, + client: TestClient, + admin_token: str, + user1_token: str, + user2_token: str, +): + user_id = mock_invoker.services.users.get_by_email("user1@test.com").user_id + item_id = _item(mock_sqlite_database, user_id) + + for action in ("claim", "confirm", "release"): + assert _deliver(client, user2_token, item_id, action).status_code == 403 + assert _deliver(client, user1_token, item_id, "claim").json()["status"] == "claimed" + assert _deliver(client, admin_token, item_id, "claim", claimant="admin-tab").json()["status"] == "held" + + +def test_a_request_is_authorized_without_reading_the_items_session( + monkeypatch: pytest.MonkeyPatch, + queue_router: None, + mock_sqlite_database: Database, + mock_invoker: Invoker, + client: TestClient, + user1_token: str, + user2_token: str, +): + """Waiting browsers poll the route: it must not load and validate the whole item each time.""" + user_id = mock_invoker.services.users.get_by_email("user1@test.com").user_id + item_id = _item(mock_sqlite_database, user_id) + + def unread(self: SessionQueue, item_id: int) -> None: + raise AssertionError("the delivery route read the whole queue item") + + monkeypatch.setattr(SessionQueue, "get_queue_item", unread) + + assert _deliver(client, user1_token, item_id, "claim").json()["status"] == "claimed" + assert _deliver(client, user2_token, item_id, "claim").status_code == 403 + + +def test_a_missing_item_or_one_of_another_queue_is_not_found( + queue_router: None, mock_sqlite_database: Database, mock_invoker: Invoker, client: TestClient, user1_token: str +): + user_id = mock_invoker.services.users.get_by_email("user1@test.com").user_id + item_id = _item(mock_sqlite_database, user_id, queue_id="other") + + assert _deliver(client, user1_token, item_id + 1000, "claim").status_code == 404 + assert _deliver(client, user1_token, item_id, "claim").status_code == 404 + assert _deliver(client, user1_token, item_id, "claim", queue_id="other").status_code == 200 + + +@pytest.mark.parametrize( + "body", + [ + {"action": "steal", "claimant": "tab-a"}, + {"action": "claim", "claimant": ""}, + {"action": "claim", "claimant": "x" * 129}, + {"action": "claim"}, + ], +) +def test_a_malformed_request_is_rejected( + queue_router: None, + mock_sqlite_database: Database, + mock_invoker: Invoker, + client: TestClient, + user1_token: str, + body: dict[str, Any], +): + user_id = mock_invoker.services.users.get_by_email("user1@test.com").user_id + item_id = _item(mock_sqlite_database, user_id) + + response = client.post(f"/api/v1/queue/default/i/{item_id}/delivery", json=body, headers=_auth(user1_token)) + + assert response.status_code == 422 diff --git a/tests/app/services/session_queue/test_session_queue_delivery.py b/tests/app/services/session_queue/test_session_queue_delivery.py new file mode 100644 index 00000000000..0adacdb73f3 --- /dev/null +++ b/tests/app/services/session_queue/test_session_queue_delivery.py @@ -0,0 +1,169 @@ +"""Which browser stages a queue item's results: a claim per item that one claimant holds until it confirms the delivery, +releases the claim or lets it lapse, decided by one conditional statement on every backend.""" + +import json +from datetime import datetime, timedelta, timezone +from typing import Any + +import pytest +from pydantic_core import to_jsonable_python +from sqlalchemy import insert, select, update + +from invokeai.app.services.session_queue.session_queue_common import ( + QueueItemDeliveryResult, + QueueItemOwner, + SessionQueueItemNotFoundError, +) +from invokeai.app.services.session_queue.session_queue_default import QUEUE_ITEM_DELIVERY_CLAIM_TTL, SessionQueue +from invokeai.app.services.shared.database.database import Database +from invokeai.app.services.shared.database.queries.session_queue import Delivery, SessionQueueQueries +from invokeai.app.services.shared.database.schema.session_queue import session_queue +from invokeai.app.services.shared.database.types import timestamp_text +from invokeai.app.services.shared.execution_state_migration import dump_execution_state +from invokeai.app.services.shared.graph import Graph, GraphExecutionState +from tests.fixtures.races import while_in_flight + +CLAIMED = QueueItemDeliveryResult(status="claimed") +DELIVERED = QueueItemDeliveryResult(status="delivered") +RELEASED = QueueItemDeliveryResult(status="released") + + +@pytest.fixture +def queue(database: Database) -> SessionQueue: + return SessionQueue(database) + + +@pytest.fixture +def item_id(database: Database) -> int: + session = GraphExecutionState(graph=Graph()) + values = { + "queue_id": "default", + "batch_id": "batch", + "session_id": session.id, + "session": json.dumps(dump_execution_state(session), default=to_jsonable_python), + "status": "completed", + "user_id": "system", + } + with database.begin(write=True) as conn: + return int(conn.execute(insert(session_queue).values(**values)).inserted_primary_key[0]) + + +def _delivery(database: Database, item_id: int) -> dict[str, Any]: + q = session_queue.c + columns = (q.delivery_claimant, q.delivery_claimed_at, q.delivered_at, q.updated_at) + with database.begin(write=False) as conn: + return conn.execute(select(*columns).where(q.item_id == item_id)).one()._asdict() + + +def _claimed_ago(database: Database, item_id: int, age: timedelta) -> None: + claimed_at = timestamp_text(datetime.now(timezone.utc) - age) + with database.begin(write=True) as conn: + conn.execute( + update(session_queue).where(session_queue.c.item_id == item_id).values(delivery_claimed_at=claimed_at) + ) + + +def test_a_claim_is_held_for_its_claimant_and_renewed_by_it(database: Database, queue: SessionQueue, item_id: int): + assert queue.claim_queue_item_delivery(item_id, "tab-a") == CLAIMED + _claimed_ago(database, item_id, timedelta(seconds=100)) + stale = _delivery(database, item_id) + + assert queue.claim_queue_item_delivery(item_id, "tab-a") == CLAIMED + + delivery = _delivery(database, item_id) + assert delivery["delivery_claimant"] == "tab-a" + assert delivery["delivery_claimed_at"] > stale["delivery_claimed_at"] + # Delivery is not a change of the item the queue's views show. + assert delivery["updated_at"] == stale["updated_at"] + + +def test_another_claimant_is_held_off_until_the_claim_lapses(database: Database, queue: SessionQueue, item_id: int): + queue.claim_queue_item_delivery(item_id, "tab-a") + _claimed_ago(database, item_id, QUEUE_ITEM_DELIVERY_CLAIM_TTL - timedelta(seconds=20)) + + held = queue.claim_queue_item_delivery(item_id, "tab-b") + + assert held.status == "held" + assert held.retry_after_ms is not None and 15_000 < held.retry_after_ms <= 20_000 + assert _delivery(database, item_id)["delivery_claimant"] == "tab-a" + + _claimed_ago(database, item_id, QUEUE_ITEM_DELIVERY_CLAIM_TTL + timedelta(seconds=1)) + assert queue.claim_queue_item_delivery(item_id, "tab-b") == CLAIMED + assert queue.claim_queue_item_delivery(item_id, "tab-a").status == "held" + + +def test_a_confirmed_delivery_is_final(database: Database, queue: SessionQueue, item_id: int): + queue.claim_queue_item_delivery(item_id, "tab-a") + + # Any claimant confirms: the results were delivered, whoever holds the claim. + assert queue.confirm_queue_item_delivery(item_id) == DELIVERED + delivered_at = _delivery(database, item_id)["delivered_at"] + assert delivered_at is not None + + _claimed_ago(database, item_id, QUEUE_ITEM_DELIVERY_CLAIM_TTL * 2) + assert queue.claim_queue_item_delivery(item_id, "tab-a") == DELIVERED + assert queue.claim_queue_item_delivery(item_id, "tab-b") == DELIVERED + assert queue.release_queue_item_delivery(item_id, "tab-a") == RELEASED + assert queue.confirm_queue_item_delivery(item_id) == DELIVERED + assert queue.claim_queue_item_delivery(item_id, "tab-b") == DELIVERED + assert _delivery(database, item_id)["delivered_at"] == delivered_at + + +def test_a_delivery_is_confirmed_without_a_claim(queue: SessionQueue, item_id: int): + assert queue.confirm_queue_item_delivery(item_id) == DELIVERED + assert queue.claim_queue_item_delivery(item_id, "tab-a") == DELIVERED + + +def test_only_its_claimant_releases_a_claim(database: Database, queue: SessionQueue, item_id: int): + queue.claim_queue_item_delivery(item_id, "tab-a") + + assert queue.release_queue_item_delivery(item_id, "tab-b") == RELEASED + assert queue.claim_queue_item_delivery(item_id, "tab-b").status == "held" + + assert queue.release_queue_item_delivery(item_id, "tab-a") == RELEASED + assert _delivery(database, item_id)["delivery_claimant"] is None + assert queue.release_queue_item_delivery(item_id, "tab-a") == RELEASED + assert queue.claim_queue_item_delivery(item_id, "tab-b") == CLAIMED + + +def test_a_claim_racing_another_waits_and_is_held_off(database: Database, queue: SessionQueue, item_id: int): + stale_before = timestamp_text(datetime.now(timezone.utc) - QUEUE_ITEM_DELIVERY_CLAIM_TTL) + results: list[QueueItemDeliveryResult] = [] + + errors = while_in_flight( + database, + lambda q: q.session_queue.claim_delivery( + item_id, "tab-a", now=timestamp_text(datetime.now(timezone.utc)), stale_before=stale_before + ), + lambda: results.append(queue.claim_queue_item_delivery(item_id, "tab-b")), + ) + + assert errors == [] + assert [result.status for result in results] == ["held"] + assert _delivery(database, item_id)["delivery_claimant"] == "tab-a" + + +def test_a_claim_released_before_its_refusal_is_explained_is_retried_at_once( + monkeypatch: pytest.MonkeyPatch, queue: SessionQueue, item_id: int +): + queue.claim_queue_item_delivery(item_id, "tab-a") + # tab-a releases between tab-b's refused claim and the read that explains the refusal. + monkeypatch.setattr(SessionQueueQueries, "delivery", lambda self, item_id: Delivery(None, None, None)) + + assert queue.claim_queue_item_delivery(item_id, "tab-b") == QueueItemDeliveryResult(status="held", retry_after_ms=1) + + +def test_an_items_owner_is_read_without_its_session(queue: SessionQueue, item_id: int): + assert queue.get_queue_item_owner(item_id) == QueueItemOwner(queue_id="default", user_id="system") + with pytest.raises(SessionQueueItemNotFoundError): + queue.get_queue_item_owner(item_id + 1000) + + +def test_a_missing_item_has_no_delivery(queue: SessionQueue, item_id: int): + missing = item_id + 1000 + with pytest.raises(SessionQueueItemNotFoundError): + queue.claim_queue_item_delivery(missing, "tab-a") + with pytest.raises(SessionQueueItemNotFoundError): + queue.confirm_queue_item_delivery(missing) + with pytest.raises(SessionQueueItemNotFoundError): + queue.release_queue_item_delivery(missing, "tab-a") diff --git a/tests/app/services/session_queue/test_session_queue_reads.py b/tests/app/services/session_queue/test_session_queue_reads.py index e1f26bd0cc6..3ee9b63d08f 100644 --- a/tests/app/services/session_queue/test_session_queue_reads.py +++ b/tests/app/services/session_queue/test_session_queue_reads.py @@ -75,13 +75,15 @@ def _item( @pytest.mark.usefixtures("accounts") class TestItems: - def test_an_item_carries_every_column_and_its_owners_name(self, database: Database) -> None: + def test_an_item_carries_every_column_but_its_delivery_and_its_owners_name(self, database: Database) -> None: item_id = _item(database, workflow='{"name": "w"}', origin="canvas", destination="gallery") item = database.queries.session_queue.item(item_id) assert item is not None - assert {column.name for column in session_queue.columns} <= item.keys() + delivery = {"delivery_claimant", "delivery_claimed_at", "delivered_at"} + columns = {column.name for column in session_queue.columns} - delivery + assert item.keys() == columns | {"user_display_name", "user_email"} assert (item["item_id"], item["user_display_name"], item["user_email"]) == ( item_id, "Alice", diff --git a/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_13_add_session_queue_delivery.py b/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_13_add_session_queue_delivery.py new file mode 100644 index 00000000000..6567b80f021 --- /dev/null +++ b/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_13_add_session_queue_delivery.py @@ -0,0 +1,65 @@ +"""Queue items gain an empty delivery; existing items keep everything else.""" + +from logging import Logger +from pathlib import Path +from unittest.mock import MagicMock + +from sqlalchemy import delete, inspect + +from invokeai.app.services.config.config_default import DefaultInvokeAIAppConfig +from invokeai.app.services.shared.database.database import Database +from invokeai.app.services.shared.database.schema.migrator import applied_migrations +from invokeai.app.services.shared.sqlite_migrator.migration_loader import MigrationBuildContext, build_migrations +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import MigrationBase +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import Migrator +from tests.fixtures.database import migrate_to_newest + +MIGRATION_ID = "2026_10_13_add_session_queue_delivery" +DELIVERY_COLUMNS = {"delivery_claimant", "delivery_claimed_at", "delivered_at"} + + +def _run(db: Database, migrations: list[MigrationBase]) -> bool: + migrator = Migrator(db) + for migration in migrations: + migrator.register_migration(migration) + return migrator.run_migrations() + + +def _columns(db: Database) -> set[str]: + with db.begin(write=False) as conn: + return {column["name"] for column in inspect(conn).get_columns("session_queue")} + + +def test_existing_items_gain_an_empty_delivery(tmp_path: Path) -> None: + logger = Logger("test") + config = DefaultInvokeAIAppConfig(use_memory_db=True, node_cache_size=0) + config._root = tmp_path + migrations = build_migrations(MigrationBuildContext(app_config=config, logger=logger, image_files=MagicMock())) + db = Database.open_sqlite(None, logger) + assert _run(db, [migration for migration in migrations if migration.id != MIGRATION_ID]) + assert not DELIVERY_COLUMNS & _columns(db) + with db.begin(write=True) as conn: + conn.exec_driver_sql( + "INSERT INTO session_queue (queue_id, batch_id, session_id, session, status, user_id) " + "VALUES ('default', 'batch', 'session', '{}', 'completed', 'system');" + ) + + assert _run(db, migrations) + + assert DELIVERY_COLUMNS <= _columns(db) + with db.begin(write=False) as conn: + row = conn.exec_driver_sql( + "SELECT session_id, status, delivery_claimant, delivery_claimed_at, delivered_at FROM session_queue;" + ).all() + assert row == [("session", "completed", None, None, None)] + db.dispose() + + +def test_running_again_after_an_interrupted_run_changes_nothing(database: Database, tmp_path: Path) -> None: + """On MySQL and MariaDB a migration that failed after its DDL runs again on columns it already added.""" + with database.begin(write=True) as conn: + conn.execute(delete(applied_migrations).where(applied_migrations.c.migration_id == MIGRATION_ID)) + + migrate_to_newest(database, tmp_path) + + assert DELIVERY_COLUMNS <= _columns(database) From 4d6875ba1a412f25a53354ec518d2aaacfa511ff Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Tue, 13 Oct 2026 02:47:44 -0600 Subject: [PATCH 34/35] fix(queue): stage a retried Canvas result once across tabs and browsers Reruns are staged only by the tab holding the server's delivery claim; the run's own tab claims at hold and renews, so it keeps the result it retried. Replaces the per-browser IndexedDB claims and reservations; mock backend serves the endpoint. --- invokeai/frontend/webv2/ARCHITECTURE.md | 2 + .../scripts/mock-backend-fixtures.test.mjs | 15 + .../frontend/webv2/scripts/mock-backend.mjs | 34 ++ .../webv2/src/features/queue/core/types.ts | 14 + .../data/httpRealtimeQueueBackend.test.ts | 22 +- .../queue/data/httpRealtimeQueueBackend.ts | 8 + .../src/features/queue/data/queries.test.ts | 1 + .../src/features/queue/data/serverApi.ts | 18 + .../src/features/queue/data/serverTypes.ts | 5 + .../webv2/src/features/queue/index.ts | 2 +- .../webv2/src/features/queue/runtime.test.ts | 565 +++++++++++++----- .../webv2/src/features/queue/runtime.ts | 348 +++++++---- .../platform/browser/webLocks.browser.test.ts | 29 +- .../webv2/src/platform/browser/webLocks.ts | 13 +- .../webv2/src/workbench/projects/README.md | 3 +- .../projects/durableSyncedPersistence.test.ts | 4 - .../queueRunJournal.browser.test.ts | 65 +- .../queue-integration/queueRunJournal.ts | 69 --- .../queue-integration/queueRunLocks.test.ts | 21 + .../queue-integration/queueRunLocks.ts | 11 +- 20 files changed, 859 insertions(+), 390 deletions(-) diff --git a/invokeai/frontend/webv2/ARCHITECTURE.md b/invokeai/frontend/webv2/ARCHITECTURE.md index 4fc653de4ca..dbebb0c0553 100644 --- a/invokeai/frontend/webv2/ARCHITECTURE.md +++ b/invokeai/frontend/webv2/ARCHITECTURE.md @@ -42,6 +42,8 @@ The queue pilot is the first migrated feature. Its private `QueueBackendPort` is Queue exposes board-independent active backend sessions, including preparation and result settlement. The Gallery progress section includes every unfinished batch slot, including queued work; Preview follows executing and settling sessions. Both use gallery-destination projections of the 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 do not participate in board counts, saved-image selection, or navigation. Saved outputs retain their actual backend board destinations; automatic result selection does not change the browsed board or its paging window. +A retry re-runs failed or canceled backend items under new ids, and the run that owns 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 and then confirms, and a claim it neither confirms nor releases lapses after two minutes. A reopened run claims its reruns as `run:` as soon as 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, and 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. + Gallery ordinary listings are Query-owned sparse pages shared by its grid, picker, Preview, and deep-reveal flows. A page key combines the account lifetime, canonical listing filters, and aligned absolute offset; each result keeps its authoritative total and absolute item positions so hydrated gaps never compact. Consumers own separate visible ranges and scroll anchors. A cold numbered page after page zero first requests count-only metadata, then reads only its clamped 60-item page; virtual scrolling discovers the count from its first item page. Recent local completions use a temporary overlay, with listing-scoped membership evidence bounded by the recent-item limit so an authoritative item does not reappear in the overlay when its page is evicted. The Query layer retains at most ten inactive idle pages per listing while protecting observed pages, and schedules at most four distinct page reads per QueryClient. Deep reveal resolves an item's filtered listing rank, reads its containing page, verifies the item at the returned absolute position, and retries that rank/page check at most once if offsets shifted. A persisted Preview selection keeps the listing identity from when the item was selected; changing the active Gallery filters does not rewrite that cursor. Preview re-resolves a stale item page after listing mutations, and its asynchronous navigation is fenced by the captured account epoch so a late page cannot stamp selection into a newer account lifetime. Intermediates owns the manager for temporary media behind `@features/intermediates`: transport mapping, account-fenced query keys, the selection core, the confirmation dialog and the operation panel. It is a Settings section, contributed as metadata through `settingsContribution` so the dialog's catalog never loads the manager or its transport until the section opens. The eligibility policy (safe, referenced, active, recent) is server-owned; the manager requests a server preview, which fixes the instant recency is judged at and describes what a cleanup would delete and keep, and confirms it once. "All matching" selections travel as a `matching` scope carrying the summary's filters and the excluded rows, which the server resolves; the client never enumerates pages it has not loaded. Operations are not persisted by the server: a manager that opens with nothing followed lists the account's operations and follows the newest unfinished one, so a reload picks a running cleanup back up, and a settled operation that left failures behind offers to delete its scope again. The client's one input to the policy is a browser hold lease: `holdLease` (a public entry, started by Workbench for the provider's lifetime) keeps the media open editors still need (live content from every open project plus the undo state each live Canvas engine retains) classified as active. Workbench owns the name set: `projects/projectAssets` scans each immutable project subtree once, and each Canvas engine registers its retained undo state with the mounted Workbench, which re-reads it when the history changes; an engine released by one Workbench is replaced, not reused, by the next, and one another Workbench still uses is refused. The lease is one per tab, holding up to the server's per-kind limit of sorted unique names, which the server replaces atomically; it skips unchanged sets without re-serialising them, forgets a send the server did not acknowledge so the next change, heartbeat or return to the foreground sends again while the previous lease still stands, refreshes every five minutes and when a tab becomes visible after missing a refresh, and releases with `keepalive` on dispose and `pagehide`; the server keeps at most eight leases per account and expires a lease after 15 minutes, which bounds anything a crash leaves behind. Realtime updates attach for the section's mounted lifetime through the Platform socket hub and invalidate the account's summaries when an operation settles, by either the socket or the polled record, or when the socket reconnects. Workbench supplies the entry points (the Data & workspace link, the project actions menu, the Users page callback) and hands the manager a one-shot focus intent (a project to preselect or an account to filter by) rather than route state. diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs index 82cd43937ac..5eeb92fef99 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs @@ -992,6 +992,21 @@ test('queue retry clones failed or canceled roots once, cancel-by-batch spares o const secondRerun = await getJson(backend, `/api/v1/queue/default/i/${again.retried_items[0].item_id}`); assert.equal(secondRerun.retried_from_item_id, failed.item_id); + // One claimant at a time stages a rerun's results; a confirmed rerun stays delivered. + const deliver = (action, claimant) => + getJson(backend, `/api/v1/queue/default/i/${clone.item_id}/delivery`, { + body: JSON.stringify({ action, claimant }), + method: 'POST', + }); + assert.deepEqual(await deliver('claim', 'tab:a'), { status: 'claimed' }); + const held = await deliver('claim', 'tab:b'); + assert.equal(held.status, 'held'); + assert.ok(held.retry_after_ms > 0); + assert.deepEqual(await deliver('release', 'tab:a'), { status: 'released' }); + assert.deepEqual(await deliver('claim', 'tab:b'), { status: 'claimed' }); + assert.deepEqual(await deliver('confirm', 'tab:b'), { status: 'delivered' }); + assert.deepEqual(await deliver('claim', 'tab:a'), { status: 'delivered' }); + const canceled = await getJson(backend, '/api/v1/queue/default/cancel_by_batch_ids', { body: JSON.stringify({ batch_ids: [pending.batch_id] }), method: 'PUT', diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs index 4be6e7163d7..b1bffe1362b 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs @@ -110,6 +110,8 @@ const STARTER_MODELS_RESPONSE = { starter_models: [STARTER_MODEL], }; +const DELIVERY_CLAIM_TTL_MS = 120_000; + const createState = (profile) => { const fixture = assertMockBackendFixture(createMockBackendFixture(profile)); @@ -137,6 +139,7 @@ const createState = (profile) => { intermediatesOperations: new Map(), intermediatesPreviews: new Map(), queueItems: new Map(fixture.queueItems.map((item) => [item.item_id, clone(item)])), + deliveryClaims: new Map(), runtimeConfig: { generation_devices: 'auto', max_queue_history: null }, invocationCache: { enabled: true, hits: 0, max_size: 1000, misses: 0, size: 0 }, videos: new Map(fixture.videos.map((video) => [video.video_name, clone(video)])), @@ -1193,6 +1196,37 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { } } + const queueItemDeliveryMatch = /^\/api\/v1\/queue\/[^/]+\/i\/(\d+)\/delivery$/.exec(path); + if (queueItemDeliveryMatch && method === 'POST') { + // Like the backend: one claimant at a time stages an item's results until it confirms, releases, or its claim + // expires; a confirmed item stays delivered. + const itemId = Number(queueItemDeliveryMatch[1]); + if (!state.queueItems.has(itemId)) { + return json(404, { detail: 'Queue item not found' }); + } + const { action, claimant } = await readJsonBody(request); + const claim = state.deliveryClaims.get(itemId); + const now = Date.now(); + if (action === 'confirm') { + state.deliveryClaims.set(itemId, { claimant, claimedAt: now, isDelivered: true }); + return json(200, { status: 'delivered' }); + } + if (action === 'release') { + if (claim?.claimant === claimant && !claim.isDelivered) { + state.deliveryClaims.delete(itemId); + } + return json(200, { status: 'released' }); + } + if (claim?.isDelivered) { + return json(200, { status: 'delivered' }); + } + if (claim && claim.claimant !== claimant && now - claim.claimedAt < DELIVERY_CLAIM_TTL_MS) { + return json(200, { retry_after_ms: DELIVERY_CLAIM_TTL_MS - (now - claim.claimedAt), status: 'held' }); + } + state.deliveryClaims.set(itemId, { claimant, claimedAt: now, isDelivered: false }); + return json(200, { status: 'claimed' }); + } + const queueItemCancelMatch = /^\/api\/v1\/queue\/[^/]+\/i\/(\d+)\/cancel$/.exec(path); if (queueItemCancelMatch && method === 'PUT') { const item = state.queueItems.get(Number(queueItemCancelMatch[1])); diff --git a/invokeai/frontend/webv2/src/features/queue/core/types.ts b/invokeai/frontend/webv2/src/features/queue/core/types.ts index bc9fe6998e5..e2dd92e53b6 100644 --- a/invokeai/frontend/webv2/src/features/queue/core/types.ts +++ b/invokeai/frontend/webv2/src/features/queue/core/types.ts @@ -336,8 +336,22 @@ export interface QueueProgressPreviewPayload { image: { width: number; height: number; dataURL: string } | null; } +/** Who stages a retried item's results, as the server agreed it across every browser and tab of the account. */ +export type QueueItemDeliveryClaim = + | { kind: 'claimed' | 'delivered' | 'released' } + | { kind: 'held'; retryInMs: number }; + export interface QueueBackendPort extends QueueFeatureCommands { acknowledgeEnqueue?(projectId: string, sourceQueueItemId: string): Promise; + /** + * Claim, confirm or release the delivery of an item's results. A claim is held until its claimant confirms or + * releases it, or until it expires; an item once confirmed is delivered for good. + */ + claimItemDelivery( + itemId: number, + action: 'claim' | 'confirm' | 'release', + claimant: string + ): Promise; getEnqueueReceipt?(projectId: string, sourceQueueItemId: string): Promise; /** The latest preview frame of each of the user's running items (`GET queue/{id}/previews`). */ readProgressPreviews?(signal?: AbortSignal): Promise; diff --git a/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.test.ts b/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.test.ts index a83a64a9062..55b04847aea 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -const server = vi.hoisted(() => ({ retryItemsById: vi.fn() })); +const server = vi.hoisted(() => ({ claimQueueItemDelivery: vi.fn(), retryItemsById: vi.fn() })); vi.mock('./serverApi', async (importOriginal) => ({ ...(await importOriginal()), ...server })); import { queueBackend } from './httpRealtimeQueueBackend'; @@ -41,3 +41,23 @@ describe('queueBackend retries', () => { detach(); }); }); + +describe('queueBackend delivery claims', () => { + it("passes the server's answer on, with how long a held claim keeps the item", async () => { + server.claimQueueItemDelivery + .mockResolvedValueOnce({ retry_after_ms: null, status: 'claimed' }) + .mockResolvedValueOnce({ retry_after_ms: 1_500, status: 'held' }) + .mockResolvedValueOnce({ retry_after_ms: null, status: 'held' }) + .mockResolvedValueOnce({ status: 'delivered' }); + + await expect(queueBackend.claimItemDelivery(99, 'claim', 'tab:a')).resolves.toEqual({ kind: 'claimed' }); + await expect(queueBackend.claimItemDelivery(99, 'claim', 'tab:b')).resolves.toEqual({ + kind: 'held', + retryInMs: 1_500, + }); + // A held answer without a time is asked again at once rather than never. + await expect(queueBackend.claimItemDelivery(99, 'claim', 'tab:b')).resolves.toEqual({ kind: 'held', retryInMs: 1 }); + await expect(queueBackend.claimItemDelivery(99, 'confirm', 'tab:a')).resolves.toEqual({ kind: 'delivered' }); + expect(server.claimQueueItemDelivery).toHaveBeenCalledWith(99, 'confirm', 'tab:a'); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.ts b/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.ts index 759173ce1c1..104264bcbb6 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/httpRealtimeQueueBackend.ts @@ -13,6 +13,7 @@ import { import { cancelByBatchIds, cancelCurrentQueueItem, + claimQueueItemDelivery, cancelQueueItem, cancelQueueItems, cancelScopedQueueItems, @@ -103,6 +104,13 @@ export const queueBackend: QueueBackendPort = { return item ? mapQueueItemDTO(item) : null; }, readStatus: async (scope, signal) => mapQueueStatusDTO(await getQueueStatus(scope, signal)), + claimItemDelivery: async (itemId, action, claimant) => { + const result = await claimQueueItemDelivery(itemId, action, claimant); + + return result.status === 'held' + ? { kind: 'held', retryInMs: Math.max(1, result.retry_after_ms ?? 0) } + : { kind: result.status }; + }, retryItems: async (itemIds) => { const result = await retryItemsById(itemIds); if (result.retried_items?.length) { diff --git a/invokeai/frontend/webv2/src/features/queue/data/queries.test.ts b/invokeai/frontend/webv2/src/features/queue/data/queries.test.ts index cc8f7b31dd5..ff9518520ad 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/queries.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/queries.test.ts @@ -58,6 +58,7 @@ const createBackend = (): QueueBackendPort => ({ }), retryItems: vi.fn(), resumeProcessor: vi.fn(), + claimItemDelivery: vi.fn(), }); describe('queue queries', () => { diff --git a/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts b/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts index f96cfe660e1..cbbc611fce1 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/serverApi.ts @@ -3,6 +3,7 @@ import type { CancelByBatchIdsResultDTO, InvocationCacheStatusDTO, QueueAndProcessorStatusDTO, + QueueItemDeliveryResultDTO, QueueItemIdsResultDTO, QueueServerItemDTO, RetryItemsResultDTO, @@ -277,6 +278,23 @@ export const retryItemsById = async (itemIds: number[]): Promise => { + const owner = captureAccountScope(); + const result = await apiFetchJson(buildQueueUrl(`i/${itemId}/delivery`), { + body: JSON.stringify({ action, claimant }), + method: 'POST', + signal: owner.signal, + }); + + assertAccountScopeCurrent(owner); + + return result; +}; + const INVOCATION_CACHE_URL = '/api/v1/app/invocation_cache'; /** Admin-only: the invocation (node output) cache, distinct from the model cache. */ diff --git a/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts b/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts index b6c3812f65f..a0c145fcf4a 100644 --- a/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts +++ b/invokeai/frontend/webv2/src/features/queue/data/serverTypes.ts @@ -46,6 +46,11 @@ export interface RetryItemsResultDTO { retried_items?: { item_id: number; source_item_id: number }[]; } +export interface QueueItemDeliveryResultDTO { + retry_after_ms?: number | null; + status: 'claimed' | 'delivered' | 'held' | 'released'; +} + export interface CancelByBatchIdsResultDTO { canceled: number; } diff --git a/invokeai/frontend/webv2/src/features/queue/index.ts b/invokeai/frontend/webv2/src/features/queue/index.ts index 1081172f95f..586ee6ca307 100644 --- a/invokeai/frontend/webv2/src/features/queue/index.ts +++ b/invokeai/frontend/webv2/src/features/queue/index.ts @@ -66,5 +66,5 @@ export { getQueueReadModelOptions, queueCommands, } from './publicApi'; -export type { QueueRerunDelivery, QueueRunLockPort } from './runtime'; +export type { QueueRunLockPort } from './runtime'; export { hasPendingWorkflowQueueItem } from './ui/queueViewModel'; diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts index a7b370e150a..c4bb98c7c5d 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.test.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.test.ts @@ -20,7 +20,6 @@ import { type QueueHistoryCommands, type QueueRunJournalPort, type QueueRunLock, - type QueueRerunDelivery, type QueueRunLockPort, } from './runtime'; @@ -61,6 +60,7 @@ const createTestBackend = (overrides: Partial = {}): QueueBack clearFinishedItems: vi.fn(), readItemDetail: vi.fn(), cancelScopedItems: vi.fn(), + claimItemDelivery: vi.fn().mockResolvedValue({ kind: 'claimed' }), clearFailedItems: vi.fn(), clearItems: vi.fn(), emit: vi.fn(), @@ -105,8 +105,6 @@ const createTestCommands = (overrides: Partial = {}): Queu }); const createTestJournal = (overrides: Partial = {}): QueueRunJournalPort => ({ - claimRerunDelivery: vi.fn().mockResolvedValue({ kind: 'claimed' }), - releaseRerunDelivery: vi.fn().mockResolvedValue({ kind: 'removed' }), listForProject: vi.fn().mockResolvedValue({ entries: [], kind: 'available' }), record: vi.fn().mockResolvedValue({ kind: 'stored' }), settle: vi.fn().mockResolvedValue({ kind: 'removed' }), @@ -114,9 +112,14 @@ const createTestJournal = (overrides: Partial = {}): QueueR }); /** Run locks that no other tab holds, around the given acquire. */ -const createTestLocks = (acquire: QueueRunLockPort['acquire']): QueueRunLockPort => ({ +const createTestLocks = ( + acquire: QueueRunLockPort['acquire'], + overrides: Partial = {} +): QueueRunLockPort => ({ acquire, isHeld: () => Promise.resolve(false), + waitForRunRelease: () => Promise.resolve(), + ...overrides, }); const runtimeServices = { @@ -372,6 +375,7 @@ describe('queue runtime', () => { readStatus: vi.fn(), resumeProcessor: vi.fn(), retryItems: vi.fn(), + claimItemDelivery: vi.fn().mockResolvedValue({ kind: 'claimed' }), }; const commands: QueueHistoryCommands = { markBackendCancelled: vi.fn(), @@ -862,15 +866,14 @@ describe('queue runtime', () => { const listeners = new Set<() => void>(); let finishAcquire: (result: { kind: 'acquired'; release(): Promise }) => void = () => undefined; const release = vi.fn().mockResolvedValue(undefined); - const locks: QueueRunLockPort = { - isHeld: () => Promise.resolve(false), - acquire: vi.fn( + const locks = createTestLocks( + vi.fn( () => new Promise((resolve) => { finishAcquire = resolve; }) - ), - }; + ) + ); const recordedItems: QueueItem[] = []; const journal = createTestJournal({ listForProject: vi.fn().mockResolvedValue({ @@ -928,9 +931,8 @@ describe('queue runtime', () => { let finishAcquire: (result: QueueRunLock) => void = () => undefined; const release = vi.fn().mockResolvedValue(undefined); let acquisitionCount = 0; - const locks: QueueRunLockPort = { - isHeld: () => Promise.resolve(false), - acquire: vi.fn(() => { + const locks = createTestLocks( + vi.fn(() => { acquisitionCount += 1; if (acquisitionCount > 1) { return Promise.resolve({ kind: 'acquired', release }); @@ -938,8 +940,8 @@ describe('queue runtime', () => { return new Promise((resolve) => { finishAcquire = resolve; }); - }), - }; + }) + ); const journal = createTestJournal({ listForProject: vi.fn().mockResolvedValue({ entries: [{ item: queueItem, projectId: 'project-1', queueItemId: queueItem.id }], @@ -2029,10 +2031,7 @@ describe('queue runtime', () => { const notify = () => listeners.forEach((listener) => listener()); const journal = createTestJournal(); const release = vi.fn().mockResolvedValue(undefined); - const locks: QueueRunLockPort = { - isHeld: () => Promise.resolve(false), - acquire: vi.fn().mockResolvedValue({ kind: 'acquired', release }), - }; + const locks = createTestLocks(vi.fn().mockResolvedValue({ kind: 'acquired', release })); const commands = createTestCommands({ markBackendSubmitted: ({ backendBatchId, backendItemIds }) => { queueItem.backendBatchId = backendBatchId; @@ -2333,6 +2332,7 @@ describe('queue runtime video board routing', () => { }), resumeProcessor: vi.fn(), retryItems: vi.fn(), + claimItemDelivery: vi.fn().mockResolvedValue({ kind: 'claimed' }), }; const commands: QueueHistoryCommands = { markBackendCancelled: vi.fn(), @@ -3137,15 +3137,21 @@ describe('queue runtime retries', () => { await runtime.dispose(); }); - it('leaves a Canvas rerun to the tab tracking its run, without taking that run from it', async () => { + it("waits for the tab tracking a Canvas rerun's run, and stages the rerun if that tab lets the run go without it", async () => { const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [7], status: 'completed' }; const getResultImages = vi.fn().mockResolvedValue([{ imageName: 'rerun.png' } as QueueResultImage]); + let letRunGo: (() => void) | undefined; // Another tab tracks the run behind rerun 99; nobody tracks the one behind rerun 100 any more. - const locks: QueueRunLockPort = { - acquire: vi.fn(), + const locks = createTestLocks(vi.fn(), { isHeld: (_projectId, queueItemId) => Promise.resolve(queueItemId === 'tracked-elsewhere'), - }; + waitForRunRelease: () => + new Promise((resolve) => { + letRunGo = resolve; + }), + }); const { commands, emit, project, runtime } = createRetryHarness(queueItem, { getResultImages }, { locks }); + const staged = () => + vi.mocked(commands.stageRetriedCanvasResults).mock.calls.map(([payload]) => payload.backendItemId); runtime.start(); emit( @@ -3161,52 +3167,94 @@ describe('queue runtime retries', () => { ); emit('queue_item_status_changed', completedEvent(100, buildQueueItemOrigin('run-gone', project.id), 'canvas')); - await vi.waitFor(() => expect(commands.stageRetriedCanvasResults).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(staged()).toEqual([100])); await settleEvents(); - expect(commands.stageRetriedCanvasResults).toHaveBeenCalledTimes(1); - expect(commands.stageRetriedCanvasResults).toHaveBeenCalledWith( - expect.objectContaining({ backendItemId: 100, queueItemId: 'run-gone' }) - ); + expect(staged()).toEqual([100]); expect(getResultImages).toHaveBeenCalledTimes(1); + letRunGo?.(); + + await vi.waitFor(() => expect(staged()).toEqual([100, 99])); expect(locks.acquire).not.toHaveBeenCalled(); await runtime.dispose(); }); - /** The account's journal of rerun delivery claims, shared by every tab of the browser. */ - const createSharedDeliveryJournal = () => { - const claims = new Set(); - const keyOf = (rerun: QueueRerunDelivery, kind: 'adoption' | 'delivery') => - JSON.stringify([rerun.backendItemId, rerun.projectId, rerun.queueItemId, kind]); - return createTestJournal({ - claimRerunDelivery: vi.fn((rerun: QueueRerunDelivery, kind: 'adoption' | 'delivery') => { - const refusedBy = kind === 'delivery' ? (['adoption', 'delivery'] as const) : (['delivery'] as const); - if (refusedBy.some((refusing) => claims.has(keyOf(rerun, refusing)))) { - return Promise.resolve({ kind: 'taken' } as const); + /** + * The server's delivery claims, which every tab and browser of the account shares. A claim lasts `ttlMs` here, so a + * claimant turned away asks again within a test. + */ + const createDeliveryServer = (ttlMs = 200) => { + const claims = new Map(); + const claimItemDelivery: QueueBackendPort['claimItemDelivery'] = vi.fn((itemId, action, claimant) => { + const claim = claims.get(itemId); + if (action === 'confirm') { + claims.set(itemId, { claimant, claimedAt: Date.now(), isDelivered: true }); + return Promise.resolve({ kind: 'delivered' } as const); + } + if (action === 'release') { + if (claim !== undefined && claim.claimant === claimant && !claim.isDelivered) { + claims.delete(itemId); } - claims.add(keyOf(rerun, kind)); - return Promise.resolve({ kind: 'claimed' } as const); - }), - releaseRerunDelivery: vi.fn((rerun: QueueRerunDelivery, kind: 'adoption' | 'delivery') => { - claims.delete(keyOf(rerun, kind)); - return Promise.resolve({ kind: 'removed' }); - }), + return Promise.resolve({ kind: 'released' } as const); + } + if (claim?.isDelivered) { + return Promise.resolve({ kind: 'delivered' } as const); + } + const age = Date.now() - (claim?.claimedAt ?? 0); + if (claim !== undefined && claim.claimant !== claimant && age < ttlMs) { + return Promise.resolve({ kind: 'held', retryInMs: ttlMs - age } as const); + } + claims.set(itemId, { claimant, claimedAt: Date.now(), isDelivered: false }); + return Promise.resolve({ kind: 'claimed' } as const); }); + return { claimItemDelivery, claims }; }; + const rerunImages = (itemId: number) => Promise.resolve([{ imageName: `rerun-${itemId}.png` } as QueueResultImage]); const openCanvasTab = ( - journal: QueueRunJournalPort, - getResultImages: QueueBackendPort['getResultImages'] = vi.fn((itemId: number) => - Promise.resolve([{ imageName: `rerun-${itemId}.png` } as QueueResultImage]) - ) + server: ReturnType, + getResultImages: QueueBackendPort['getResultImages'] = vi.fn(rerunImages), + locks: QueueRunLockPort = createTestLocks(vi.fn()) ) => createRetryHarness( { ...createPendingQueueItem(), backendItemIds: [7], status: 'completed' }, - { getResultImages }, - { journal, locks: createTestLocks(vi.fn()) } + { claimItemDelivery: server.claimItemDelivery, getResultImages }, + { locks } ); - const stagedItemIds = (tab: ReturnType) => + /** A tab whose failed Canvas run reopens around the reruns its retry reports, and reads them as completed. */ + const openAdoptingTab = ( + server: ReturnType, + run: QueueItem = { ...createPendingQueueItem(), backendItemIds: [41], status: 'failed' } + ) => { + const tab = createRetryHarness(run, { + claimItemDelivery: server.claimItemDelivery, + getItem: vi.fn((id: number) => + Promise.resolve({ + batchId: 'batch', + id, + origin: buildQueueItemOrigin(run.id, 'project-1'), + status: 'completed' as const, + }) + ), + getResultImages: vi.fn(rerunImages), + }); + vi.mocked(tab.commands.reopenForRetry).mockImplementation(({ retriedItems }) => { + run.retriedBackendItemIds = run.backendItemIds; + run.backendItemIds = retriedItems.map(({ itemId }) => itemId); + run.status = 'running'; + tab.notify(); + }); + return { ...tab, run }; + }; + /** The rerun images a tab routed or staged into its Canvas. */ + const stagedImageNames = (tab: ReturnType) => + [ + ...vi.mocked(tab.commands.stageRetriedCanvasResults).mock.calls.flatMap(([payload]) => payload.images), + ...vi.mocked(tab.commands.routePartialResults).mock.calls.flatMap(([payload]) => payload.images), + ...vi.mocked(tab.commands.routeResults).mock.calls.flatMap(([payload]) => payload.images), + ].map((image) => image.imageName); + const stagedItemIds = (tab: ReturnType) => vi.mocked(tab.commands.stageRetriedCanvasResults).mock.calls.map(([payload]) => payload.backendItemId); - const emitReruns = (tab: ReturnType, itemIds: number[]) => + const emitReruns = (tab: ReturnType, itemIds: number[]) => tab.emit( 'queue_items_retried', retriedEvent(itemIds.map((itemId) => ({ item_id: itemId, source_item_id: itemId + 1_000 }))) @@ -3216,10 +3264,10 @@ describe('queue runtime retries', () => { setTimeout(resolve); }); - it('stages a Canvas rerun once across tabs, however late another tab sees it and after the first tab closed', async () => { - const journal = createSharedDeliveryJournal(); - const first = openCanvasTab(journal); - const second = openCanvasTab(journal); + it('stages a Canvas rerun once across tabs and browsers, however late another sees it and after the first closed', async () => { + const server = createDeliveryServer(); + const first = openCanvasTab(server); + const second = openCanvasTab(server); const completed = (itemId: number) => completedEvent(itemId, buildQueueItemOrigin('run-gone', first.project.id), 'canvas'); @@ -3242,10 +3290,62 @@ describe('queue runtime retries', () => { await second.runtime.dispose(); }); + it('stages a Canvas rerun once when tabs handle its completion at the same moment', async () => { + const server = createDeliveryServer(); + const tabs = [0, 1, 2].map(() => openCanvasTab(server)); + const completed = completedEvent(99, buildQueueItemOrigin('run-gone', tabs[0]!.project.id), 'canvas'); + + for (const tab of tabs) { + tab.runtime.start(); + emitReruns(tab, [99]); + } + for (const tab of tabs) { + tab.emit('queue_item_status_changed', completed); + } + + await vi.waitFor(() => expect(tabs.flatMap(stagedItemIds)).toEqual([99])); + // The winner claims, checks its claim before staging and confirms; the others ask again until it is confirmed. + await vi.waitFor(() => expect(server.claimItemDelivery).toHaveBeenCalledTimes(7)); + expect(tabs.flatMap(stagedItemIds)).toEqual([99]); + + await Promise.all(tabs.map((tab) => tab.runtime.dispose())); + }); + + it('stages a Canvas rerun after the tab that claimed it closed before staging, once the claim expires', async () => { + const server = createDeliveryServer(); + // This tab closes while it reads the results, so it neither stages nor gives the claim back. + const closing = openCanvasTab( + server, + vi.fn( + () => + new Promise(() => { + // Never settles: the tab is gone. + }) + ) + ); + const open = openCanvasTab(server); + const completed = completedEvent(99, buildQueueItemOrigin('run-gone', closing.project.id), 'canvas'); + + for (const tab of [closing, open]) { + tab.runtime.start(); + emitReruns(tab, [99]); + } + closing.emit('queue_item_status_changed', completed); + await vi.waitFor(() => expect(server.claims.has(99)).toBe(true)); + open.emit('queue_item_status_changed', completed); + await settleEvents(); + expect(stagedItemIds(open)).toEqual([]); + + await vi.waitFor(() => expect(stagedItemIds(open)).toEqual([99])); + + await open.runtime.dispose(); + }); + it('leaves a Canvas rerun to a tab that sees it later when this one could not read its results', async () => { - const journal = createSharedDeliveryJournal(); - const failing = openCanvasTab(journal, vi.fn().mockRejectedValue(new Error('Server restarting'))); - const later = openCanvasTab(journal); + // Claims outlast the test, so only giving the claim back lets the later tab stage it. + const server = createDeliveryServer(60_000); + const failing = openCanvasTab(server, vi.fn().mockRejectedValue(new Error('Server restarting'))); + const later = openCanvasTab(server); const completed = completedEvent(99, buildQueueItemOrigin('run-gone', failing.project.id), 'canvas'); for (const tab of [failing, later]) { @@ -3265,10 +3365,11 @@ describe('queue runtime retries', () => { await later.runtime.dispose(); }); - it('stages a Canvas rerun when the delivery journal cannot answer', async () => { - const tab = openCanvasTab( - createTestJournal({ claimRerunDelivery: vi.fn().mockRejectedValue(new Error('IndexedDB closed')) }) - ); + it('stages a Canvas rerun when the server keeps no delivery claims', async () => { + const tab = openCanvasTab({ + claimItemDelivery: vi.fn().mockRejectedValue(new ApiError('Not Found', 404)), + claims: new Map(), + }); tab.runtime.start(); emitReruns(tab, [99]); @@ -3282,12 +3383,33 @@ describe('queue runtime retries', () => { await tab.runtime.dispose(); }); + it('asks again after a failed claim instead of staging a rerun another tab staged meanwhile', async () => { + const server = createDeliveryServer(); + server.claims.set(99, { claimant: 'tab:other', claimedAt: Date.now(), isDelivered: true }); + const claimItemDelivery = vi.mocked(server.claimItemDelivery); + claimItemDelivery.mockRejectedValueOnce(new Error('Failed to fetch')); + const tab = openCanvasTab(server); + + tab.runtime.start(); + emitReruns(tab, [99]); + tab.emit( + 'queue_item_status_changed', + completedEvent(99, buildQueueItemOrigin('run-gone', tab.project.id), 'canvas') + ); + + await vi.waitFor(() => expect(claimItemDelivery).toHaveBeenCalledTimes(2), { timeout: 6_000 }); + await settleEvents(); + expect(stagedItemIds(tab)).toEqual([]); + + await tab.runtime.dispose(); + }, 10_000); + it('stages a Canvas rerun that finishes while this tab is still freeing the run lock it gave up', async () => { const queueItem: QueueItem = { ...createPendingQueueItem(), backendItemIds: [88], status: 'running' }; let held = false; let freeRunLock: (() => void) | undefined; - const locks: QueueRunLockPort = { - acquire: vi.fn(() => { + const locks = createTestLocks( + vi.fn(() => { held = true; return Promise.resolve({ kind: 'acquired', @@ -3300,12 +3422,12 @@ describe('queue runtime retries', () => { }), } as const); }), - isHeld: () => Promise.resolve(held), - }; + { isHeld: () => Promise.resolve(held) } + ); const { commands, emit, notify, project, runtime } = createRetryHarness( queueItem, { getResultImages: vi.fn().mockResolvedValue([{ imageName: 'rerun.png' } as QueueResultImage]) }, - { journal: createSharedDeliveryJournal(), locks } + { locks } ); runtime.start(); @@ -3329,10 +3451,10 @@ describe('queue runtime retries', () => { }); it('leaves a Canvas rerun to another tab when its project closed here while the results loaded', async () => { - const journal = createSharedDeliveryJournal(); + const server = createDeliveryServer(); let finishRead!: (images: QueueResultImage[]) => void; const closing = openCanvasTab( - journal, + server, vi.fn( () => new Promise((resolve) => { @@ -3340,7 +3462,7 @@ describe('queue runtime retries', () => { }) ) ); - const open = openCanvasTab(journal); + const open = openCanvasTab(server); const completed = completedEvent(99, buildQueueItemOrigin('run-gone', closing.project.id), 'canvas'); for (const tab of [closing, open]) { @@ -3361,117 +3483,274 @@ describe('queue runtime retries', () => { await open.runtime.dispose(); }); - it('gives back the claim of a rerun whose project closed while it was being claimed', async () => { - const journal = createSharedDeliveryJournal(); - const claim = vi.mocked(journal.claimRerunDelivery).getMockImplementation()!; - const closing = openCanvasTab(journal); - vi.mocked(journal.claimRerunDelivery).mockImplementationOnce((rerun, kind) => { - closing.closeProject(); - return claim(rerun, kind); + it('stages a rerun at its run in one tab while another tab leaves it, whichever handles it first', async () => { + const server = createDeliveryServer(); + const adopting = openAdoptingTab(server); + const other = openCanvasTab(server); + + for (const tab of [adopting, other]) { + tab.runtime.start(); + tab.emit('queue_items_retried', retriedEvent([{ item_id: 99, source_item_id: 41 }])); + } + await vi.waitFor(() => expect(stagedImageNames(adopting)).toContain('rerun-99.png')); + await vi.waitFor(() => expect(server.claims.get(99)?.isDelivered).toBe(true)); + other.emit( + 'queue_item_status_changed', + completedEvent(99, buildQueueItemOrigin(adopting.run.id, other.project.id), 'canvas') + ); + await vi.waitFor(() => + expect(server.claimItemDelivery).toHaveBeenCalledWith(99, 'claim', expect.stringMatching(/^tab:/)) + ); + await settleEvents(); + + expect(stagedItemIds(other)).toEqual([]); + + await adopting.runtime.dispose(); + await other.runtime.dispose(); + }); + + it('leaves a rerun to the run reopened around it in another browser, even when this one sees it finish first', async () => { + // Long enough that only the run confirming lets this browser stop asking. + const server = createDeliveryServer(60_000); + const adopting = openAdoptingTab(server); + vi.mocked(adopting.backend.getItem).mockResolvedValue({ + batchId: 'batch', + id: 99, + origin: buildQueueItemOrigin(adopting.run.id, 'project-1'), + status: 'in_progress', }); - const open = openCanvasTab(journal); - const completed = completedEvent(99, buildQueueItemOrigin('run-gone', closing.project.id), 'canvas'); + const other = openCanvasTab(server); + const completed = completedEvent(99, buildQueueItemOrigin(adopting.run.id, other.project.id), 'canvas'); - for (const tab of [closing, open]) { + for (const tab of [adopting, other]) { + tab.runtime.start(); + tab.emit('queue_items_retried', retriedEvent([{ item_id: 99, source_item_id: 41 }])); + } + // The run claims its rerun as soon as its tab holds it, long before the rerun finishes. + await vi.waitFor(() => expect(server.claims.get(99)?.claimant).toMatch(/^run:/)); + other.emit('queue_item_status_changed', completed); + await vi.waitFor(() => + expect(server.claimItemDelivery).toHaveBeenCalledWith(99, 'claim', expect.stringMatching(/^tab:/)) + ); + adopting.emit('queue_item_status_changed', completed); + + await vi.waitFor(() => expect(stagedImageNames(adopting)).toContain('rerun-99.png')); + expect(server.claims.get(99)?.isDelivered).toBe(true); + await vi.waitFor( + () => + expect( + vi.mocked(server.claimItemDelivery).mock.calls.filter(([, , claimant]) => claimant.startsWith('tab:')) + ).toHaveLength(2), + { timeout: 6_000 } + ); + await settleEvents(); + expect(stagedItemIds(other)).toEqual([]); + + await adopting.runtime.dispose(); + await other.runtime.dispose(); + }, 10_000); + + it('keeps the claim of a rerun its run is still waiting on', async () => { + vi.useFakeTimers({ toFake: ['setInterval'] }); + try { + const server = createDeliveryServer(60_000); + const adopting = openAdoptingTab(server); + vi.mocked(adopting.backend.getItem).mockResolvedValue({ + batchId: 'batch', + id: 99, + origin: buildQueueItemOrigin(adopting.run.id, 'project-1'), + status: 'pending', + }); + + adopting.runtime.start(); + adopting.emit('queue_items_retried', retriedEvent([{ item_id: 99, source_item_id: 41 }])); + await vi.waitFor(() => expect(server.claims.get(99)?.claimant).toMatch(/^run:/)); + await settleEvents(); + // The rerun waits in the queue for longer than an unrenewed claim would last. + server.claims.set(99, { ...server.claims.get(99)!, claimedAt: Date.now() - 50_000 }); + vi.advanceTimersByTime(40_000); + + await vi.waitFor(() => expect(Date.now() - server.claims.get(99)!.claimedAt).toBeLessThan(10_000)); + + await adopting.runtime.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('gives back the claim of a rerun whose project closed before its run staged it', async () => { + // Claims outlast the test, so only giving the claim back lets the other tab stage it. + const server = createDeliveryServer(60_000); + const adopting = openAdoptingTab(server); + vi.mocked(adopting.backend.getItem).mockResolvedValue({ + batchId: 'batch', + id: 99, + origin: buildQueueItemOrigin(adopting.run.id, 'project-1'), + status: 'in_progress', + }); + const other = openCanvasTab(server); + + for (const tab of [adopting, other]) { + tab.runtime.start(); + tab.emit('queue_items_retried', retriedEvent([{ item_id: 99, source_item_id: 41 }])); + } + await vi.waitFor(() => expect(server.claims.get(99)?.claimant).toMatch(/^run:/)); + adopting.closeProject(); + await vi.waitFor(() => expect(server.claims.has(99)).toBe(false)); + other.emit( + 'queue_item_status_changed', + completedEvent(99, buildQueueItemOrigin(adopting.run.id, other.project.id), 'canvas') + ); + + await vi.waitFor(() => expect(stagedItemIds(other)).toEqual([99])); + + await adopting.runtime.dispose(); + await other.runtime.dispose(); + }); + + it('does not stage a rerun whose claim lapsed while its results loaded and another tab staged meanwhile', async () => { + const server = createDeliveryServer(); + let finishRead: ((images: QueueResultImage[]) => void) | undefined; + const slow = openCanvasTab( + server, + vi.fn( + () => + new Promise((resolve) => { + finishRead = resolve; + }) + ) + ); + const other = openCanvasTab(server); + const completed = completedEvent(99, buildQueueItemOrigin('run-gone', slow.project.id), 'canvas'); + + for (const tab of [slow, other]) { tab.runtime.start(); emitReruns(tab, [99]); } - closing.emit('queue_item_status_changed', completed); - await vi.waitFor(() => expect(journal.releaseRerunDelivery).toHaveBeenCalled()); - open.emit('queue_item_status_changed', completed); + slow.emit('queue_item_status_changed', completed); + await vi.waitFor(() => expect(finishRead).toBeDefined()); + other.emit('queue_item_status_changed', completed); + await vi.waitFor(() => expect(stagedItemIds(other)).toEqual([99])); + finishRead?.([{ imageName: 'rerun-99.png' } as QueueResultImage]); + await settleEvents(); + await settleEvents(); - await vi.waitFor(() => expect(stagedItemIds(open)).toEqual([99])); - expect(stagedItemIds(closing)).toEqual([]); + expect(stagedItemIds(slow)).toEqual([]); - await closing.runtime.dispose(); - await open.runtime.dispose(); + await slow.runtime.dispose(); + await other.runtime.dispose(); }); - it('does not adopt a rerun another tab already staged before this one learned of the retry', async () => { - const journal = createSharedDeliveryJournal(); - const failedRun: QueueItem = { ...createPendingQueueItem(), backendItemIds: [41], status: 'failed' }; - const delayed = createRetryHarness(failedRun, {}, { journal }); - const staging = openCanvasTab(journal); - const retried = retriedEvent([{ item_id: 99, source_item_id: 41 }]); + it('leaves out of a reopened run a rerun another tab or browser staged first', async () => { + const server = createDeliveryServer(); + const staging = openCanvasTab(server); + // Reopened around rerun 99 in a tab that closed, or by a retry this tab learned of late, and restored here. + const restored = openAdoptingTab(server, { + ...createPendingQueueItem(), + backendItemIds: [99], + retriedBackendItemIds: [41], + status: 'running', + }); staging.runtime.start(); - staging.emit('queue_items_retried', retried); + emitReruns(staging, [99]); staging.emit( 'queue_item_status_changed', - completedEvent(99, buildQueueItemOrigin(failedRun.id, staging.project.id), 'canvas') + completedEvent(99, buildQueueItemOrigin(restored.run.id, staging.project.id), 'canvas') ); await vi.waitFor(() => expect(stagedItemIds(staging)).toEqual([99])); - delayed.runtime.start(); - delayed.emit('queue_items_retried', retried); - await settleEvents(); + restored.runtime.start(); - expect(delayed.commands.reopenForRetry).not.toHaveBeenCalled(); - expect(failedRun.status).toBe('failed'); + await vi.waitFor(() => expect(restored.commands.routeResults).toHaveBeenCalled()); + expect(stagedImageNames(restored)).toEqual([]); - await delayed.runtime.dispose(); await staging.runtime.dispose(); + await restored.runtime.dispose(); }); - it('gives back the reservation of a rerun its run refused, so another tab can stage it', async () => { - const journal = createSharedDeliveryJournal(); - // Still running, so the run refuses to reopen around the rerun of its other failed item. - const activeRun: QueueItem = { + it('stages a rerun in the run restored after its tab closed while the run held the claim', async () => { + const server = createDeliveryServer(60_000); + const run: QueueItem = { ...createPendingQueueItem(), backendItemIds: [99], - retriedBackendItemIds: [41, 42], + retriedBackendItemIds: [41], status: 'running', }; - const refusing = createRetryHarness(activeRun, {}, { journal }); - const other = openCanvasTab(journal); - const retried = retriedEvent([{ item_id: 100, source_item_id: 42 }]); + // The run's tab claimed the rerun and closed before confirming; the reload restores the same run. + server.claims.set(99, { + claimant: `run:${JSON.stringify(['project-1', run.id])}`, + claimedAt: Date.now(), + isDelivered: false, + }); + const restored = openAdoptingTab(server, run); - refusing.runtime.start(); - refusing.emit('queue_items_retried', retried); - await vi.waitFor(() => expect(refusing.commands.reopenForRetry).toHaveBeenCalled()); - await vi.waitFor(() => expect(journal.releaseRerunDelivery).toHaveBeenCalled()); - other.runtime.start(); - other.emit('queue_items_retried', retried); + restored.runtime.start(); + + await vi.waitFor(() => expect(stagedImageNames(restored)).toContain('rerun-99.png')); + await vi.waitFor(() => expect(server.claims.get(99)?.isDelivered).toBe(true)); + + await restored.runtime.dispose(); + }); + + it('gives back the claim of a rerun whose run ends before staging it, so another tab stages it', async () => { + // Claims outlast the test, so only giving the claim back lets the other tab stage it. + const server = createDeliveryServer(60_000); + const adopting = openAdoptingTab(server); + let finishRead: ((images: QueueResultImage[]) => void) | undefined; + // The run is removed while it reads. + vi.mocked(adopting.backend.getResultImages).mockReturnValue( + new Promise((resolve) => { + finishRead = resolve; + }) + ); + const other = openCanvasTab(server); + + for (const tab of [adopting, other]) { + tab.runtime.start(); + tab.emit('queue_items_retried', retriedEvent([{ item_id: 99, source_item_id: 41 }])); + } + await vi.waitFor(() => expect(server.claims.get(99)?.claimant).toMatch(/^run:/)); + adopting.project.queue.items = []; + adopting.notify(); other.emit( 'queue_item_status_changed', - completedEvent(100, buildQueueItemOrigin(activeRun.id, other.project.id), 'canvas') + completedEvent(99, buildQueueItemOrigin(adopting.run.id, other.project.id), 'canvas') ); - await vi.waitFor(() => expect(stagedItemIds(other)).toEqual([100])); + await vi.waitFor(() => expect(stagedItemIds(other)).toEqual([99])); - await refusing.runtime.dispose(); + finishRead?.([]); + await adopting.runtime.dispose(); await other.runtime.dispose(); }); - it('leaves a rerun another tab adopted to that tab, after its run is let go', async () => { - const journal = createSharedDeliveryJournal(); - const failedRun: QueueItem = { ...createPendingQueueItem(), backendItemIds: [41], status: 'failed' }; - const adopting = createRetryHarness(failedRun, {}, { journal }); - vi.mocked(adopting.commands.reopenForRetry).mockImplementation(({ retriedItems }) => { - failedRun.retriedBackendItemIds = [41]; - failedRun.backendItemIds = retriedItems.map(({ itemId }) => itemId); - failedRun.status = 'running'; - adopting.notify(); - }); - // This tab opened the project after the run was gone from it, so it remembers the rerun as unadopted; the - // adopting tab no longer holds the run's lock when the completion reaches this one. - const late = openCanvasTab(journal); + it('stages a rerun its run refused to reopen around in a tab that has its project open', async () => { + const server = createDeliveryServer(); + // Still running, so the run refuses to reopen around the rerun of its other failed item. + const activeRun: QueueItem = { + ...createPendingQueueItem(), + backendItemIds: [99], + retriedBackendItemIds: [41, 42], + status: 'running', + }; + const refusing = createRetryHarness(activeRun, { claimItemDelivery: server.claimItemDelivery }); + const other = openCanvasTab(server); + const retried = retriedEvent([{ item_id: 100, source_item_id: 42 }]); - adopting.runtime.start(); - late.runtime.start(); - for (const tab of [adopting, late]) { - tab.emit('queue_items_retried', retriedEvent([{ item_id: 99, source_item_id: 41 }])); + for (const tab of [refusing, other]) { + tab.runtime.start(); + tab.emit('queue_items_retried', retried); } - await vi.waitFor(() => expect(failedRun.backendItemIds).toEqual([99])); - late.emit( + await vi.waitFor(() => expect(refusing.commands.reopenForRetry).toHaveBeenCalled()); + other.emit( 'queue_item_status_changed', - completedEvent(99, buildQueueItemOrigin(failedRun.id, late.project.id), 'canvas') + completedEvent(100, buildQueueItemOrigin(activeRun.id, other.project.id), 'canvas') ); - await settleEvents(); - expect(late.commands.stageRetriedCanvasResults).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(stagedItemIds(other)).toEqual([100])); - await adopting.runtime.dispose(); - await late.runtime.dispose(); + await refusing.runtime.dispose(); + await other.runtime.dispose(); }); it("handles a retry once when both the tab's own answer and the socket deliver it, and ignores others' retries", async () => { diff --git a/invokeai/frontend/webv2/src/features/queue/runtime.ts b/invokeai/frontend/webv2/src/features/queue/runtime.ts index 67ae76df4d8..0729d5b8429 100644 --- a/invokeai/frontend/webv2/src/features/queue/runtime.ts +++ b/invokeai/frontend/webv2/src/features/queue/runtime.ts @@ -3,6 +3,7 @@ import type { QueueBackendPort, QueueEnqueueGenerateRequest, QueueEnqueueWorkflowRequest, + QueueItemDeliveryClaim, QueueResultImage, QueueResultImageOptions, QueueResultVideo, @@ -39,6 +40,7 @@ import { type ReconcileInput, type ReconcileOutcome, } from '@features/queue/runtime/coordinator'; +import { createUuid } from '@platform/browser/randomUuid'; import { mapWithConcurrency } from '@platform/core/concurrency'; import { captureAccountScope, isAccountScopeCurrent } from '@platform/state/accountLifecycle'; import { normalizeServerTimestamp } from '@platform/time/serverTimestamp'; @@ -54,25 +56,7 @@ export interface QueueRuntime { start(): void; } -/** A backend rerun of a local run; the run names it apart from an id the server reuses after its database is reset. */ -export interface QueueRerunDelivery { - backendItemId: number; - projectId: string; - queueItemId: string; -} - export interface QueueRunJournalPort { - /** - * Claim a backend rerun of a local run for this tab, across the account's tabs: an adoption reserves it for the run - * that reopened around it and is refused only by a delivery; a delivery, made just before staging, is refused by - * either. - */ - claimRerunDelivery( - rerun: QueueRerunDelivery, - kind: 'adoption' | 'delivery' - ): Promise<{ kind: 'claimed' | 'taken' | 'unavailable' }>; - /** Give back a claim whose delivery cannot happen. */ - releaseRerunDelivery(rerun: QueueRerunDelivery, kind: 'adoption' | 'delivery'): Promise; listForProject( projectId: string ): Promise< @@ -90,6 +74,8 @@ export type QueueRunLock = { kind: 'acquired'; release(): Promise } | { ki export interface QueueRunLockPort { acquire(projectId: string, queueItemId: string): Promise; + /** Settle once no tab holds the run, or the signal aborts. */ + waitForRunRelease(projectId: string, queueItemId: string, signal: AbortSignal): Promise; /** Whether any tab holds the run, asked without requesting it; null when the browser cannot tell. */ isHeld(projectId: string, queueItemId: string): Promise; } @@ -401,6 +387,11 @@ const QUEUE_RUNTIME_CONCURRENCY = 16; * retry can re-run up to the backend's default `max_queue_size`. */ const UNADOPTED_RERUN_LIMIT = 10_000; +/** How long a claimant turned away from a rerun waits at most before asking again whether its holder confirmed. */ +const RERUN_CLAIM_POLL_MS = 3_000; +/** How long the server keeps an unconfirmed delivery claim (its contract), and how often a run renews its claims. */ +const RERUN_CLAIM_TTL_MS = 120_000; +const RERUN_CLAIM_RENEW_MS = 40_000; interface RunAttempt { generation: number; @@ -481,6 +472,7 @@ export const createQueueRuntime = ({ const cancelRequestedRunKeys = new Set(); let reconcileRetryDelayMs = 100; let retryTimer: ReturnType | undefined; + let renewTimer: ReturnType | undefined; let hasInitialized = false; let isInitializing = false; let isReconciling = false; @@ -782,6 +774,7 @@ export const createQueueRuntime = ({ } trackedRuns.delete(key); durableJournalSignatures.delete(key); + releaseAdoptedReruns(projectId, queueItemId); startedRunKeys.delete(key); pendingReconcileRunKeys.delete(key); reconcileRetryRunKeys.delete(key); @@ -1111,6 +1104,7 @@ export const createQueueRuntime = ({ return; } + confirmAdoptedReruns(projectId, queueItem, backendItemIds); commands.routeResults({ images, projectId, queueItemId: queueItem.id, videos }); if (queueItem.snapshot.destination === 'gallery') { commands.refreshBackendData(); @@ -1141,14 +1135,16 @@ export const createQueueRuntime = ({ attempt: RunAttempt ): Promise => { try { - const images = await runResultRead(() => - backend.getResultImages( - backendItemId, - queueItem.id, - queueItem.snapshot.submittedAt, - getQueueItemResultImageOptions(queueItem) - ) - ); + const images = isAdoptedRerunDecidedHere(projectId, queueItem, backendItemId) + ? await runResultRead(() => + backend.getResultImages( + backendItemId, + queueItem.id, + queueItem.snapshot.submittedAt, + getQueueItemResultImageOptions(queueItem) + ) + ) + : []; if (!isAttemptCurrent(attempt)) { return; @@ -1162,7 +1158,10 @@ export const createQueueRuntime = ({ return; } - const visibleImages = await deliverVisibleImages(queueItem, images); + // A renewal may have found the claim lost while the results loaded; another claimant stages the rerun then. + const visibleImages = isAdoptedRerunDecidedHere(projectId, queueItem, backendItemId) + ? await deliverVisibleImages(queueItem, images) + : []; // Never throws — a board-attach hiccup must not block routePartialResults below. const videos = await deliverResultVideos(projectId, queueItem, [backendItemId]); @@ -1176,6 +1175,7 @@ export const createQueueRuntime = ({ queueItem.id, visibleImages.map((image) => image.imageName) ); + confirmAdoptedReruns(projectId, queueItem, [backendItemId]); commands.routePartialResults({ backendItemId, images: visibleImages, @@ -1258,7 +1258,13 @@ export const createQueueRuntime = ({ const coordinatorBackend: QueueBackendPort = { ...backend, - getResultImages: (...args) => runResultRead(() => backend.getResultImages(...args)), + getResultImages: async (itemId, localQueueItemId, ...rest) => { + const target = getRouteTarget(history, localQueueItemId); + if (target?.queueItem && !(await isAdoptedRerunStagedHere(target.project.id, target.queueItem, itemId))) { + return []; + } + return runResultRead(() => backend.getResultImages(itemId, localQueueItemId, ...rest)); + }, }; const coordinator = createQueueCoordinator( { @@ -1322,72 +1328,185 @@ export const createQueueRuntime = ({ } }; + /** Ends every wait on another tab's run, or on a rerun another claimant holds, once this runtime stops. */ + const rerunWaits = new AbortController(); + const waitForRerunRetry = (delayMs: number): Promise => + new Promise((resolve) => { + const timer = setTimeout(resolve, delayMs); + rerunWaits.signal.addEventListener( + 'abort', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true } + ); + }); + /** This tab, as the claimant of the reruns it stages outside any run. */ + const tabClaimant = `tab:${createUuid()}`; + /** A run, as the claimant of the reruns it was reopened around; the run restored by a reload claims as it did. */ + const getRunClaimant = (projectId: string, queueItemId: string): string => `run:${getRunKey(projectId, queueItemId)}`; + /** - * Claim a rerun in the account's journal, which every tab shares and which outlives this one, so a rerun one tab - * adopted or staged is never staged again by a tab that handles it later. Without the journal no tab can coordinate, - * so each delivers. + * Whether this claimant stages a retried Canvas item, as the server agrees it for every browser and tab of the + * account. While another claimant holds the item it asks again, until that one confirms or its claim expires; a + * request that fails is asked again for as long as a claim could last. A server without claims (a 404) lets every + * claimant stage it, as before claims existed. */ - const claimRerunDelivery = async (rerun: QueueRerunDelivery, kind: 'adoption' | 'delivery'): Promise => { - if (!journal) { - return true; + const claimRerunDelivery = async (itemId: number, claimant: string, canDeliver: () => boolean): Promise => { + for (let failures = 0; ;) { + let claim: QueueItemDeliveryClaim; + try { + claim = await backend.claimItemDelivery(itemId, 'claim', claimant); + } catch (error) { + failures += 1; + if ( + (error instanceof ApiError && error.status === 404) || + failures * RERUN_CLAIM_POLL_MS >= RERUN_CLAIM_TTL_MS + ) { + return canDeliver(); + } + claim = { kind: 'held', retryInMs: RERUN_CLAIM_POLL_MS }; + } + if (claim.kind !== 'held') { + return claim.kind === 'claimed'; + } + // Soon enough to see the holder confirm, and spread out so claimants turned away together do not ask together. + await waitForRerunRetry(Math.min(claim.retryInMs, RERUN_CLAIM_POLL_MS) * (1 + Math.random() / 2)); + if (!canDeliver()) { + return false; + } } + }; + /** Whether this claimant still holds an item it claimed, renewing the claim; a server that cannot answer keeps it. */ + const isRerunClaimHeld = async (itemId: number, claimant: string): Promise => { try { - return (await journal.claimRerunDelivery(rerun, kind)).kind !== 'taken'; + return (await backend.claimItemDelivery(itemId, 'claim', claimant)).kind === 'claimed'; } catch { return true; } }; - const releaseRerunDelivery = (rerun: QueueRerunDelivery, kind: 'adoption' | 'delivery'): void => { - void journal?.releaseRerunDelivery(rerun, kind).catch(() => undefined); + const settleRerunClaim = (itemId: number, action: 'confirm' | 'release', claimant: string): void => { + void backend.claimItemDelivery(itemId, action, claimant).catch(() => undefined); }; /** - * Reopen a run around its reruns once each is reserved for it. A rerun another tab already staged is left alone, - * since adopting it would deliver it twice; a rerun the run refuses gives its reservation back and stays remembered - * as unadopted. + * The Canvas reruns the runs of this tab were reopened around, each claimed by its run as soon as the tab holds the + * run and renewed while the run lasts, so a tab or browser that sees the rerun finish first leaves it to the run. */ - const adoptRetriedItems = async (run: { - projectId: string; - queueItemId: string; - retriedItems: { itemId: number; sourceItemId: number }[]; - }): Promise => { - const toRerun = (itemId: number): QueueRerunDelivery => ({ - backendItemId: itemId, - projectId: run.projectId, - queueItemId: run.queueItemId, - }); - const reservations = await Promise.all( - run.retriedItems.map(async (item) => ({ - isReserved: await claimRerunDelivery(toRerun(item.itemId), 'adoption'), - item, - })) - ); - const retriedItems = reservations.filter(({ isReserved }) => isReserved).map(({ item }) => item); - if (!isActive()) { - retriedItems.forEach(({ itemId }) => releaseRerunDelivery(toRerun(itemId), 'adoption')); - return; + interface AdoptedRerunClaim { + decision: Promise; + isClaimed: boolean; + isConfirmed: boolean; + } + const adoptedRerunClaims = new Map(); + const getAdoptedRerunKey = (projectId: string, queueItemId: string, backendItemId: number): string => + JSON.stringify([projectId, queueItemId, backendItemId]); + const isAdoptedRerun = (queueItem: QueueItem, backendItemId: number): boolean => + queueItem.snapshot.destination === 'canvas' && + Boolean(queueItem.retriedBackendItemIds?.length) && + queueItem.backendItemIds?.includes(backendItemId) === true; + const claimAdoptedRerun = (projectId: string, queueItemId: string, backendItemId: number): AdoptedRerunClaim => { + const key = getAdoptedRerunKey(projectId, queueItemId, backendItemId); + const existing = adoptedRerunClaims.get(key); + if (existing) { + return existing; + } + const claim: AdoptedRerunClaim = { + // A claim the run let go of meanwhile stops asking. + decision: claimRerunDelivery( + backendItemId, + getRunClaimant(projectId, queueItemId), + () => isActive() && adoptedRerunClaims.get(key) === claim + ).then((isClaimed) => { + claim.isClaimed = isClaimed; + return isClaimed; + }), + isClaimed: false, + isConfirmed: false, + }; + adoptedRerunClaims.set(key, claim); + return claim; + }; + /** Claim the Canvas reruns of a run this tab holds that it has yet to stage. */ + const claimAdoptedReruns = (projectId: string, queueItem: QueueItem): void => { + for (const backendItemId of queueItem.backendItemIds ?? []) { + if (isAdoptedRerun(queueItem, backendItemId) && !queueItem.completedBackendItemIds?.includes(backendItemId)) { + claimAdoptedRerun(projectId, queueItem.id, backendItemId); + } } - if (retriedItems.length === 0) { - return; + }; + /** Keep the claims of runs still going from lapsing; a claim lost meanwhile leaves its rerun out of the run. */ + const renewAdoptedRerunClaims = (): void => { + for (const [key, claim] of adoptedRerunClaims) { + if (!claim.isClaimed || claim.isConfirmed) { + continue; + } + const [projectId, queueItemId, backendItemId] = JSON.parse(key) as [string, string, number]; + void backend + .claimItemDelivery(backendItemId, 'claim', getRunClaimant(projectId, queueItemId)) + .then((renewal) => { + if (renewal.kind !== 'claimed' && adoptedRerunClaims.get(key) === claim && !claim.isConfirmed) { + claim.isClaimed = false; + } + }) + .catch(() => undefined); } - commands.reopenForRetry({ ...run, retriedItems }); - const reopened = getRouteTarget(history, run.queueItemId)?.queueItem; - let isAdopted = false; - - for (const { itemId } of retriedItems) { - if (reopened?.status === 'running' && reopened.backendItemIds?.includes(itemId)) { - isAdopted = true; - unadoptedReruns.delete(itemId); - } else { - releaseRerunDelivery(toRerun(itemId), 'adoption'); + }; + /** + * Whether a run stages a rerun it was reopened around. A rerun another tab or browser staged first, as when the run's + * tab had closed, is left out of the run's results, so a run restored after that never stages it again. + */ + const isAdoptedRerunStagedHere = async ( + projectId: string, + queueItem: QueueItem, + backendItemId: number + ): Promise => { + if (!isAdoptedRerun(queueItem, backendItemId)) { + return true; + } + const claim = claimAdoptedRerun(projectId, queueItem.id, backendItemId); + await claim.decision; + return claim.isClaimed; + }; + /** + * Whether a run stages a rerun now, without waiting on a claim still undecided; the run's end stages it once decided. + * Result routing is shared by every run of the tab, so one claim must not hold the others up. + */ + const isAdoptedRerunDecidedHere = (projectId: string, queueItem: QueueItem, backendItemId: number): boolean => + !isAdoptedRerun(queueItem, backendItemId) || + adoptedRerunClaims.get(getAdoptedRerunKey(projectId, queueItem.id, backendItemId))?.isClaimed === true; + /** + * Confirm the reruns a run is about to stage. Called just before their results are routed, since routing them can + * finish the run, and a finished run gives back the claims it did not confirm. + */ + const confirmAdoptedReruns = (projectId: string, queueItem: QueueItem, backendItemIds: number[]): void => { + for (const backendItemId of backendItemIds) { + const claim = adoptedRerunClaims.get(getAdoptedRerunKey(projectId, queueItem.id, backendItemId)); + if (claim?.isClaimed && !claim.isConfirmed) { + claim.isConfirmed = true; + settleRerunClaim(backendItemId, 'confirm', getRunClaimant(projectId, queueItem.id)); } } - if (isAdopted) { - const key = getRunKey(run.projectId, run.queueItemId); - // A settle still waiting on the journal belongs to the finished run; it must not remove the reopened record. - pendingJournalSettles.delete(key); - pendingReconcileRunKeys.add(key); - synchronize(); + }; + /** + * A run this tab lets go of, whether it ended or its project closed, gives back the claims it did not confirm, so + * another tab stages those reruns at once; a later hold of the run claims again. + */ + const releaseAdoptedReruns = (projectId: string, queueItemId: string): void => { + for (const [key, claim] of adoptedRerunClaims) { + const [keyProjectId, keyQueueItemId, backendItemId] = JSON.parse(key) as [string, string, number]; + if (keyProjectId !== projectId || keyQueueItemId !== queueItemId) { + continue; + } + adoptedRerunClaims.delete(key); + if (!claim.isConfirmed) { + void claim.decision.then((isClaimed) => { + if (isClaimed) { + settleRerunClaim(backendItemId, 'release', getRunClaimant(projectId, queueItemId)); + } + }); + } } }; @@ -1442,21 +1561,25 @@ export const createQueueRuntime = ({ retriedItemsByRun.set(key, run); } - for (const run of retriedItemsByRun.values()) { - // Remembered until adopted, so a completion arriving while the run's reservation is made is handled like any - // unadopted one; the reservation, or the delivery that beat it, keeps the rerun from being delivered twice. - run.retriedItems.forEach(({ itemId }) => rememberUnadoptedRerun(itemId)); - void adoptRetriedItems(run).catch((error: unknown) => { - if (isActive()) { - commands.recordError({ - area: 'queue-results', - message: toErrorMessage(error), - namespace: 'queue', - projectId: run.projectId, - }); + for (const [key, run] of retriedItemsByRun) { + commands.reopenForRetry(run); + const reopened = getRouteTarget(history, run.queueItemId)?.queueItem; + let isAdopted = false; + + for (const { itemId } of run.retriedItems) { + if (reopened?.status === 'running' && reopened.backendItemIds?.includes(itemId)) { + isAdopted = true; + } else { + rememberUnadoptedRerun(itemId); } - }); + } + if (isAdopted) { + // A settle still waiting on the journal belongs to the finished run; it must not remove the reopened record. + pendingJournalSettles.delete(key); + pendingReconcileRunKeys.add(key); + } } + synchronize(); }; /** @@ -1493,8 +1616,8 @@ export const createQueueRuntime = ({ /** * Whether another tab tracks the run behind a Canvas rerun no run adopted. That tab knows the run's result nodes, so - * it stages the rerun and this one leaves it; the lock is only looked at, never requested, so that tab is never - * turned away. Tabs that do not track the run are kept apart by the rerun's delivery claim instead. + * it stages the rerun and this one waits for it; the lock is only looked at here, so that tab is never turned away. + * Tabs that do not track the run, and other browsers, are kept apart by the server's delivery claim instead. */ const isRunTrackedElsewhere = async (projectId: string, queueItemId: string): Promise => { const key = getRunKey(projectId, queueItemId); @@ -1558,24 +1681,29 @@ export const createQueueRuntime = ({ return; } - const rerun: QueueRerunDelivery = { backendItemId: event.item_id, projectId, queueItemId }; const canStage = (): boolean => isActive() && history.getSnapshot().projects.some((project) => project.id === projectId); const stage = async (): Promise => { + // A tab that tracks the run knows its result nodes and stages the rerun itself; this one waits until that tab lets + // the run go, and then stages only a rerun it left. if (await isRunTrackedElsewhere(projectId, queueItemId)) { - return; + await locks?.waitForRunRelease(projectId, queueItemId, rerunWaits.signal); } - const images = await readUnadoptedRerunImages(event, queueItemId, run); - // Claimed only when staging can happen, and given back when the project closed during the claim, so a rerun this - // tab cannot stage is left to a tab that can. - if (images.length === 0 || !canStage() || !(await claimRerunDelivery(rerun, 'delivery'))) { + if (!canStage() || !(await claimRerunDelivery(event.item_id, tabClaimant, canStage))) { return; } - if (!canStage()) { - releaseRerunDelivery(rerun, 'delivery'); - return; + // Confirmed only once staged, so a tab that cannot stage it, or closes first, leaves the rerun to the next. + let isStaged = false; + try { + const images = await readUnadoptedRerunImages(event, queueItemId, run); + // A read slower than the claim lasts may have let another claimant stage it meanwhile. + if (images.length > 0 && canStage() && (await isRerunClaimHeld(event.item_id, tabClaimant)) && canStage()) { + commands.stageRetriedCanvasResults({ backendItemId: event.item_id, images, projectId, queueItemId }); + isStaged = true; + } + } finally { + settleRerunClaim(event.item_id, isStaged ? 'confirm' : 'release', tabClaimant); } - commands.stageRetriedCanvasResults({ backendItemId: event.item_id, images, projectId, queueItemId }); }; void stage().catch(recordError); }; @@ -1756,6 +1884,7 @@ export const createQueueRuntime = ({ activeKeys.add(key); if (!locks || ownedLocks.has(key) || trackedRuns.has(key)) { void recordActiveRun(project.id, item); + claimAdoptedReruns(project.id, item); } } else if (item.status === 'cancelled' && item.cancellationPending === true) { activeKeys.add(key); @@ -1783,6 +1912,7 @@ export const createQueueRuntime = ({ settleRun(tracked.projectId, tracked.queueItemId); } else { coordinator.detachRun(tracked.queueItemId); + releaseAdoptedReruns(tracked.projectId, tracked.queueItemId); trackedRuns.delete(key); startedRunKeys.delete(key); pendingReconcileRunKeys.delete(key); @@ -1802,6 +1932,7 @@ export const createQueueRuntime = ({ pendingReconcileRunKeys.delete(key); reconcileRetryRunKeys.delete(key); coordinator.detachRun(attempt.queueItemId); + releaseAdoptedReruns(attempt.projectId, attempt.queueItemId); if (!trackedRuns.has(key)) { releaseRunOwnershipAfterJournal(attempt.projectId, attempt.queueItemId); } @@ -2266,6 +2397,7 @@ export const createQueueRuntime = ({ isStarted = true; coordinator.connect(); ensureTemplatesLoaded(); + renewTimer = setInterval(renewAdoptedRerunClaims, RERUN_CLAIM_RENEW_MS); detachers.push( history.subscribe(synchronize), backend.on('queue_items_retried', handleItemsRetried), @@ -2294,6 +2426,7 @@ export const createQueueRuntime = ({ const dispose = async (): Promise => { isDisposed = true; + rerunWaits.abort(); if (retryTimer !== undefined) { clearTimeout(retryTimer); retryTimer = undefined; @@ -2325,6 +2458,15 @@ export const createQueueRuntime = ({ ) ); } + if (renewTimer !== undefined) { + clearInterval(renewTimer); + renewTimer = undefined; + } + // A run restored by the next hold claims again as the same claimant; meanwhile another tab may stage the rerun. + for (const key of adoptedRerunClaims.keys()) { + const [projectId, queueItemId] = JSON.parse(key) as [string, string]; + releaseAdoptedReruns(projectId, queueItemId); + } startedRunKeys.clear(); preparingRunKeys.clear(); pendingJournalSettles.clear(); diff --git a/invokeai/frontend/webv2/src/platform/browser/webLocks.browser.test.ts b/invokeai/frontend/webv2/src/platform/browser/webLocks.browser.test.ts index e968a4bf6e5..8105e411cd6 100644 --- a/invokeai/frontend/webv2/src/platform/browser/webLocks.browser.test.ts +++ b/invokeai/frontend/webv2/src/platform/browser/webLocks.browser.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { acquireExclusiveLock, isLockHeld } from './webLocks'; +import { acquireExclusiveLock, isLockHeld, waitForSharedLock } from './webLocks'; describe('Web Locks adapter', () => { it('holds one exclusive owner and releases it idempotently', async () => { @@ -40,6 +40,33 @@ describe('Web Locks adapter', () => { } }); + it('waits for a held lock until its holder lets it go, and stops waiting once aborted', async () => { + const name = `invokeai:web-lock-test:${crypto.randomUUID()}`; + const held = await acquireExclusiveLock(name); + const waiting = waitForSharedLock(name, new AbortController().signal); + const abort = new AbortController(); + const abandoned = waitForSharedLock(name, abort.signal); + let isGranted = false; + void waiting.then(() => { + isGranted = true; + }); + + abort.abort(); + await expect(abandoned).resolves.toEqual({ kind: 'unavailable' }); + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + expect(isGranted).toBe(false); + if (held.kind === 'acquired') { + await held.release(); + } + const next = await waiting; + expect(next.kind).toBe('acquired'); + if (next.kind === 'acquired') { + await next.release(); + } + }); + it('cannot tell without the Web Locks API', async () => { await expect(isLockHeld('invokeai:web-lock-test:none', {} as LockManager)).resolves.toBeNull(); }); diff --git a/invokeai/frontend/webv2/src/platform/browser/webLocks.ts b/invokeai/frontend/webv2/src/platform/browser/webLocks.ts index f46b08d2405..659ee184fb4 100644 --- a/invokeai/frontend/webv2/src/platform/browser/webLocks.ts +++ b/invokeai/frontend/webv2/src/platform/browser/webLocks.ts @@ -6,7 +6,9 @@ export type BrowserLockResult = const acquireLock = ( name: string, mode: 'exclusive' | 'shared', - lockManager?: LockManager + lockManager?: LockManager, + /** Wait for the lock until it is granted or this aborts, instead of taking it only when free. */ + waitSignal?: AbortSignal ): Promise => { let manager = lockManager; if (!manager) { @@ -30,7 +32,7 @@ const acquireLock = ( try { let request: Promise; request = manager - .request(name, { ifAvailable: true, mode }, async (lock) => { + .request(name, waitSignal ? { mode, signal: waitSignal } : { ifAvailable: true, mode }, async (lock) => { if (!lock) { didResolve = true; resolve({ kind: 'contended' }); @@ -71,6 +73,13 @@ export const acquireExclusiveLock = (name: string, lockManager?: LockManager): P export const acquireSharedLock = (name: string, lockManager?: LockManager): Promise => acquireLock(name, 'shared', lockManager); +/** Wait until the shared lock is granted; `unavailable` without the Web Locks API or once the signal aborts. */ +export const waitForSharedLock = ( + name: string, + signal: AbortSignal, + lockManager?: LockManager +): Promise => acquireLock(name, 'shared', lockManager, signal); + /** * Whether any context of this origin currently holds the lock, read without requesting it, so a holder about to claim * the name is never turned away. `null` when the Web Locks API or its query is unavailable. diff --git a/invokeai/frontend/webv2/src/workbench/projects/README.md b/invokeai/frontend/webv2/src/workbench/projects/README.md index 0b8b837a5d0..71a5ecd08c0 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/README.md +++ b/invokeai/frontend/webv2/src/workbench/projects/README.md @@ -18,7 +18,7 @@ Project links are consumed after a successful open, so reloading restores the sa ## Browser recovery -- Account-owned IndexedDB stores only unacknowledged project drafts, unload journal entries, active queue runs, receipt acknowledgements, rerun delivery claims, and bounded recall values. Clean server documents are not mirrored. The journal has a database of its own, deleted with the workbench database by account cleanup and by clearing workspace data. +- Account-owned IndexedDB stores only unacknowledged project drafts, unload journal entries, active queue runs, receipt acknowledgements, and bounded recall values. Clean server documents are not mirrored. The journal has a database of its own, deleted with the workbench database by account cleanup and by clearing workspace data. - Draft generations fence acknowledgements. Draft writer ownership and cross-tab notifications prevent one editor from overwriting another editor's unacknowledged work. - **Unload journal.** Staging a draft takes fenced read-then-write transactions, which an unloading page never finishes. So when the page is hidden or unloads, before the save starts, each open project whose document is not what the next load would restore (its durably staged draft, or without one the acknowledged server copy) is written as one blind, explicitly committed IndexedDB write. It lives in its own database because Chromium drops such a write at unload while a read-write transaction of the same database is still open, which a stage in flight is. An entry carries the account, project, lineage (an editor session, possibly another tab's that the session blob handed on), the editor session of the page that wrote it, writer, base revision and canvas schema floor, and a draft generation: above everything the writer staged or journaled before, or equal to a stage of the same document still in flight. A writer's generations never restart while its page lives, even when a project is closed and reopened. Each write also deletes the writer's lower generations for the lineage, so there is at most one entry per lineage and writer, however long saves cannot be acknowledged. - **Settled mark.** A lineage's writer claim records the generation through which its writer has settled its journal: everything it acknowledged, discarded, or holds in recovery. The acknowledgement and the draft deletion write it in the same transaction that settles or deletes the draft; a document back to what recovery holds (an undo, say) writes it on its next save, and claims the lineage for the writer if it has none. It is an optional claim field, so claims written earlier read as having settled nothing and the workbench database stays at version 4; a mark of an earlier writer is ignored. It is what tells "never staged" from "acknowledged and moved on" once an acknowledgement has deleted the draft, so an entry whose deletion failed, or that another tab reconciles at the moment of the acknowledgement, can never come back. @@ -27,7 +27,6 @@ Project links are consumed after a successful open, so reloading restores the sa - An entry the draft store cannot take waits. One for a damaged lineage waits for that lineage's cleanup, for at most 7 days, and is then discarded. One refused for lack of storage is applied by a later load only while its lineage is unclaimed; the load that refused it usually claims the lineage when it opens the project, and then the edit is lost. Both report degraded local recovery for that project until its next stage succeeds. An unavailable journal (its database will not open, or reconciliation fails) is logged, not reported: recovery then works exactly as it did without the journal. A journal operation that fails on its own (a deletion whose transaction could not be opened, say) fails alone; the next hidden event journals again. - A writer settles and removes its entries when the server acknowledges a generation at or above them, when the document returns to what recovery holds (on the next save; a hidden event meanwhile also deletes the entry, and the save still writes the mark), and when it deletes the project or discards its draft; a removal that fails leaves the entry settled, and one whose mark fails is retried with the next save. When an older generation is acknowledged, a newer entry is rewritten onto the acknowledged revision, as a newer staged draft is rebased. A journal write that aborts is written again on the next hidden event. Nothing is journaled for a closed session, after the account lifetime ended, for a project being deleted, or beyond 8 MiB of documents per event; larger projects rely on autosave, and the skip is logged. The editor route counts projects with entries as ones that would open, so a new project typed into and reloaded before its first save reopens. - Queue ownership uses Web Locks. Journal writes precede submission; backend receipts make retries safe after lost responses. Accepted IDs must be durable before their receipt can expire. Fresh runs that never reached browser storage use best-effort terminal acknowledgement instead. -- A retried backend item is delivered once per browser through claims in the workbench database's metadata store, which every tab of the account shares and which outlive the tab that wrote them. A tab adopting a rerun into its run first reserves it; a reservation is refused only when another tab already staged the rerun (the late adopter then leaves the run closed), and is given back when the run refuses to reopen. A Canvas rerun no run adopted is staged by the tab tracking its run while it lives (other tabs only check that run's Web Lock, without requesting it); otherwise a tab claims its delivery once the results are read and its project is open, gives the claim back if the project closed meanwhile, and skips a rerun another tab reserved or staged, even after that tab closed. Claims are keyed by the backend item and its local run, so an id the server reuses after its database is reset is a different claim; they are kept 7 days and capped at 10,000, oldest first. A tab that closes in the moment between reserving a rerun and journaling its reopened run leaves a reservation nothing delivers, so that Canvas rerun is not staged. Without browser storage, tabs that handle the rerun at the same moment can each deliver it; tabs in other browsers are never coordinated. - Completed, failed, and cancelled runs retain exact recall values in an LRU cache, capped at 500 entries / 32 MiB. Eviction removes recall convenience, not project data or active-run recovery. - Projects with journal entries remain reachable even if another tab saved an empty session. Automatic reopening is bounded; remaining entries can be opened, exported as queue-recovery JSON, or explicitly discarded. Discarding local recovery does not cancel backend work. - Browser-storage failures do not block backend project loads or saves. Recovery warnings distinguish degraded local durability from successful server persistence. diff --git a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts index 686eb3d7b5d..41f58adfd61 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts @@ -163,8 +163,6 @@ beforeEach(() => { vi.mocked(createAccountOwnedQueueRunJournal).mockResolvedValue({ availability: 'available', acknowledgeReceipt: vi.fn(), - claimRerunDelivery: vi.fn(), - releaseRerunDelivery: vi.fn(), listPendingReceipts: vi.fn(), close: vi.fn(), deleteForProject: vi.fn(), @@ -337,8 +335,6 @@ describe('durable project persistence', () => { vi.mocked(createAccountOwnedQueueRunJournal).mockResolvedValueOnce({ availability: 'available', acknowledgeReceipt: vi.fn(), - claimRerunDelivery: vi.fn(), - releaseRerunDelivery: vi.fn(), listPendingReceipts: vi.fn(), close: vi.fn(), deleteForProject: vi.fn(), diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts index 5c5881740ff..2533f6544e1 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.browser.test.ts @@ -8,7 +8,7 @@ import { import { createWorkbenchStore } from '@workbench/workbenchStore'; import { afterEach, describe, expect, it } from 'vitest'; -import { createIndexedDbQueueRunJournal, QUEUE_RERUN_DELIVERY_RETENTION_MS } from './queueRunJournal'; +import { createIndexedDbQueueRunJournal } from './queueRunJournal'; const databases: WorkbenchDatabase[] = []; const suffixes = new Set(); @@ -18,7 +18,7 @@ const createJournal = async (options?: Parameters = {}): WorkbenchQueueItem => @@ -128,67 +128,6 @@ describe('IndexedDB queue run journal', () => { }); }); - const rerun = (backendItemId: number, queueItemId = 'run-1') => ({ - backendItemId, - projectId: 'project-1', - queueItemId, - }); - - it('lets only the first claim deliver a rerun, from any connection, until the claim expires', async () => { - let now = 1_000; - const { journal, suffix } = await createJournal({ now: () => now }); - const otherTabDatabase = await openWorkbenchDatabase(suffix); - databases.push(otherTabDatabase); - const otherTab = createIndexedDbQueueRunJournal(otherTabDatabase, { now: () => now }); - - await expect( - Promise.all([ - journal.claimRerunDelivery(rerun(99), 'delivery'), - otherTab.claimRerunDelivery(rerun(99), 'delivery'), - ]).then((results) => results.map((result) => result.kind).sort()) - ).resolves.toEqual(['claimed', 'taken']); - await expect(otherTab.claimRerunDelivery(rerun(100), 'delivery')).resolves.toEqual({ kind: 'claimed' }); - // A server whose database was reset hands the same id to another run's rerun. - await expect(otherTab.claimRerunDelivery(rerun(99, 'run-2'), 'delivery')).resolves.toEqual({ kind: 'claimed' }); - - now += QUEUE_RERUN_DELIVERY_RETENTION_MS; - await expect(journal.claimRerunDelivery(rerun(99), 'delivery')).resolves.toEqual({ kind: 'claimed' }); - }); - - it('keeps rerun delivery claims within the entry budget by dropping the oldest, apart from active runs', async () => { - let now = 1_000; - const { journal } = await createJournal({ maxEntries: 2, now: () => now++ }); - await journal.record('project-1', createItem()); - - for (const backendItemId of [1, 2, 3]) { - await expect(journal.claimRerunDelivery(rerun(backendItemId), 'delivery')).resolves.toEqual({ kind: 'claimed' }); - } - - await expect(journal.claimRerunDelivery(rerun(3), 'delivery')).resolves.toEqual({ kind: 'taken' }); - await expect(journal.claimRerunDelivery(rerun(1), 'delivery')).resolves.toEqual({ kind: 'claimed' }); - await expect(journal.listAll()).resolves.toMatchObject({ entries: [{ queueItemId: 'queue-item-1' }] }); - }); - - it('lets an adoption reserve a rerun against staging, and a staged rerun refuse a later adoption', async () => { - const { journal } = await createJournal(); - - await expect(journal.claimRerunDelivery(rerun(99), 'adoption')).resolves.toEqual({ kind: 'claimed' }); - await expect(journal.claimRerunDelivery(rerun(99), 'adoption')).resolves.toEqual({ kind: 'claimed' }); - await expect(journal.claimRerunDelivery(rerun(99), 'delivery')).resolves.toEqual({ kind: 'taken' }); - await expect(journal.releaseRerunDelivery(rerun(99), 'adoption')).resolves.toEqual({ kind: 'removed' }); - await expect(journal.claimRerunDelivery(rerun(99), 'delivery')).resolves.toEqual({ kind: 'claimed' }); - await expect(journal.claimRerunDelivery(rerun(99), 'adoption')).resolves.toEqual({ kind: 'taken' }); - await expect(journal.releaseRerunDelivery(rerun(99), 'delivery')).resolves.toEqual({ kind: 'removed' }); - await expect(journal.claimRerunDelivery(rerun(99), 'delivery')).resolves.toEqual({ kind: 'claimed' }); - }); - - it('claims no rerun once closed', async () => { - const { journal } = await createJournal(); - journal.close(); - - await expect(journal.claimRerunDelivery(rerun(99), 'delivery')).resolves.toEqual({ kind: 'unavailable' }); - }); - it('lists distinct projects without decoding their run payloads', async () => { const { database, journal } = await createJournal(); await journal.record('project-1', createItem()); diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.ts b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.ts index 59324b14e4c..e95e7f594df 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.ts +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunJournal.ts @@ -1,4 +1,3 @@ -import type { QueueRerunDelivery } from '@features/queue'; import type { AccountScope } from '@platform/state/accountLifecycle'; import type { IDBPObjectStore } from 'idb'; @@ -18,8 +17,6 @@ import { normalizeRestoredQueueItem } from '@workbench/queue-integration/queueRu export const QUEUE_RUN_MAX_BYTES = 32 * 1024 * 1024; export const QUEUE_RUN_TOTAL_MAX_BYTES = 128 * 1024 * 1024; export const QUEUE_RUN_MAX_ENTRIES = 10_000; -/** How long a rerun delivery claim is kept: past any tab still handling the rerun's completion, even one long queued. */ -export const QUEUE_RERUN_DELIVERY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; export interface QueueRunJournalEntry { /** Untrusted serialized candidate. Normalize it against the current project before restoration. */ @@ -34,21 +31,11 @@ export type QueueRunJournalLoadResult = | { kind: 'unavailable' }; export type QueueRunJournalWriteResult = { kind: 'invalid' | 'quota' | 'stored' | 'too-large' | 'unavailable' }; export type QueueRunJournalSettleResult = { kind: 'removed' | 'unavailable' }; -export type QueueRerunDeliveryClaimResult = { kind: 'claimed' | 'taken' | 'unavailable' }; -/** - * An adoption reserves a rerun for the run that reopened around it, which delivers it later; a delivery records that a - * tab staged it. A delivery is refused by either, an adoption only by a delivery. - */ -export type QueueRerunDeliveryKind = 'adoption' | 'delivery'; export interface QueueRunJournal { readonly availability: 'available' | 'unavailable'; close(): void; acknowledgeReceipt(projectId: string, queueItemId: string): Promise; - /** Claim a backend rerun's delivery for this tab, shared by every tab of the account. */ - claimRerunDelivery(rerun: QueueRerunDelivery, kind: QueueRerunDeliveryKind): Promise; - /** Give back a claim whose delivery cannot happen, so another tab may still deliver the rerun. */ - releaseRerunDelivery(rerun: QueueRerunDelivery, kind: QueueRerunDeliveryKind): Promise; listPendingReceipts(): Promise< { entries: QueueReceiptAcknowledgement[]; kind: 'available' } | { kind: 'unavailable' } >; @@ -64,12 +51,6 @@ const getByteSize = (value: string): number => new TextEncoder().encode(value).b const QUEUE_RUN_SUBMISSION_SEQUENCE_KEY = 'queueRunSubmissionSequence'; const QUEUE_RUN_TOTAL_BYTES_KEY = 'queueRunTotalBytes'; const QUEUE_RUN_COUNT_KEY = 'queueRunCount'; -// Claims share the metadata store, under a prefix of their own, rather than needing a database upgrade. -const QUEUE_RERUN_DELIVERY_KEY_PREFIX = 'queueRerunDelivery:'; -const getRerunDeliveryKey = ( - { backendItemId, projectId, queueItemId }: QueueRerunDelivery, - kind: QueueRerunDeliveryKind -): string => `${QUEUE_RERUN_DELIVERY_KEY_PREFIX}${JSON.stringify([backendItemId, projectId, queueItemId, kind])}`; const QUEUE_RUN_STORES = [ WORKBENCH_DATABASE_METADATA_STORE, WORKBENCH_QUEUE_RUN_STORE, @@ -343,54 +324,6 @@ export const createIndexedDbQueueRunJournal = ( return { kind: 'unavailable' }; } }, - async claimRerunDelivery(rerun, kind) { - if (!isAvailable()) { - return { kind: 'unavailable' }; - } - try { - const transaction = observeTransaction(database.transaction(WORKBENCH_DATABASE_METADATA_STORE, 'readwrite')); - const metadataStore = transaction.store; - const key = getRerunDeliveryKey(rerun, kind); - const refusedBy = new Set( - kind === 'delivery' - ? [getRerunDeliveryKey(rerun, 'adoption'), getRerunDeliveryKey(rerun, 'delivery')] - : [getRerunDeliveryKey(rerun, 'delivery')] - ); - const claimedAt = now(); - // One read of every claim, so the store the queue journal writes to is held only briefly. - const claims = await metadataStore.getAll( - IDBKeyRange.bound(QUEUE_RERUN_DELIVERY_KEY_PREFIX, `${QUEUE_RERUN_DELIVERY_KEY_PREFIX}\uffff`) - ); - const isLive = (claim: { value: number }) => - Number.isFinite(claim.value) && claimedAt - claim.value < QUEUE_RERUN_DELIVERY_RETENTION_MS; - const isTaken = claims.some((claim) => refusedBy.has(claim.key) && isLive(claim)); - const kept = claims.filter((claim) => claim.key !== key && isLive(claim)); - const stale = claims.filter((claim) => !isLive(claim)); - if (!isTaken) { - kept.sort((left, right) => left.value - right.value); - stale.push(...kept.slice(0, Math.max(0, kept.length - Math.max(0, entryBudget - 1)))); - } - await Promise.all([ - ...stale.map((claim) => metadataStore.delete(claim.key)), - ...(isTaken ? [] : [metadataStore.put({ key, value: claimedAt })]), - ]); - await transaction.done; - return { kind: isTaken ? 'taken' : 'claimed' }; - } catch { - return { kind: 'unavailable' }; - } - }, - async releaseRerunDelivery(rerun, kind) { - if (!isAvailable()) { - return { kind: 'unavailable' }; - } - try { - await database.delete(WORKBENCH_DATABASE_METADATA_STORE, getRerunDeliveryKey(rerun, kind)); - return { kind: 'removed' }; - } catch { - return { kind: 'unavailable' }; - } - }, async deleteForProject(projectId) { if (!isAvailable()) { return { kind: 'unavailable' }; @@ -590,8 +523,6 @@ export const createAccountOwnedQueueRunJournal = async ( return { availability: 'unavailable', acknowledgeReceipt: () => Promise.resolve({ kind: 'unavailable' }), - claimRerunDelivery: () => Promise.resolve({ kind: 'unavailable' }), - releaseRerunDelivery: () => Promise.resolve({ kind: 'unavailable' }), listPendingReceipts: () => Promise.resolve({ kind: 'unavailable' }), close: () => undefined, deleteForProject: () => Promise.resolve({ kind: 'unavailable' }), diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunLocks.test.ts b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunLocks.test.ts index c724af535d0..525fac5d30d 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunLocks.test.ts +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunLocks.test.ts @@ -34,6 +34,27 @@ describe('queue run locks', () => { expect(isHeld).toHaveBeenCalledWith(acquire.mock.calls[0]?.[0]); }); + it('waits on a run under the name its owner acquires, taking it only for a moment', async () => { + const acquire = vi.fn().mockResolvedValue({ kind: 'contended' }); + const release = vi.fn().mockResolvedValue(undefined); + const waitShared = vi.fn().mockResolvedValue({ kind: 'acquired', release }); + const locks = createQueueRunLockPort( + ':account-1', + acquire, + vi.fn().mockResolvedValue({ kind: 'acquired', release: vi.fn() }), + vi.fn(), + waitShared + ); + const signal = new AbortController().signal; + + await locks.acquire('project-1', 'item-1'); + await locks.waitForRunRelease('project-1', 'item-1', signal); + + expect(waitShared).toHaveBeenCalledWith(acquire.mock.calls[0]?.[0], signal); + // Released at once, so the tab that wants the run next is not kept out. + expect(release).toHaveBeenCalledOnce(); + }); + it('holds a shared project lease for the lifetime of an acquired run', async () => { const calls: string[] = []; const locks = createQueueRunLockPort( diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunLocks.ts b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunLocks.ts index 197554b0a7f..0f8dd00679c 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunLocks.ts +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/queueRunLocks.ts @@ -6,6 +6,7 @@ import { type BrowserLockResult, type ExclusiveLockResult, isLockHeld, + waitForSharedLock, } from '@platform/browser/webLocks'; import { getProjectLifecycleLockName } from '@workbench/projects/projectLifecycleLocks'; @@ -18,9 +19,17 @@ export const createQueueRunLockPort = ( storageSuffix: string, acquireRun: (name: string) => Promise = acquireExclusiveLock, acquireProject: (name: string) => Promise = acquireSharedLock, - isHeld: (name: string) => Promise = isLockHeld + isHeld: (name: string) => Promise = isLockHeld, + waitShared: (name: string, signal: AbortSignal) => Promise = waitForSharedLock ): QueueRunLockPort => ({ isHeld: (projectId, queueItemId) => isHeld(getQueueRunLockName(storageSuffix, projectId, queueItemId)), + async waitForRunRelease(projectId, queueItemId, signal) { + // Shared, so tabs waiting on the same run never queue behind one another. + const lock = await waitShared(getQueueRunLockName(storageSuffix, projectId, queueItemId), signal); + if (lock.kind === 'acquired') { + await lock.release(); + } + }, async acquire(projectId, queueItemId) { const projectLock = await acquireProject(getProjectLifecycleLockName(storageSuffix, projectId)); if (projectLock.kind !== 'acquired') { From 0f6d680b4e71899f24cf0b961f8eb6400129d39a Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 17 Oct 2026 12:23:14 -0600 Subject: [PATCH 35/35] test(gallery): wait for the grid before scrolling to a load-more failure The scroll helper read the grid viewport right after render, so on a slow runner the listing was not mounted yet and the test failed on a null viewport. --- .../src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx | 1 + 1 file changed, 1 insertion(+) 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 d51c44a0fc7..0b72938b623 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 @@ -266,6 +266,7 @@ const dogs = (count: number, from = 0) => * measures the failure notice against the viewport and the loaded tiles. */ const scrollToLoadMoreFailure = async () => { + await waitFor(() => expect(gridViewport()).not.toBeNull()); const viewport = gridViewport()!; const scrollToEnd = () => { viewport.scrollTop = viewport.scrollHeight;