Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions src/platform/workflow/persistence/base/storageIO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
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.
*/
Expand All @@ -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 {
Comment on lines +51 to +63

@coderabbitai coderabbitai Bot Feb 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/platform/workflow/persistence/base/storageIO.ts | head -100

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 3156


🏁 Script executed:

cat -n src/platform/workflow/persistence/base/storageIO.ts | sed -n '100,230p'

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 3905


🏁 Script executed:

# Check the type definitions to understand the expected shapes
cat -n src/platform/workflow/persistence/base/draftTypes.ts | head -80

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 2740


🏁 Script executed:

# Search for all call sites of these read functions
rg -n 'readIndex\s*\(' src/ --type ts --type vue -A 2

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 97


🏁 Script executed:

# Search for all call sites of these read functions
rg -n 'readIndex\s*\(' src/ --type ts -A 2

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1661


🏁 Script executed:

# Check how readIndex is used in the main consumer
cat -n src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts | sed -n '60,90p'

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1126


🏁 Script executed:

# Check the removeOrphanedEntries function to see if it validates the stored data
rg -n 'removeOrphanedEntries' src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts -B 5 -A 10

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1001


🏁 Script executed:

# Check the draftCacheV2 file for the removeOrphanedEntries implementation
cat -n src/platform/workflow/persistence/base/draftCacheV2.ts | head -150

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 4448


🏁 Script executed:

cat -n src/platform/workflow/persistence/base/draftCacheV2.ts | sed -n '150,200p'

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1481


🏁 Script executed:

# Search for readPayload call sites
rg -n 'readPayload\s*\(' src/ --type ts -A 2 -B 2

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 3805


🏁 Script executed:

# Check how readPayload is used in the store
cat -n src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts | sed -n '225,260p'

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1166


🏁 Script executed:

# Search for readActivePath and readOpenPaths call sites
rg -n 'readActivePath\s*\(|readOpenPaths\s*\(' src/ --type ts -A 2 -B 2

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 3667


🏁 Script executed:

cat -n src/platform/workflow/persistence/composables/useWorkflowTabState.ts | sed -n '35,85p'

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1825


🏁 Script executed:

# Check if Zod is used in the project
fd -t f 'package.json' -x cat {} | grep -i zod

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 168


🏁 Script executed:

# Check for existing Zod schema patterns in the codebase
rg -n 'z\.object\|ZodSchema\|z\.parse' src/ --type ts -B 2 -A 5 | head -100

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Search for Zod imports
rg -n "from 'zod'|from \"zod\"" src/ --type ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1255


Validate parsed storage data before returning.

localStorage/sessionStorage values are user-modifiable; returning parsed JSON without shape validation risks propagating corrupt data. Add type guards or Zod schemas for all four functions:

  • readIndex: Only checks v === 2, lacks validation for updatedAt (number), order (string[]), and entries (object with DraftEntryMeta values)
  • readPayload: No validation; directly casts parsed JSON as DraftPayloadV2
  • readActivePath and readOpenPaths: No validation in readers (though call sites in useWorkflowTabState.ts validate workspaceId, the data shape itself is unchecked)

Zod and zod-validation-error are available in the project. Use type guards or Zod schemas to ensure shape correctness before returning.

Applies to lines 31–46 (readIndex), 69–84 (readPayload), 175–185 (readActivePath), and 205–215 (readOpenPaths).

Per coding guidelines: "Validate trusted sources before processing".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/platform/workflow/persistence/base/storageIO.ts` around lines 31 - 43,
Add strict runtime validation for all four readers: readIndex, readPayload,
readActivePath, and readOpenPaths by defining Zod schemas (or equivalent type
guards) for DraftIndexV2, DraftPayloadV2, active path, and open paths and using
schema.safeParse(parsed) before returning; for readIndex ensure parsed.v === 2
and validate updatedAt is a number, order is string[], and entries is an object
with DraftEntryMeta values; for readPayload validate the full DraftPayloadV2
shape; for readActivePath validate it's either null or a string and for
readOpenPaths validate it's an array of strings; on validation failure return
null (and optionally log or swallow error) so no malformed storage data is cast
and propagated.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed — added isValidIndex type guard that validates v === 2, updatedAt is a number, order is an array, and entries is a non-null object.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the current implementation of all four reader functions to verify validation
cat -n src/platform/workflow/persistence/base/storageIO.ts | sed -n '26,230p'

Length of output: 6193


@christian-byrne, thanks for adding the isValidIndex type guard—readIndex is now properly validated! 👍

However, the original review flagged four functions that need validation. The remaining three still cast parsed JSON without validation:

  1. readPayload (line 98): Should validate that data is a string and updatedAt is a number before returning as DraftPayloadV2
  2. readActivePath (line 197): Should validate that workspaceId and path are strings before returning as ActivePathPointer
  3. readOpenPaths (line 227): Should validate that workspaceId is a string, paths is a string array, and activeIndex is a number before returning as OpenPathsPointer

You can follow the same pattern as isValidIndex—create type guards like isValidPayload, isValidActivePath, and isValidOpenPaths to ensure malformed storage data doesn't propagate through the system.


🧬 Code Graph Analysis Results
  • File: src/platform/workflow/persistence/base/draftTypes.ts, lines 30-39
export interface DraftIndexV2 {
  /** Schema version */
  v: 2
  /** Last update timestamp */
  updatedAt: number
  /** LRU order: oldest → newest (draftKey array) */
  order: string[]
  /** Metadata keyed by draftKey (hash of path) */
  entries: Record<string, DraftEntryMeta>
}
  • File: src/platform/workflow/persistence/base/draftTypes.ts, lines 46-51
export interface DraftPayloadV2 {
  /** Serialized workflow JSON */
  data: string
  /** Last update timestamp */
  updatedAt: number
}
  • File: src/platform/workflow/persistence/base/draftTypes.ts, lines 59-64
export interface ActivePathPointer {
  /** Workspace ID for validation */
  workspaceId: string
  /** Path to the active workflow */
  path: string
}
  • File: src/platform/workflow/persistence/base/draftTypes.ts, lines 72-79
export interface OpenPathsPointer {
  /** Workspace ID for validation */
  workspaceId: string
  /** Ordered list of open workflow paths */
  paths: string[]
  /** Index of the active workflow in paths array */
  activeIndex: number
}
  • File: src/platform/workflow/persistence/base/storageKeys.ts, lines 40-93
export const StorageKeys = {
  /**
   * Draft index key for localStorage.
   * Contains LRU order and metadata for all drafts.
   */
  draftIndex(workspaceId: string = CURRENT_WORKSPACE_ID): string {
    return `Comfy.Workflow.DraftIndex.v2:${workspaceId}`
  },

  /**
   * 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 {
    const draftKey = hashPath(path)
    return `Comfy.Workflow.Draft.v2:${workspaceId}:${draftKey}`
  },

  /**
   * Creates a draft key (hash) from a workflow path.
   */
  draftKey(path: string): string {
    return hashPath(path)
  },

  /**
   * Active workflow pointer key for sessionStorage.
   * `@param` clientId - Browser tab identifier from api.clientId
   */
  activePath(clientId: string): string {
    return `Comfy.Workflow.ActivePath:${clientId}`
  },

  /**
   * Open workflows pointer key for sessionStorage.
   * `@param` clientId - Browser tab identifier from api.clientId
   */
  openPaths(clientId: string): string {
    return `Comfy.Workflow.OpenPaths:${clientId}`
  },

  /**
   * Prefix patterns for cleanup operations.
   */
  prefixes: {
    draftIndex: 'Comfy.Workflow.DraftIndex.v2:',
    draftPayload: 'Comfy.Workflow.Draft.v2:',
    activePath: 'Comfy.Workflow.ActivePath:',
    openPaths: 'Comfy.Workflow.OpenPaths:'
  }
} as const

🧠 Learnings used
Learnt from: DrJKL
Repo: Comfy-Org/ComfyUI_frontend PR: 7746
File: src/platform/assets/services/assetService.ts:484-491
Timestamp: 2026-01-06T19:20:56.167Z
Learning: In `src/platform/assets/services/assetService.ts`, prefer using `schema.safeParse()` over `schema.parse()` for validating API responses to avoid throwing ZodError with internal schema details; follow the existing pattern: call `safeParse()`, check `result.success`, and use `fromZodError(result.error)` to format error messages for logging while throwing user-friendly errors.

Learnt from: DrJKL
Repo: Comfy-Org/ComfyUI_frontend PR: 8307
File: src/stores/bootstrapStore.ts:61-78
Timestamp: 2026-01-26T18:39:26.986Z
Learning: In src/stores/**/*.ts: Do not suggest exposing internal state or error refs from stores unless there is demonstrated active usage of those properties elsewhere in the codebase. Keep internal state private by default; only expose what is actively consumed.

return null
}
Expand All @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Expand Down Expand Up @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
})
})
})
101 changes: 101 additions & 0 deletions src/platform/workflow/persistence/composables/useWorkflowTabState.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading