From 116685595bafed2e09213adf8075fd0c05f4c1a4 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Thu, 19 Feb 2026 23:53:02 -0800 Subject: [PATCH] feat(persistence): add draft store and tab state management (#8519) ## Summary Adds the Pinia store for managing workflow drafts and a composable for tracking open workflow tabs per browser tab. Uses sessionStorage for tab-specific state to support multiple ComfyUI tabs without conflicts. ## Changes - **What**: - `workflowDraftStoreV2.ts` - Pinia store wrapping the LRU cache with save/load/remove operations - `useWorkflowTabState.ts` - Composable for tracking active workflow path and open tabs in sessionStorage (scoped by clientId) - **Why**: Browser tabs need independent workflow state, but the current system uses shared localStorage keys causing tab conflicts ## Review Focus - Store API design in `workflowDraftStoreV2.ts` - Session vs local storage split in `useWorkflowTabState.ts` --- *Part 3 of 4 in the workflow persistence improvements stack* --------- Co-authored-by: Amp --- .../workflow/persistence/base/storageIO.ts | 36 +- .../composables/useWorkflowTabState.test.ts | 98 +++++ .../composables/useWorkflowTabState.ts | 101 +++++ .../stores/workflowDraftStoreV2.test.ts | 227 +++++++++++ .../stores/workflowDraftStoreV2.ts | 359 ++++++++++++++++++ 5 files changed, 811 insertions(+), 10 deletions(-) create mode 100644 src/platform/workflow/persistence/composables/useWorkflowTabState.test.ts create mode 100644 src/platform/workflow/persistence/composables/useWorkflowTabState.ts create mode 100644 src/platform/workflow/persistence/stores/workflowDraftStoreV2.test.ts create mode 100644 src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts diff --git a/src/platform/workflow/persistence/base/storageIO.ts b/src/platform/workflow/persistence/base/storageIO.ts index 6f97b46c33..a18eaa2897 100644 --- a/src/platform/workflow/persistence/base/storageIO.ts +++ b/src/platform/workflow/persistence/base/storageIO.ts @@ -15,16 +15,36 @@ import { StorageKeys } from './storageKeys' /** Flag indicating if storage is available */ let storageAvailable = true -/** @knipIgnoreUsedByStackedPR Used by workflowPersistenceV2.ts (PR #3) */ export function isStorageAvailable(): boolean { return storageAvailable } -/** @knipIgnoreUsedByStackedPR Used by workflowPersistenceV2.ts (PR #3) */ export function markStorageUnavailable(): void { storageAvailable = false } +function isQuotaExceeded(error: unknown): boolean { + return ( + error instanceof DOMException && + (error.name === 'QuotaExceededError' || + error.name === 'NS_ERROR_DOM_QUOTA_REACHED' || + error.code === 22 || + error.code === 1014) + ) +} + +function isValidIndex(value: unknown): value is DraftIndexV2 { + if (typeof value !== 'object' || value === null) return false + const obj = value as Record + return ( + obj.v === 2 && + typeof obj.updatedAt === 'number' && + Array.isArray(obj.order) && + typeof obj.entries === 'object' && + obj.entries !== null + ) +} + /** * Reads and parses the draft index from localStorage. */ @@ -37,9 +57,9 @@ export function readIndex(workspaceId: string): DraftIndexV2 | null { if (!json) return null const parsed = JSON.parse(json) - if (parsed.v !== 2) return null + if (!isValidIndex(parsed)) return null - return parsed as DraftIndexV2 + return parsed } catch { return null } @@ -56,9 +76,7 @@ export function writeIndex(workspaceId: string, index: DraftIndexV2): boolean { localStorage.setItem(key, JSON.stringify(index)) return true } catch (error) { - if (error instanceof DOMException && error.name === 'QuotaExceededError') { - return false - } + if (isQuotaExceeded(error)) return false throw error } } @@ -98,9 +116,7 @@ export function writePayload( localStorage.setItem(key, JSON.stringify(payload)) return true } catch (error) { - if (error instanceof DOMException && error.name === 'QuotaExceededError') { - return false - } + if (isQuotaExceeded(error)) return false throw error } } diff --git a/src/platform/workflow/persistence/composables/useWorkflowTabState.test.ts b/src/platform/workflow/persistence/composables/useWorkflowTabState.test.ts new file mode 100644 index 0000000000..05e5218caa --- /dev/null +++ b/src/platform/workflow/persistence/composables/useWorkflowTabState.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/scripts/api', () => ({ + api: { + clientId: 'test-client-id', + initialClientId: 'test-client-id' + } +})) + +describe('useWorkflowTabState', () => { + beforeEach(() => { + vi.resetModules() + sessionStorage.clear() + }) + + describe('activePath', () => { + it('returns null when no pointer exists', async () => { + const { useWorkflowTabState } = await import('./useWorkflowTabState') + const { getActivePath } = useWorkflowTabState() + + expect(getActivePath()).toBeNull() + }) + + it('saves and retrieves active path', async () => { + const { useWorkflowTabState } = await import('./useWorkflowTabState') + const { getActivePath, setActivePath } = useWorkflowTabState() + + setActivePath('workflows/test.json') + expect(getActivePath()).toBe('workflows/test.json') + }) + + it('ignores pointer from different workspace', async () => { + sessionStorage.setItem( + 'Comfy.Workspace.Current', + JSON.stringify({ type: 'team', id: 'ws-1' }) + ) + const { useWorkflowTabState } = await import('./useWorkflowTabState') + const { setActivePath } = useWorkflowTabState() + setActivePath('workflows/test.json') + + vi.resetModules() + sessionStorage.setItem( + 'Comfy.Workspace.Current', + JSON.stringify({ type: 'team', id: 'ws-2' }) + ) + + const { useWorkflowTabState: useWorkflowTabState2 } = + await import('./useWorkflowTabState') + const { getActivePath } = useWorkflowTabState2() + + expect(getActivePath()).toBeNull() + }) + }) + + describe('openPaths', () => { + it('returns null when no pointer exists', async () => { + const { useWorkflowTabState } = await import('./useWorkflowTabState') + const { getOpenPaths } = useWorkflowTabState() + + expect(getOpenPaths()).toBeNull() + }) + + it('saves and retrieves open paths', async () => { + const { useWorkflowTabState } = await import('./useWorkflowTabState') + const { getOpenPaths, setOpenPaths } = useWorkflowTabState() + + const paths = ['workflows/a.json', 'workflows/b.json'] + setOpenPaths(paths, 1) + + const result = getOpenPaths() + expect(result).not.toBeNull() + expect(result!.paths).toEqual(paths) + expect(result!.activeIndex).toBe(1) + }) + + it('ignores pointer from different workspace', async () => { + sessionStorage.setItem( + 'Comfy.Workspace.Current', + JSON.stringify({ type: 'team', id: 'ws-1' }) + ) + const { useWorkflowTabState } = await import('./useWorkflowTabState') + const { setOpenPaths } = useWorkflowTabState() + setOpenPaths(['workflows/test.json'], 0) + + vi.resetModules() + sessionStorage.setItem( + 'Comfy.Workspace.Current', + JSON.stringify({ type: 'team', id: 'ws-2' }) + ) + + const { useWorkflowTabState: useWorkflowTabState2 } = + await import('./useWorkflowTabState') + const { getOpenPaths } = useWorkflowTabState2() + + expect(getOpenPaths()).toBeNull() + }) + }) +}) diff --git a/src/platform/workflow/persistence/composables/useWorkflowTabState.ts b/src/platform/workflow/persistence/composables/useWorkflowTabState.ts new file mode 100644 index 0000000000..e1dfc3888e --- /dev/null +++ b/src/platform/workflow/persistence/composables/useWorkflowTabState.ts @@ -0,0 +1,101 @@ +/** + * Tab State Management - Per-tab workflow pointers in sessionStorage. + * + * Uses api.clientId to scope pointers per browser tab. + * Includes workspaceId for validation to prevent cross-workspace contamination. + */ + +import type { ActivePathPointer, OpenPathsPointer } from '../base/draftTypes' +import { getWorkspaceId } from '../base/storageKeys' +import { + readActivePath, + readOpenPaths, + writeActivePath, + writeOpenPaths +} from '../base/storageIO' +import { api } from '@/scripts/api' + +/** + * Gets the current client ID for browser tab identification. + * Falls back to initialClientId if clientId is not yet set. + */ +function getClientId(): string | null { + return api.clientId ?? api.initialClientId ?? 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() + 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 + } + + /** + * Sets the active workflow path for the current tab. + */ + function setActivePath(path: string): void { + const clientId = getClientId() + if (!clientId) return + + const pointer: ActivePathPointer = { + workspaceId: currentWorkspaceId, + path + } + writeActivePath(clientId, pointer) + } + + /** + * Gets the open workflow paths for the current tab. + * Returns null if no pointer exists or workspaceId doesn't match. + */ + function getOpenPaths(): { paths: string[]; activeIndex: number } | null { + const clientId = getClientId() + if (!clientId) return null + + const pointer = readOpenPaths(clientId) + if (!pointer) return null + + // Validate workspace + if (pointer.workspaceId !== currentWorkspaceId) return null + + return { paths: pointer.paths, activeIndex: pointer.activeIndex } + } + + /** + * Sets the open workflow paths for the current tab. + */ + function setOpenPaths(paths: string[], activeIndex: number): void { + const clientId = getClientId() + if (!clientId) return + + const pointer: OpenPathsPointer = { + workspaceId: currentWorkspaceId, + paths, + activeIndex + } + writeOpenPaths(clientId, pointer) + } + + return { + getActivePath, + setActivePath, + getOpenPaths, + setOpenPaths + } +} diff --git a/src/platform/workflow/persistence/stores/workflowDraftStoreV2.test.ts b/src/platform/workflow/persistence/stores/workflowDraftStoreV2.test.ts new file mode 100644 index 0000000000..4e7c9503cb --- /dev/null +++ b/src/platform/workflow/persistence/stores/workflowDraftStoreV2.test.ts @@ -0,0 +1,227 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { MAX_DRAFTS } from '../base/draftTypes' +import { useWorkflowDraftStoreV2 } from './workflowDraftStoreV2' + +vi.mock('@/scripts/api', () => ({ + api: { + clientId: 'test-client', + initialClientId: 'test-client' + } +})) + +vi.mock('@/scripts/app', () => ({ + app: { + loadGraphData: vi.fn().mockResolvedValue(undefined) + } +})) + +describe('workflowDraftStoreV2', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + localStorage.clear() + sessionStorage.clear() + vi.clearAllMocks() + }) + + afterEach(() => { + localStorage.clear() + sessionStorage.clear() + }) + + describe('saveDraft', () => { + it('saves draft to localStorage with separate payload', () => { + const store = useWorkflowDraftStoreV2() + + const result = store.saveDraft('workflows/test.json', '{"nodes":[]}', { + name: 'test', + isTemporary: true + }) + + expect(result).toBe(true) + + // Verify index exists + const indexKey = 'Comfy.Workflow.DraftIndex.v2:personal' + const indexJson = localStorage.getItem(indexKey) + expect(indexJson).not.toBeNull() + + const index = JSON.parse(indexJson!) + expect(index.v).toBe(2) + expect(index.order).toHaveLength(1) + + // Verify payload exists separately + const payloadKeys = Object.keys(localStorage).filter((k) => + k.startsWith('Comfy.Workflow.Draft.v2:personal:') + ) + expect(payloadKeys).toHaveLength(1) + }) + + it('updates existing draft', () => { + const store = useWorkflowDraftStoreV2() + + store.saveDraft('workflows/test.json', '{"nodes":[]}', { + name: 'test', + isTemporary: true + }) + + store.saveDraft('workflows/test.json', '{"nodes":[1,2,3]}', { + name: 'test-updated', + isTemporary: false + }) + + const draft = store.getDraft('workflows/test.json') + expect(draft).not.toBeNull() + expect(draft!.data).toBe('{"nodes":[1,2,3]}') + expect(draft!.name).toBe('test-updated') + expect(draft!.isTemporary).toBe(false) + }) + + it('evicts oldest when over limit', () => { + const store = useWorkflowDraftStoreV2() + + for (let i = 0; i < MAX_DRAFTS; i++) { + store.saveDraft(`workflows/draft${i}.json`, `{"id":${i}}`, { + name: `draft${i}`, + isTemporary: true + }) + } + + // Save one more + store.saveDraft('workflows/new.json', '{"id":"new"}', { + name: 'new', + isTemporary: true + }) + + // First draft should be evicted + expect(store.getDraft('workflows/draft0.json')).toBeNull() + expect(store.getDraft('workflows/new.json')).not.toBeNull() + }) + }) + + describe('removeDraft', () => { + it('removes draft from index and payload', () => { + const store = useWorkflowDraftStoreV2() + + store.saveDraft('workflows/test.json', '{}', { + name: 'test', + isTemporary: true + }) + expect(store.getDraft('workflows/test.json')).not.toBeNull() + + store.removeDraft('workflows/test.json') + expect(store.getDraft('workflows/test.json')).toBeNull() + + // Verify payload is deleted + const payloadKeys = Object.keys(localStorage).filter((k) => + k.startsWith('Comfy.Workflow.Draft.v2:personal:') + ) + expect(payloadKeys).toHaveLength(0) + }) + }) + + describe('moveDraft', () => { + it('moves draft to new path with new name', () => { + const store = useWorkflowDraftStoreV2() + + store.saveDraft('workflows/old.json', '{"data":"test"}', { + name: 'old', + isTemporary: true + }) + + store.moveDraft('workflows/old.json', 'workflows/new.json', 'new') + + expect(store.getDraft('workflows/old.json')).toBeNull() + + const newDraft = store.getDraft('workflows/new.json') + expect(newDraft).not.toBeNull() + expect(newDraft!.name).toBe('new') + expect(newDraft!.data).toBe('{"data":"test"}') + }) + }) + + describe('getMostRecentPath', () => { + it('returns most recently saved path', () => { + const store = useWorkflowDraftStoreV2() + + store.saveDraft('workflows/a.json', '{}', { + name: 'a', + isTemporary: true + }) + store.saveDraft('workflows/b.json', '{}', { + name: 'b', + isTemporary: true + }) + + expect(store.getMostRecentPath()).toBe('workflows/b.json') + }) + + it('returns null when no drafts', () => { + const store = useWorkflowDraftStoreV2() + expect(store.getMostRecentPath()).toBeNull() + }) + }) + + describe('loadPersistedWorkflow', () => { + it('loads from preferred path when available', async () => { + const store = useWorkflowDraftStoreV2() + + store.saveDraft('workflows/test.json', '{"nodes":[]}', { + name: 'test', + isTemporary: true + }) + + const result = await store.loadPersistedWorkflow({ + workflowName: 'test', + preferredPath: 'workflows/test.json' + }) + + expect(result).toBe(true) + }) + + it('falls back to most recent when preferredPath missing', async () => { + const store = useWorkflowDraftStoreV2() + + store.saveDraft('workflows/recent.json', '{"nodes":[]}', { + name: 'recent', + isTemporary: true + }) + + const result = await store.loadPersistedWorkflow({ + workflowName: null, + preferredPath: 'workflows/missing.json', + fallbackToLatestDraft: true + }) + + expect(result).toBe(true) + }) + + it('returns false when no drafts available', async () => { + const store = useWorkflowDraftStoreV2() + + const result = await store.loadPersistedWorkflow({ + workflowName: null, + fallbackToLatestDraft: true + }) + + expect(result).toBe(false) + }) + }) + + describe('reset', () => { + it('clears in-memory cache', () => { + const store = useWorkflowDraftStoreV2() + + store.saveDraft('workflows/test.json', '{}', { + name: 'test', + isTemporary: true + }) + + store.reset() + + // Draft should still be loadable from localStorage + expect(store.getDraft('workflows/test.json')).not.toBeNull() + }) + }) +}) diff --git a/src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts b/src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts new file mode 100644 index 0000000000..ce426de244 --- /dev/null +++ b/src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts @@ -0,0 +1,359 @@ +/** + * V2 Workflow Draft Store + * + * Uses per-draft keys in localStorage instead of a single blob. + * Handles LRU eviction and quota management. + */ + +import { defineStore } from 'pinia' +import { ref } from 'vue' + +import type { DraftIndexV2 } from '../base/draftTypes' +import { MAX_DRAFTS } from '../base/draftTypes' +import { + createEmptyIndex, + getEntryByPath, + getMostRecentKey, + moveEntry, + removeEntry, + removeOrphanedEntries, + upsertEntry +} from '../base/draftCacheV2' +import { hashPath } from '../base/hashUtil' +import { getWorkspaceId } from '../base/storageKeys' +import { + deleteOrphanPayloads, + deletePayload, + deletePayloads, + getPayloadKeys, + isStorageAvailable, + markStorageUnavailable, + readIndex, + readPayload, + writeIndex, + writePayload +} from '../base/storageIO' +import { api } from '@/scripts/api' +import { app as comfyApp } from '@/scripts/app' + +interface DraftMeta { + name: string + isTemporary: boolean +} + +interface LoadPersistedWorkflowOptions { + workflowName: string | null + preferredPath?: string | null + fallbackToLatestDraft?: boolean +} + +export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => { + const workspaceId = getWorkspaceId() + + // In-memory cache of the index (synced with localStorage) + const indexCache = ref(null) + + /** + * Loads the index from localStorage or creates empty. + */ + function loadIndex(): DraftIndexV2 { + if (indexCache.value) return indexCache.value + + 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 + + // Also clean up orphan payloads + const indexKeys = new Set(cleaned.order) + deleteOrphanPayloads(workspaceId, indexKeys) + + return cleaned + } + + indexCache.value = createEmptyIndex() + return indexCache.value + } + + /** + * Persists the current index to localStorage. + */ + function persistIndex(index: DraftIndexV2): boolean { + indexCache.value = index + return writeIndex(workspaceId, index) + } + + /** + * Saves a draft (data + metadata). + * Writes payload first, then updates index. + */ + function saveDraft(path: string, data: string, meta: DraftMeta): boolean { + if (!isStorageAvailable()) return false + + const draftKey = hashPath(path) + const now = Date.now() + + // Write payload first (before index update) + const payloadWritten = writePayload(workspaceId, draftKey, { + data, + updatedAt: now + }) + + if (!payloadWritten) { + // Quota exceeded - try eviction loop + return handleQuotaExceeded(path, data, meta) + } + + // Update index + const index = loadIndex() + const { index: newIndex, evicted } = upsertEntry( + index, + path, + { ...meta, updatedAt: now }, + MAX_DRAFTS + ) + + // Delete evicted payloads + deletePayloads(workspaceId, evicted) + + // Persist index + if (!persistIndex(newIndex)) { + // Index write failed - try to recover + deletePayload(workspaceId, draftKey) + return false + } + + return true + } + + /** + * Handles quota exceeded by evicting oldest drafts until write succeeds. + */ + function handleQuotaExceeded( + path: string, + data: string, + meta: DraftMeta + ): boolean { + const index = loadIndex() + const draftKey = hashPath(path) + + // Try evicting oldest entries until we can write + let currentIndex = index + while (currentIndex.order.length > 0) { + const oldestKey = currentIndex.order.find((key) => key !== draftKey) + if (!oldestKey) break // Only the target draft remains + + // Evict oldest + const oldestEntry = Object.values(currentIndex.entries).find( + (e) => hashPath(e.path) === oldestKey + ) + if (!oldestEntry) break + + const result = removeEntry(currentIndex, oldestEntry.path) + currentIndex = result.index + if (result.removedKey) { + deletePayload(workspaceId, result.removedKey) + } + + // Try writing again + const success = writePayload(workspaceId, draftKey, { + data, + updatedAt: Date.now() + }) + + if (success) { + // Update index with the new entry + const { index: finalIndex } = upsertEntry( + currentIndex, + path, + { ...meta, updatedAt: Date.now() }, + MAX_DRAFTS + ) + if (!persistIndex(finalIndex)) { + deletePayload(workspaceId, draftKey) + return false + } + return true + } + } + + // All evictions failed - mark storage as unavailable + markStorageUnavailable() + return false + } + + /** + * Removes a draft. + */ + function removeDraft(path: string): void { + const index = loadIndex() + const { index: newIndex, removedKey } = removeEntry(index, path) + + if (removedKey) { + deletePayload(workspaceId, removedKey) + persistIndex(newIndex) + } + } + + /** + * Moves a draft from one path to another (rename). + */ + function moveDraft(oldPath: string, newPath: string, name: string): void { + const index = loadIndex() + const result = moveEntry(index, oldPath, newPath, name) + + 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) + } + } + } + + /** + * Gets draft data by path. + */ + function getDraft( + path: string + ): { data: string; name: string; isTemporary: boolean } | null { + const index = loadIndex() + const entry = getEntryByPath(index, path) + if (!entry) return null + + const draftKey = hashPath(path) + const payload = readPayload(workspaceId, draftKey) + if (!payload) { + // Payload missing - clean up index + removeDraft(path) + return null + } + + return { + data: payload.data, + name: entry.name, + isTemporary: entry.isTemporary + } + } + + /** + * 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 + } + + /** + * Tries to load workflow data into the graph. + */ + async function tryLoadGraph( + payload: string | null, + workflowName: string | null, + onFailure?: () => void + ): Promise { + if (!payload) return false + try { + const workflow = JSON.parse(payload) + await comfyApp.loadGraphData(workflow, true, true, workflowName) + return true + } catch (err) { + console.error('Failed to load persisted workflow', err) + onFailure?.() + return false + } + } + + /** + * Loads a draft into the graph. + */ + async function loadDraft(path: string): Promise { + const draft = getDraft(path) + if (!draft) return false + + const loaded = await tryLoadGraph(draft.data, draft.name, () => { + removeDraft(path) + }) + + return loaded + } + + /** + * Loads a persisted workflow with fallback chain. + */ + async function loadPersistedWorkflow( + options: LoadPersistedWorkflowOptions + ): Promise { + const { + workflowName, + 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 + } + } + + // 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) + } catch { + return false + } + } + + /** + * Resets the store (clears in-memory cache). + */ + function reset(): void { + indexCache.value = null + } + + return { + saveDraft, + removeDraft, + moveDraft, + getDraft, + getMostRecentPath, + loadPersistedWorkflow, + reset + } +})