Compare commits

...

3 Commits

Author SHA1 Message Date
Glary-Bot
d2990331e1 test: assert setTemplateBaseline called with active-state and fetched-json fallback
Per CodeRabbit feedback: previously the test added a setTemplateBaseline
mock but did not assert it was called with the expected baseline, so the
new baseline-capture behavior could regress silently.

- assert active-state baseline path uses changeTracker.activeState
- assert fallback path uses the fetched JSON when no active state exists
- restructure workflowStore mock to use vi.hoisted so activeWorkflow can
  be swapped per-test
2026-05-16 03:27:41 +00:00
Glary-Bot
0647d7eba9 fix: address review feedback for template change classification
- classifier now normalizes both v0.4 tuple and v1 object link shapes
- subgraph definition diffs are treated as structural (conservative)
- baseline is captured after loadGraphData() so it matches the normalized
  state the user actually starts editing from
- getTemplateBaseline returns a defensive deep clone
- baseline store switched to LRU semantics (re-set refreshes recency)
- added v1-link, subgraph-definition, and MAX_BASELINES eviction tests
- mocked workflowStore + baselineStore in useTemplateWorkflows.test.ts
2026-05-16 03:18:37 +00:00
Glary-Bot
c0611876d0 feat: track template change classification in execution_start
Adds template_change_type property to the PostHog execution_start event
so analytics can distinguish runs that explore variations (seed/prompt
only) from runs that materially change the workflow graph.

On template load, the original workflow JSON is captured as a baseline.
At execution time, the baseline is diffed against the current workflow:

- unchanged: no diff
- seed_only: only seed-named widget values differ
- prompt_only: only prompt/text-named widget values differ
- seed_and_prompt: both seed and prompt widget values differ
- structural: any other diff (node added/removed, link change, type
  change, non-seed/non-prompt widget value, length mismatch)

The field is only attached when is_template is true and a baseline was
captured; it is omitted for custom workflows and templates loaded
before this change.
2026-05-16 03:00:23 +00:00
9 changed files with 1016 additions and 2 deletions

View File

@@ -73,6 +73,22 @@ export interface RunButtonProperties {
is_app_mode?: boolean
}
/**
* Classifies how a template workflow has been edited relative to its
* original baseline at the time of execution. Used to distinguish runs
* that explore variations (seed/prompt only) from runs that materially
* change the workflow graph.
*
* Only meaningful when `is_template = true` and a baseline was captured
* when the template was loaded. Omitted otherwise.
*/
export type TemplateChangeType =
| 'unchanged'
| 'seed_only'
| 'prompt_only'
| 'seed_and_prompt'
| 'structural'
/**
* Execution context for workflow tracking
*/
@@ -86,6 +102,7 @@ export interface ExecutionContext {
template_models?: string[]
template_use_case?: string
template_license?: string
template_change_type?: TemplateChangeType
// Node composition metrics
custom_node_count: number
api_node_count: number

View File

@@ -0,0 +1,444 @@
import { describe, expect, it } from 'vitest'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { classifyTemplateChange } from './classifyTemplateChange'
import type { LiveNodeLookup } from './classifyTemplateChange'
function makeWorkflow(
nodes: Array<{
id: number | string
type: string
widgets_values?: unknown[]
inputs?: unknown[]
outputs?: unknown[]
}>,
links: Array<[number, number, number, number, number, string]> = []
): ComfyWorkflowJSON {
return {
nodes,
links,
groups: [],
config: {},
extra: {},
version: 0.4
} as unknown as ComfyWorkflowJSON
}
function liveNodes(
entries: Array<[number | string, Array<{ name?: string; type?: string }>]>
): LiveNodeLookup {
const map: LiveNodeLookup = new Map()
for (const [id, widgets] of entries) {
map.set(id, { widgets })
}
return map
}
describe('classifyTemplateChange', () => {
it('returns unchanged when baseline and current are identical', () => {
const baseline = makeWorkflow([
{ id: 1, type: 'KSampler', widgets_values: [42, 20, 7.5] }
])
const current = makeWorkflow([
{ id: 1, type: 'KSampler', widgets_values: [42, 20, 7.5] }
])
const result = classifyTemplateChange(
baseline,
current,
liveNodes([[1, [{ name: 'seed' }, { name: 'steps' }, { name: 'cfg' }]]])
)
expect(result).toBe('unchanged')
})
it('returns seed_only when only a seed-named widget changed', () => {
const baseline = makeWorkflow([
{ id: 1, type: 'KSampler', widgets_values: [42, 20, 7.5] }
])
const current = makeWorkflow([
{ id: 1, type: 'KSampler', widgets_values: [99, 20, 7.5] }
])
const result = classifyTemplateChange(
baseline,
current,
liveNodes([[1, [{ name: 'seed' }, { name: 'steps' }, { name: 'cfg' }]]])
)
expect(result).toBe('seed_only')
})
it('returns seed_only for a widget named noise_seed', () => {
const baseline = makeWorkflow([
{ id: 7, type: 'SamplerCustom', widgets_values: [123] }
])
const current = makeWorkflow([
{ id: 7, type: 'SamplerCustom', widgets_values: [456] }
])
const result = classifyTemplateChange(
baseline,
current,
liveNodes([[7, [{ name: 'noise_seed' }]]])
)
expect(result).toBe('seed_only')
})
it('returns prompt_only when only a text-named widget changed', () => {
const baseline = makeWorkflow([
{ id: 2, type: 'CLIPTextEncode', widgets_values: ['a cat'] }
])
const current = makeWorkflow([
{ id: 2, type: 'CLIPTextEncode', widgets_values: ['a dog'] }
])
const result = classifyTemplateChange(
baseline,
current,
liveNodes([[2, [{ name: 'text' }]]])
)
expect(result).toBe('prompt_only')
})
it('detects positive_prompt and negative_prompt as prompt widgets', () => {
const baseline = makeWorkflow([
{ id: 3, type: 'PromptNode', widgets_values: ['cat', 'blurry'] }
])
const current = makeWorkflow([
{ id: 3, type: 'PromptNode', widgets_values: ['cat', 'low quality'] }
])
const result = classifyTemplateChange(
baseline,
current,
liveNodes([
[3, [{ name: 'positive_prompt' }, { name: 'negative_prompt' }]]
])
)
expect(result).toBe('prompt_only')
})
it('returns seed_and_prompt when both seed and prompt changed', () => {
const baseline = makeWorkflow([
{ id: 1, type: 'KSampler', widgets_values: [42] },
{ id: 2, type: 'CLIPTextEncode', widgets_values: ['a cat'] }
])
const current = makeWorkflow([
{ id: 1, type: 'KSampler', widgets_values: [99] },
{ id: 2, type: 'CLIPTextEncode', widgets_values: ['a dog'] }
])
const result = classifyTemplateChange(
baseline,
current,
liveNodes([
[1, [{ name: 'seed' }]],
[2, [{ name: 'text' }]]
])
)
expect(result).toBe('seed_and_prompt')
})
it('returns structural when a non-seed/non-prompt widget changed', () => {
const baseline = makeWorkflow([
{ id: 1, type: 'KSampler', widgets_values: [42, 20, 7.5] }
])
const current = makeWorkflow([
{ id: 1, type: 'KSampler', widgets_values: [42, 30, 7.5] }
])
const result = classifyTemplateChange(
baseline,
current,
liveNodes([[1, [{ name: 'seed' }, { name: 'steps' }, { name: 'cfg' }]]])
)
expect(result).toBe('structural')
})
it('returns structural when a node is added', () => {
const baseline = makeWorkflow([{ id: 1, type: 'KSampler' }])
const current = makeWorkflow([
{ id: 1, type: 'KSampler' },
{ id: 2, type: 'LoadImage' }
])
const result = classifyTemplateChange(
baseline,
current,
liveNodes([
[1, []],
[2, []]
])
)
expect(result).toBe('structural')
})
it('returns structural when a node is removed', () => {
const baseline = makeWorkflow([
{ id: 1, type: 'KSampler' },
{ id: 2, type: 'LoadImage' }
])
const current = makeWorkflow([{ id: 1, type: 'KSampler' }])
const result = classifyTemplateChange(
baseline,
current,
liveNodes([[1, []]])
)
expect(result).toBe('structural')
})
it('returns structural when a node type changes for the same id', () => {
const baseline = makeWorkflow([{ id: 1, type: 'KSampler' }])
const current = makeWorkflow([{ id: 1, type: 'KSamplerAdvanced' }])
const result = classifyTemplateChange(
baseline,
current,
liveNodes([[1, []]])
)
expect(result).toBe('structural')
})
it('returns structural when links change', () => {
const baseline = makeWorkflow(
[
{ id: 1, type: 'A' },
{ id: 2, type: 'B' }
],
[[1, 1, 0, 2, 0, 'IMAGE']]
)
const current = makeWorkflow(
[
{ id: 1, type: 'A' },
{ id: 2, type: 'B' }
],
[]
)
const result = classifyTemplateChange(
baseline,
current,
liveNodes([
[1, []],
[2, []]
])
)
expect(result).toBe('structural')
})
it('returns structural when widget values array length differs', () => {
const baseline = makeWorkflow([
{ id: 1, type: 'KSampler', widgets_values: [42, 20] }
])
const current = makeWorkflow([
{ id: 1, type: 'KSampler', widgets_values: [42, 20, 7.5] }
])
const result = classifyTemplateChange(
baseline,
current,
liveNodes([[1, [{ name: 'seed' }, { name: 'steps' }, { name: 'cfg' }]]])
)
expect(result).toBe('structural')
})
it('returns structural when widget name cannot be classified', () => {
const baseline = makeWorkflow([
{ id: 1, type: 'CustomNode', widgets_values: [1] }
])
const current = makeWorkflow([
{ id: 1, type: 'CustomNode', widgets_values: [2] }
])
const result = classifyTemplateChange(
baseline,
current,
liveNodes([[1, [{ name: 'mystery_param' }]]])
)
expect(result).toBe('structural')
})
it('treats link reordering as unchanged when the link set is equal', () => {
const baseline = makeWorkflow(
[
{ id: 1, type: 'A' },
{ id: 2, type: 'B' }
],
[
[1, 1, 0, 2, 0, 'IMAGE'],
[2, 1, 1, 2, 1, 'MASK']
]
)
const current = makeWorkflow(
[
{ id: 1, type: 'A' },
{ id: 2, type: 'B' }
],
[
[2, 1, 1, 2, 1, 'MASK'],
[1, 1, 0, 2, 0, 'IMAGE']
]
)
const result = classifyTemplateChange(
baseline,
current,
liveNodes([
[1, []],
[2, []]
])
)
expect(result).toBe('unchanged')
})
it('handles v1 object-shaped links without throwing', () => {
const baseline = {
nodes: [
{ id: 1, type: 'A' },
{ id: 2, type: 'B' }
],
links: [
{
id: 1,
origin_id: 1,
origin_slot: 0,
target_id: 2,
target_slot: 0,
type: 'IMAGE'
}
],
groups: [],
version: 1
} as unknown as ComfyWorkflowJSON
const current = {
nodes: [
{ id: 1, type: 'A' },
{ id: 2, type: 'B' }
],
links: [
{
id: 1,
origin_id: 1,
origin_slot: 0,
target_id: 2,
target_slot: 0,
type: 'IMAGE'
}
],
groups: [],
version: 1
} as unknown as ComfyWorkflowJSON
const result = classifyTemplateChange(
baseline,
current,
liveNodes([
[1, []],
[2, []]
])
)
expect(result).toBe('unchanged')
})
it('detects link removal across the v1 object shape as structural', () => {
const baseline = {
nodes: [
{ id: 1, type: 'A' },
{ id: 2, type: 'B' }
],
links: [
{
id: 1,
origin_id: 1,
origin_slot: 0,
target_id: 2,
target_slot: 0,
type: 'IMAGE'
}
],
groups: [],
version: 1
} as unknown as ComfyWorkflowJSON
const current = {
nodes: [
{ id: 1, type: 'A' },
{ id: 2, type: 'B' }
],
links: [],
groups: [],
version: 1
} as unknown as ComfyWorkflowJSON
const result = classifyTemplateChange(
baseline,
current,
liveNodes([
[1, []],
[2, []]
])
)
expect(result).toBe('structural')
})
it('classifies edits inside subgraph definitions as structural', () => {
const baseline = {
nodes: [{ id: 1, type: 'Subgraph' }],
links: [],
groups: [],
version: 1,
definitions: {
subgraphs: [
{
id: 'sg-1',
nodes: [{ id: 10, type: 'KSampler', widgets_values: [42] }]
}
]
}
} as unknown as ComfyWorkflowJSON
const current = {
nodes: [{ id: 1, type: 'Subgraph' }],
links: [],
groups: [],
version: 1,
definitions: {
subgraphs: [
{
id: 'sg-1',
nodes: [
{ id: 10, type: 'KSampler', widgets_values: [42] },
{ id: 11, type: 'LoadImage', widgets_values: [] }
]
}
]
}
} as unknown as ComfyWorkflowJSON
const result = classifyTemplateChange(
baseline,
current,
liveNodes([[1, []]])
)
expect(result).toBe('structural')
})
})

View File

@@ -0,0 +1,181 @@
import { isEqual } from 'es-toolkit/compat'
import type {
ComfyNode,
ComfyWorkflowJSON
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { TemplateChangeType } from '../types'
const SEED_WIDGET_NAME_PATTERN = /(^|_)seed($|_)|noise_seed/i
const PROMPT_WIDGET_NAME_PATTERN = /(^|_)(prompt|text|positive|negative)($|_)/i
export type LiveWidgetInfo = {
name?: string
type?: string
}
export type LiveNodeInfo = {
widgets?: LiveWidgetInfo[]
}
export type LiveNodeLookup = Map<string | number, LiveNodeInfo>
type WidgetKind = 'seed' | 'prompt' | 'other'
type NormalizedLink = readonly [
src: string | number | undefined,
srcSlot: number | undefined,
dst: string | number | undefined,
dstSlot: number | undefined,
dataType: string | undefined
]
type LinkObjectShape = {
origin_id?: string | number
origin_slot?: number
target_id?: string | number
target_slot?: number
type?: string
}
function widgetKind(widget: LiveWidgetInfo | undefined): WidgetKind {
const name = widget?.name
if (!name) return 'other'
if (SEED_WIDGET_NAME_PATTERN.test(name)) return 'seed'
if (PROMPT_WIDGET_NAME_PATTERN.test(name)) return 'prompt'
return 'other'
}
function nodesById(
workflow: ComfyWorkflowJSON
): Map<string | number, ComfyNode> {
const map = new Map<string | number, ComfyNode>()
for (const node of workflow.nodes ?? []) {
if (node?.id !== undefined) map.set(node.id, node as ComfyNode)
}
return map
}
function normalizeLink(link: unknown): NormalizedLink | undefined {
if (Array.isArray(link)) {
const [, src, srcSlot, dst, dstSlot, dataType] = link as [
unknown,
string | number | undefined,
number | undefined,
string | number | undefined,
number | undefined,
string | undefined
]
return [src, srcSlot, dst, dstSlot, dataType]
}
if (link && typeof link === 'object') {
const obj = link as LinkObjectShape
return [
obj.origin_id,
obj.origin_slot,
obj.target_id,
obj.target_slot,
obj.type
]
}
return undefined
}
function normalizeLinks(
links: ComfyWorkflowJSON['links'] | undefined
): string[] {
const result: string[] = []
for (const link of links ?? []) {
const normalized = normalizeLink(link)
if (normalized) result.push(JSON.stringify(normalized))
}
return result.sort()
}
function linksDiffer(
baseline: ComfyWorkflowJSON,
current: ComfyWorkflowJSON
): boolean {
return !isEqual(normalizeLinks(baseline.links), normalizeLinks(current.links))
}
function subgraphDefinitionsDiffer(
baseline: ComfyWorkflowJSON,
current: ComfyWorkflowJSON
): boolean {
const a = (baseline as { definitions?: unknown }).definitions
const b = (current as { definitions?: unknown }).definitions
if (a === undefined && b === undefined) return false
return !isEqual(a, b)
}
function structurallyDifferent(
baselineNode: ComfyNode,
currentNode: ComfyNode
): boolean {
if (baselineNode.type !== currentNode.type) return true
if (!isEqual(baselineNode.inputs ?? [], currentNode.inputs ?? [])) return true
if (!isEqual(baselineNode.outputs ?? [], currentNode.outputs ?? []))
return true
return false
}
function combine(
hasSeedChange: boolean,
hasPromptChange: boolean
): TemplateChangeType {
if (hasSeedChange && hasPromptChange) return 'seed_and_prompt'
if (hasSeedChange) return 'seed_only'
if (hasPromptChange) return 'prompt_only'
return 'unchanged'
}
export function classifyTemplateChange(
baseline: ComfyWorkflowJSON,
current: ComfyWorkflowJSON,
liveNodes: LiveNodeLookup
): TemplateChangeType {
if (subgraphDefinitionsDiffer(baseline, current)) return 'structural'
const baselineNodes = nodesById(baseline)
const currentNodes = nodesById(current)
if (baselineNodes.size !== currentNodes.size) return 'structural'
for (const id of baselineNodes.keys()) {
if (!currentNodes.has(id)) return 'structural'
}
if (linksDiffer(baseline, current)) return 'structural'
let hasSeedChange = false
let hasPromptChange = false
for (const [id, baselineNode] of baselineNodes) {
const currentNode = currentNodes.get(id)!
if (structurallyDifferent(baselineNode, currentNode)) return 'structural'
const baselineValues = baselineNode.widgets_values ?? []
const currentValues = currentNode.widgets_values ?? []
if (!Array.isArray(baselineValues) || !Array.isArray(currentValues)) {
if (!isEqual(baselineValues, currentValues)) return 'structural'
continue
}
if (baselineValues.length !== currentValues.length) return 'structural'
const widgets = liveNodes.get(id)?.widgets ?? []
for (let i = 0; i < baselineValues.length; i++) {
if (isEqual(baselineValues[i], currentValues[i])) continue
const kind = widgetKind(widgets[i])
if (kind === 'seed') hasSeedChange = true
else if (kind === 'prompt') hasPromptChange = true
else return 'structural'
}
}
return combine(hasSeedChange, hasPromptChange)
}

View File

@@ -4,10 +4,16 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
const hoisted = vi.hoisted(() => ({
mockNodeDefsByName: {} as Record<string, unknown>,
mockNodes: [] as Pick<LGraphNode, 'type' | 'isSubgraphNode'>[],
mockNodes: [] as Array<
Pick<LGraphNode, 'type' | 'isSubgraphNode'> & {
id?: number | string
widgets?: Array<{ name?: string; type?: string }>
}
>,
mockActiveWorkflow: null as null | {
filename: string
fullFilename: string
changeTracker?: { activeState?: unknown }
},
mockKnownTemplateNames: new Set<string>(),
mockTemplateByName: null as null | { sourceModule?: string }
@@ -65,6 +71,12 @@ vi.mock('@/scripts/app', () => ({
}))
import { getExecutionContext } from './getExecutionContext'
import {
clearTemplateBaselines,
setTemplateBaseline
} from './templateBaselineStore'
type WorkflowJSON = Parameters<typeof setTemplateBaseline>[1]
describe('getExecutionContext', () => {
beforeEach(() => {
@@ -76,6 +88,7 @@ describe('getExecutionContext', () => {
hoisted.mockActiveWorkflow = null
hoisted.mockKnownTemplateNames = new Set()
hoisted.mockTemplateByName = null
clearTemplateBaselines()
})
it('returns has_toolkit_nodes false when no toolkit nodes are present', () => {
@@ -212,4 +225,112 @@ describe('getExecutionContext', () => {
expect(context.is_template).toBe(false)
})
})
describe('template_change_type', () => {
function setUpTemplateWorkflow(
name: string,
activeState: WorkflowJSON,
liveNodes: Array<{
id: number
type: string
widgets?: Array<{ name?: string; type?: string }>
}>
) {
hoisted.mockKnownTemplateNames = new Set([name])
hoisted.mockTemplateByName = { sourceModule: 'default' }
hoisted.mockActiveWorkflow = {
filename: name,
fullFilename: `${name}.json`,
changeTracker: { activeState }
}
for (const node of liveNodes) {
hoisted.mockNodes.push({
...node,
isSubgraphNode: (() => false) as LGraphNode['isSubgraphNode']
} as never)
}
}
it('is omitted when no baseline was captured for the template', () => {
setUpTemplateWorkflow(
'flux-dev',
{
nodes: [{ id: 1, type: 'KSampler', widgets_values: [42] }],
links: []
} as unknown as WorkflowJSON,
[{ id: 1, type: 'KSampler', widgets: [{ name: 'seed' }] }]
)
const context = getExecutionContext()
expect(context.is_template).toBe(true)
expect(context.template_change_type).toBeUndefined()
})
it('reports unchanged when baseline matches current state', () => {
const baseline = {
nodes: [{ id: 1, type: 'KSampler', widgets_values: [42] }],
links: []
} as unknown as WorkflowJSON
setTemplateBaseline('flux-dev', baseline)
setUpTemplateWorkflow('flux-dev', baseline, [
{ id: 1, type: 'KSampler', widgets: [{ name: 'seed' }] }
])
const context = getExecutionContext()
expect(context.template_change_type).toBe('unchanged')
})
it('reports seed_only when only the seed widget value differs', () => {
const baseline = {
nodes: [{ id: 1, type: 'KSampler', widgets_values: [42, 20] }],
links: []
} as unknown as WorkflowJSON
setTemplateBaseline('flux-dev', baseline)
setUpTemplateWorkflow(
'flux-dev',
{
nodes: [{ id: 1, type: 'KSampler', widgets_values: [99, 20] }],
links: []
} as unknown as WorkflowJSON,
[
{
id: 1,
type: 'KSampler',
widgets: [{ name: 'seed' }, { name: 'steps' }]
}
]
)
const context = getExecutionContext()
expect(context.template_change_type).toBe('seed_only')
})
it('reports structural when nodes were added', () => {
setTemplateBaseline('flux-dev', {
nodes: [{ id: 1, type: 'KSampler', widgets_values: [42] }],
links: []
} as unknown as WorkflowJSON)
setUpTemplateWorkflow(
'flux-dev',
{
nodes: [
{ id: 1, type: 'KSampler', widgets_values: [42] },
{ id: 2, type: 'LoadImage', widgets_values: [] }
],
links: []
} as unknown as WorkflowJSON,
[
{ id: 1, type: 'KSampler', widgets: [{ name: 'seed' }] },
{ id: 2, type: 'LoadImage', widgets: [] }
]
)
const context = getExecutionContext()
expect(context.template_change_type).toBe('structural')
})
})
})

View File

@@ -9,7 +9,10 @@ import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import { reduceAllNodes } from '@/utils/graphTraversalUtil'
import type { ExecutionContext } from '../types'
import type { ExecutionContext, TemplateChangeType } from '../types'
import { classifyTemplateChange } from './classifyTemplateChange'
import type { LiveNodeLookup } from './classifyTemplateChange'
import { getTemplateBaseline } from './templateBaselineStore'
type NodeMetrics = {
custom_node_count: number
@@ -23,6 +26,41 @@ type NodeMetrics = {
toolkit_node_names: string[]
}
function buildLiveNodeLookup(): LiveNodeLookup {
return reduceAllNodes<LiveNodeLookup>(
app.rootGraph,
(acc, node) => {
if (node?.id !== undefined) {
acc.set(node.id, {
widgets: node.widgets?.map((w) => ({
name: w?.name,
type: w?.type
}))
})
}
return acc
},
new Map()
)
}
function getTemplateChangeType(
templateName: string
): TemplateChangeType | undefined {
const baseline = getTemplateBaseline(templateName)
if (!baseline) return undefined
const currentState =
useWorkflowStore().activeWorkflow?.changeTracker?.activeState
if (!currentState) return undefined
try {
return classifyTemplateChange(baseline, currentState, buildLiveNodeLookup())
} catch {
return undefined
}
}
export function getExecutionContext(): ExecutionContext {
const workflowStore = useWorkflowStore()
const templatesStore = useWorkflowTemplatesStore()
@@ -101,6 +139,7 @@ export function getExecutionContext(): ExecutionContext {
template_models: englishMetadata?.models ?? template?.models,
template_use_case: englishMetadata?.useCase ?? template?.useCase,
template_license: englishMetadata?.license ?? template?.license,
template_change_type: getTemplateChangeType(templateName),
...nodeCounts
}
}

View File

@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it } from 'vitest'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import {
MAX_BASELINES,
clearTemplateBaselines,
getTemplateBaseline,
setTemplateBaseline
} from './templateBaselineStore'
function makeWorkflow(seed: number): ComfyWorkflowJSON {
return {
nodes: [{ id: 1, type: 'KSampler', widgets_values: [seed] }],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4
} as unknown as ComfyWorkflowJSON
}
describe('templateBaselineStore', () => {
beforeEach(() => {
clearTemplateBaselines()
})
it('stores and retrieves a baseline by workflow name', () => {
const workflow = makeWorkflow(42)
setTemplateBaseline('flux-dev', workflow)
const retrieved = getTemplateBaseline('flux-dev')
expect(retrieved).toEqual(workflow)
})
it('returns undefined for unknown workflow names', () => {
expect(getTemplateBaseline('does-not-exist')).toBeUndefined()
})
it('deep-clones stored baselines so mutations to the source do not leak', () => {
const workflow = makeWorkflow(42)
setTemplateBaseline('flux-dev', workflow)
workflow.nodes[0].widgets_values = [999]
const retrieved = getTemplateBaseline('flux-dev')
expect(retrieved?.nodes[0].widgets_values).toEqual([42])
})
it('replaces an existing baseline when set again with the same name', () => {
setTemplateBaseline('flux-dev', makeWorkflow(1))
setTemplateBaseline('flux-dev', makeWorkflow(2))
expect(getTemplateBaseline('flux-dev')?.nodes[0].widgets_values).toEqual([
2
])
})
it('ignores empty workflow names', () => {
setTemplateBaseline('', makeWorkflow(1))
expect(getTemplateBaseline('')).toBeUndefined()
})
it('returns a defensive clone so callers cannot mutate the stored baseline', () => {
setTemplateBaseline('flux-dev', makeWorkflow(42))
const first = getTemplateBaseline('flux-dev')
expect(first).toBeDefined()
first!.nodes[0].widgets_values = [999]
const second = getTemplateBaseline('flux-dev')
expect(second?.nodes[0].widgets_values).toEqual([42])
})
it('evicts the oldest baseline when MAX_BASELINES is exceeded', () => {
for (let i = 0; i < MAX_BASELINES; i++) {
setTemplateBaseline(`template-${i}`, makeWorkflow(i))
}
setTemplateBaseline('overflow', makeWorkflow(999))
expect(getTemplateBaseline('template-0')).toBeUndefined()
expect(getTemplateBaseline('template-1')?.nodes[0].widgets_values).toEqual([
1
])
expect(getTemplateBaseline('overflow')?.nodes[0].widgets_values).toEqual([
999
])
})
it('refreshes recency when re-setting an existing key', () => {
for (let i = 0; i < MAX_BASELINES; i++) {
setTemplateBaseline(`template-${i}`, makeWorkflow(i))
}
setTemplateBaseline('template-0', makeWorkflow(1000))
setTemplateBaseline('overflow', makeWorkflow(999))
expect(getTemplateBaseline('template-0')?.nodes[0].widgets_values).toEqual([
1000
])
expect(getTemplateBaseline('template-1')).toBeUndefined()
expect(getTemplateBaseline('overflow')?.nodes[0].widgets_values).toEqual([
999
])
})
})

View File

@@ -0,0 +1,36 @@
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
const baselineByWorkflowName = new Map<string, ComfyWorkflowJSON>()
export const MAX_BASELINES = 32
function clone(workflow: ComfyWorkflowJSON): ComfyWorkflowJSON {
return JSON.parse(JSON.stringify(workflow)) as ComfyWorkflowJSON
}
export function setTemplateBaseline(
workflowName: string,
workflow: ComfyWorkflowJSON
): void {
if (!workflowName) return
if (baselineByWorkflowName.has(workflowName)) {
baselineByWorkflowName.delete(workflowName)
} else if (baselineByWorkflowName.size >= MAX_BASELINES) {
const oldestKey = baselineByWorkflowName.keys().next().value
if (oldestKey !== undefined) baselineByWorkflowName.delete(oldestKey)
}
baselineByWorkflowName.set(workflowName, clone(workflow))
}
export function getTemplateBaseline(
workflowName: string
): ComfyWorkflowJSON | undefined {
const baseline = baselineByWorkflowName.get(workflowName)
return baseline ? clone(baseline) : undefined
}
export function clearTemplateBaselines(): void {
baselineByWorkflowName.clear()
}

View File

@@ -1,8 +1,18 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setTemplateBaseline } from '@/platform/telemetry/utils/templateBaselineStore'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
const hoisted = vi.hoisted(() => ({
mockActiveWorkflow: {
changeTracker: { activeState: { nodes: [], links: [] } as unknown }
} as
| { changeTracker?: { activeState?: unknown } | undefined }
| null
| undefined
}))
async function flushPromises() {
await new Promise((r) => setTimeout(r, 0))
}
@@ -49,6 +59,18 @@ vi.mock('@/stores/dialogStore', () => ({
}))
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get activeWorkflow() {
return hoisted.mockActiveWorkflow
}
})
}))
vi.mock('@/platform/telemetry/utils/templateBaselineStore', () => ({
setTemplateBaseline: vi.fn()
}))
// Mock fetch
global.fetch = vi.fn()
@@ -115,6 +137,11 @@ describe('useTemplateWorkflows', () => {
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({ workflow: 'data' })
} as Partial<Response> as Response)
hoisted.mockActiveWorkflow = {
changeTracker: { activeState: { nodes: [], links: [] } as unknown }
}
vi.mocked(setTemplateBaseline).mockClear()
})
it('should load templates from store', async () => {
@@ -285,6 +312,40 @@ describe('useTemplateWorkflows', () => {
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
})
it('captures the normalized active state as the template baseline', async () => {
const { loadWorkflowTemplate } = useTemplateWorkflows()
mockWorkflowTemplatesStore.isLoaded = true
const normalizedState = {
nodes: [{ id: 1, type: 'KSampler', widgets_values: [42] }],
links: []
}
hoisted.mockActiveWorkflow = {
changeTracker: { activeState: normalizedState }
}
await loadWorkflowTemplate('template1', 'default')
await flushPromises()
expect(setTemplateBaseline).toHaveBeenCalledWith(
'template1',
normalizedState
)
})
it('falls back to fetched JSON when no active state is available', async () => {
const { loadWorkflowTemplate } = useTemplateWorkflows()
mockWorkflowTemplatesStore.isLoaded = true
hoisted.mockActiveWorkflow = undefined
await loadWorkflowTemplate('template1', 'default')
await flushPromises()
expect(setTemplateBaseline).toHaveBeenCalledWith('template1', {
workflow: 'data'
})
})
it('should handle errors when loading templates', async () => {
const { loadWorkflowTemplate, loadingTemplateId } = useTemplateWorkflows()

View File

@@ -3,6 +3,8 @@ import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { setTemplateBaseline } from '@/platform/telemetry/utils/templateBaselineStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type {
TemplateGroup,
@@ -144,6 +146,10 @@ export function useTemplateWorkflows() {
openSource: 'template'
})
const loadedBaseline =
useWorkflowStore().activeWorkflow?.changeTracker?.activeState ?? json
setTemplateBaseline(id, loadedBaseline)
return true
} catch (error) {
console.error('Error loading workflow template:', error)