Compare commits

...

7 Commits

Author SHA1 Message Date
jaeone94
768a4c7219 test: tighten workflow persistence review coverage 2026-05-13 15:32:38 +09:00
jaeone94
0fa468e063 test: read active workflow tab label only 2026-05-13 14:59:51 +09:00
jaeone94
1c39223336 fix: tighten workflow persistence review follow-ups 2026-05-13 13:28:36 +09:00
jaeone94
2e32714047 fix: address workflow persistence review feedback 2026-05-13 12:36:32 +09:00
jaeone94
9a3bbcc1b7 fix: harden workflow draft v2 shadow writes 2026-05-12 15:25:50 +09:00
jaeone94
6587964487 test: cover workflow tab persistence 2026-05-12 14:47:25 +09:00
jaeone94
7801697188 fix: persist workflow tabs through v2 drafts 2026-05-12 14:39:23 +09:00
15 changed files with 1163 additions and 220 deletions

View File

@@ -27,9 +27,7 @@ export class Topbar {
}
async getActiveTabName(): Promise<string> {
return this.page
.locator('.workflow-tabs .p-togglebutton-checked')
.innerText()
return this.getActiveTab().locator('.workflow-label').innerText()
}
/**

View File

@@ -89,6 +89,61 @@ test.describe('Workflow Persistence', () => {
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(nodeCountA)
})
test(
'Open workflow tabs are restored after page reload',
{ tag: '@workflow' },
async ({ comfyPage }) => {
test.info().annotations.push({
type: 'regression',
description:
'FE-367 — multiple saved workflow tabs should persist across page reload'
})
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await comfyPage.workflow.setupWorkflowsDirectory({
'restore-a.json': 'default.json',
'restore-b.json': 'nodes/single_ksampler.json'
})
const workflowsTab = comfyPage.menu.workflowsTab
await workflowsTab.open()
await workflowsTab.getPersistedItem('restore-a').dblclick()
await comfyPage.workflow.waitForWorkflowIdle()
const restoreANodeCount = await comfyPage.nodeOps.getNodeCount()
await workflowsTab.getPersistedItem('restore-b').dblclick()
await comfyPage.workflow.waitForWorkflowIdle()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(1)
await expect
.poll(() => comfyPage.menu.topbar.getActiveTabName())
.toBe('restore-b')
const expectedTabNames = ['restore-a', 'restore-b']
await expect
.poll(() => comfyPage.menu.topbar.getTabNames())
.toEqual(expect.arrayContaining(expectedTabNames))
const expectedActiveTabName = 'restore-b'
await comfyPage.workflow.reloadAndWaitForApp()
await expect
.poll(() => comfyPage.menu.topbar.getTabNames())
.toEqual(expectedTabNames)
await expect
.poll(() => comfyPage.menu.topbar.getActiveTabName())
.toBe(expectedActiveTabName)
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(1)
await comfyPage.menu.topbar.getWorkflowTab('restore-a').click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(restoreANodeCount)
}
)
test('Node outputs are preserved when switching workflow tabs', async ({
comfyPage
}) => {

View File

@@ -557,7 +557,6 @@ onMounted(async () => {
// Restore saved workflow and workflow tabs state
await workflowPersistence.initializeWorkflow()
await workflowPersistence.restoreWorkflowTabsState()
const sharedWorkflowLoadStatus =
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()

View File

@@ -388,6 +388,7 @@ describe('useWorkflowService', () => {
{ missingNodeTypes: ['CustomNode1'] },
{ loadable: true }
)
workflow.load = vi.fn().mockResolvedValue(workflow as LoadedComfyWorkflow)
const service = useWorkflowService()
@@ -402,6 +403,18 @@ describe('useWorkflowService', () => {
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(2)
})
it('should pass force through when reloading a loaded workflow', async () => {
const workflowStore = useWorkflowStore()
const workflow = createWorkflow(null, { loadable: true })
const load = vi.fn().mockResolvedValue(workflow)
workflow.load = load
workflowStore.activeWorkflow = workflow as LoadedComfyWorkflow
await useWorkflowService().reloadCurrentWorkflow()
expect(load).toHaveBeenCalledWith({ force: true })
})
})
describe('saveWorkflow', () => {

View File

@@ -10,7 +10,8 @@ import {
normalizePendingWarnings,
updatePendingWarnings
} from '@/platform/workflow/core/utils/pendingWarnings'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import { createFailedToSaveDraftToast } from '@/platform/workflow/persistence/base/draftToast'
import { useWorkflowDraftStoreV2 } from '@/platform/workflow/persistence/stores/workflowDraftStoreV2'
import {
ComfyWorkflow,
useWorkflowStore
@@ -51,7 +52,7 @@ export const useWorkflowService = () => {
const workflowThumbnail = useWorkflowThumbnail()
const domWidgetStore = useDomWidgetStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const workflowDraftStore = useWorkflowDraftStore()
const workflowDraftStore = useWorkflowDraftStoreV2()
function confirmOverwrite(targetPath: string) {
return dialogService.confirm({
@@ -62,6 +63,10 @@ export const useWorkflowService = () => {
})
}
function showFailedToSaveDraftToast() {
toastStore.add(createFailedToSaveDraftToast(t))
}
async function getFilename(defaultName: string): Promise<string | null> {
if (settingStore.get('Comfy.PromptFilename')) {
let filename = await dialogService.prompt({
@@ -245,9 +250,9 @@ export const useWorkflowService = () => {
) => {
if (workflowStore.isActive(workflow) && !options.force) return
const loadFromRemote = !workflow.isLoaded
const loadFromRemote = options.force || !workflow.isLoaded
if (loadFromRemote) {
await workflow.load()
await workflow.load({ force: options.force })
}
await app.loadGraphData(
@@ -380,18 +385,20 @@ export const useWorkflowService = () => {
if (activeState) {
try {
const workflowJson = JSON.stringify(activeState)
workflowDraftStore.saveDraft(activeWorkflow.path, {
data: workflowJson,
updatedAt: Date.now(),
name: activeWorkflow.key,
isTemporary: activeWorkflow.isTemporary
})
const saved = workflowDraftStore.saveDraft(
activeWorkflow.path,
workflowJson,
{
name: activeWorkflow.key,
isTemporary: activeWorkflow.isTemporary
}
)
if (!saved) {
showFailedToSaveDraftToast()
}
} catch {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToSaveDraft')
})
showFailedToSaveDraftToast()
}
}
}

View File

@@ -97,10 +97,14 @@ export class ComfyWorkflow extends UserFile {
override async load({ force = false }: { force?: boolean } = {}): Promise<
this & LoadedComfyWorkflow
> {
const { useWorkflowDraftStore } =
await import('@/platform/workflow/persistence/stores/workflowDraftStore')
if (!force && this.isLoaded && this.changeTracker) {
return this as this & LoadedComfyWorkflow
}
const { useWorkflowDraftStoreV2 } =
await import('@/platform/workflow/persistence/stores/workflowDraftStoreV2')
const { useSettingStore } = await import('@/platform/settings/settingStore')
const draftStore = useWorkflowDraftStore()
const draftStore = useWorkflowDraftStoreV2()
const persistEnabled = useSettingStore().get('Comfy.Workflow.Persist')
let draft =
!force && persistEnabled ? draftStore.getDraft(this.path) : undefined
@@ -125,7 +129,6 @@ export class ComfyWorkflow extends UserFile {
}
await super.load({ force })
if (!force && this.isLoaded) return this as this & LoadedComfyWorkflow
if (this.originalContent == null) {
throw new Error(
@@ -155,9 +158,9 @@ export class ComfyWorkflow extends UserFile {
}
override async save() {
const { useWorkflowDraftStore } =
await import('@/platform/workflow/persistence/stores/workflowDraftStore')
const draftStore = useWorkflowDraftStore()
const { useWorkflowDraftStoreV2 } =
await import('@/platform/workflow/persistence/stores/workflowDraftStoreV2')
const draftStore = useWorkflowDraftStoreV2()
this.content = JSON.stringify(this.activeState)
// Force save to ensure the content is updated in remote storage incase
// the isModified state is screwed by changeTracker.
@@ -174,9 +177,9 @@ export class ComfyWorkflow extends UserFile {
* @returns this
*/
override async saveAs(path: string) {
const { useWorkflowDraftStore } =
await import('@/platform/workflow/persistence/stores/workflowDraftStore')
const draftStore = useWorkflowDraftStore()
const { useWorkflowDraftStoreV2 } =
await import('@/platform/workflow/persistence/stores/workflowDraftStoreV2')
const draftStore = useWorkflowDraftStoreV2()
this.content = JSON.stringify(this.activeState)
const result = await super.saveAs(path)
draftStore.removeDraft(path)

View File

@@ -13,7 +13,7 @@ import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import { useWorkflowDraftStoreV2 } from '@/platform/workflow/persistence/stores/workflowDraftStoreV2'
// eslint-disable-next-line import-x/no-restricted-paths
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { api } from '@/scripts/api'
@@ -340,7 +340,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
openWorkflowPaths.value = openWorkflowPaths.value.filter(
(path) => path !== workflow.path
)
useWorkflowDraftStore().removeDraft(workflow.path)
useWorkflowDraftStoreV2().removeDraft(workflow.path)
if (workflow.isTemporary) {
clearThumbnail(workflow.key)
delete workflowLookup.value[workflow.path]
@@ -496,7 +496,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
const oldPath = workflow.path
const oldKey = workflow.key
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
const draftStore = useWorkflowDraftStore()
const draftStore = useWorkflowDraftStoreV2()
await workflow.rename(newPath)
@@ -528,7 +528,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
isBusy.value = true
try {
await workflow.delete()
useWorkflowDraftStore().removeDraft(workflow.path)
useWorkflowDraftStoreV2().removeDraft(workflow.path)
if (bookmarkStore.isBookmarked(workflow.path)) {
await bookmarkStore.setBookmarked(workflow.path, false)
}

View File

@@ -183,6 +183,7 @@ describe('draftCacheV2', () => {
expect(result!.index.entries[result!.newKey].path).toBe(
'workflows/new.json'
)
expect(result!.index.entries[result!.newKey].updatedAt).toBe(1000)
expect(result!.index.entries[result!.oldKey]).toBeUndefined()
})

View File

@@ -128,8 +128,7 @@ export function moveEntry(
entries[newKey] = {
...oldEntry,
path: newPath,
name: newName,
updatedAt: Date.now()
name: newName
}
const order = index.order

View File

@@ -0,0 +1,9 @@
type Translate = (key: string) => string
export function createFailedToSaveDraftToast(t: Translate) {
return {
severity: 'error' as const,
summary: t('g.error'),
detail: t('toastMessages.failedToSaveDraft')
}
}

View File

@@ -1,27 +1,35 @@
import { cleanup, render } from '@testing-library/vue'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as I18n from 'vue-i18n'
import { defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { PERSIST_DEBOUNCE_MS } from '../base/draftTypes'
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
import { useWorkflowPersistenceV2 } from './useWorkflowPersistenceV2'
const settingMocks = vi.hoisted(() => ({
persistRef: null as { value: boolean } | null
persistRef: null as { value: boolean } | null,
tutorialCompletedRef: null as { value: boolean } | null,
set: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', async () => {
const { ref } = await import('vue')
settingMocks.persistRef = ref(true)
settingMocks.tutorialCompletedRef = ref(true)
return {
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.Workflow.Persist')
return settingMocks.persistRef!.value
if (key === 'Comfy.TutorialCompleted')
return settingMocks.tutorialCompletedRef!.value
return undefined
}),
set: vi.fn()
set: settingMocks.set
}))
}
})
@@ -48,16 +56,6 @@ vi.mock(
})
)
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof I18n>()
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
const openWorkflowMock = vi.fn()
const loadBlankWorkflowMock = vi.fn()
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
@@ -76,10 +74,12 @@ vi.mock(
})
)
const commandMocks = vi.hoisted(() => ({
execute: vi.fn()
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: vi.fn()
})
useCommandStore: () => commandMocks
}))
vi.mock('vue-router', () => ({
@@ -126,6 +126,7 @@ const mocks = vi.hoisted(() => {
const apiMock = {
clientId: 'test-client',
initialClientId: 'test-client',
listUserDataFullInfo: vi.fn(),
addEventListener: vi.fn((event: string, handler: () => void) => {
if (event === 'graphChanged') {
state.graphChangedHandler = handler
@@ -153,6 +154,41 @@ vi.mock('@/scripts/api', () => ({
api: mocks.apiMock
}))
type WorkflowPersistenceV2 = ReturnType<typeof useWorkflowPersistenceV2>
function mountWorkflowPersistence() {
const result: { persistence: WorkflowPersistenceV2 | null } = {
persistence: null
}
const HostComponent = defineComponent({
setup() {
result.persistence = useWorkflowPersistenceV2()
return () => null
}
})
render(HostComponent, {
global: {
plugins: [
createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
]
}
})
if (!result.persistence) {
throw new Error('Workflow persistence did not initialize')
}
return result.persistence
}
describe('useWorkflowPersistenceV2', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -162,12 +198,14 @@ describe('useWorkflowPersistenceV2', () => {
sessionStorage.clear()
vi.clearAllMocks()
settingMocks.persistRef!.value = true
settingMocks.tutorialCompletedRef!.value = true
mocks.state.graphChangedHandler = null
mocks.state.currentGraph = { initial: true }
mocks.serializeMock.mockImplementation(() => mocks.state.currentGraph)
mocks.loadGraphDataMock.mockReset()
mocks.apiMock.clientId = 'test-client'
mocks.apiMock.initialClientId = 'test-client'
mocks.apiMock.listUserDataFullInfo.mockResolvedValue([])
mocks.apiMock.addEventListener.mockImplementation(
(event: string, handler: () => void) => {
if (event === 'graphChanged') {
@@ -181,6 +219,7 @@ describe('useWorkflowPersistenceV2', () => {
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
@@ -207,6 +246,83 @@ describe('useWorkflowPersistenceV2', () => {
)
}
describe('persistCurrentWorkflow', () => {
it('persists graph changes and updates the active path pointer', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
const workflow = await workflowStore
.createTemporary('Autosave.json')
.load()
workflowStore.activeWorkflow = workflow
mocks.state.currentGraph = { nodes: [{ id: 1 }] }
mountWorkflowPersistence()
mocks.state.graphChangedHandler?.()
await vi.advanceTimersByTimeAsync(PERSIST_DEBOUNCE_MS)
const draft = draftStore.getDraft(workflow.path)
expect(draft?.data).toBe(JSON.stringify(mocks.state.currentGraph))
const activePointer = JSON.parse(
sessionStorage.getItem('Comfy.Workflow.ActivePath:test-client')!
)
expect(activePointer.path).toBe(workflow.path)
})
it('shows a toast when saving the active workflow draft fails', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
const workflow = await workflowStore
.createTemporary('SaveFailure.json')
.load()
workflowStore.activeWorkflow = workflow
vi.spyOn(draftStore, 'saveDraft').mockReturnValue(false)
mountWorkflowPersistence()
mocks.state.graphChangedHandler?.()
await vi.advanceTimersByTimeAsync(PERSIST_DEBOUNCE_MS)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'toastMessages.failedToSaveDraft'
})
expect(
sessionStorage.getItem('Comfy.Workflow.ActivePath:test-client')
).toBeNull()
})
it('shows a toast when saving the active workflow draft throws', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
const workflow = await workflowStore
.createTemporary('SaveException.json')
.load()
workflowStore.activeWorkflow = workflow
const saveDraftMock = vi
.spyOn(draftStore, 'saveDraft')
.mockImplementation(() => {
throw new Error('storage unavailable')
})
mountWorkflowPersistence()
mocks.state.graphChangedHandler?.()
await vi.advanceTimersByTimeAsync(PERSIST_DEBOUNCE_MS)
mocks.state.graphChangedHandler?.()
await vi.advanceTimersByTimeAsync(PERSIST_DEBOUNCE_MS)
expect(saveDraftMock).toHaveBeenCalledTimes(2)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'toastMessages.failedToSaveDraft'
})
expect(
sessionStorage.getItem('Comfy.Workflow.ActivePath:test-client')
).toBeNull()
})
})
describe('loadPreviousWorkflowFromStorage', () => {
it('loads saved workflow when draft is missing for session path', async () => {
const workflowStore = useWorkflowStore()
@@ -215,13 +331,33 @@ describe('useWorkflowPersistenceV2', () => {
// Set session path to the saved workflow but do NOT create a draft
writeActivePath(savedWorkflow.path)
const { initializeWorkflow } = useWorkflowPersistenceV2()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
// Should call workflowService.openWorkflow with the saved workflow
expect(openWorkflowMock).toHaveBeenCalledWith(savedWorkflow)
// Should NOT fall through to loadGraphData (fallbackToLatestDraft)
expect(mocks.loadGraphDataMock).not.toHaveBeenCalled()
// Should not sync metadata when the workflow is already known locally.
expect(mocks.apiMock.listUserDataFullInfo).not.toHaveBeenCalled()
})
it('syncs workflow metadata when saved session path is not known locally', async () => {
mocks.apiMock.listUserDataFullInfo.mockResolvedValue([
{ path: 'SavedWorkflow.json', modified: 100, size: 1 }
])
writeActivePath('workflows/SavedWorkflow.json')
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
const workflowStore = useWorkflowStore()
expect(mocks.apiMock.listUserDataFullInfo).toHaveBeenCalledWith(
'workflows'
)
expect(openWorkflowMock).toHaveBeenCalledWith(
workflowStore.getWorkflowByPath('workflows/SavedWorkflow.json')
)
})
it('prefers draft over saved workflow when draft exists', async () => {
@@ -238,12 +374,13 @@ describe('useWorkflowPersistenceV2', () => {
mocks.loadGraphDataMock.mockResolvedValue(undefined)
const { initializeWorkflow } = useWorkflowPersistenceV2()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
// Should load draft via loadGraphData, not via workflowService.openWorkflow
expect(mocks.loadGraphDataMock).toHaveBeenCalled()
expect(openWorkflowMock).not.toHaveBeenCalled()
expect(mocks.apiMock.listUserDataFullInfo).not.toHaveBeenCalled()
})
it('falls back to latest draft only when no session path exists', async () => {
@@ -258,16 +395,111 @@ describe('useWorkflowPersistenceV2', () => {
mocks.loadGraphDataMock.mockResolvedValue(undefined)
const { initializeWorkflow } = useWorkflowPersistenceV2()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
// Should load via fallbackToLatestDraft
expect(mocks.loadGraphDataMock).toHaveBeenCalled()
expect(openWorkflowMock).not.toHaveBeenCalled()
expect(mocks.apiMock.listUserDataFullInfo).not.toHaveBeenCalled()
})
})
describe('restoreWorkflowTabsState', () => {
describe('initializeWorkflow', () => {
it('restores stored tab state instead of loading a default workflow', async () => {
mocks.apiMock.listUserDataFullInfo.mockResolvedValue([
{ path: 'A.json', modified: 100, size: 1 },
{ path: 'B.json', modified: 200, size: 1 }
])
writeTabState(['workflows/A.json', 'workflows/B.json'], 1)
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
const workflowStore = useWorkflowStore()
expect(workflowStore.openWorkflows.map((w) => w?.path)).toEqual([
'workflows/A.json',
'workflows/B.json'
])
expect(openWorkflowMock).toHaveBeenCalledWith(
workflowStore.getWorkflowByPath('workflows/B.json')
)
expect(mocks.loadGraphDataMock).not.toHaveBeenCalled()
expect(loadBlankWorkflowMock).not.toHaveBeenCalled()
expect(mocks.apiMock.listUserDataFullInfo).toHaveBeenCalledWith(
'workflows'
)
})
it('preserves stored tab state while initialization restores tabs', async () => {
mocks.apiMock.listUserDataFullInfo.mockResolvedValue([
{ path: 'A.json', modified: 100, size: 1 }
])
writeTabState(['workflows/A.json'], 0)
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
await nextTick()
expect(
JSON.parse(
sessionStorage.getItem('Comfy.Workflow.OpenPaths:test-client')!
)
).toMatchObject({
paths: ['workflows/A.json'],
activeIndex: 0
})
})
it('loads the onboarding blank workflow when persistence is disabled before tutorial completion', async () => {
settingMocks.persistRef!.value = false
settingMocks.tutorialCompletedRef!.value = false
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(settingMocks.set).toHaveBeenCalledWith(
'Comfy.TutorialCompleted',
true
)
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandMocks.execute).toHaveBeenCalledWith('Comfy.BrowseTemplates')
})
it('loads the default graph when persistence is disabled after tutorial completion', async () => {
settingMocks.persistRef!.value = false
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(mocks.loadGraphDataMock).toHaveBeenCalled()
expect(loadBlankWorkflowMock).not.toHaveBeenCalled()
})
it('does not enable tab state writes when default workflow load fails', async () => {
mocks.loadGraphDataMock.mockRejectedValueOnce(
new Error('default unavailable')
)
const { initializeWorkflow } = mountWorkflowPersistence()
await expect(initializeWorkflow()).rejects.toThrow('default unavailable')
const workflowStore = useWorkflowStore()
const workflow = workflowStore.createTemporary('Current.json')
workflowStore.attachWorkflow(workflow, 0)
workflowStore.activeWorkflow = workflow as NonNullable<
typeof workflowStore.activeWorkflow
>
await nextTick()
expect(
sessionStorage.getItem('Comfy.Workflow.OpenPaths:test-client')
).toBeNull()
})
})
describe('tab state restore during initialization', () => {
it('activates the correct workflow at storedActiveIndex', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
@@ -288,8 +520,8 @@ describe('useWorkflowPersistenceV2', () => {
// storedActiveIndex = 1 → WorkflowB should be activated
writeTabState([workflowA.path, workflowB.path], 1)
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(openWorkflowMock).toHaveBeenCalledWith(workflowB)
})
@@ -312,18 +544,50 @@ describe('useWorkflowPersistenceV2', () => {
writeTabState([workflowA.path, workflowB.path], 0)
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(openWorkflowMock).toHaveBeenCalledWith(workflowA)
})
it('does not call openWorkflow when no restorable state', async () => {
// No tab state written to sessionStorage
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(openWorkflowMock).not.toHaveBeenCalled()
expect(mocks.apiMock.listUserDataFullInfo).not.toHaveBeenCalled()
})
it('does not restore when storedActiveIndex is out of range', async () => {
writeTabState(['workflows/A.json'], 1)
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(openWorkflowMock).not.toHaveBeenCalled()
expect(mocks.apiMock.listUserDataFullInfo).not.toHaveBeenCalled()
})
it('loads saved workflows before restoring stored tabs', async () => {
mocks.apiMock.listUserDataFullInfo.mockResolvedValue([
{ path: 'A.json', modified: 100, size: 1 },
{ path: 'B.json', modified: 200, size: 1 }
])
writeTabState(['workflows/A.json', 'workflows/B.json'], 1)
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
const workflowStore = useWorkflowStore()
expect(workflowStore.openWorkflows.map((w) => w?.path)).toEqual([
'workflows/A.json',
'workflows/B.json'
])
expect(openWorkflowMock).toHaveBeenCalledWith(
workflowStore.getWorkflowByPath('workflows/B.json')
)
})
it('restores temporary workflows and adds them to tabs', async () => {
@@ -339,8 +603,8 @@ describe('useWorkflowPersistenceV2', () => {
writeTabState([path], 0)
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
const restored = workflowStore.getWorkflowByPath(path)
expect(restored).toBeTruthy()
@@ -348,11 +612,33 @@ describe('useWorkflowPersistenceV2', () => {
expect(workflowStore.openWorkflows.map((w) => w?.path)).toContain(path)
})
it('falls back to a default temporary workflow when a stored draft cannot be parsed', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
const path = 'workflows/Broken.json'
draftStore.saveDraft(path, 'not-json', {
name: 'Broken.json',
isTemporary: true
})
writeTabState([path], 0)
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(draftStore.getDraft(path)).toBeNull()
expect(
workflowStore.workflows.some(
(workflow) => workflow.fullFilename === 'Broken.json'
)
).toBe(true)
})
it('skips activation when persistence is disabled', async () => {
settingMocks.persistRef!.value = false
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(openWorkflowMock).not.toHaveBeenCalled()
})

View File

@@ -29,6 +29,7 @@ import {
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { PERSIST_DEBOUNCE_MS } from '../base/draftTypes'
import { createFailedToSaveDraftToast } from '../base/draftToast'
import { clearAllV2Storage } from '../base/storageIO'
import { migrateV1toV2 } from '../migration/migrateV1toV2'
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
@@ -39,6 +40,11 @@ import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
interface RestorableTabState {
paths: string[]
activeIndex: number
}
export function useWorkflowPersistenceV2() {
const { t } = useI18n()
const workflowStore = useWorkflowStore()
@@ -85,7 +91,7 @@ export function useWorkflowPersistenceV2() {
watch(workflowPersistenceEnabled, (enabled) => {
if (!enabled) {
draftStore.reset()
draftStore.clearIndexCache()
lastSavedJsonByPath.value = {}
}
})
@@ -102,18 +108,19 @@ export function useWorkflowPersistenceV2() {
// Skip if unchanged
if (workflowJson === lastSavedJsonByPath.value[workflowPath]) return
// Save to V2 draft store
const saved = draftStore.saveDraft(workflowPath, workflowJson, {
name: activeWorkflow.key,
isTemporary: activeWorkflow.isTemporary
})
let saved = false
try {
saved = draftStore.saveDraft(workflowPath, workflowJson, {
name: activeWorkflow.key,
isTemporary: activeWorkflow.isTemporary
})
} catch {
toast.add(createFailedToSaveDraftToast(t))
return
}
if (!saved) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToSaveDraft')
})
toast.add(createFailedToSaveDraftToast(t))
return
}
@@ -131,6 +138,25 @@ export function useWorkflowPersistenceV2() {
// Debounced version for graphChanged events
const debouncedPersist = debounce(persistCurrentWorkflow, PERSIST_DEBOUNCE_MS)
/**
* Opens a saved workflow referenced by a persisted path pointer.
*
* Drafts are tried before this fallback because they contain unsaved workflow
* JSON. This function only syncs workflow file metadata when the path is not
* already known locally; it does not load every saved workflow body.
*/
const openSavedWorkflowFromPath = async (path: string) => {
let saved = workflowStore.getWorkflowByPath(path)
if (!saved) {
await workflowStore.loadWorkflows()
saved = workflowStore.getWorkflowByPath(path)
}
if (!saved) return false
await useWorkflowService().openWorkflow(saved)
return true
}
const loadPreviousWorkflowFromStorage = async () => {
const sessionPath = tabState.getActivePath()
@@ -146,9 +172,7 @@ export function useWorkflowPersistenceV2() {
// 2. Try saved workflow by path (draft may not exist for saved+unmodified workflows)
if (sessionPath) {
const saved = workflowStore.getWorkflowByPath(sessionPath)
if (saved) {
await useWorkflowService().openWorkflow(saved)
if (await openSavedWorkflowFromPath(sessionPath)) {
return true
}
}
@@ -170,21 +194,44 @@ export function useWorkflowPersistenceV2() {
}
}
const getRestorableTabState = (): RestorableTabState | null => {
const storedTabState = tabState.getOpenPaths()
const paths = storedTabState?.paths ?? []
const activeIndex = storedTabState?.activeIndex ?? -1
if (paths.length === 0) return null
if (activeIndex < 0 || activeIndex >= paths.length) return null
return { paths, activeIndex }
}
// Keep tab-state writes disabled until initialization has consumed any
// stored tab pointer.
let tabStateRestored = false
const initializeWorkflow = async () => {
if (!workflowPersistenceEnabled.value) {
await loadDefaultWorkflow()
tabStateRestored = true
return
}
try {
if (getRestorableTabState()) {
await restoreWorkflowTabsState()
return
}
const restored = await loadPreviousWorkflowFromStorage()
if (!restored) {
await loadDefaultWorkflow()
if (restored) {
tabStateRestored = true
return
}
} catch (err) {
console.error('Error loading previous workflow', err)
await loadDefaultWorkflow()
}
await loadDefaultWorkflow()
tabStateRestored = true
}
const loadTemplateFromUrlIfPresent = async () => {
@@ -242,10 +289,6 @@ export function useWorkflowPersistenceV2() {
}
)
// Track whether tab state has been properly restored to avoid
// overwriting with stale data during initialization
let tabStateRestored = false
watch(restoreState, ({ paths, activeIndex }) => {
// Only persist after tab state has been restored to avoid
// writing leaked data from wrong workspace during init
@@ -260,19 +303,15 @@ export function useWorkflowPersistenceV2() {
return
}
// Read storage fresh at restore time, not at composable init,
// to ensure workspace is properly determined
const storedTabState = tabState.getOpenPaths()
const storedWorkflows = storedTabState?.paths ?? []
const storedActiveIndex = storedTabState?.activeIndex ?? -1
const isRestorable = storedWorkflows.length > 0 && storedActiveIndex >= 0
if (!isRestorable) {
const storedTabState = getRestorableTabState()
if (!storedTabState) {
tabStateRestored = true
return
}
storedWorkflows.forEach((path: string) => {
await workflowStore.loadWorkflows()
storedTabState.paths.forEach((path: string) => {
if (workflowStore.getWorkflowByPath(path)) return
const draft = draftStore.getDraft(path)
if (!draft?.isTemporary) return
@@ -290,26 +329,25 @@ export function useWorkflowPersistenceV2() {
})
workflowStore.openWorkflowsInBackground({
left: storedWorkflows.slice(0, storedActiveIndex),
right: storedWorkflows.slice(storedActiveIndex)
left: storedTabState.paths.slice(0, storedTabState.activeIndex),
right: storedTabState.paths.slice(storedTabState.activeIndex)
})
tabStateRestored = true
// Activate the correct workflow at storedActiveIndex
const activePath = storedWorkflows[storedActiveIndex]
const activePath = storedTabState.paths[storedTabState.activeIndex]
const workflow = activePath
? workflowStore.getWorkflowByPath(activePath)
: null
if (workflow) {
await useWorkflowService().openWorkflow(workflow)
}
tabStateRestored = true
}
return {
initializeWorkflow,
loadSharedWorkflowFromUrlIfPresent,
loadTemplateFromUrlIfPresent,
restoreWorkflowTabsState
loadTemplateFromUrlIfPresent
}
}

View File

@@ -279,9 +279,9 @@ class StoreResetCommand implements Command<PersistenceModel, PersistenceReal> {
}
run(model: PersistenceModel, real: PersistenceReal) {
// Call reset() directly to clear in-memory cache without recreating Pinia.
// Clear in-memory cache without recreating Pinia.
// This exercises the orphan cleanup path in loadIndex() on next access.
real.draftStore.reset()
real.draftStore.clearIndexCache()
for (const [path, expected] of model.drafts) {
const draft = real.draftStore.getDraft(path)

View File

@@ -3,6 +3,7 @@ import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_DRAFTS } from '../base/draftTypes'
import { useWorkflowDraftStore } from './workflowDraftStore'
import { useWorkflowDraftStoreV2 } from './workflowDraftStoreV2'
vi.mock('@/scripts/api', () => ({
@@ -19,6 +20,19 @@ vi.mock('@/scripts/app', () => ({
}))
describe('workflowDraftStoreV2', () => {
function failV2IndexWrites() {
const originalSetItem = localStorage.setItem.bind(localStorage)
return vi
.spyOn(localStorage, 'setItem')
.mockImplementation((key: string, value: string) => {
if (key.startsWith('Comfy.Workflow.DraftIndex.v2:')) {
throw new DOMException('Quota exceeded', 'QuotaExceededError')
}
return originalSetItem(key, value)
})
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
localStorage.clear()
@@ -29,6 +43,8 @@ describe('workflowDraftStoreV2', () => {
afterEach(() => {
localStorage.clear()
sessionStorage.clear()
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('saveDraft', () => {
@@ -58,6 +74,49 @@ describe('workflowDraftStoreV2', () => {
expect(payloadKeys).toHaveLength(1)
})
it('shadow-writes the legacy draft store', () => {
const store = useWorkflowDraftStoreV2()
store.saveDraft('workflows/test.json', '{"nodes":[]}', {
name: 'test',
isTemporary: true
})
expect(
useWorkflowDraftStore().getDraft('workflows/test.json')
).toMatchObject({
data: '{"nodes":[]}',
name: 'test',
isTemporary: true
})
})
it('uses the same timestamp for V2 and legacy shadow-writes', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'))
const store = useWorkflowDraftStoreV2()
const path = 'workflows/test.json'
store.saveDraft(path, '{"source":"v2"}', {
name: 'test',
isTemporary: true
})
const v2Draft = store.getDraft(path)
const legacyDraft = useWorkflowDraftStore().getDraft(path)
expect(v2Draft?.updatedAt).toBe(Date.now())
expect(legacyDraft?.updatedAt).toBe(Date.now())
useWorkflowDraftStore().saveDraft(path, {
data: '{"source":"legacy"}',
updatedAt: Date.now(),
name: 'test',
isTemporary: true
})
expect(store.getDraft(path)?.data).toBe('{"source":"v2"}')
})
it('updates existing draft', () => {
const store = useWorkflowDraftStoreV2()
@@ -78,6 +137,56 @@ describe('workflowDraftStoreV2', () => {
expect(draft!.isTemporary).toBe(false)
})
it('returns true when V2 saves and legacy shadow-write fails', () => {
const store = useWorkflowDraftStoreV2()
const legacyStore = useWorkflowDraftStore()
vi.spyOn(legacyStore, 'saveDraft').mockImplementation(() => {
throw new Error('legacy unavailable')
})
const result = store.saveDraft('workflows/test.json', '{}', {
name: 'test',
isTemporary: true
})
expect(result).toBe(true)
expect(store.getDraft('workflows/test.json')?.data).toBe('{}')
})
it('returns false without shadow-writing legacy when V2 save fails', () => {
const store = useWorkflowDraftStoreV2()
const path = 'workflows/test.json'
const setItemSpy = failV2IndexWrites()
const result = store.saveDraft(path, '{}', {
name: 'test',
isTemporary: true
})
setItemSpy.mockRestore()
expect(result).toBe(false)
expect(store.getDraft(path)).toBeNull()
expect(useWorkflowDraftStore().getDraft(path)).toBeUndefined()
})
it('does not cache a draft index entry when storage write fails', () => {
const store = useWorkflowDraftStoreV2()
store.saveDraft('workflows/old.json', '{"id":"old"}', {
name: 'old',
isTemporary: true
})
const setItemSpy = failV2IndexWrites()
const result = store.saveDraft('workflows/new.json', '{"id":"new"}', {
name: 'new',
isTemporary: true
})
setItemSpy.mockRestore()
expect(result).toBe(false)
expect(store.getMostRecentPath()).toBe('workflows/old.json')
})
it('evicts oldest when over limit', () => {
const store = useWorkflowDraftStoreV2()
@@ -118,6 +227,9 @@ describe('workflowDraftStoreV2', () => {
k.startsWith('Comfy.Workflow.Draft.v2:personal:')
)
expect(payloadKeys).toHaveLength(0)
expect(
useWorkflowDraftStore().getDraft('workflows/test.json')
).toBeUndefined()
})
})
@@ -138,6 +250,193 @@ describe('workflowDraftStoreV2', () => {
expect(newDraft).not.toBeNull()
expect(newDraft!.name).toBe('new')
expect(newDraft!.data).toBe('{"data":"test"}')
const legacyStore = useWorkflowDraftStore()
expect(legacyStore.getDraft('workflows/old.json')).toBeUndefined()
expect(legacyStore.getDraft('workflows/new.json')).toMatchObject({
name: 'new',
data: '{"data":"test"}'
})
})
it('preserves the draft timestamp when moving V2 and legacy data', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'))
const store = useWorkflowDraftStoreV2()
const savedAt = Date.now()
store.saveDraft('workflows/old.json', '{"data":"test"}', {
name: 'old',
isTemporary: true
})
vi.setSystemTime(new Date('2025-01-01T00:05:00Z'))
store.moveDraft('workflows/old.json', 'workflows/new.json', 'new')
expect(store.getDraft('workflows/new.json')?.updatedAt).toBe(savedAt)
expect(
useWorkflowDraftStore().getDraft('workflows/new.json')?.updatedAt
).toBe(savedAt)
})
it('does not move legacy shadow data when V2 move fails', () => {
const store = useWorkflowDraftStoreV2()
const legacyStore = useWorkflowDraftStore()
store.saveDraft('workflows/old.json', '{"data":"test"}', {
name: 'old',
isTemporary: true
})
const setItemSpy = failV2IndexWrites()
const moved = store.moveDraft(
'workflows/old.json',
'workflows/new.json',
'new'
)
setItemSpy.mockRestore()
expect(moved).toBe(false)
expect(store.getDraft('workflows/old.json')).toMatchObject({
data: '{"data":"test"}'
})
expect(store.getDraft('workflows/new.json')).toBeNull()
expect(legacyStore.getDraft('workflows/old.json')).toMatchObject({
data: '{"data":"test"}'
})
expect(legacyStore.getDraft('workflows/new.json')).toBeUndefined()
})
it('does not move legacy-only data when V2 source is missing', () => {
const store = useWorkflowDraftStoreV2()
const legacyStore = useWorkflowDraftStore()
legacyStore.saveDraft('workflows/old.json', {
data: '{"source":"legacy"}',
updatedAt: Date.now(),
name: 'old',
isTemporary: true
})
const moved = store.moveDraft(
'workflows/old.json',
'workflows/new.json',
'new'
)
expect(moved).toBe(false)
expect(store.getDraft('workflows/old.json')).toMatchObject({
data: '{"source":"legacy"}'
})
expect(store.getDraft('workflows/new.json')).toBeNull()
expect(legacyStore.getDraft('workflows/old.json')).toMatchObject({
data: '{"source":"legacy"}'
})
expect(legacyStore.getDraft('workflows/new.json')).toBeUndefined()
})
})
describe('getDraft', () => {
it('uses legacy fallback content only when it is strictly newer than V2', () => {
const store = useWorkflowDraftStoreV2()
const path = 'workflows/test.json'
store.saveDraft(path, '{"source":"v2"}', {
name: 'test',
isTemporary: true
})
useWorkflowDraftStore().saveDraft(path, {
data: '{"source":"legacy"}',
updatedAt: Date.now() + 1000,
name: 'test',
isTemporary: true
})
expect(store.getDraft(path)?.data).toBe('{"source":"legacy"}')
})
it('does not read or write legacy drafts outside personal workspace', () => {
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({ id: 'workspace-1', type: 'org' })
)
const store = useWorkflowDraftStoreV2()
const legacyStore = useWorkflowDraftStore()
const path = 'workflows/test.json'
store.saveDraft(path, '{"source":"v2"}', {
name: 'test',
isTemporary: true
})
legacyStore.saveDraft(path, {
data: '{"source":"legacy"}',
updatedAt: Date.now() + 1000,
name: 'test',
isTemporary: true
})
legacyStore.saveDraft('workflows/legacy-only.json', {
data: '{"source":"legacy-only"}',
updatedAt: Date.now() + 1000,
name: 'legacy-only',
isTemporary: true
})
expect(legacyStore.getDraft(path)).toMatchObject({
data: '{"source":"legacy"}'
})
expect(store.getDraft(path)?.data).toBe('{"source":"v2"}')
expect(store.getDraft('workflows/legacy-only.json')).toBeNull()
})
it('returns null when legacy fallback read fails', () => {
const store = useWorkflowDraftStoreV2()
const legacyStore = useWorkflowDraftStore()
vi.spyOn(legacyStore, 'getDraft').mockImplementation(() => {
throw new Error('legacy unavailable')
})
expect(store.getDraft('workflows/missing.json')).toBeNull()
})
it('cleans up the V2 index when a payload is missing', () => {
const store = useWorkflowDraftStoreV2()
const path = 'workflows/missing-payload.json'
store.saveDraft(path, '{"nodes":[]}', {
name: 'missing-payload',
isTemporary: true
})
useWorkflowDraftStore().removeDraft(path)
const payloadKey = Object.keys(localStorage).find((key) =>
key.startsWith('Comfy.Workflow.Draft.v2:personal:')
)
expect(payloadKey).toBeDefined()
localStorage.removeItem(payloadKey!)
expect(store.getDraft(path)).toBeNull()
const index = JSON.parse(
localStorage.getItem('Comfy.Workflow.DraftIndex.v2:personal')!
)
expect(index.order).toHaveLength(0)
})
})
describe('markDraftUsed', () => {
it('updates the V2 recency order', () => {
const store = useWorkflowDraftStoreV2()
store.saveDraft('workflows/a.json', '{}', {
name: 'a',
isTemporary: true
})
store.saveDraft('workflows/b.json', '{}', {
name: 'b',
isTemporary: true
})
store.markDraftUsed('workflows/a.json')
expect(store.getMostRecentPath()).toBe('workflows/a.json')
})
})
@@ -197,6 +496,23 @@ describe('workflowDraftStoreV2', () => {
expect(result).toBe(true)
})
it('falls back to legacy drafts when V2 has no matching draft', async () => {
const store = useWorkflowDraftStoreV2()
useWorkflowDraftStore().saveDraft('workflows/legacy.json', {
data: '{"nodes":[]}',
updatedAt: Date.now(),
name: 'legacy',
isTemporary: true
})
const result = await store.loadPersistedWorkflow({
workflowName: null,
preferredPath: 'workflows/legacy.json'
})
expect(result).toBe(true)
})
it('returns false when no drafts available', async () => {
const store = useWorkflowDraftStoreV2()
@@ -207,9 +523,75 @@ describe('workflowDraftStoreV2', () => {
expect(result).toBe(false)
})
it('removes an invalid persisted draft after load fails', async () => {
const store = useWorkflowDraftStoreV2()
const path = 'workflows/bad.json'
store.saveDraft(path, 'not-json', {
name: 'bad',
isTemporary: true
})
const result = await store.loadPersistedWorkflow({
workflowName: null,
preferredPath: path
})
expect(result).toBe(false)
expect(store.getDraft(path)).toBeNull()
})
it('returns false for an empty persisted draft payload', async () => {
const store = useWorkflowDraftStoreV2()
const path = 'workflows/empty.json'
store.saveDraft(path, '', {
name: 'empty',
isTemporary: true
})
const result = await store.loadPersistedWorkflow({
workflowName: null,
preferredPath: path
})
expect(result).toBe(false)
})
it('returns false when legacy persisted workflow fallback throws', async () => {
const store = useWorkflowDraftStoreV2()
const legacyStore = useWorkflowDraftStore()
vi.spyOn(legacyStore, 'loadPersistedWorkflow').mockImplementation(() => {
throw new Error('legacy unavailable')
})
const result = await store.loadPersistedWorkflow({
workflowName: null,
preferredPath: 'workflows/missing.json'
})
expect(result).toBe(false)
})
it('does not use legacy persisted workflow fallback outside personal workspace', async () => {
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({ id: 'workspace-1', type: 'org' })
)
const store = useWorkflowDraftStoreV2()
const legacyStore = useWorkflowDraftStore()
const legacyLoad = vi.spyOn(legacyStore, 'loadPersistedWorkflow')
const result = await store.loadPersistedWorkflow({
workflowName: null,
preferredPath: 'workflows/missing.json'
})
expect(result).toBe(false)
expect(legacyLoad).not.toHaveBeenCalled()
})
})
describe('reset', () => {
describe('clearIndexCache', () => {
it('clears in-memory cache', () => {
const store = useWorkflowDraftStoreV2()
@@ -218,7 +600,7 @@ describe('workflowDraftStoreV2', () => {
isTemporary: true
})
store.reset()
store.clearIndexCache()
// Draft should still be loadable from localStorage
expect(store.getDraft('workflows/test.json')).not.toBeNull()

View File

@@ -17,8 +17,10 @@ import {
moveEntry,
removeEntry,
removeOrphanedEntries,
touchOrder,
upsertEntry
} from '../base/draftCacheV2'
import type { WorkflowDraftSnapshot } from '../base/draftCache'
import { hashPath } from '../base/hashUtil'
import { getWorkspaceId } from '../base/storageKeys'
import {
@@ -33,7 +35,7 @@ import {
writeIndex,
writePayload
} from '../base/storageIO'
import { api } from '@/scripts/api'
import { useWorkflowDraftStore } from './workflowDraftStore'
import { app as comfyApp } from '@/scripts/app'
interface DraftMeta {
@@ -42,6 +44,10 @@ interface DraftMeta {
}
interface LoadPersistedWorkflowOptions {
/**
* Forwarded only to the legacy V1 rollback fallback for session/localStorage
* graph loads. Remove with the V1 rollback path after 2026-07-15.
*/
workflowName: string | null
preferredPath?: string | null
fallbackToLatestDraft?: boolean
@@ -94,20 +100,144 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
*/
function persistIndex(index: DraftIndexV2): boolean {
const workspaceId = currentWorkspaceId()
const written = writeIndex(workspaceId, index)
if (!written) return false
indexCacheByWorkspace.value[workspaceId] = index
return writeIndex(workspaceId, index)
return true
}
/**
* Saves a draft (data + metadata).
* Primes index cache, writes payload, then persists updated index.
* Saves a draft to V2 and shadow-writes the legacy store for rollback.
*/
function saveDraft(path: string, data: string, meta: DraftMeta): boolean {
const snapshot = createDraftSnapshot(data, meta)
const savedV2 = saveDraftV2(path, snapshot)
if (!savedV2) return false
saveLegacyDraft(path, snapshot)
return true
}
/**
* Removes a draft from V2 and legacy rollback storage.
*/
function removeDraft(path: string): void {
removeDraftV2(path)
removeLegacyDraft(path)
}
/**
* Moves a draft from one path to another in V2 and legacy rollback storage.
*/
function moveDraft(oldPath: string, newPath: string, name: string): boolean {
const moved = moveDraftV2(oldPath, newPath, name)
if (moved) {
moveLegacyDraft(oldPath, newPath, name)
}
return moved
}
/**
* Gets draft data by path, using legacy only as a rollback fallback.
*
* V2 wins equal timestamps because save and move operations keep the
* shadow-written legacy snapshot timestamp aligned with V2.
*/
function getDraft(path: string): WorkflowDraftSnapshot | null {
const v2Draft = getDraftV2(path)
const legacyDraft = getLegacyDraft(path)
if (!v2Draft) return legacyDraft
if (!legacyDraft) return v2Draft
return legacyDraft.updatedAt > v2Draft.updatedAt ? legacyDraft : v2Draft
}
/**
* Marks a draft as recently used without rewriting its payload.
*/
function markDraftUsed(path: string): void {
markDraftUsedV2(path)
markLegacyDraftUsed(path)
}
/**
* Gets the most recent draft path.
*/
function getMostRecentPath(): string | null {
const index = loadIndex()
const key = getMostRecentKey(index)
if (!key) return null
const entry = index.entries[key]
return entry?.path ?? null
}
/**
* Loads a draft into the graph.
*/
async function loadDraft(path: string): Promise<boolean> {
const draft = getDraft(path)
if (!draft) return false
const loaded = await tryLoadGraph(draft.data, draft.name, () => {
removeDraft(path)
})
if (loaded) {
markDraftUsed(path)
}
return loaded
}
/**
* Loads a persisted workflow with fallback chain.
*/
async function loadPersistedWorkflow(
options: LoadPersistedWorkflowOptions
): Promise<boolean> {
const { preferredPath, fallbackToLatestDraft = false } = options
// 1. Try preferred path
if (preferredPath && (await loadDraft(preferredPath))) {
return true
}
// 2. Fall back to most recent draft
if (fallbackToLatestDraft) {
const mostRecent = getMostRecentPath()
if (mostRecent && (await loadDraft(mostRecent))) {
return true
}
}
// Legacy fallback is personal-only because those keys are not
// workspace-scoped. Remove after 2026-07-15 with the V1 rollback path.
return await loadLegacyPersistedWorkflow(options)
}
/**
* Saves a draft (data + metadata) to V2.
* Loads the index before writing payload, then persists the updated index.
*/
function createDraftSnapshot(
data: string,
meta: DraftMeta
): WorkflowDraftSnapshot {
return {
data,
updatedAt: Date.now(),
name: meta.name,
isTemporary: meta.isTemporary
}
}
function saveDraftV2(path: string, snapshot: WorkflowDraftSnapshot): boolean {
if (!isStorageAvailable()) return false
const workspaceId = currentWorkspaceId()
const draftKey = hashPath(path)
const now = Date.now()
// Prime the index cache before writing payload.
// loadIndex() runs orphan cleanup on cache miss, which would
@@ -116,18 +246,22 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
// Write payload before persisting the updated index
const payloadWritten = writePayload(workspaceId, draftKey, {
data,
updatedAt: now
data: snapshot.data,
updatedAt: snapshot.updatedAt
})
if (!payloadWritten) {
// Quota exceeded - try eviction loop
return handleQuotaExceeded(path, data, meta)
return handleQuotaExceeded(path, snapshot)
}
const { index: newIndex, evicted } = upsertEntry(
index,
path,
{ ...meta, updatedAt: now },
{
name: snapshot.name,
isTemporary: snapshot.isTemporary,
updatedAt: snapshot.updatedAt
},
MAX_DRAFTS
)
@@ -149,8 +283,7 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
*/
function handleQuotaExceeded(
path: string,
data: string,
meta: DraftMeta
snapshot: WorkflowDraftSnapshot
): boolean {
const workspaceId = currentWorkspaceId()
const index = loadIndex()
@@ -176,8 +309,8 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
// Try writing again
const success = writePayload(workspaceId, draftKey, {
data,
updatedAt: Date.now()
data: snapshot.data,
updatedAt: snapshot.updatedAt
})
if (success) {
@@ -185,7 +318,11 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
const { index: finalIndex } = upsertEntry(
currentIndex,
path,
{ ...meta, updatedAt: Date.now() },
{
name: snapshot.name,
isTemporary: snapshot.isTemporary,
updatedAt: snapshot.updatedAt
},
MAX_DRAFTS
)
if (!persistIndex(finalIndex)) {
@@ -201,10 +338,7 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
return false
}
/**
* Removes a draft.
*/
function removeDraft(path: string): void {
function removeDraftV2(path: string): void {
const workspaceId = currentWorkspaceId()
const index = loadIndex()
const { index: newIndex, removedKey } = removeEntry(index, path)
@@ -215,38 +349,48 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
}
}
/**
* Moves a draft from one path to another (rename).
*/
function moveDraft(oldPath: string, newPath: string, name: string): void {
function moveDraftV2(
oldPath: string,
newPath: string,
name: string
): boolean {
const workspaceId = currentWorkspaceId()
const index = loadIndex()
const oldEntry = getEntryByPath(index, oldPath)
if (!oldEntry) return false
const oldKey = hashPath(oldPath)
const newKey = hashPath(newPath)
const newEntry = getEntryByPath(index, newPath)
if (oldKey !== newKey && newEntry) return false
const result = moveEntry(index, oldPath, newPath, name)
if (!result) return false
if (result) {
const oldPayload = readPayload(workspaceId, result.oldKey)
if (oldPayload) {
const written = writePayload(workspaceId, result.newKey, {
data: oldPayload.data,
updatedAt: Date.now()
})
if (!written) return
if (!persistIndex(result.index)) {
deletePayload(workspaceId, result.newKey)
return
}
deletePayload(workspaceId, result.oldKey)
}
const oldPayload = readPayload(workspaceId, result.oldKey)
if (!oldPayload) {
removeDraftV2(oldPath)
return false
}
const written = writePayload(workspaceId, result.newKey, {
data: oldPayload.data,
updatedAt: oldPayload.updatedAt
})
if (!written) return false
if (!persistIndex(result.index)) {
deletePayload(workspaceId, result.newKey)
return false
}
if (result.oldKey !== result.newKey) {
deletePayload(workspaceId, result.oldKey)
}
return true
}
/**
* Gets draft data by path.
*/
function getDraft(
path: string
): { data: string; name: string; isTemporary: boolean } | null {
function getDraftV2(path: string): WorkflowDraftSnapshot | null {
const workspaceId = currentWorkspaceId()
const index = loadIndex()
const entry = getEntryByPath(index, path)
@@ -256,27 +400,29 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
const payload = readPayload(workspaceId, draftKey)
if (!payload) {
// Payload missing - clean up index
removeDraft(path)
removeDraftV2(path)
return null
}
return {
data: payload.data,
name: entry.name,
isTemporary: entry.isTemporary
isTemporary: entry.isTemporary,
updatedAt: payload.updatedAt
}
}
/**
* Gets the most recent draft path.
*/
function getMostRecentPath(): string | null {
function markDraftUsedV2(path: string): void {
const index = loadIndex()
const key = getMostRecentKey(index)
if (!key) return null
const entry = getEntryByPath(index, path)
if (!entry) return
const entry = index.entries[key]
return entry?.path ?? null
const draftKey = hashPath(path)
persistIndex({
...index,
updatedAt: Date.now(),
order: touchOrder(index.order, draftKey)
})
}
/**
@@ -299,78 +445,84 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
}
}
/**
* Loads a draft into the graph.
*/
async function loadDraft(path: string): Promise<boolean> {
const draft = getDraft(path)
if (!draft) return false
function saveLegacyDraft(
path: string,
snapshot: WorkflowDraftSnapshot
): boolean {
if (!canUseLegacyDraftStore()) return false
const loaded = await tryLoadGraph(draft.data, draft.name, () => {
removeDraft(path)
})
return loaded
try {
useWorkflowDraftStore().saveDraft(path, snapshot)
return true
} catch {
// Legacy writes are rollback-only.
// Do not block V2 persistence if they fail.
return false
}
}
/**
* Loads a persisted workflow with fallback chain.
*/
async function loadPersistedWorkflow(
function getLegacyDraft(path: string): WorkflowDraftSnapshot | null {
if (!canUseLegacyDraftStore()) return null
try {
return useWorkflowDraftStore().getDraft(path) ?? null
} catch {
// Legacy reads are rollback fallback only. Keep V2 authoritative.
return null
}
}
function removeLegacyDraft(path: string): void {
if (!canUseLegacyDraftStore()) return
try {
useWorkflowDraftStore().removeDraft(path)
} catch {
// Legacy writes are rollback-only.
// Do not block V2 persistence if they fail.
}
}
function moveLegacyDraft(oldPath: string, newPath: string, name: string) {
if (!canUseLegacyDraftStore()) return
try {
useWorkflowDraftStore().moveDraft(oldPath, newPath, name)
} catch {
// Legacy writes are rollback-only.
// Do not block V2 persistence if they fail.
}
}
function markLegacyDraftUsed(path: string) {
if (!canUseLegacyDraftStore()) return
try {
useWorkflowDraftStore().markDraftUsed(path)
} catch {
// Legacy writes are rollback-only.
// Do not block V2 persistence if they fail.
}
}
function canUseLegacyDraftStore(): boolean {
return currentWorkspaceId() === 'personal'
}
async function loadLegacyPersistedWorkflow(
options: LoadPersistedWorkflowOptions
): Promise<boolean> {
const {
workflowName,
preferredPath,
fallbackToLatestDraft = false
} = options
if (currentWorkspaceId() !== 'personal') return false
// 1. Try preferred path
if (preferredPath && (await loadDraft(preferredPath))) {
return true
}
// 2. Fall back to most recent draft
if (fallbackToLatestDraft) {
const mostRecent = getMostRecentPath()
if (mostRecent && (await loadDraft(mostRecent))) {
return true
}
}
// Legacy fallbacks are NOT workspace-scoped and must only be used for
// personal workspace to prevent cross-workspace data leakage.
// These exist only for migration from V1 and should be removed after 2026-07-15.
if (currentWorkspaceId() !== 'personal') {
return false
}
// 3. Legacy fallback: sessionStorage payload (remove after 2026-07-15)
const clientId = api.initialClientId ?? api.clientId
if (clientId) {
try {
const sessionPayload = sessionStorage.getItem(`workflow:${clientId}`)
if (await tryLoadGraph(sessionPayload, workflowName)) {
return true
}
} catch {
// Ignore storage access errors and continue fallback chain
}
}
// 4. Legacy fallback: localStorage payload (remove after 2026-07-15)
try {
const localPayload = localStorage.getItem('workflow')
return await tryLoadGraph(localPayload, workflowName)
return await useWorkflowDraftStore().loadPersistedWorkflow(options)
} catch {
// Legacy reads are rollback fallback only. Keep V2 authoritative.
return false
}
}
/**
* Resets the store (clears in-memory cache for current workspace).
*/
function reset(): void {
function clearIndexCache(): void {
const workspaceId = currentWorkspaceId()
delete indexCacheByWorkspace.value[workspaceId]
}
@@ -380,8 +532,9 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
removeDraft,
moveDraft,
getDraft,
markDraftUsed,
getMostRecentPath,
loadPersistedWorkflow,
reset
clearIndexCache
}
})