feat(persistence): fix QuotaExceededError and cross-workspace draft leakage (#8520)

## Summary

Completes the workflow persistence overhaul by integrating the new draft
system into the app and migrating existing data. Fixes two critical
bugs:

1. **QuotaExceededError** - localStorage fills up with workflow drafts,
breaking auto-save
2. **Cross-workspace data leakage** - Drafts from one ComfyUI instance
appear in another

## Changes

- **What**: 
- `useWorkflowPersistenceV2.ts` - Main composable that hooks into graph
changes with 512ms debounce
- `migrateV1toV2.ts` - One-time migration of existing drafts to the new
scoped format
  - Updated E2E tests for new storage key patterns
- **Why**: Users lose work when storage quota is exceeded, and see
confusing workflows from other instances

## How It Works

- **Workspace scoping**: Each ComfyUI instance (identified by server
URL) has isolated draft storage
- **LRU eviction**: When storage is full, oldest drafts are
automatically removed (keeps 32 most recent)
- **Tab isolation**: Each browser tab tracks its own active/open
workflows via sessionStorage
- **Debounced saves**: Graph changes are batched with 512ms delay to
reduce storage writes

## Migration

Existing V1 drafts are automatically migrated on first load. The
migration:
1. Reads drafts from old `Comfy.Workflow.*` keys
2. Converts to new workspace-scoped format
3. Cleans up old keys after successful migration

---
*Part 4 of 4 in the workflow persistence improvements stack*

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
This commit is contained in:
Christian Byrne
2026-02-21 00:57:50 -08:00
committed by GitHub
parent 3d3a4dd1a2
commit 40aa7c5974
17 changed files with 1016 additions and 89 deletions

View File

@@ -244,21 +244,9 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
const parsed = await (
await comfyPage.page.waitForFunction(
() => {
const workflow = localStorage.getItem('workflow')
if (!workflow) return null
try {
const data = JSON.parse(workflow)
return Array.isArray(data?.nodes) ? data : null
} catch {
return null
}
},
{ timeout: 3000 }
)
).jsonValue()
const parsed = await comfyPage.page.evaluate(() => {
return window['app'].graph.serialize()
})
expect(parsed.nodes).toBeDefined()
expect(Array.isArray(parsed.nodes)).toBe(true)
for (const node of parsed.nodes) {

View File

@@ -736,6 +736,25 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await expect(comfyPage.canvas).toHaveScreenshot(
'single_ksampler_modified.png'
)
// Wait for V2 persistence debounce to save the modified workflow
const start = Date.now()
await comfyPage.page.waitForFunction((since) => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (!key?.startsWith('Comfy.Workflow.DraftIndex.v2:')) continue
const json = window.localStorage.getItem(key)
if (!json) continue
try {
const index = JSON.parse(json)
if (typeof index.updatedAt === 'number' && index.updatedAt >= since) {
return true
}
} catch {
// ignore
}
}
return false
}, start)
await comfyPage.setup({ clearStorage: false })
await expect(comfyPage.canvas).toHaveScreenshot(
'single_ksampler_modified.png'
@@ -758,10 +777,17 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage to persist the workflow paths before reloading
await comfyPage.page.waitForFunction(
() => !!window.localStorage.getItem('Comfy.OpenWorkflowsPaths')
)
// Wait for sessionStorage to persist the workflow paths before reloading
// V2 persistence uses sessionStorage with client-scoped keys
await comfyPage.page.waitForFunction(() => {
for (let i = 0; i < window.sessionStorage.length; i++) {
const key = window.sessionStorage.key(i)
if (key?.startsWith('Comfy.Workflow.OpenPaths:')) {
return true
}
}
return false
})
await comfyPage.setup({ clearStorage: false })
})

View File

@@ -152,7 +152,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'

View File

@@ -9,6 +9,7 @@ import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { BillingPortalTargetTier } from '@/stores/firebaseAuthStore'
@@ -51,6 +52,17 @@ export const useFirebaseAuthActions = () => {
}
const logout = wrapWithErrorHandlingAsync(async () => {
const workflowStore = useWorkflowStore()
if (workflowStore.modifiedWorkflows.length > 0) {
const dialogService = useDialogService()
const confirmed = await dialogService.confirm({
title: t('auth.signOut.unsavedChangesTitle'),
message: t('auth.signOut.unsavedChangesMessage'),
type: 'dirtyClose'
})
if (!confirmed) return
}
await authStore.logout()
toastStore.add({
severity: 'success',

View File

@@ -2012,7 +2012,9 @@
"signOut": {
"signOut": "Log Out",
"success": "Signed out successfully",
"successDetail": "You have been signed out of your account."
"successDetail": "You have been signed out of your account.",
"unsavedChangesTitle": "Unsaved Changes",
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Do you want to continue?"
},
"passwordUpdate": {
"success": "Password Updated",

View File

@@ -49,22 +49,26 @@ export function upsertEntry(
[draftKey]: { ...meta, path }
}
const order = touchOrder(index.order, draftKey)
const touchedOrder = touchOrder(index.order, draftKey)
const evicted: string[] = []
while (order.length > effectiveLimit) {
const oldest = order.shift()
let evictCount = 0
while (touchedOrder.length - evictCount > effectiveLimit) {
const oldest = touchedOrder[evictCount]
if (oldest && oldest !== draftKey) {
delete entries[oldest]
evicted.push(oldest)
}
evictCount++
}
const finalOrder = touchedOrder.slice(evictCount)
return {
index: {
v: 2,
updatedAt: Date.now(),
order,
order: finalOrder,
entries
},
evicted

View File

@@ -81,5 +81,4 @@ export interface OpenPathsPointer {
/** Maximum number of drafts to keep per workspace */
export const MAX_DRAFTS = 32
/** @knipIgnoreUsedByStackedPR Used by workflowPersistenceV2.ts (PR #3) */
export const PERSIST_DEBOUNCE_MS = 512

View File

@@ -186,6 +186,99 @@ describe('storageIO', () => {
it('returns null for missing open paths', () => {
expect(readOpenPaths('missing')).toBeNull()
})
it('falls back to workspace search when clientId does not match and migrates', () => {
const oldClientId = 'old-client'
const newClientId = 'new-client'
const workspaceId = 'ws-123'
// Store pointer with old clientId
const pointer = {
workspaceId,
paths: ['workflows/a.json', 'workflows/b.json'],
activeIndex: 0
}
writeOpenPaths(oldClientId, pointer)
// Read with new clientId but same workspace - should find via fallback
const read = readOpenPaths(newClientId, workspaceId)
expect(read).toEqual(pointer)
// Should have migrated to new key and removed old key
const oldKey = `Comfy.Workflow.OpenPaths:${oldClientId}`
const newKey = `Comfy.Workflow.OpenPaths:${newClientId}`
expect(sessionStorage.getItem(oldKey)).toBeNull()
expect(sessionStorage.getItem(newKey)).not.toBeNull()
})
it('does not fall back to different workspace pointer', () => {
const oldClientId = 'old-client'
const newClientId = 'new-client'
// Store pointer for workspace-A
writeOpenPaths(oldClientId, {
workspaceId: 'workspace-A',
paths: ['workflows/a.json'],
activeIndex: 0
})
// Read with new clientId looking for workspace-B - should not find
const read = readOpenPaths(newClientId, 'workspace-B')
expect(read).toBeNull()
})
it('prefers exact clientId match over fallback search', () => {
const clientId = 'my-client'
const workspaceId = 'ws-123'
// Store pointer with different clientId for same workspace
writeOpenPaths('other-client', {
workspaceId,
paths: ['workflows/old.json'],
activeIndex: 0
})
// Store pointer with exact clientId match
const exactPointer = {
workspaceId,
paths: ['workflows/exact.json'],
activeIndex: 0
}
writeOpenPaths(clientId, exactPointer)
// Should return exact match, not fallback
const read = readOpenPaths(clientId, workspaceId)
expect(read).toEqual(exactPointer)
})
it('removes stale exact match from wrong workspace and falls back', () => {
const clientId = 'my-client'
// Store pointer for workspace-A under this clientId
writeActivePath(clientId, {
workspaceId: 'ws-A',
path: 'workflows/stale.json'
})
// Store pointer for workspace-B under a different clientId
writeActivePath('old-client', {
workspaceId: 'ws-B',
path: 'workflows/correct.json'
})
// Reading with workspace-B should skip the stale ws-A pointer and find the fallback
const result = readActivePath(clientId, 'ws-B')
expect(result).toEqual({
workspaceId: 'ws-B',
path: 'workflows/correct.json'
})
// Stale pointer should have been removed
const raw = sessionStorage.getItem(
`Comfy.Workflow.ActivePath:${clientId}`
)
expect(JSON.parse(raw!).workspaceId).toBe('ws-B')
})
})
describe('clearAllV2Storage', () => {

View File

@@ -186,20 +186,83 @@ export function deleteOrphanPayloads(
}
/**
* Reads the active path pointer from sessionStorage.
* Searches sessionStorage for a pointer matching the target workspaceId
* when the exact clientId key has no entry (e.g. clientId changed after reload).
* Migrates the found pointer to the new clientId key.
*/
export function readActivePath(clientId: string): ActivePathPointer | null {
try {
const key = StorageKeys.activePath(clientId)
const json = sessionStorage.getItem(key)
if (!json) return null
function findAndMigratePointer<T extends { workspaceId: string }>(
newKey: string,
prefix: string,
targetWorkspaceId: string
): T | null {
for (let i = 0; i < sessionStorage.length; i++) {
const storageKey = sessionStorage.key(i)
if (!storageKey?.startsWith(prefix) || storageKey === newKey) continue
return JSON.parse(json) as ActivePathPointer
const json = sessionStorage.getItem(storageKey)
if (!json) continue
try {
const pointer = JSON.parse(json) as T
if (pointer.workspaceId === targetWorkspaceId) {
sessionStorage.setItem(newKey, json)
sessionStorage.removeItem(storageKey)
return pointer
}
} catch {
continue
}
}
return null
}
/**
* Reads a session pointer by clientId with workspace-based fallback.
* Validates workspace on exact match and removes stale cross-workspace pointers.
* If no valid entry exists, searches for any pointer matching the target
* workspaceId and migrates it to the new key.
*/
function readSessionPointer<T extends { workspaceId: string }>(
key: string,
prefix: string,
targetWorkspaceId?: string
): T | null {
try {
const json = sessionStorage.getItem(key)
if (json) {
const pointer = JSON.parse(json) as T
if (targetWorkspaceId && pointer.workspaceId !== targetWorkspaceId) {
sessionStorage.removeItem(key)
} else {
return pointer
}
}
if (targetWorkspaceId) {
return findAndMigratePointer<T>(key, prefix, targetWorkspaceId)
}
return null
} catch {
return null
}
}
/**
* Reads the active path pointer from sessionStorage.
* Falls back to workspace-based search when clientId changes after reload.
*/
export function readActivePath(
clientId: string,
targetWorkspaceId?: string
): ActivePathPointer | null {
return readSessionPointer<ActivePathPointer>(
StorageKeys.activePath(clientId),
StorageKeys.prefixes.activePath,
targetWorkspaceId
)
}
/**
* Writes the active path pointer to sessionStorage.
*/
@@ -217,17 +280,17 @@ export function writeActivePath(
/**
* Reads the open paths pointer from sessionStorage.
* Falls back to workspace-based search when clientId changes after reload.
*/
export function readOpenPaths(clientId: string): OpenPathsPointer | null {
try {
const key = StorageKeys.openPaths(clientId)
const json = sessionStorage.getItem(key)
if (!json) return null
return JSON.parse(json) as OpenPathsPointer
} catch {
return null
}
export function readOpenPaths(
clientId: string,
targetWorkspaceId?: string
): OpenPathsPointer | null {
return readSessionPointer<OpenPathsPointer>(
StorageKeys.openPaths(clientId),
StorageKeys.prefixes.openPaths,
targetWorkspaceId
)
}
/**

View File

@@ -35,17 +35,46 @@ describe('storageKeys', () => {
const { getWorkspaceId } = await import('./storageKeys')
expect(getWorkspaceId()).toBe('personal')
})
it('returns personal when workspace has no id', async () => {
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({ type: 'team', id: '' })
)
const { getWorkspaceId } = await import('./storageKeys')
expect(getWorkspaceId()).toBe('personal')
})
it('reads fresh value on each call (not cached)', async () => {
const { getWorkspaceId } = await import('./storageKeys')
// Initially no workspace set
expect(getWorkspaceId()).toBe('personal')
// Set workspace after import
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({ type: 'team', id: 'ws-new' })
)
// Should read the new value (not cached 'personal')
expect(getWorkspaceId()).toBe('ws-new')
// Change workspace again
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({ type: 'team', id: 'ws-another' })
)
expect(getWorkspaceId()).toBe('ws-another')
})
})
describe('StorageKeys', () => {
it('generates draftIndex key with workspace scope', async () => {
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({ type: 'team', id: 'ws-123' })
)
const { StorageKeys } = await import('./storageKeys')
expect(StorageKeys.draftIndex()).toBe(
expect(StorageKeys.draftIndex('ws-123')).toBe(
'Comfy.Workflow.DraftIndex.v2:ws-123'
)
})

View File

@@ -5,8 +5,12 @@ import { hashPath } from './hashUtil'
/**
* Gets the current workspace ID from sessionStorage.
* Returns 'personal' for personal workspace or when no workspace is set.
*
* NOTE: This is called fresh each time rather than cached at module load,
* because the workspace auth store may not have set sessionStorage yet
* when this module is first imported.
*/
function getCurrentWorkspaceId(): string {
export function getWorkspaceId(): string {
try {
const json = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE
@@ -21,16 +25,6 @@ function getCurrentWorkspaceId(): string {
}
}
// Cache workspace ID at module load (static for page lifetime, workspace switch reloads page)
const CURRENT_WORKSPACE_ID = getCurrentWorkspaceId()
/**
* Returns the current workspace ID used for storage key scoping.
*/
export function getWorkspaceId(): string {
return CURRENT_WORKSPACE_ID
}
/**
* Storage key generators for V2 workflow persistence.
*
@@ -42,7 +36,7 @@ export const StorageKeys = {
* Draft index key for localStorage.
* Contains LRU order and metadata for all drafts.
*/
draftIndex(workspaceId: string = CURRENT_WORKSPACE_ID): string {
draftIndex(workspaceId: string): string {
return `Comfy.Workflow.DraftIndex.v2:${workspaceId}`
},
@@ -50,10 +44,7 @@ export const StorageKeys = {
* Individual draft payload key for localStorage.
* @param path - Workflow path (will be hashed to create key)
*/
draftPayload(
path: string,
workspaceId: string = CURRENT_WORKSPACE_ID
): string {
draftPayload(path: string, workspaceId: string): string {
const draftKey = hashPath(path)
return `Comfy.Workflow.Draft.v2:${workspaceId}:${draftKey}`
},

View File

@@ -0,0 +1,285 @@
/**
* 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<Record<string, string>>({})
watch(workflowPersistenceEnabled, (enabled) => {
if (!enabled) {
draftStore.reset()
lastSavedJsonByPath.value = {}
}
})
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
// Flush any pending persistence from the previous workflow
debouncedPersist.flush()
// Persist the new workflow immediately
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 }
}
)
// 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
if (workflowPersistenceEnabled.value && tabStateRestored) {
tabState.setOpenPaths(paths, activeIndex)
}
})
const restoreWorkflowTabsState = () => {
if (!workflowPersistenceEnabled.value) {
tabStateRestored = true
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
tabStateRestored = true
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
}
}

View File

@@ -94,5 +94,21 @@ describe('useWorkflowTabState', () => {
expect(getOpenPaths()).toBeNull()
})
it('retains paths when staying in same workspace', async () => {
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({ type: 'team', id: 'ws-1' })
)
const { useWorkflowTabState } = await import('./useWorkflowTabState')
const { setOpenPaths, getOpenPaths } = useWorkflowTabState()
setOpenPaths(['workflows/test.json'], 0)
// Simulate re-reading (same workspace, same clientId)
const result = getOpenPaths()
expect(result).not.toBeNull()
expect(result!.paths).toEqual(['workflows/test.json'])
})
})
})

View File

@@ -27,23 +27,17 @@ function getClientId(): string | null {
* Composable for managing per-tab workflow state in sessionStorage.
*/
export function useWorkflowTabState() {
const currentWorkspaceId = getWorkspaceId()
/**
* Gets the active workflow path for the current tab.
* Returns null if no pointer exists or workspaceId doesn't match.
*/
function getActivePath(): string | null {
const clientId = getClientId()
const workspaceId = getWorkspaceId()
if (!clientId) return null
const pointer = readActivePath(clientId)
if (!pointer) return null
// Validate workspace - ignore stale pointers from different workspace
if (pointer.workspaceId !== currentWorkspaceId) return null
return pointer.path
const pointer = readActivePath(clientId, workspaceId)
return pointer?.path ?? null
}
/**
@@ -51,10 +45,11 @@ export function useWorkflowTabState() {
*/
function setActivePath(path: string): void {
const clientId = getClientId()
const workspaceId = getWorkspaceId()
if (!clientId) return
const pointer: ActivePathPointer = {
workspaceId: currentWorkspaceId,
workspaceId,
path
}
writeActivePath(clientId, pointer)
@@ -66,14 +61,12 @@ export function useWorkflowTabState() {
*/
function getOpenPaths(): { paths: string[]; activeIndex: number } | null {
const clientId = getClientId()
const workspaceId = getWorkspaceId()
if (!clientId) return null
const pointer = readOpenPaths(clientId)
const pointer = readOpenPaths(clientId, workspaceId)
if (!pointer) return null
// Validate workspace
if (pointer.workspaceId !== currentWorkspaceId) return null
return { paths: pointer.paths, activeIndex: pointer.activeIndex }
}
@@ -82,10 +75,11 @@ export function useWorkflowTabState() {
*/
function setOpenPaths(paths: string[], activeIndex: number): void {
const clientId = getClientId()
const workspaceId = getWorkspaceId()
if (!clientId) return
const pointer: OpenPathsPointer = {
workspaceId: currentWorkspaceId,
workspaceId,
paths,
activeIndex
}

View File

@@ -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)
})
})
})

View File

@@ -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<string, V1DraftSnapshot>; 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<string, V1DraftSnapshot>
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
if (!writeIndex(workspaceId, createEmptyIndex())) return -1
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
}
}

View File

@@ -48,23 +48,34 @@ interface LoadPersistedWorkflowOptions {
}
export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
const workspaceId = getWorkspaceId()
// In-memory cache of the index per workspace (synced with localStorage)
// Key is workspaceId, value is the cached index
const indexCacheByWorkspace = ref<Record<string, DraftIndexV2>>({})
// In-memory cache of the index (synced with localStorage)
const indexCache = ref<DraftIndexV2 | null>(null)
/**
* Gets the current workspace ID fresh (not cached).
* This ensures operations use the correct workspace after switches.
*/
function currentWorkspaceId(): string {
return getWorkspaceId()
}
/**
* Loads the index from localStorage or creates empty.
*/
function loadIndex(): DraftIndexV2 {
if (indexCache.value) return indexCache.value
const workspaceId = currentWorkspaceId()
if (indexCacheByWorkspace.value[workspaceId]) {
return indexCacheByWorkspace.value[workspaceId]
}
const stored = readIndex(workspaceId)
if (stored) {
// Clean up any index/payload drift
const payloadKeys = new Set(getPayloadKeys(workspaceId))
const cleaned = removeOrphanedEntries(stored, payloadKeys)
indexCache.value = cleaned
indexCacheByWorkspace.value[workspaceId] = cleaned
// Also clean up orphan payloads
const indexKeys = new Set(cleaned.order)
@@ -73,15 +84,17 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
return cleaned
}
indexCache.value = createEmptyIndex()
return indexCache.value
const emptyIndex = createEmptyIndex()
indexCacheByWorkspace.value[workspaceId] = emptyIndex
return emptyIndex
}
/**
* Persists the current index to localStorage.
*/
function persistIndex(index: DraftIndexV2): boolean {
indexCache.value = index
const workspaceId = currentWorkspaceId()
indexCacheByWorkspace.value[workspaceId] = index
return writeIndex(workspaceId, index)
}
@@ -92,6 +105,7 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
function saveDraft(path: string, data: string, meta: DraftMeta): boolean {
if (!isStorageAvailable()) return false
const workspaceId = currentWorkspaceId()
const draftKey = hashPath(path)
const now = Date.now()
@@ -136,6 +150,7 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
data: string,
meta: DraftMeta
): boolean {
const workspaceId = currentWorkspaceId()
const index = loadIndex()
const draftKey = hashPath(path)
@@ -188,6 +203,7 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
* Removes a draft.
*/
function removeDraft(path: string): void {
const workspaceId = currentWorkspaceId()
const index = loadIndex()
const { index: newIndex, removedKey } = removeEntry(index, path)
@@ -201,6 +217,7 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
* Moves a draft from one path to another (rename).
*/
function moveDraft(oldPath: string, newPath: string, name: string): void {
const workspaceId = currentWorkspaceId()
const index = loadIndex()
const result = moveEntry(index, oldPath, newPath, name)
@@ -228,6 +245,7 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
function getDraft(
path: string
): { data: string; name: string; isTemporary: boolean } | null {
const workspaceId = currentWorkspaceId()
const index = loadIndex()
const entry = getEntryByPath(index, path)
if (!entry) return null
@@ -318,6 +336,13 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
}
}
// 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) {
@@ -341,10 +366,11 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
}
/**
* Resets the store (clears in-memory cache).
* Resets the store (clears in-memory cache for current workspace).
*/
function reset(): void {
indexCache.value = null
const workspaceId = currentWorkspaceId()
delete indexCacheByWorkspace.value[workspaceId]
}
return {