Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion invokeai/app/services/shared/workflow_graph_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,14 @@ def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph:
for field_name, field_value in inputs.items():
if not isinstance(field_name, str) or not _is_mapping(field_value):
continue
graph_node[field_name] = field_value.get("value")
# Saved workflows may include input metadata for unfilled optional fields without a "value".
# Omit those fields so invocation defaults are applied instead of forcing None.
if "value" in field_value:
# The frontend board picker may persist the "auto" sentinel; graph nodes model automatic
# board selection as the board field default, None.
if field_name == "board" and field_value["value"] == "auto":
continue
graph_node[field_name] = field_value["value"]

parsed_nodes[node_id] = graph_node

Expand Down
1 change: 1 addition & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2489,6 +2489,7 @@
"noRecentWorkflows": "No Recent Workflows",
"private": "Private",
"shared": "Shared",
"callable": "Callable",
"savedWorkflowUnsupportedDescription": "This workflow cannot currently be used by Call Saved Workflow.",
"savedWorkflowCompatibility": {
"missingWorkflowReturn": "The workflow must contain a Workflow Return node.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,27 @@ describe('callSavedWorkflowFormUtils', () => {
expect(getRenderableWorkflowForm(workflow, templates)).toBe(form);
});

it('adds exposed fields missing from a non-empty stored form', () => {
const form = getDefaultForm();
const element = buildNodeFieldElement('node-1', 'a', addInputA.type);
form.elements[element.id] = { ...element, parentId: form.rootElementId };
getRootChildren(form).push(element.id);
const workflow = buildWorkflowResponse({
exposedFields: [
{ nodeId: 'node-1', fieldName: 'a' },
{ nodeId: 'node-1', fieldName: 'b' },
],
form,
});

const dynamicFields = getSavedWorkflowDynamicFields(workflow, templates);

expect(dynamicFields.map((field) => field.fieldName)).toEqual([
'saved_workflow_input::node-1::a',
'saved_workflow_input::node-1::b',
]);
});

it('builds a fallback form from exposed fields when the stored form is empty', () => {
const workflow = buildWorkflowResponse({
exposedFields: [{ nodeId: 'node-1', fieldName: 'a' }],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,82 @@ const buildFormFromExposedFields = (
return form;
};

const getFormFieldKey = (nodeId: string, fieldName: string) => `${nodeId}.${fieldName}`;

const getFormFieldKeys = (form: BuilderForm): Set<string> => {
const fieldKeys = new Set<string>();

for (const element of Object.values(form.elements)) {
if (!element || element.type !== 'node-field') {
continue;
}
const { nodeId, fieldName } = element.data.fieldIdentifier;
fieldKeys.add(getFormFieldKey(nodeId, fieldName));
}

return fieldKeys;
};

const addMissingExposedFieldsToForm = (
form: BuilderForm,
workflow: WorkflowResponse | undefined,
templates: Templates
): BuilderForm => {
const exposedFields = (workflow?.workflow.exposedFields ?? []) as ExposedFieldLike[];
if (exposedFields.length === 0) {
return form;
}

const nodes = getWorkflowNodes(workflow);
const fieldKeys = getFormFieldKeys(form);
const nextForm = structuredClone(form);

for (const { nodeId, fieldName } of exposedFields) {
if (!nodeId || !fieldName || fieldKeys.has(getFormFieldKey(nodeId, fieldName))) {
continue;
}

const node = nodes.find((candidate) => candidate.data?.id === nodeId);
const nodeType = node?.data?.type;
if (!nodeType) {
continue;
}

const fieldTemplate = templates[nodeType]?.inputs[fieldName];
if (!fieldTemplate) {
continue;
}

const element = buildNodeFieldElement(nodeId, fieldName, fieldTemplate.type);
element.data.showDescription = false;
addElement({
form: nextForm,
element,
parentId: nextForm.rootElementId,
index: getRootChildCount(nextForm),
});
fieldKeys.add(getFormFieldKey(nodeId, fieldName));
}

return nextForm;
};

const getRootChildCount = (form: BuilderForm): number => {
const rootElement = form.elements[form.rootElementId];
if (!rootElement || !isContainerElement(rootElement)) {
return 0;
}
return rootElement.data.children.length;
};

export const getRenderableWorkflowForm = (
workflow: WorkflowResponse | undefined,
templates: Templates
): BuilderForm | null => {
const storedForm = getStoredForm(workflow);

if (storedForm && validateFormStructure(storedForm) && !getIsFormEmpty(storedForm)) {
return storedForm;
return addMissingExposedFieldsToForm(storedForm, workflow, templates);
}

const fallbackForm = buildFormFromExposedFields(workflow, templates);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,18 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
{t('workflows.shared')}
</Badge>
)}
{listItemState.showCallableBadge && (
<Badge
color="invokeBlue.400"
borderColor="invokeBlue.700"
borderWidth={1}
bg="transparent"
flexShrink={0}
variant="subtle"
>
{t('workflows.callable')}
</Badge>
)}
{listItemState.showUnsupportedBadge && (
<Tooltip
label={
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { get } from 'es-toolkit/compat';
import { addElement } from 'features/nodes/components/sidePanel/builder/form-manipulation';
import { CONNECTOR_INPUT_HANDLE, CONNECTOR_OUTPUT_HANDLE } from 'features/nodes/store/util/connectorTopology';
import { img_resize, main_model_loader, workflow_return } from 'features/nodes/store/util/testUtils';
import {
add,
call_saved_workflow,
img_resize,
main_model_loader,
workflow_return,
} from 'features/nodes/store/util/testUtils';
import type { InvocationTemplate } from 'features/nodes/types/invocation';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { buildNodeFieldElement, getDefaultForm, isNodeFieldElement } from 'features/nodes/types/workflow';
import { buildInvocationNode } from 'features/nodes/util/node/buildInvocationNode';
import { buildFieldInputInstance } from 'features/nodes/util/schema/buildFieldInputInstance';
import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow';
import { describe, expect, it, vi } from 'vitest';

Expand Down Expand Up @@ -484,4 +491,97 @@ describe('validateWorkflow', () => {
}
expect(updatedElement.data.fieldIdentifier.fieldName).toBe('images');
});

it('should refresh call_saved_workflow dynamic inputs while loading a stale serialized workflow', async () => {
const workflow = getWorkflow();
const callNode = buildInvocationNode({ x: 0, y: 0 }, call_saved_workflow);
const workflowIdInput = callNode.data.inputs.workflow_id;
if (!workflowIdInput) {
throw new Error('Expected workflow_id input');
}
workflowIdInput.value = 'saved-workflow-1';
const addInputA = add.inputs.a;
if (!addInputA) {
throw new Error('Expected add.a input template');
}
const oldFieldName = 'saved_workflow_input::child-add::a';
const oldFieldTemplate = structuredClone(addInputA);
oldFieldTemplate.name = oldFieldName;
oldFieldTemplate.title = 'A';
oldFieldTemplate.input = 'any';
oldFieldTemplate.ui_hidden = false;
callNode.data.dynamicInputTemplates[oldFieldName] = oldFieldTemplate;
callNode.data.inputs[oldFieldName] = buildFieldInputInstance(oldFieldName, oldFieldTemplate);
callNode.data.inputs[oldFieldName]!.value = 99;
workflow.nodes = [callNode];

const validationResult = await validateWorkflow({
workflow,
templates: { add, call_saved_workflow },
checkImageAccess: resolveTrue,
checkBoardAccess: resolveTrue,
checkModelAccess: resolveTrue,
getWorkflow: (workflowId) => {
expect(workflowId).toBe('saved-workflow-1');
return Promise.resolve({
workflow_id: 'saved-workflow-1',
name: 'Saved workflow',
created_at: '2026-04-08T00:00:00Z',
updated_at: '2026-04-08T00:00:00Z',
opened_at: null,
user_id: 'user-1',
is_public: false,
thumbnail_url: null,
workflow: {
id: 'saved-workflow-1',
name: 'Saved workflow',
author: '',
description: '',
version: '',
contact: '',
tags: '',
notes: '',
exposedFields: [
{ nodeId: 'child-add', fieldName: 'a' },
{ nodeId: 'child-add', fieldName: 'b' },
],
meta: { version: '4.0.0', category: 'user' },
form: getDefaultForm(),
nodes: [
{
id: 'child-add',
type: 'invocation',
data: {
id: 'child-add',
type: 'add',
version: '1.0.1',
label: '',
notes: '',
dynamicInputTemplates: {},
isOpen: true,
isIntermediate: true,
useCache: true,
nodePack: 'invokeai',
inputs: {
a: { name: 'a', label: 'A', description: '', value: 1 },
b: { name: 'b', label: 'B', description: '', value: 2 },
},
},
position: { x: 0, y: 0 },
},
],
edges: [],
},
});
},
});

const refreshedCallNode = validationResult.workflow.nodes[0];
if (!refreshedCallNode || refreshedCallNode.type !== 'invocation') {
throw new Error('Expected invocation node');
}
expect(refreshedCallNode.data.inputs['saved_workflow_input::child-add::a']?.value).toBe(99);
expect(refreshedCallNode.data.inputs['saved_workflow_input::child-add::b']?.value).toBe(2);
expect(refreshedCallNode.data.dynamicInputTemplates['saved_workflow_input::child-add::b']).toBeDefined();
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { parseify } from 'common/util/serialize';
import { getSavedWorkflowDynamicFields } from 'features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils';
import { addElement, getIsFormEmpty } from 'features/nodes/components/sidePanel/builder/form-manipulation';
import { CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX } from 'features/nodes/store/nodesSlice';
import type { Templates } from 'features/nodes/store/types';
import { validateConnection } from 'features/nodes/store/util/validateConnection';
import {
Expand All @@ -23,6 +25,7 @@ import {
getUpdatedFieldName,
updateNode,
} from 'features/nodes/util/node/nodeUpdate';
import { buildFieldInputInstance } from 'features/nodes/util/schema/buildFieldInputInstance';
import { t } from 'i18next';
import type { JsonObject } from 'type-fest';

Expand All @@ -40,13 +43,88 @@ type ValidateWorkflowArgs = {
checkImageAccess: (name: string) => Promise<boolean>;
checkBoardAccess: (id: string) => Promise<boolean>;
checkModelAccess: (key: string) => Promise<boolean>;
getWorkflow?: (workflowId: string) => Promise<NonNullable<Parameters<typeof getSavedWorkflowDynamicFields>[0]>>;
};

type ValidateWorkflowResult = {
workflow: WorkflowV3;
warnings: WorkflowWarning[];
};

const refreshCallSavedWorkflowDynamicInputs = async ({
workflow,
templates,
getWorkflow,
warnings,
}: {
workflow: WorkflowV3;
templates: Templates;
getWorkflow: ValidateWorkflowArgs['getWorkflow'];
warnings: WorkflowWarning[];
}) => {
if (!getWorkflow) {
return;
}

for (const node of workflow.nodes) {
if (!isWorkflowInvocationNode(node) || node.data.type !== 'call_saved_workflow') {
continue;
}

const workflowId = node.data.inputs.workflow_id?.value;
if (typeof workflowId !== 'string' || !workflowId) {
continue;
}

try {
const savedWorkflow = await getWorkflow(workflowId);
const fields = getSavedWorkflowDynamicFields(savedWorkflow, templates);
const nextFieldNames = new Set(fields.map((field) => field.fieldName));

for (const fieldName of Object.keys(node.data.inputs)) {
if (fieldName.startsWith(CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX) && !nextFieldNames.has(fieldName)) {
delete node.data.inputs[fieldName];
delete node.data.dynamicInputTemplates[fieldName];
}
}

for (const { fieldName, fieldTemplate, label, description, initialValue } of fields) {
const existingTemplate = node.data.dynamicInputTemplates[fieldName];
node.data.dynamicInputTemplates[fieldName] = fieldTemplate;
const existing = node.data.inputs[fieldName];
if (existing) {
if (
existingTemplate?.type.name !== fieldTemplate.type.name ||
existingTemplate?.type.cardinality !== fieldTemplate.type.cardinality ||
existingTemplate?.type.batch !== fieldTemplate.type.batch
) {
const instance = buildFieldInputInstance(fieldName, fieldTemplate);
instance.label = label;
instance.description = description;
instance.value = initialValue;
node.data.inputs[fieldName] = instance;
continue;
}
existing.label = label;
existing.description = description;
continue;
}

const instance = buildFieldInputInstance(fieldName, fieldTemplate);
instance.label = label;
instance.description = description;
instance.value = initialValue;
node.data.inputs[fieldName] = instance;
}
} catch {
warnings.push({
message: t('toast.problemRetrievingWorkflow'),
data: { workflowId },
});
}
}
};

/**
* Parses and validates a workflow:
* - Parses the workflow schema, and migrates it to the latest version if necessary.
Expand All @@ -57,7 +135,7 @@ type ValidateWorkflowResult = {
* @throws {z.ZodError} If there is a validation error.
*/
export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise<ValidateWorkflowResult> => {
const { workflow, templates, checkImageAccess, checkBoardAccess, checkModelAccess } = args;
const { workflow, templates, checkImageAccess, checkBoardAccess, checkModelAccess, getWorkflow } = args;
// Parse the raw workflow data & migrate it to the latest version
const _workflow = parseAndMigrateWorkflow(workflow);

Expand All @@ -66,6 +144,8 @@ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise<Vali
const warnings: WorkflowWarning[] = [];
const validEdges: WorkflowV3['edges'] = [];

await refreshCallSavedWorkflowDynamicInputs({ workflow: _workflow, templates, getWorkflow, warnings });

for (const edge of edges) {
// Validate each edge. If the edge is invalid, we must remove it to prevent runtime errors with reactflow.
const sourceNode = nodes.find(({ id }) => id === edge.source);
Expand Down
Loading
Loading