diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 27c038076..96a52147e 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -138,7 +138,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore' import { useWorkflowService } from '@/platform/workflow/core/services/workflowService' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave' -import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence' +import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistenceV2' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue' diff --git a/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.ts b/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.ts new file mode 100644 index 000000000..565c26c40 --- /dev/null +++ b/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.ts @@ -0,0 +1,263 @@ +/** + * V2 Workflow Persistence Composable + * + * Key changes from V1: + * - Uses V2 draft store with per-draft keys + * - Uses tab state composable for session pointers + * - Adds 512ms debounce on graph change persistence + * - Runs V1→V2 migration on first load + */ + +import { debounce } from 'es-toolkit' +import { useToast } from 'primevue' +import { tryOnScopeDispose } from '@vueuse/core' +import { computed, ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' +import { useRoute, useRouter } from 'vue-router' + +import { useCurrentUser } from '@/composables/auth/useCurrentUser' +import { + hydratePreservedQuery, + mergePreservedQueryIntoQuery +} from '@/platform/navigation/preservedQueryManager' +import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces' +import { isCloud } from '@/platform/distribution/types' +import { useSettingStore } from '@/platform/settings/settingStore' +import { useWorkflowService } from '@/platform/workflow/core/services/workflowService' +import { + ComfyWorkflow, + useWorkflowStore +} from '@/platform/workflow/management/stores/workflowStore' +import { PERSIST_DEBOUNCE_MS } from '../base/draftTypes' +import { clearAllV2Storage } from '../base/storageIO' +import { migrateV1toV2 } from '../migration/migrateV1toV2' +import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2' +import { useWorkflowTabState } from './useWorkflowTabState' +import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader' +import { api } from '@/scripts/api' +import { app as comfyApp } from '@/scripts/app' +import { useCommandStore } from '@/stores/commandStore' + +export function useWorkflowPersistenceV2() { + const { t } = useI18n() + const workflowStore = useWorkflowStore() + const settingStore = useSettingStore() + const route = useRoute() + const router = useRouter() + const templateUrlLoader = useTemplateUrlLoader() + const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE + const draftStore = useWorkflowDraftStoreV2() + const tabState = useWorkflowTabState() + const toast = useToast() + const { onUserLogout } = useCurrentUser() + + // Run migration on module load + migrateV1toV2() + + // Clear workflow persistence storage when user signs out (cloud only) + onUserLogout(() => { + if (isCloud) { + clearAllV2Storage() + } + }) + + const ensureTemplateQueryFromIntent = async () => { + hydratePreservedQuery(TEMPLATE_NAMESPACE) + const mergedQuery = mergePreservedQueryIntoQuery( + TEMPLATE_NAMESPACE, + route.query + ) + + if (mergedQuery) { + await router.replace({ query: mergedQuery }) + } + + return mergedQuery ?? route.query + } + + const workflowPersistenceEnabled = computed(() => + settingStore.get('Comfy.Workflow.Persist') + ) + + const lastSavedJsonByPath = ref>({}) + + const persistCurrentWorkflow = () => { + if (!workflowPersistenceEnabled.value) return + const activeWorkflow = workflowStore.activeWorkflow + if (!activeWorkflow) return + + const graphData = comfyApp.rootGraph.serialize() + const workflowJson = JSON.stringify(graphData) + const workflowPath = activeWorkflow.path + + // 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 + }) + + if (!saved) { + toast.add({ + severity: 'error', + summary: t('g.error'), + detail: t('toastMessages.failedToSaveDraft'), + life: 3000 + }) + return + } + + // Update session pointer + tabState.setActivePath(workflowPath) + + lastSavedJsonByPath.value[workflowPath] = workflowJson + + // Clean up draft if workflow is saved and unmodified + if (!activeWorkflow.isTemporary && !activeWorkflow.isModified) { + draftStore.removeDraft(workflowPath) + } + } + + // Debounced version for graphChanged events + const debouncedPersist = debounce(persistCurrentWorkflow, PERSIST_DEBOUNCE_MS) + + const loadPreviousWorkflowFromStorage = async () => { + // 1. Try session pointer (for tab restoration) + const sessionPath = tabState.getActivePath() + if ( + sessionPath && + (await draftStore.loadPersistedWorkflow({ + workflowName: null, + preferredPath: sessionPath + })) + ) { + return true + } + + // 2. Fall back to most recent draft + return await draftStore.loadPersistedWorkflow({ + workflowName: null, + fallbackToLatestDraft: true + }) + } + + const loadDefaultWorkflow = async () => { + if (!settingStore.get('Comfy.TutorialCompleted')) { + await settingStore.set('Comfy.TutorialCompleted', true) + await useWorkflowService().loadBlankWorkflow() + await useCommandStore().execute('Comfy.BrowseTemplates') + } else { + await comfyApp.loadGraphData() + } + } + + const initializeWorkflow = async () => { + if (!workflowPersistenceEnabled.value) return + + try { + const restored = await loadPreviousWorkflowFromStorage() + if (!restored) { + await loadDefaultWorkflow() + } + } catch (err) { + console.error('Error loading previous workflow', err) + await loadDefaultWorkflow() + } + } + + const loadTemplateFromUrlIfPresent = async () => { + const query = await ensureTemplateQueryFromIntent() + const hasTemplateUrl = query.template && typeof query.template === 'string' + + if (hasTemplateUrl) { + await templateUrlLoader.loadTemplateFromUrl() + } + } + + // Setup watchers + watch( + () => workflowStore.activeWorkflow?.key, + (activeWorkflowKey) => { + if (!activeWorkflowKey) return + // Persist immediately on workflow switch (not debounced) + persistCurrentWorkflow() + } + ) + + // Debounced persistence on graph changes + api.addEventListener('graphChanged', debouncedPersist) + + // Clean up event listener when component unmounts + tryOnScopeDispose(() => { + api.removeEventListener('graphChanged', debouncedPersist) + debouncedPersist.cancel() + }) + + // Restore workflow tabs states + const openWorkflows = computed(() => workflowStore.openWorkflows) + const activeWorkflow = computed(() => workflowStore.activeWorkflow) + const restoreState = computed<{ paths: string[]; activeIndex: number }>( + () => { + if (!openWorkflows.value || !activeWorkflow.value) { + return { paths: [], activeIndex: -1 } + } + + const paths = openWorkflows.value + .map((workflow) => workflow?.path) + .filter( + (path): path is string => + typeof path === 'string' && path.startsWith(ComfyWorkflow.basePath) + ) + const activeIndex = paths.indexOf(activeWorkflow.value.path) + + return { paths, activeIndex } + } + ) + + // Get storage values before setting watchers + const storedTabState = tabState.getOpenPaths() + const storedWorkflows = storedTabState?.paths ?? [] + const storedActiveIndex = storedTabState?.activeIndex ?? -1 + + watch(restoreState, ({ paths, activeIndex }) => { + if (workflowPersistenceEnabled.value) { + tabState.setOpenPaths(paths, activeIndex) + } + }) + + const restoreWorkflowTabsState = () => { + if (!workflowPersistenceEnabled.value) return + const isRestorable = storedWorkflows.length > 0 && storedActiveIndex >= 0 + if (!isRestorable) return + + storedWorkflows.forEach((path: string) => { + if (workflowStore.getWorkflowByPath(path)) return + const draft = draftStore.getDraft(path) + if (!draft?.isTemporary) return + try { + const workflowData = JSON.parse(draft.data) + workflowStore.createTemporary(draft.name, workflowData) + } catch (err) { + console.warn( + 'Failed to parse workflow draft, creating with default', + err + ) + draftStore.removeDraft(path) + workflowStore.createTemporary(draft.name) + } + }) + + workflowStore.openWorkflowsInBackground({ + left: storedWorkflows.slice(0, storedActiveIndex), + right: storedWorkflows.slice(storedActiveIndex) + }) + } + + return { + initializeWorkflow, + loadTemplateFromUrlIfPresent, + restoreWorkflowTabsState + } +} diff --git a/src/platform/workflow/persistence/migration/migrateV1toV2.test.ts b/src/platform/workflow/persistence/migration/migrateV1toV2.test.ts new file mode 100644 index 000000000..40681560a --- /dev/null +++ b/src/platform/workflow/persistence/migration/migrateV1toV2.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { hashPath } from '../base/hashUtil' +import { + cleanupV1Data, + getMigrationStatus, + isV2MigrationComplete, + migrateV1toV2 +} from './migrateV1toV2' + +describe('migrateV1toV2', () => { + const workspaceId = 'test-workspace' + + beforeEach(() => { + vi.resetModules() + localStorage.clear() + sessionStorage.clear() + }) + + afterEach(() => { + localStorage.clear() + sessionStorage.clear() + }) + + function setV1Data( + drafts: Record< + string, + { data: string; updatedAt: number; name: string; isTemporary: boolean } + >, + order: string[] + ) { + localStorage.setItem( + `Comfy.Workflow.Drafts:${workspaceId}`, + JSON.stringify(drafts) + ) + localStorage.setItem( + `Comfy.Workflow.DraftOrder:${workspaceId}`, + JSON.stringify(order) + ) + } + + describe('isV2MigrationComplete', () => { + it('returns false when no V2 index exists', () => { + expect(isV2MigrationComplete(workspaceId)).toBe(false) + }) + + it('returns true when V2 index exists', () => { + localStorage.setItem( + `Comfy.Workflow.DraftIndex.v2:${workspaceId}`, + JSON.stringify({ v: 2, order: [], entries: {}, updatedAt: Date.now() }) + ) + expect(isV2MigrationComplete(workspaceId)).toBe(true) + }) + }) + + describe('migrateV1toV2', () => { + it('returns -1 if V2 already exists', () => { + localStorage.setItem( + `Comfy.Workflow.DraftIndex.v2:${workspaceId}`, + JSON.stringify({ v: 2, order: [], entries: {}, updatedAt: Date.now() }) + ) + + expect(migrateV1toV2(workspaceId)).toBe(-1) + }) + + it('creates empty V2 index if no V1 data', () => { + expect(migrateV1toV2(workspaceId)).toBe(0) + + const indexJson = localStorage.getItem( + `Comfy.Workflow.DraftIndex.v2:${workspaceId}` + ) + expect(indexJson).not.toBeNull() + + const index = JSON.parse(indexJson!) + expect(index.v).toBe(2) + expect(index.order).toEqual([]) + }) + + it('migrates V1 drafts to V2 format', () => { + const v1Drafts = { + 'workflows/a.json': { + data: '{"nodes":[1]}', + updatedAt: 1000, + name: 'a', + isTemporary: true + }, + 'workflows/b.json': { + data: '{"nodes":[2]}', + updatedAt: 2000, + name: 'b', + isTemporary: false + } + } + setV1Data(v1Drafts, ['workflows/a.json', 'workflows/b.json']) + + const migrated = migrateV1toV2(workspaceId) + expect(migrated).toBe(2) + + // Check V2 index + const indexJson = localStorage.getItem( + `Comfy.Workflow.DraftIndex.v2:${workspaceId}` + ) + const index = JSON.parse(indexJson!) + expect(index.order).toHaveLength(2) + + // Check payloads + const keyA = hashPath('workflows/a.json') + const keyB = hashPath('workflows/b.json') + + const payloadA = localStorage.getItem( + `Comfy.Workflow.Draft.v2:${workspaceId}:${keyA}` + ) + const payloadB = localStorage.getItem( + `Comfy.Workflow.Draft.v2:${workspaceId}:${keyB}` + ) + + expect(payloadA).not.toBeNull() + expect(payloadB).not.toBeNull() + + expect(JSON.parse(payloadA!).data).toBe('{"nodes":[1]}') + expect(JSON.parse(payloadB!).data).toBe('{"nodes":[2]}') + }) + + it('preserves LRU order during migration', () => { + const v1Drafts = { + 'workflows/first.json': { + data: '{}', + updatedAt: 1000, + name: 'first', + isTemporary: true + }, + 'workflows/second.json': { + data: '{}', + updatedAt: 2000, + name: 'second', + isTemporary: true + }, + 'workflows/third.json': { + data: '{}', + updatedAt: 3000, + name: 'third', + isTemporary: true + } + } + setV1Data(v1Drafts, [ + 'workflows/first.json', + 'workflows/second.json', + 'workflows/third.json' + ]) + + migrateV1toV2(workspaceId) + + const indexJson = localStorage.getItem( + `Comfy.Workflow.DraftIndex.v2:${workspaceId}` + ) + const index = JSON.parse(indexJson!) + + // Order should be preserved (oldest to newest) + const expectedOrder = [ + hashPath('workflows/first.json'), + hashPath('workflows/second.json'), + hashPath('workflows/third.json') + ] + expect(index.order).toEqual(expectedOrder) + }) + + it('keeps V1 data intact after migration', () => { + const v1Drafts = { + 'workflows/test.json': { + data: '{}', + updatedAt: 1000, + name: 'test', + isTemporary: true + } + } + setV1Data(v1Drafts, ['workflows/test.json']) + + migrateV1toV2(workspaceId) + + // V1 data should still exist + expect( + localStorage.getItem(`Comfy.Workflow.Drafts:${workspaceId}`) + ).not.toBeNull() + expect( + localStorage.getItem(`Comfy.Workflow.DraftOrder:${workspaceId}`) + ).not.toBeNull() + }) + }) + + describe('cleanupV1Data', () => { + it('removes V1 keys', () => { + setV1Data( + { + 'workflows/test.json': { + data: '{}', + updatedAt: 1, + name: 'test', + isTemporary: true + } + }, + ['workflows/test.json'] + ) + + cleanupV1Data(workspaceId) + + expect( + localStorage.getItem(`Comfy.Workflow.Drafts:${workspaceId}`) + ).toBeNull() + expect( + localStorage.getItem(`Comfy.Workflow.DraftOrder:${workspaceId}`) + ).toBeNull() + }) + }) + + describe('getMigrationStatus', () => { + it('reports correct status', () => { + setV1Data( + { + 'workflows/a.json': { + data: '{}', + updatedAt: 1, + name: 'a', + isTemporary: true + }, + 'workflows/b.json': { + data: '{}', + updatedAt: 2, + name: 'b', + isTemporary: true + } + }, + ['workflows/a.json', 'workflows/b.json'] + ) + + const status = getMigrationStatus(workspaceId) + expect(status.v1Exists).toBe(true) + expect(status.v2Exists).toBe(false) + expect(status.v1DraftCount).toBe(2) + expect(status.v2DraftCount).toBe(0) + }) + }) +}) diff --git a/src/platform/workflow/persistence/migration/migrateV1toV2.ts b/src/platform/workflow/persistence/migration/migrateV1toV2.ts new file mode 100644 index 000000000..29eb394cc --- /dev/null +++ b/src/platform/workflow/persistence/migration/migrateV1toV2.ts @@ -0,0 +1,157 @@ +/** + * V1 to V2 Migration + * + * Migrates draft data from V1 blob format to V2 per-draft keys. + * Runs once on first load if V2 index doesn't exist. + * Keeps V1 data intact for rollback until 2026-07-15. + */ + +import type { DraftIndexV2 } from '../base/draftTypes' +import { upsertEntry, createEmptyIndex } from '../base/draftCacheV2' +import { hashPath } from '../base/hashUtil' +import { getWorkspaceId } from '../base/storageKeys' +import { readIndex, writeIndex, writePayload } from '../base/storageIO' + +/** + * V1 draft snapshot structure (from draftCache.ts) + */ +interface V1DraftSnapshot { + data: string + updatedAt: number + name: string + isTemporary: boolean +} + +/** + * V1 storage keys - workspace-scoped blob format + */ +const V1_KEYS = { + drafts: (workspaceId: string) => `Comfy.Workflow.Drafts:${workspaceId}`, + order: (workspaceId: string) => `Comfy.Workflow.DraftOrder:${workspaceId}` +} + +/** + * Checks if V2 migration has been completed for the current workspace. + */ +export function isV2MigrationComplete(workspaceId: string): boolean { + const v2Index = readIndex(workspaceId) + return v2Index !== null +} + +/** + * Reads V1 drafts from localStorage. + */ +function readV1Drafts( + workspaceId: string +): { drafts: Record; order: string[] } | null { + try { + const draftsJson = localStorage.getItem(V1_KEYS.drafts(workspaceId)) + const orderJson = localStorage.getItem(V1_KEYS.order(workspaceId)) + + if (!draftsJson) return null + + const drafts = JSON.parse(draftsJson) as Record + const order = orderJson ? (JSON.parse(orderJson) as string[]) : [] + + return { drafts, order } + } catch { + return null + } +} + +/** + * Migrates V1 drafts to V2 format. + * + * @returns Number of drafts migrated, or -1 if migration not needed/failed + */ +export function migrateV1toV2(workspaceId: string = getWorkspaceId()): number { + // Check if V2 already exists + if (isV2MigrationComplete(workspaceId)) { + return -1 + } + + // Read V1 data + const v1Data = readV1Drafts(workspaceId) + if (!v1Data) { + // No V1 data to migrate - create empty V2 index + writeIndex(workspaceId, createEmptyIndex()) + return 0 + } + + // Build V2 index and write payloads + let index: DraftIndexV2 = createEmptyIndex() + let migrated = 0 + + // Process in order (oldest first) to maintain LRU order + for (const path of v1Data.order) { + const draft = v1Data.drafts[path] + if (!draft) continue + + const draftKey = hashPath(path) + + // Write payload + const payloadWritten = writePayload(workspaceId, draftKey, { + data: draft.data, + updatedAt: draft.updatedAt + }) + + if (!payloadWritten) { + console.warn(`[V2 Migration] Failed to write payload for ${path}`) + continue + } + + // Update index + const { index: newIndex } = upsertEntry(index, path, { + name: draft.name, + isTemporary: draft.isTemporary, + updatedAt: draft.updatedAt + }) + index = newIndex + migrated++ + } + + // Write final index + if (!writeIndex(workspaceId, index)) { + console.error('[V2 Migration] Failed to write index') + return -1 + } + + if (migrated > 0) { + console.warn(`[V2 Migration] Migrated ${migrated} drafts from V1 to V2`) + } + return migrated +} + +/** + * Cleans up V1 data after successful migration. + * Should NOT be called until 2026-07-15 to allow rollback. + */ +export function cleanupV1Data(workspaceId: string = getWorkspaceId()): void { + try { + localStorage.removeItem(V1_KEYS.drafts(workspaceId)) + localStorage.removeItem(V1_KEYS.order(workspaceId)) + console.warn('[V2 Migration] Cleaned up V1 data') + } catch { + // Ignore cleanup errors + } +} + +/** + * Gets migration status for debugging. + */ +export function getMigrationStatus(workspaceId: string = getWorkspaceId()): { + v1Exists: boolean + v2Exists: boolean + v1DraftCount: number + v2DraftCount: number +} { + const v1Data = readV1Drafts(workspaceId) + const v2Index = readIndex(workspaceId) + + return { + v1Exists: v1Data !== null, + v2Exists: v2Index !== null, + v1DraftCount: v1Data ? v1Data.order.length : 0, + v2DraftCount: v2Index ? v2Index.order.length : 0 + } +}