Compare commits

..

5 Commits

Author SHA1 Message Date
Dante
cbb9af1323 Merge branch 'main' into jaewon/hotfix-shared-workflow-linear-data-validation 2026-05-25 11:13:56 +09:00
dante01yoon
9dd5bbb025 fix(sharing): match regular load policy on shared workflow validation
Switch to raw workflow_json fallback when Zod validation fails,
matching the policy at scripts/app.ts:1191-1198. Share service no
longer validates directly — loadGraphData applies the same fallback
under Comfy.Validation.Workflows, removing the cross-path policy
inconsistency.

Drops the tolerantArray combinator and per-field linearData.inputs
relaxation added earlier in this PR. Zod schemas stay strict as the
canonical spec; tolerance lives at the consumer boundary.

Fixes the reported share=21e32125c692 load failure and any future
extra.* shape drift, not just linearData.inputs.
2026-05-14 11:19:35 +09:00
dante01yoon
db969dd50e refactor(validation): move linearData.inputs tolerance to tolerantArray combinator
Address review feedback: schema should represent the *specified* shape,
not "what will work and what won't". The previous in-schema transform
mixed canonical spec with tolerance, making the schema misleading to read.

- Restore `zLinearInput` to its strict union (2-tuple OR 3-tuple with
  `{ height? }`) — this is the canonical write contract.
- Introduce `tolerantArray(itemSchema, label)` combinator that drops
  invalid entries with a console warning, leaving valid ones intact.
  The item schema stays strict; tolerance lives at the array container.
- Apply `tolerantArray` only to `extra.linearData.inputs` for now.
  Other `extra.*` arrays remain untouched until proven necessary.
- Document that execution-critical arrays (`nodes`, `links`,
  `widget_values`) must keep `z.array` so malformed entries fail loudly.

Behavior change vs the previous commit: legacy `[id, name, <number>]`
shapes are no longer coerced to `[id, name, { height: <number> }]` —
they're dropped. Without seeing the actual production data, coercing
guesses are riskier than dropping; a dedicated migration can be added
later if a recognizable legacy shape is confirmed.
2026-05-13 13:14:32 +09:00
dante01yoon
4c20d44e6b docs(validation): convert zLinearInput comment to JSDoc 2026-05-13 12:46:46 +09:00
dante01yoon
a3e8f8625d fix(validation): relax linearData.inputs schema to load legacy shares
Strict `z.union([3-tuple, 2-tuple])` rejected the entire workflow when
any single `extra.linearData.inputs` entry didn't match, producing
"Failed to load shared workflow: invalid workflow data" for published
shares whose entries were stored with legacy or unknown 3rd-element
shapes (raw height number, forward-compat fields, etc).

The cloud accepts and serves `workflow_json` as an opaque
`Record<string, unknown>` (see `packages/ingest-types/src/zod.gen.ts`),
so non-conforming shapes do get persisted and surface only on the
frontend's read path. Until the SOT gap is closed, validation here
must be tolerant of historical variants.

Replace the union with a permissive `tuple([nodeId, string]).rest(unknown)`
that normalizes the trailing element:
- `[id, name]` → unchanged
- `[id, name, { height? }]` → unchanged
- `[id, name, <number>]` → coerced to `[id, name, { height: <number> }]`
- `[id, name, <other>]` → trailing dropped, kept as 2-tuple
- `[id, name, _, _, ...]` → extras dropped

Only entries missing the `[nodeId, string]` prefix still reject.
2026-05-13 12:29:28 +09:00
14 changed files with 50 additions and 389 deletions

View File

@@ -51,20 +51,6 @@ export class FeatureFlagHelper {
})
}
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
await this.page.evaluate((flagMap: Record<string, unknown>) => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
...flagMap
}
}, flags)
}
async setServerFlag(name: string, value: unknown): Promise<void> {
await this.setServerFlags({ [name]: value })
}
/**
* Mock server feature flags via route interception on /api/features.
*/

View File

@@ -309,50 +309,6 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
)
})
test.describe('Empty graph defaults', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.setServerFlag(
'node_library_essentials_enabled',
true
)
})
test('Defaults to Essentials when graph is empty', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await searchBoxV2.open()
const essentialsBtn = searchBoxV2.rootCategoryButton(
RootCategory.Essentials
)
await expect(essentialsBtn).toBeVisible()
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
})
test('Defaults to Most Relevant when graph has nodes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(0)
await searchBoxV2.open()
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
'aria-current',
'true'
)
await expect(
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
).toHaveAttribute('aria-pressed', 'false')
})
})
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage

View File

@@ -5,12 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
@@ -74,8 +71,7 @@ describe('NodeSearchBoxPopover', () => {
const NodeSearchContentStub = defineComponent({
name: 'NodeSearchContent',
props: {
filters: { type: Array, default: () => [] },
defaultRootFilter: { type: String, default: null }
filters: { type: Array, default: () => [] }
},
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
setup(_, { emit }) {
@@ -83,8 +79,7 @@ describe('NodeSearchBoxPopover', () => {
emit('addNode', nodeDef, dragEvent)
return {}
},
template:
'<div data-testid="search-content-v2" :data-default-root-filter="defaultRootFilter"></div>'
template: '<div data-testid="search-content-v2"></div>'
})
const pinia = createTestingPinia({
@@ -281,75 +276,4 @@ describe('NodeSearchBoxPopover', () => {
)
})
})
describe('defaultRootFilter on dialog open', () => {
function setGraphNodes(nodes: unknown[]) {
const canvasStore = useCanvasStore()
canvasStore.canvas = {
graph: { nodes },
allow_searchbox: false,
setDirty: vi.fn(),
linkConnector: {
events: new EventTarget(),
reset: vi.fn(),
disconnectLinks: vi.fn()
}
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
}
async function openSearch() {
useSearchBoxStore().visible = true
await nextTick()
}
it('defaults to Essentials when the graph is empty', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to Essentials when the canvas is not yet available', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to null when the graph has nodes', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
it('re-evaluates each time the dialog opens', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
useSearchBoxStore().visible = false
await nextTick()
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
})
})

View File

@@ -27,7 +27,6 @@
<div v-if="useSearchBoxV2" role="search" class="relative">
<NodeSearchContent
:filters="nodeFilters"
:default-root-filter="defaultRootFilter"
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@@ -77,8 +76,6 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import NodeSearchContent from './v2/NodeSearchContent.vue'
import NodeSearchBox from './NodeSearchBox.vue'
@@ -90,7 +87,6 @@ let disconnectOnReset = false
const settingStore = useSettingStore()
const searchBoxStore = useSearchBoxStore()
const litegraphService = useLitegraphService()
const canvasStore = useCanvasStore()
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
@@ -106,13 +102,6 @@ const enableNodePreview = computed(
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
)
const defaultRootFilter = ref<RootCategoryId | null>(null)
watch(visible, (isVisible) => {
if (!isVisible) return
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
? RootCategory.Essentials
: null
})
function getNewNodeLocation(): Point {
return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY]
@@ -137,6 +126,7 @@ function clearFilters() {
function closeDialog() {
visible.value = false
}
const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')

View File

@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import {
createMockNodeDef,
setViewport,
@@ -231,48 +230,6 @@ describe('NodeSearchContent', () => {
})
})
it('should apply defaultRootFilter when provided and category is available', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Essential Node')
})
})
it('should ignore defaultRootFilter of Essentials when no essentials exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'FrequentNode',
display_name: 'Frequent Node'
})
])
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
useNodeDefStore().nodeDefsByName['FrequentNode']
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Frequent Node')
})
})
it('should show only API nodes when Partner Nodes filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({

View File

@@ -141,9 +141,8 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
[RootCategory.Custom]: isCustomNode
}
const { filters, defaultRootFilter = null } = defineProps<{
const { filters } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
defaultRootFilter?: RootCategoryId | null
}>()
const emit = defineEmits<{
@@ -194,12 +193,8 @@ function onSearchFocus() {
if (isMobile.value) isSidebarOpen.value = false
}
const rootFilter = ref<RootCategoryId | null>(
defaultRootFilter === RootCategory.Essentials &&
!nodeAvailability.value.essential
? null
: defaultRootFilter
)
// Root filter from filter bar category buttons (radio toggle)
const rootFilter = ref<RootCategoryId | null>(null)
const rootFilterLabel = computed(() => {
switch (rootFilter.value) {

View File

@@ -238,7 +238,6 @@ import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContex
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { useFlatOutputAssetsGrouped } from '@/platform/assets/composables/media/useFlatOutputAssetsGrouped'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
@@ -312,7 +311,7 @@ const formattedExecutionTime = computed(() => {
const toast = useToast()
const inputAssets = useAssetsApi('input')
const outputAssets = useFlatOutputAssetsGrouped()
const outputAssets = useAssetsApi('output')
// Asset selection
const {

View File

@@ -1,105 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { Ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useFlatOutputAssetsGrouped } from './useFlatOutputAssetsGrouped'
const mediaRef: Ref<AssetItem[]> = ref([])
vi.mock('./useFlatOutputAssets', () => ({
useFlatOutputAssets: () => ({
media: mediaRef,
loading: ref(false),
error: ref(null),
hasMore: ref(false),
isLoadingMore: ref(false),
fetchMediaList: vi.fn(),
refresh: vi.fn(),
loadMore: vi.fn()
})
}))
function asset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-id',
name: 'output.png',
tags: ['output'],
...overrides
}
}
describe('useFlatOutputAssetsGrouped', () => {
it('collapses rows with the same job_id into a single representative', () => {
mediaRef.value = [
asset({ id: 'a', name: 'out1.png', job_id: 'job-1' }),
asset({ id: 'b', name: 'out2.png', job_id: 'job-1' }),
asset({ id: 'c', name: 'out3.png', job_id: 'job-1' }),
asset({ id: 'd', name: 'solo.png', job_id: 'job-2' })
]
const { media } = useFlatOutputAssetsGrouped()
expect(media.value.map((a) => a.id)).toEqual(['a', 'd'])
})
it('exposes the group size as user_metadata.outputCount', () => {
mediaRef.value = [
asset({ id: 'a', job_id: 'job-1' }),
asset({ id: 'b', job_id: 'job-1' }),
asset({ id: 'c', job_id: 'job-1' }),
asset({ id: 'd', job_id: 'job-2' })
]
const { media } = useFlatOutputAssetsGrouped()
expect(media.value[0].user_metadata?.outputCount).toBe(3)
expect(media.value[0].user_metadata?.jobId).toBe('job-1')
expect(media.value[1].user_metadata?.outputCount).toBe(1)
})
it('falls back to prompt_id when job_id is absent (legacy)', () => {
mediaRef.value = [
asset({ id: 'a', prompt_id: 'job-legacy' }),
asset({ id: 'b', prompt_id: 'job-legacy' })
]
const { media } = useFlatOutputAssetsGrouped()
expect(media.value).toHaveLength(1)
expect(media.value[0].user_metadata?.jobId).toBe('job-legacy')
expect(media.value[0].user_metadata?.outputCount).toBe(2)
})
it('passes through rows that have neither job_id nor prompt_id', () => {
mediaRef.value = [asset({ id: 'orphan-a' }), asset({ id: 'orphan-b' })]
const { media } = useFlatOutputAssetsGrouped()
expect(media.value.map((a) => a.id)).toEqual(['orphan-a', 'orphan-b'])
})
it('preserves the order of the first occurrence per job_id', () => {
mediaRef.value = [
asset({ id: 'a', job_id: 'job-A' }),
asset({ id: 'b', job_id: 'job-B' }),
asset({ id: 'c', job_id: 'job-A' }),
asset({ id: 'd', job_id: 'job-C' })
]
const { media } = useFlatOutputAssetsGrouped()
expect(media.value.map((a) => a.id)).toEqual(['a', 'b', 'd'])
})
it('does not mutate the underlying assets', () => {
const original = asset({ id: 'a', job_id: 'job-1' })
mediaRef.value = [original, asset({ id: 'b', job_id: 'job-1' })]
const { media } = useFlatOutputAssetsGrouped()
void media.value
expect(original.user_metadata).toBeUndefined()
})
})

View File

@@ -1,58 +0,0 @@
import { computed } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { IAssetsProvider } from './IAssetsProvider'
import { useFlatOutputAssets } from './useFlatOutputAssets'
/**
* Cloud `/api/assets?include_tags=output` returns one row per individual output
* file. The asset sidebar's stack UX expects one card per job with an
* `outputCount` badge, so collapse rows that share a `job_id` into a single
* representative (the first occurrence — assets are returned newest-first).
*
* The siblings remain reachable through the existing stack-expand path via
* `resolveOutputAssetItems(metadata)`.
*/
export function useFlatOutputAssetsGrouped(): IAssetsProvider {
const inner = useFlatOutputAssets()
const media = computed(() => groupByJobId(inner.media.value))
return {
...inner,
media
}
}
function groupByJobId(assets: AssetItem[]): AssetItem[] {
const countsByJobId = new Map<string, number>()
for (const asset of assets) {
const jobId = asset.job_id ?? asset.prompt_id
if (!jobId) continue
countsByJobId.set(jobId, (countsByJobId.get(jobId) ?? 0) + 1)
}
const seenJobIds = new Set<string>()
const grouped: AssetItem[] = []
for (const asset of assets) {
const jobId = asset.job_id ?? asset.prompt_id ?? null
if (!jobId) {
grouped.push(asset)
continue
}
if (seenJobIds.has(jobId)) continue
seenJobIds.add(jobId)
const outputCount = countsByJobId.get(jobId) ?? 1
grouped.push({
...asset,
user_metadata: {
...asset.user_metadata,
jobId,
outputCount
}
})
}
return grouped
}

View File

@@ -56,7 +56,7 @@ export function useOutputStacks({ assets }: UseOutputStacksOptions) {
function getStackJobId(asset: AssetItem): string | null {
const metadata = getOutputAssetMetadata(asset.user_metadata)
return metadata?.jobId ?? asset.job_id ?? null
return metadata?.jobId ?? null
}
function isStackExpanded(asset: AssetItem): boolean {

View File

@@ -2,14 +2,13 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
import type { ResultItemImpl } from '@/stores/queueStore'
/**
* Metadata for output assets. Originates from the queue/history mapping but
* also surfaces on assets sourced directly from `/api/assets?include_tags=output`,
* which carry `jobId` only (no per-output `nodeId` / `subfolder`).
* Metadata for output assets from queue store
* Extends Record<string, unknown> for compatibility with AssetItem schema
*/
export interface OutputAssetMetadata extends Record<string, unknown> {
jobId: string
nodeId?: string | number
subfolder?: string
nodeId: string | number
subfolder: string
executionTimeInSeconds?: number
format?: string
workflow?: ComfyWorkflowJSON
@@ -17,11 +16,17 @@ export interface OutputAssetMetadata extends Record<string, unknown> {
allOutputs?: ResultItemImpl[]
}
/**
* Type guard to check if metadata is OutputAssetMetadata
*/
function isOutputAssetMetadata(
metadata: Record<string, unknown> | undefined
): metadata is OutputAssetMetadata {
if (!metadata) return false
return typeof metadata.jobId === 'string'
return (
typeof metadata.jobId === 'string' &&
(typeof metadata.nodeId === 'string' || typeof metadata.nodeId === 'number')
)
}
/**

View File

@@ -18,10 +18,7 @@ const zAsset = z.object({
is_immutable: z.boolean().optional(),
last_access_time: z.string().optional(),
metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
job_id: z.string().nullish(),
// Deprecated alias of job_id. See ingest-types Asset schema; both backends emit this during the L6 transition.
prompt_id: z.string().nullish()
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
})
const zAssetResponse = zListAssetsResponse

View File

@@ -16,14 +16,6 @@ const mockGetShareableAssets = vi.fn()
const mockFetchApi = vi.fn()
const mockInvalidateInputAssetsIncludingPublic = vi.hoisted(() => vi.fn())
vi.mock(
'@/platform/workflow/validation/schemas/workflowSchema',
async (importOriginal) => ({
...(await importOriginal()),
validateComfyWorkflow: vi.fn(async (json: unknown) => json)
})
)
vi.mock('@/scripts/api', () => ({
api: {
getShareableAssets: (...args: unknown[]) => mockGetShareableAssets(...args),
@@ -408,6 +400,36 @@ describe(useWorkflowShareService, () => {
)
})
it('returns raw workflow_json when it does not match ComfyWorkflowJSON schema', async () => {
const rawWorkflowJson = {
extra: {
linearData: {
inputs: [
[1, 'prompt'],
[2, 'seed', 'invalid-third-element']
],
outputs: []
}
}
}
mockFetchApi.mockResolvedValue(
mockJsonResponse({
share_id: 'share-raw',
workflow_id: 'wf-raw',
name: 'Raw',
listed: false,
publish_time: null,
workflow_json: rawWorkflowJson,
assets: []
})
)
const service = useWorkflowShareService()
const shared = await service.getSharedWorkflow('share-raw')
expect(shared.workflowJson).toEqual(rawWorkflowJson)
})
it('treats malformed publish-status payload as unpublished', async () => {
mockFetchApi.mockResolvedValue(mockJsonResponse({ is_published: true }))

View File

@@ -9,7 +9,6 @@ import type {
import { assetService } from '@/platform/assets/services/assetService'
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { AssetInfo } from '@/schemas/apiSchema'
import {
zHubWorkflowPrefillResponse,
@@ -249,12 +248,6 @@ export function useWorkflowShareService() {
throw new Error('Failed to load shared workflow: invalid response')
}
const validated = await validateComfyWorkflow(workflow.workflowJson)
if (!validated) {
throw new Error('Failed to load shared workflow: invalid workflow data')
}
workflow.workflowJson = validated
return workflow
}