Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
125 changes: 91 additions & 34 deletions apps/realtime/src/database/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
isKnownWorkflowTriggerBlock,
isWorkflowAnnotationOnlyBlockType,
isWorkflowBlockProtected,
normalizeWorkflowEdgeSourceHandle,
normalizeWorkflowEdgeTargetHandle,
} from '@sim/workflow-types/workflow'
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/postgres-js'
Expand All @@ -57,13 +59,21 @@ function toEdgeHandles(edge: PersistedEdgeRecord) {
}

interface EdgeAddCandidate {
id?: string
id: string
source: string
target: string
sourceHandle?: string | null
targetHandle?: string | null
}

function canonicalizeEdgeAddCandidate(edge: EdgeAddCandidate): EdgeAddCandidate {
return {
...edge,
sourceHandle: normalizeWorkflowEdgeSourceHandle(edge.sourceHandle),
targetHandle: normalizeWorkflowEdgeTargetHandle(edge.targetHandle),
}
}

interface FilterEdgesForPersistResult<T> {
safeEdges: T[]
droppedCounts: Record<string, number>
Expand Down Expand Up @@ -283,8 +293,8 @@ async function insertAutoConnectEdge(
workflowId,
sourceBlockId: autoConnectEdge.source,
targetBlockId: autoConnectEdge.target,
sourceHandle: autoConnectEdge.sourceHandle || null,
targetHandle: autoConnectEdge.targetHandle || null,
sourceHandle: normalizeWorkflowEdgeSourceHandle(autoConnectEdge.sourceHandle),
targetHandle: normalizeWorkflowEdgeTargetHandle(autoConnectEdge.targetHandle),
})
logger.debug(
`Added auto-connect edge ${autoConnectEdge.id}: ${autoConnectEdge.source} -> ${autoConnectEdge.target}`
Expand Down Expand Up @@ -736,6 +746,33 @@ async function handleBlockOperationTx(
break
}

case BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED: {
if (!payload.id || payload.errorEnabled === undefined) {
throw new Error('Missing required fields for update error enabled operation')
}

const updateResult = await tx
.update(workflowBlocks)
.set({
data: sql`jsonb_set(
coalesce(${workflowBlocks.data}, '{}'::jsonb),
'{errorEnabled}',
${JSON.stringify(payload.errorEnabled)}::jsonb,
true
)`,
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
.returning({ id: workflowBlocks.id })

if (updateResult.length === 0) {
throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`)
}

logger.debug(`Updated block error output: ${payload.id} -> ${payload.errorEnabled}`)
break
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Error toggle lost on replace

Medium Severity

UPDATE_ERROR_ENABLED persists errorEnabled into block data, but the client store only updates the top-level field and REPLACE_STATE rewrites blocks from block.data alone without merging that top-level value the way full saves do. After undo/redo or any replace-state, a later reload can drop an enabled error output that has no error edge yet.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7f6cc9c. Configure here.


case BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE: {
if (!payload.id || !payload.canonicalId || !payload.canonicalMode) {
throw new Error('Missing required fields for update canonical mode operation')
Expand Down Expand Up @@ -1024,8 +1061,8 @@ async function handleBlocksOperationTx(
// blocksById lookup (a plain `tx.select` from `workflowBlocks`) also
// sees the blocks this same batch just inserted — reads observe a
// transaction's own prior writes.
const candidates: EdgeAddCandidate[] = (edges as Array<Record<string, unknown>>).map(
(e) => ({
const candidates: EdgeAddCandidate[] = (edges as Array<Record<string, unknown>>).map((e) =>
canonicalizeEdgeAddCandidate({
id: e.id as string,
source: e.source as string,
target: e.target as string,
Expand All @@ -1051,8 +1088,8 @@ async function handleBlocksOperationTx(
workflowId,
sourceBlockId: edge.source,
targetBlockId: edge.target,
sourceHandle: edge.sourceHandle || null,
targetHandle: edge.targetHandle || null,
sourceHandle: normalizeWorkflowEdgeSourceHandle(edge.sourceHandle),
targetHandle: normalizeWorkflowEdgeTargetHandle(edge.targetHandle),
}))

await tx
Expand Down Expand Up @@ -1509,18 +1546,17 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str
throw new Error('Missing required fields for add edge operation')
}

const candidate = canonicalizeEdgeAddCandidate({
id: payload.id,
source: payload.source,
target: payload.target,
sourceHandle: payload.sourceHandle ?? null,
targetHandle: payload.targetHandle ?? null,
})
const { safeEdges, droppedCounts, droppedDuplicates } = await filterEdgesForPersist(
tx,
workflowId,
[
{
id: payload.id,
source: payload.source,
target: payload.target,
sourceHandle: payload.sourceHandle ?? null,
targetHandle: payload.targetHandle ?? null,
},
]
[candidate]
)

if (safeEdges.length === 0) {
Expand All @@ -1534,13 +1570,14 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str
break
}

const [safeEdge] = safeEdges
await tx.insert(workflowEdges).values({
id: payload.id,
id: safeEdge.id,
workflowId,
sourceBlockId: payload.source,
targetBlockId: payload.target,
sourceHandle: payload.sourceHandle || null,
targetHandle: payload.targetHandle || null,
sourceBlockId: safeEdge.source,
targetBlockId: safeEdge.target,
sourceHandle: normalizeWorkflowEdgeSourceHandle(safeEdge.sourceHandle),
targetHandle: normalizeWorkflowEdgeTargetHandle(safeEdge.targetHandle),
})

logger.debug(`Added edge ${payload.id}: ${payload.source} -> ${payload.target}`)
Expand Down Expand Up @@ -1755,13 +1792,15 @@ async function handleEdgesOperationTx(

logger.info(`Batch adding ${edges.length} edges to workflow ${workflowId}`)

const candidates: EdgeAddCandidate[] = (edges as Array<Record<string, unknown>>).map((e) => ({
id: e.id as string,
source: e.source as string,
target: e.target as string,
sourceHandle: (e.sourceHandle as string | null) ?? null,
targetHandle: (e.targetHandle as string | null) ?? null,
}))
const candidates: EdgeAddCandidate[] = (edges as Array<Record<string, unknown>>).map((e) =>
canonicalizeEdgeAddCandidate({
id: e.id as string,
source: e.source as string,
target: e.target as string,
sourceHandle: (e.sourceHandle as string | null) ?? null,
targetHandle: (e.targetHandle as string | null) ?? null,
})
)

const { safeEdges, droppedCounts, droppedDuplicates, droppedCyclic } =
await filterEdgesForPersist(tx, workflowId, candidates)
Expand All @@ -1784,8 +1823,8 @@ async function handleEdgesOperationTx(
workflowId,
sourceBlockId: edge.source,
targetBlockId: edge.target,
sourceHandle: edge.sourceHandle || null,
targetHandle: edge.targetHandle || null,
sourceHandle: normalizeWorkflowEdgeSourceHandle(edge.sourceHandle),
targetHandle: normalizeWorkflowEdgeTargetHandle(edge.targetHandle),
}))

await tx
Expand Down Expand Up @@ -2152,16 +2191,34 @@ async function handleWorkflowOperationTx(

// Insert all edges from the new state
if (edges && edges.length > 0) {
const edgeValues = edges.map((edge: any) => ({
const canonicalEdges = (edges as Array<Record<string, unknown>>).map((edge) =>
canonicalizeEdgeAddCandidate({
id: edge.id as string,
source: edge.source as string,
target: edge.target as string,
sourceHandle: (edge.sourceHandle as string | null) ?? null,
targetHandle: (edge.targetHandle as string | null) ?? null,
})
)
const uniqueEdges = filterUniqueWorkflowEdges(canonicalEdges, [])
const edgeValues = uniqueEdges.map((edge) => ({
id: edge.id,
workflowId,
sourceBlockId: edge.source,
targetBlockId: edge.target,
sourceHandle: edge.sourceHandle || null,
targetHandle: edge.targetHandle || null,
sourceHandle: edge.sourceHandle ?? null,
targetHandle: edge.targetHandle ?? null,
}))

await tx.insert(workflowEdges).values(edgeValues)
if (uniqueEdges.length < edges.length) {
logger.info(`Dropped ${edges.length - uniqueEdges.length} duplicate edge(s)`, {
operation: WORKFLOW_OPERATIONS.REPLACE_STATE,
})
}

if (edgeValues.length > 0) {
await tx.insert(workflowEdges).values(edgeValues)
}
}

// Insert all loops from the new state
Expand Down
1 change: 1 addition & 0 deletions apps/realtime/src/middleware/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const WRITE_OPERATIONS: string[] = [
BLOCK_OPERATIONS.TOGGLE_ENABLED,
BLOCK_OPERATIONS.UPDATE_PARENT,
BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE,
BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED,
BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE,
BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES,
BLOCK_OPERATIONS.TOGGLE_HANDLES,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
import type { ReactNode } from 'react'
import { createContext, use, useEffect, useMemo, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import { FROSTED_GLASS_SURFACE } from '@/lib/ui/glass-surface'

/**
* Frosted near-white surface for the scrolled bar - `--bg` at 92% + a strong 40px
* blur, edge to edge. Exported as the single source of truth so the mobile
* dropdown sheet ({@link MobileNav}) wears the exact same glass as the bar and the
* two can never drift.
*/
export const NAVBAR_GLASS_SURFACE =
'bg-[color-mix(in_srgb,var(--bg)_92%,transparent)] backdrop-blur-2xl'
export const NAVBAR_GLASS_SURFACE = FROSTED_GLASS_SURFACE

interface NavbarFrostContextValue {
/**
Expand Down
Loading
Loading