mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 09:48:09 +00:00
Compare commits
7 Commits
split/node
...
jaeone/fe-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
768a4c7219 | ||
|
|
0fa468e063 | ||
|
|
1c39223336 | ||
|
|
2e32714047 | ||
|
|
9a3bbcc1b7 | ||
|
|
6587964487 | ||
|
|
7801697188 |
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}) => {
|
||||
|
||||
@@ -557,7 +557,6 @@ onMounted(async () => {
|
||||
|
||||
// Restore saved workflow and workflow tabs state
|
||||
await workflowPersistence.initializeWorkflow()
|
||||
await workflowPersistence.restoreWorkflowTabsState()
|
||||
|
||||
const sharedWorkflowLoadStatus =
|
||||
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -128,8 +128,7 @@ export function moveEntry(
|
||||
entries[newKey] = {
|
||||
...oldEntry,
|
||||
path: newPath,
|
||||
name: newName,
|
||||
updatedAt: Date.now()
|
||||
name: newName
|
||||
}
|
||||
|
||||
const order = index.order
|
||||
|
||||
9
src/platform/workflow/persistence/base/draftToast.ts
Normal file
9
src/platform/workflow/persistence/base/draftToast.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user