From f11c3d27e91391ca5fa7c33403dee76e31431a06 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 22 Jul 2026 11:08:41 -0700 Subject: [PATCH 1/2] feat(workflows): add IDE-style reference viewer for workflows Adds a "Show references" viewer so you can see how workflows connect: which workflows call a given workflow ("Used by") and which workflows it calls ("Uses"), rendered as recursive, clickable trees. - Opened via Cmd/Ctrl+click on a sidebar workflow row and a "Show references" context-menu item. - Resolves references through both the workflow / workflow_input blocks (reusing isWorkflowBlockType) and published custom blocks (custom_block_* -> source workflow), scoped to the workspace. - Builds the whole workspace reference graph once from live workflow_blocks state; cycle-safe DFS marks A->B->A loops as (cycle) leaves. - Contract-bound GET /api/workflows/[id]/references with workspace-level authz; React Query hook gated to fetch only when the modal opens. - Unit tests for the pure graph/tree logic (cycles, self-refs, dangling drop, custom-block + workflow_input resolution) and route tests (401/400/403/200). --- .../workflows/[id]/references/route.test.ts | 74 ++++++ .../api/workflows/[id]/references/route.ts | 30 +++ .../components/context-menu/context-menu.tsx | 19 +- .../reference-tree/reference-tree.tsx | 74 ++++++ .../references-modal/references-modal.tsx | 87 +++++++ .../workflow-item/workflow-item.tsx | 18 ++ apps/sim/hooks/queries/workflow-references.ts | 42 ++++ .../lib/api/contracts/workflow-references.ts | 59 +++++ .../workflows/references/operations.test.ts | 131 +++++++++++ .../lib/workflows/references/operations.ts | 219 ++++++++++++++++++ scripts/check-api-validation-contracts.ts | 4 +- 11 files changed, 754 insertions(+), 3 deletions(-) create mode 100644 apps/sim/app/api/workflows/[id]/references/route.test.ts create mode 100644 apps/sim/app/api/workflows/[id]/references/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal.tsx create mode 100644 apps/sim/hooks/queries/workflow-references.ts create mode 100644 apps/sim/lib/api/contracts/workflow-references.ts create mode 100644 apps/sim/lib/workflows/references/operations.test.ts create mode 100644 apps/sim/lib/workflows/references/operations.ts diff --git a/apps/sim/app/api/workflows/[id]/references/route.test.ts b/apps/sim/app/api/workflows/[id]/references/route.test.ts new file mode 100644 index 00000000000..29fba71ef84 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/references/route.test.ts @@ -0,0 +1,74 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockGetUserEntityPermissions, mockGetWorkflowReferences } = vi.hoisted( + () => ({ + mockGetSession: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkflowReferences: vi.fn(), + }) +) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/workflows/references/operations', () => ({ + getWorkflowReferences: mockGetWorkflowReferences, +})) + +import { GET } from '@/app/api/workflows/[id]/references/route' + +const REFERENCES = { + callers: [{ id: 'b', name: 'B', cycle: false, children: [] }], + callees: [], +} + +function callRoute(id = 'wf-1', workspaceId: string | null = 'ws-1') { + const url = workspaceId + ? `http://localhost:3000/api/workflows/${id}/references?workspaceId=${workspaceId}` + : `http://localhost:3000/api/workflows/${id}/references` + return GET(createMockRequest('GET', undefined, {}, url), { params: Promise.resolve({ id }) }) +} + +describe('GET /api/workflows/[id]/references', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('read') + mockGetWorkflowReferences.mockResolvedValue(REFERENCES) + }) + + it('returns 401 without a session', async () => { + mockGetSession.mockResolvedValue(null) + const response = await callRoute() + expect(response.status).toBe(401) + expect(mockGetWorkflowReferences).not.toHaveBeenCalled() + }) + + it('returns 400 without a workspaceId', async () => { + const response = await callRoute('wf-1', null) + expect(response.status).toBe(400) + }) + + it('returns 403 when the user cannot access the workspace', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + const response = await callRoute() + expect(response.status).toBe(403) + expect(mockGetWorkflowReferences).not.toHaveBeenCalled() + }) + + it('returns the reference trees for the workflow', async () => { + const response = await callRoute() + expect(response.status).toBe(200) + expect(await response.json()).toEqual(REFERENCES) + expect(mockGetWorkflowReferences).toHaveBeenCalledWith('ws-1', 'wf-1') + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/references/route.ts b/apps/sim/app/api/workflows/[id]/references/route.ts new file mode 100644 index 00000000000..4a611ed1498 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/references/route.ts @@ -0,0 +1,30 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getWorkflowReferencesContract } from '@/lib/api/contracts/workflow-references' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getWorkflowReferences } from '@/lib/workflows/references/operations' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +type RouteContext = { params: Promise<{ id: string }> } + +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getWorkflowReferencesContract, request, context) + if (!parsed.success) return parsed.response + + const { params, query } = parsed.data + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', query.workspaceId) + if (permission === null) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const references = await getWorkflowReferences(query.workspaceId, params.id) + return NextResponse.json(references) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index 13e4dc6ab16..bd35b5b2614 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -22,6 +22,7 @@ import { SquareArrowUpRight, Trash, Unlock, + Workflow, } from '@sim/emcn/icons' import { Pin, PinOff } from 'lucide-react' @@ -31,6 +32,7 @@ interface ContextMenuProps { menuRef: React.RefObject onClose: () => void onOpenInNewTab?: () => void + onFindReferences?: () => void onMarkAsRead?: () => void onMarkAsUnread?: () => void onTogglePin?: () => void @@ -53,6 +55,7 @@ interface ContextMenuProps { onExport?: () => void onDelete: () => void showOpenInNewTab?: boolean + showFindReferences?: boolean showMarkAsRead?: boolean showMarkAsUnread?: boolean showPin?: boolean @@ -93,6 +96,7 @@ export function ContextMenu({ menuRef, onClose, onOpenInNewTab, + onFindReferences, onMarkAsRead, onMarkAsUnread, onTogglePin, @@ -104,6 +108,7 @@ export function ContextMenu({ onExport, onDelete, showOpenInNewTab = false, + showFindReferences = false, showMarkAsRead = false, showMarkAsUnread = false, showPin = false, @@ -133,7 +138,8 @@ export function ContextMenu({ showUploadLogo = false, disableUploadLogo = false, }: ContextMenuProps) { - const hasNavigationSection = showOpenInNewTab && onOpenInNewTab + const hasNavigationSection = + (showOpenInNewTab && onOpenInNewTab) || (showFindReferences && onFindReferences) const hasStatusSection = (showMarkAsRead && onMarkAsRead) || (showMarkAsUnread && onMarkAsUnread) || @@ -195,6 +201,17 @@ export function ContextMenu({ Open in new tab )} + {showFindReferences && onFindReferences && ( + { + onFindReferences() + onClose() + }} + > + + Show references + + )} {hasNavigationSection && (hasStatusSection || hasEditSection || hasCopySection) && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree.tsx new file mode 100644 index 00000000000..32d84ebd90f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree.tsx @@ -0,0 +1,74 @@ +'use client' + +import { cn } from '@sim/emcn' +import { WorkflowIcon } from '@/components/icons' +import type { ReferenceNode } from '@/lib/api/contracts/workflow-references' + +const CONFIG = { + /** Horizontal indent added per tree level, in pixels. */ + INDENT_PER_LEVEL: 16, + /** Base horizontal padding for a row, in pixels. */ + BASE_INDENT: 4, + /** Workflow-block brand color (`WorkflowBlock.bgColor`). */ + ICON_COLOR: '#6366F1', +} as const + +interface ReferenceTreeProps { + nodes: ReferenceNode[] + /** Invoked with a workflow id when a row is activated. */ + onNavigate: (workflowId: string) => void +} + +interface ReferenceTreeItemProps { + node: ReferenceNode + depth: number + onNavigate: (workflowId: string) => void +} + +function ReferenceTreeItem({ node, depth, onNavigate }: ReferenceTreeItemProps) { + return ( +
+ + {node.children.length > 0 && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ) +} + +/** + * Read-only recursive tree of workflow references. Each row navigates to its + * workflow on click; cyclic leaves are marked and render no children. + */ +export function ReferenceTree({ nodes, onNavigate }: ReferenceTreeProps) { + return ( +
+ {nodes.map((node) => ( + + ))} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal.tsx new file mode 100644 index 00000000000..9c096725536 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal.tsx @@ -0,0 +1,87 @@ +'use client' + +import { useCallback, useState } from 'react' +import { ChipModal, ChipModalBody, ChipModalHeader, ChipModalTabs } from '@sim/emcn' +import { useRouter } from 'next/navigation' +import type { ReferenceNode } from '@/lib/api/contracts/workflow-references' +import { ReferenceTree } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree' +import { useWorkflowReferences } from '@/hooks/queries/workflow-references' + +type ReferencesTab = 'callers' | 'callees' + +const TABS = [ + { value: 'callers', label: 'Used by' }, + { value: 'callees', label: 'Uses' }, +] as const + +const EMPTY_MESSAGE: Record = { + callers: 'No workflows call this workflow.', + callees: "This workflow doesn't call any other workflows.", +} + +interface ReferencesModalProps { + isOpen: boolean + onClose: () => void + workspaceId: string + workflowId: string + workflowName: string +} + +/** + * IDE-style reference viewer for a workflow. "Used by" lists the workflows that + * call it (inbound); "Uses" lists the workflows it calls (outbound). Both are + * recursive trees whose rows navigate to the referenced workflow. + */ +export function ReferencesModal({ + isOpen, + onClose, + workspaceId, + workflowId, + workflowName, +}: ReferencesModalProps) { + const router = useRouter() + const [activeTab, setActiveTab] = useState('callers') + const [prevIsOpen, setPrevIsOpen] = useState(false) + + if (isOpen !== prevIsOpen) { + setPrevIsOpen(isOpen) + if (isOpen) setActiveTab('callers') + } + + const { data, isPending, isError } = useWorkflowReferences(workspaceId, workflowId, { + enabled: isOpen, + }) + + const handleNavigate = useCallback( + (targetId: string) => { + router.push(`/workspace/${workspaceId}/w/${targetId}`) + onClose() + }, + [router, workspaceId, onClose] + ) + + const nodes: ReferenceNode[] = data ? data[activeTab] : [] + + return ( + !next && onClose()} srTitle='References'> + References · {workflowName} + + setActiveTab(value as ReferencesTab)} + aria-label='Reference direction' + /> + {isPending ? ( +

Loading references…

+ ) : isError ? ( +

Failed to load references.

+ ) : nodes.length === 0 ? ( +

{EMPTY_MESSAGE[activeTab]}

+ ) : ( + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index fcc7d8a28b8..23ee3eae416 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -10,6 +10,7 @@ import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' +import { ReferencesModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal' import { Avatars } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars' import { useContextMenu, @@ -80,6 +81,7 @@ export const WorkflowItem = memo(function WorkflowItem({ const { canDeleteWorkflows, canDeleteFolder } = useCanDelete({ workspaceId }) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) + const [isReferencesOpen, setIsReferencesOpen] = useState(false) const [deleteItemType, setDeleteItemType] = useState<'workflow' | 'mixed'>('workflow') const [deleteModalNames, setDeleteModalNames] = useState('') const [canDeleteSelection, setCanDeleteSelection] = useState(true) @@ -386,6 +388,8 @@ export const WorkflowItem = memo(function WorkflowItem({ } if (e.metaKey || e.ctrlKey) { + e.preventDefault() + setIsReferencesOpen(true) return } @@ -398,6 +402,10 @@ export const WorkflowItem = memo(function WorkflowItem({ [shouldPreventClickRef, workflow.id, onWorkflowClick, isEditing] ) + const handleFindReferences = useCallback(() => { + setIsReferencesOpen(true) + }, []) + return ( <> + + setIsReferencesOpen(false)} + workspaceId={workspaceId} + workflowId={workflow.id} + workflowName={workflow.name} + /> ) }) diff --git a/apps/sim/hooks/queries/workflow-references.ts b/apps/sim/hooks/queries/workflow-references.ts new file mode 100644 index 00000000000..da075095a80 --- /dev/null +++ b/apps/sim/hooks/queries/workflow-references.ts @@ -0,0 +1,42 @@ +import { useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + getWorkflowReferencesContract, + type WorkflowReferencesResponse, +} from '@/lib/api/contracts/workflow-references' + +/** Short — the graph reflects live editor state and should stay fresh on reopen. */ +export const WORKFLOW_REFERENCES_STALE_TIME = 30 * 1000 + +export const workflowReferenceKeys = { + all: ['workflow-references'] as const, + details: () => [...workflowReferenceKeys.all, 'detail'] as const, + detail: (workspaceId?: string, workflowId?: string) => + [...workflowReferenceKeys.details(), workspaceId ?? '', workflowId ?? ''] as const, +} + +async function fetchWorkflowReferences( + workspaceId: string, + workflowId: string, + signal?: AbortSignal +): Promise { + return requestJson(getWorkflowReferencesContract, { + params: { id: workflowId }, + query: { workspaceId }, + signal, + }) +} + +export function useWorkflowReferences( + workspaceId?: string, + workflowId?: string, + options?: { enabled?: boolean } +) { + return useQuery({ + queryKey: workflowReferenceKeys.detail(workspaceId, workflowId), + queryFn: ({ signal }) => + fetchWorkflowReferences(workspaceId as string, workflowId as string, signal), + enabled: Boolean(workspaceId && workflowId) && (options?.enabled ?? true), + staleTime: WORKFLOW_REFERENCES_STALE_TIME, + }) +} diff --git a/apps/sim/lib/api/contracts/workflow-references.ts b/apps/sim/lib/api/contracts/workflow-references.ts new file mode 100644 index 00000000000..918b1ea69a5 --- /dev/null +++ b/apps/sim/lib/api/contracts/workflow-references.ts @@ -0,0 +1,59 @@ +import { z } from 'zod' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' + +/** + * One node in a workflow reference tree. Recursive, so the Zod schema below needs + * `z.lazy` plus this explicit interface annotation — TypeScript cannot infer a + * self-referential type. Shared by the server graph builder (its return element + * type) and the client hook, keeping both off `z.output<...>`. + */ +export interface ReferenceNode { + /** Referenced workflow id. */ + id: string + /** Referenced workflow name (falls back to the id if unresolved). */ + name: string + /** + * True when this node closes a cycle already on the current path (e.g. the + * root, or `A → B → A`). Cyclic nodes carry no `children`. + */ + cycle: boolean + children: ReferenceNode[] +} + +export const referenceNodeSchema: z.ZodType = z.lazy(() => + z.object({ + id: z.string(), + name: z.string(), + cycle: z.boolean(), + children: z.array(referenceNodeSchema), + }) +) + +export const workflowReferencesParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) + +export const workflowReferencesQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +export const workflowReferencesResponseSchema = z.object({ + /** Workflows that call this workflow (inbound), each recursively expanded. */ + callers: z.array(referenceNodeSchema), + /** Workflows this workflow calls (outbound), each recursively expanded. */ + callees: z.array(referenceNodeSchema), +}) + +export type WorkflowReferencesResponse = z.output + +export const getWorkflowReferencesContract = defineRouteContract({ + method: 'GET', + path: '/api/workflows/[id]/references', + params: workflowReferencesParamsSchema, + query: workflowReferencesQuerySchema, + response: { + mode: 'json', + schema: workflowReferencesResponseSchema, + }, +}) diff --git a/apps/sim/lib/workflows/references/operations.test.ts b/apps/sim/lib/workflows/references/operations.test.ts new file mode 100644 index 00000000000..969e8bedca1 --- /dev/null +++ b/apps/sim/lib/workflows/references/operations.test.ts @@ -0,0 +1,131 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type CustomBlockLink, + type ReferenceBlockRow, + resolveWorkflowReferences, + type WorkflowNode, +} from '@/lib/workflows/references/operations' + +const workflows: WorkflowNode[] = [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + { id: 'c', name: 'C' }, + { id: 'd', name: 'D' }, +] + +function workflowBlock( + parentId: string, + childId: string, + mode: 'basic' | 'manual' = 'basic', + type: 'workflow' | 'workflow_input' = 'workflow' +): ReferenceBlockRow { + return { + parentId, + type, + childFromSelector: mode === 'basic' ? childId : null, + childFromManual: mode === 'manual' ? childId : null, + } +} + +describe('resolveWorkflowReferences', () => { + it('resolves direct callers and callees', () => { + // A → B, A → C + const blocks = [workflowBlock('a', 'b'), workflowBlock('a', 'c')] + const { callers, callees } = resolveWorkflowReferences('a', workflows, blocks, []) + + expect(callers).toEqual([]) + expect(callees.map((n) => n.id)).toEqual(['b', 'c']) + + const bResult = resolveWorkflowReferences('b', workflows, blocks, []) + expect(bResult.callers.map((n) => n.id)).toEqual(['a']) + expect(bResult.callees).toEqual([]) + }) + + it('resolves references made through workflow_input blocks', () => { + // A → B → C, all via the workflow_input block type. + const blocks = [ + workflowBlock('a', 'b', 'basic', 'workflow_input'), + workflowBlock('b', 'c', 'basic', 'workflow_input'), + ] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.id)).toEqual(['b']) + expect(callees[0].children.map((n) => n.id)).toEqual(['c']) + + const cResult = resolveWorkflowReferences('c', workflows, blocks, []) + expect(cResult.callers.map((n) => n.id)).toEqual(['b']) + expect(cResult.callers[0].children.map((n) => n.id)).toEqual(['a']) + }) + + it('resolves the advanced-mode manualWorkflowId value', () => { + const blocks = [workflowBlock('a', 'b', 'manual')] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.id)).toEqual(['b']) + }) + + it('marks cycles as leaves and stops recursing', () => { + // A → B → A + const blocks = [workflowBlock('a', 'b'), workflowBlock('b', 'a')] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + + expect(callees).toHaveLength(1) + expect(callees[0]).toMatchObject({ id: 'b', cycle: false }) + expect(callees[0].children).toHaveLength(1) + expect(callees[0].children[0]).toMatchObject({ id: 'a', cycle: true, children: [] }) + }) + + it('drops self-references', () => { + const blocks = [workflowBlock('a', 'a')] + const { callers, callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callers).toEqual([]) + expect(callees).toEqual([]) + }) + + it('drops dangling / out-of-workspace child ids', () => { + const blocks = [workflowBlock('a', 'missing')] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees).toEqual([]) + }) + + it('resolves references made through custom blocks', () => { + // D places custom_block_x, which is bound to source workflow C. + const blocks: ReferenceBlockRow[] = [ + { parentId: 'd', type: 'custom_block_x', childFromSelector: null, childFromManual: null }, + ] + const customBlocks: CustomBlockLink[] = [{ type: 'custom_block_x', workflowId: 'c' }] + + const cResult = resolveWorkflowReferences('c', workflows, blocks, customBlocks) + expect(cResult.callers.map((n) => n.id)).toEqual(['d']) + + const dResult = resolveWorkflowReferences('d', workflows, blocks, customBlocks) + expect(dResult.callees.map((n) => n.id)).toEqual(['c']) + }) + + it('ignores custom blocks with no bound source in scope', () => { + const blocks: ReferenceBlockRow[] = [ + { + parentId: 'd', + type: 'custom_block_unknown', + childFromSelector: null, + childFromManual: null, + }, + ] + const { callees } = resolveWorkflowReferences('d', workflows, blocks, []) + expect(callees).toEqual([]) + }) + + it('returns empty trees when the workflow is not a workspace node', () => { + const blocks = [workflowBlock('a', 'b')] + const result = resolveWorkflowReferences('unknown', workflows, blocks, []) + expect(result).toEqual({ callers: [], callees: [] }) + }) + + it('sorts children by name', () => { + // Names: B, C — insert in reverse to prove sorting. + const blocks = [workflowBlock('a', 'c'), workflowBlock('a', 'b')] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.name)).toEqual(['B', 'C']) + }) +}) diff --git a/apps/sim/lib/workflows/references/operations.ts b/apps/sim/lib/workflows/references/operations.ts new file mode 100644 index 00000000000..6045b643dbe --- /dev/null +++ b/apps/sim/lib/workflows/references/operations.ts @@ -0,0 +1,219 @@ +import { db } from '@sim/db' +import { workflow, workflowBlocks } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, inArray, isNull, like, or, sql } from 'drizzle-orm' +import type { ReferenceNode } from '@/lib/api/contracts/workflow-references' +import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' +import { CUSTOM_BLOCK_TYPE_PREFIX } from '@/blocks/custom/build-config' +import { BlockType, isWorkflowBlockType } from '@/executor/constants' + +const logger = createLogger('WorkflowReferences') + +/** + * Depth ceiling for a reference tree — mirrors `MAX_CALL_CHAIN_DEPTH` in + * `@/lib/execution/call-chain`. The per-path visited set is the real cycle guard; + * this is a belt-and-suspenders bound on pathological graphs. + */ +const MAX_REFERENCE_DEPTH = 25 + +/** A workspace-local, non-archived workflow node. */ +export interface WorkflowNode { + id: string + name: string +} + +/** A placed block that may reference another workflow (raw, pre-resolution). */ +export interface ReferenceBlockRow { + parentId: string + type: string + /** `workflowId` sub-block value (basic mode), if a workflow block. */ + childFromSelector: string | null + /** `manualWorkflowId` sub-block value (advanced mode), if a workflow block. */ + childFromManual: string | null +} + +/** A custom-block type slug bound to its source workflow. */ +export interface CustomBlockLink { + type: string + workflowId: string +} + +/** + * The workspace-wide reference graph derived from live editor state: every + * workflow node's name, plus forward (callee) and reverse (caller) adjacency. + */ +interface ReferenceGraph { + nameById: Map + forward: Map> + reverse: Map> +} + +/** + * Build the directed reference graph from raw workspace rows. Pure (no I/O) so it + * can be unit-tested directly. Resolves two reference shapes: + * - direct **workflow blocks** (`workflow` and `workflow_input`), whose child id + * is the `workflowId` (basic) or `manualWorkflowId` (advanced) sub-block value; and + * - **custom blocks** (`custom_block_`), whose type slug maps to a bound + * source workflow via `customBlocks`. + * + * Edges are scoped to workspace-local, non-archived workflows: self-references, + * empty values, and ids outside `workflows` are dropped. + */ +export function buildReferenceGraph( + workflows: WorkflowNode[], + blocks: ReferenceBlockRow[], + customBlocks: CustomBlockLink[] +): ReferenceGraph { + const nameById = new Map() + for (const node of workflows) nameById.set(node.id, node.name) + + const sourceByCustomType = new Map() + for (const link of customBlocks) sourceByCustomType.set(link.type, link.workflowId) + + const forward = new Map>() + const reverse = new Map>() + + const addEdge = (parentId: string, childId: string) => { + if (!childId || childId === parentId) return + if (!nameById.has(parentId) || !nameById.has(childId)) return + if (!forward.has(parentId)) forward.set(parentId, new Set()) + forward.get(parentId)?.add(childId) + if (!reverse.has(childId)) reverse.set(childId, new Set()) + reverse.get(childId)?.add(parentId) + } + + for (const block of blocks) { + if (isWorkflowBlockType(block.type)) { + const childId = block.childFromSelector || block.childFromManual + if (childId) addEdge(block.parentId, childId) + continue + } + const sourceId = sourceByCustomType.get(block.type) + if (sourceId) addEdge(block.parentId, sourceId) + } + + return { nameById, forward, reverse } +} + +/** + * Expand a direction of the graph into a tree rooted at `rootId`. `adjacency` is + * either the forward (callees) or reverse (callers) map. A node already on the + * current DFS path is emitted as a `cycle: true` leaf and not re-expanded, so + * `A → B → A` terminates. Children are sorted by name for stable rendering. + */ +function buildTree( + rootId: string, + adjacency: Map>, + nameById: Map +): ReferenceNode[] { + const path = new Set([rootId]) + + const expand = (id: string, depth: number): ReferenceNode[] => { + if (depth >= MAX_REFERENCE_DEPTH) return [] + const neighbors = adjacency.get(id) + if (!neighbors || neighbors.size === 0) return [] + + const sorted = [...neighbors].sort((a, b) => { + const nameA = nameById.get(a) ?? a + const nameB = nameById.get(b) ?? b + return nameA.localeCompare(nameB) + }) + + const nodes: ReferenceNode[] = [] + for (const childId of sorted) { + const name = nameById.get(childId) ?? childId + if (path.has(childId)) { + nodes.push({ id: childId, name, cycle: true, children: [] }) + continue + } + path.add(childId) + nodes.push({ id: childId, name, cycle: false, children: expand(childId, depth + 1) }) + path.delete(childId) + } + return nodes + } + + return expand(rootId, 0) +} + +/** + * Resolve the reference trees for one workflow from raw workspace rows. Pure so it + * can be unit-tested without a database. Returns empty arrays when the workflow is + * not a workspace-local node or has no references. + */ +export function resolveWorkflowReferences( + workflowId: string, + workflows: WorkflowNode[], + blocks: ReferenceBlockRow[], + customBlocks: CustomBlockLink[] +): { callers: ReferenceNode[]; callees: ReferenceNode[] } { + const { nameById, forward, reverse } = buildReferenceGraph(workflows, blocks, customBlocks) + + if (!nameById.has(workflowId)) { + return { callers: [], callees: [] } + } + + return { + callers: buildTree(workflowId, reverse, nameById), + callees: buildTree(workflowId, forward, nameById), + } +} + +/** + * Resolve the reference trees for one workflow: `callers` (workflows that call + * it, inbound) and `callees` (workflows it calls, outbound), read from the live + * `workflowBlocks` (draft) table — the state the sidebar and editor show. The + * root itself is not a node; each array holds its direct references, recursively + * expanded. + */ +export async function getWorkflowReferences( + workspaceId: string, + workflowId: string +): Promise<{ callers: ReferenceNode[]; callees: ReferenceNode[] }> { + const [workflowRows, blockRows, customBlockRows] = await Promise.all([ + db + .select({ id: workflow.id, name: workflow.name }) + .from(workflow) + .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))), + db + .select({ + parentId: workflowBlocks.workflowId, + type: workflowBlocks.type, + childFromSelector: sql< + string | null + >`${workflowBlocks.subBlocks} -> 'workflowId' ->> 'value'`, + childFromManual: sql< + string | null + >`${workflowBlocks.subBlocks} -> 'manualWorkflowId' ->> 'value'`, + }) + .from(workflowBlocks) + .innerJoin(workflow, eq(workflow.id, workflowBlocks.workflowId)) + .where( + and( + eq(workflow.workspaceId, workspaceId), + isNull(workflow.archivedAt), + or( + inArray(workflowBlocks.type, [BlockType.WORKFLOW, BlockType.WORKFLOW_INPUT]), + like(workflowBlocks.type, `${CUSTOM_BLOCK_TYPE_PREFIX}%`) + ) + ) + ), + getCustomBlockRowsForWorkspace(workspaceId), + ]) + + const result = resolveWorkflowReferences( + workflowId, + workflowRows, + blockRows, + customBlockRows.map((row) => ({ type: row.type, workflowId: row.workflowId })) + ) + + if (!workflowRows.some((row) => row.id === workflowId)) { + logger.warn('Workflow not found in workspace when resolving references', { + workspaceId, + workflowId, + }) + } + + return result +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 14a54b13f51..e7644a4d00d 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 966, - zodRoutes: 966, + totalRoutes: 967, + zodRoutes: 967, nonZodRoutes: 0, } as const From 1888e6226f43e078f4ff64322832e644ace3a08c Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 22 Jul 2026 11:21:42 -0700 Subject: [PATCH 2/2] fix(workflows): correct reference resolution for active mode, cycles, cache, and graph size Addresses review findings on the reference viewer: - Resolve the workflow-block child via resolveActiveCanonicalValue (the shared SOT) instead of basic-first `||`, so an advanced-mode block whose old basic workflowId value lingers resolves to the active manual value. - Keep self-references (A -> A) and render them as a cycle leaf instead of dropping the edge, matching the cycle-safe viewer's purpose. - Set the references query staleTime to 0 so reopening the always-mounted modal refetches live editor state instead of serving a stale cached graph. - Bound converging paths: a node already expanded elsewhere in the tree is emitted once more as a plain leaf (edge stays visible) rather than re-expanded, so a densely reconverging graph can't grow exponentially. --- apps/sim/hooks/queries/workflow-references.ts | 9 ++- .../workflows/references/operations.test.ts | 51 +++++++++++++++-- .../lib/workflows/references/operations.ts | 57 ++++++++++++++++--- 3 files changed, 103 insertions(+), 14 deletions(-) diff --git a/apps/sim/hooks/queries/workflow-references.ts b/apps/sim/hooks/queries/workflow-references.ts index da075095a80..0083fc526ff 100644 --- a/apps/sim/hooks/queries/workflow-references.ts +++ b/apps/sim/hooks/queries/workflow-references.ts @@ -5,8 +5,13 @@ import { type WorkflowReferencesResponse, } from '@/lib/api/contracts/workflow-references' -/** Short — the graph reflects live editor state and should stay fresh on reopen. */ -export const WORKFLOW_REFERENCES_STALE_TIME = 30 * 1000 +/** + * Zero — the graph reflects live editor state (workflow blocks/names can change + * between opens). The modal stays mounted with the query disabled while closed, so + * a non-zero window would serve a cached graph on reopen without refetching. Zero + * marks the data stale immediately, forcing a refetch each time the modal reopens. + */ +export const WORKFLOW_REFERENCES_STALE_TIME = 0 export const workflowReferenceKeys = { all: ['workflow-references'] as const, diff --git a/apps/sim/lib/workflows/references/operations.test.ts b/apps/sim/lib/workflows/references/operations.test.ts index 969e8bedca1..0244f0397ef 100644 --- a/apps/sim/lib/workflows/references/operations.test.ts +++ b/apps/sim/lib/workflows/references/operations.test.ts @@ -27,6 +27,7 @@ function workflowBlock( type, childFromSelector: mode === 'basic' ? childId : null, childFromManual: mode === 'manual' ? childId : null, + canonicalModes: null, } } @@ -65,6 +66,22 @@ describe('resolveWorkflowReferences', () => { expect(callees.map((n) => n.id)).toEqual(['b']) }) + it('uses the active mode, not a retained inactive value', () => { + // Advanced mode active (canonicalModes override), but a stale basic value + // (`b`) lingers. Must resolve to the advanced value (`c`), not the stale basic. + const blocks: ReferenceBlockRow[] = [ + { + parentId: 'a', + type: 'workflow', + childFromSelector: 'b', + childFromManual: 'c', + canonicalModes: { workflowId: 'advanced' }, + }, + ] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.id)).toEqual(['c']) + }) + it('marks cycles as leaves and stops recursing', () => { // A → B → A const blocks = [workflowBlock('a', 'b'), workflowBlock('b', 'a')] @@ -76,11 +93,30 @@ describe('resolveWorkflowReferences', () => { expect(callees[0].children[0]).toMatchObject({ id: 'a', cycle: true, children: [] }) }) - it('drops self-references', () => { + it('shows a self-reference as a cycle leaf', () => { + // A → A: the reference is real and belongs in the cycle-safe viewer. const blocks = [workflowBlock('a', 'a')] const { callers, callees } = resolveWorkflowReferences('a', workflows, blocks, []) - expect(callers).toEqual([]) - expect(callees).toEqual([]) + expect(callees).toEqual([{ id: 'a', name: 'A', cycle: true, children: [] }]) + expect(callers).toEqual([{ id: 'a', name: 'A', cycle: true, children: [] }]) + }) + + it('bounds converging paths (diamond) instead of re-expanding', () => { + // A → B, A → C, B → D, C → D. D reconverges; it must appear under both B and C + // but only expand once (here D is a leaf anyway; the guard prevents blow-up). + const blocks = [ + workflowBlock('a', 'b'), + workflowBlock('a', 'c'), + workflowBlock('b', 'd'), + workflowBlock('c', 'd'), + ] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + const b = callees.find((n) => n.id === 'b') + const c = callees.find((n) => n.id === 'c') + // D expands under the first-visited branch (B) and is a plain leaf under C. + expect(b?.children.map((n) => n.id)).toEqual(['d']) + expect(c?.children.map((n) => n.id)).toEqual(['d']) + expect(c?.children[0]).toMatchObject({ id: 'd', cycle: false, children: [] }) }) it('drops dangling / out-of-workspace child ids', () => { @@ -92,7 +128,13 @@ describe('resolveWorkflowReferences', () => { it('resolves references made through custom blocks', () => { // D places custom_block_x, which is bound to source workflow C. const blocks: ReferenceBlockRow[] = [ - { parentId: 'd', type: 'custom_block_x', childFromSelector: null, childFromManual: null }, + { + parentId: 'd', + type: 'custom_block_x', + childFromSelector: null, + childFromManual: null, + canonicalModes: null, + }, ] const customBlocks: CustomBlockLink[] = [{ type: 'custom_block_x', workflowId: 'c' }] @@ -110,6 +152,7 @@ describe('resolveWorkflowReferences', () => { type: 'custom_block_unknown', childFromSelector: null, childFromManual: null, + canonicalModes: null, }, ] const { callees } = resolveWorkflowReferences('d', workflows, blocks, []) diff --git a/apps/sim/lib/workflows/references/operations.ts b/apps/sim/lib/workflows/references/operations.ts index 6045b643dbe..ed1b1972d17 100644 --- a/apps/sim/lib/workflows/references/operations.ts +++ b/apps/sim/lib/workflows/references/operations.ts @@ -4,6 +4,11 @@ import { createLogger } from '@sim/logger' import { and, eq, inArray, isNull, like, or, sql } from 'drizzle-orm' import type { ReferenceNode } from '@/lib/api/contracts/workflow-references' import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' +import { + type CanonicalGroup, + type CanonicalModeOverrides, + resolveActiveCanonicalValue, +} from '@/lib/workflows/subblocks/visibility' import { CUSTOM_BLOCK_TYPE_PREFIX } from '@/blocks/custom/build-config' import { BlockType, isWorkflowBlockType } from '@/executor/constants' @@ -16,6 +21,18 @@ const logger = createLogger('WorkflowReferences') */ const MAX_REFERENCE_DEPTH = 25 +/** + * The `workflowId` canonical pair on a workflow / workflow_input block: the basic + * `workflowId` selector and the advanced `manualWorkflowId` input. Used with + * {@link resolveActiveCanonicalValue} so the reference resolves to the value of the + * block's ACTIVE mode — never a dormant mode's retained (stale) value. + */ +const WORKFLOW_ID_CANONICAL_GROUP: CanonicalGroup = { + canonicalId: 'workflowId', + basicId: 'workflowId', + advancedIds: ['manualWorkflowId'], +} + /** A workspace-local, non-archived workflow node. */ export interface WorkflowNode { id: string @@ -30,6 +47,11 @@ export interface ReferenceBlockRow { childFromSelector: string | null /** `manualWorkflowId` sub-block value (advanced mode), if a workflow block. */ childFromManual: string | null + /** + * The block's `data.canonicalModes` override (basic/advanced per canonical id), + * used to pick the active `workflowId` value. Absent for most blocks. + */ + canonicalModes: CanonicalModeOverrides | null } /** A custom-block type slug bound to its source workflow. */ @@ -56,8 +78,11 @@ interface ReferenceGraph { * - **custom blocks** (`custom_block_`), whose type slug maps to a bound * source workflow via `customBlocks`. * - * Edges are scoped to workspace-local, non-archived workflows: self-references, - * empty values, and ids outside `workflows` are dropped. + * The workflow-block child is the value of the block's ACTIVE mode + * ({@link resolveActiveCanonicalValue}), so a dormant basic/advanced value can't + * mask the live one. Edges are scoped to workspace-local, non-archived workflows; + * empty values and ids outside `workflows` are dropped. A workflow that calls + * itself is kept — the tree builder renders it as a `cycle` leaf. */ export function buildReferenceGraph( workflows: WorkflowNode[], @@ -74,7 +99,7 @@ export function buildReferenceGraph( const reverse = new Map>() const addEdge = (parentId: string, childId: string) => { - if (!childId || childId === parentId) return + if (!childId) return if (!nameById.has(parentId) || !nameById.has(childId)) return if (!forward.has(parentId)) forward.set(parentId, new Set()) forward.get(parentId)?.add(childId) @@ -84,8 +109,12 @@ export function buildReferenceGraph( for (const block of blocks) { if (isWorkflowBlockType(block.type)) { - const childId = block.childFromSelector || block.childFromManual - if (childId) addEdge(block.parentId, childId) + const active = resolveActiveCanonicalValue( + WORKFLOW_ID_CANONICAL_GROUP, + { workflowId: block.childFromSelector, manualWorkflowId: block.childFromManual }, + block.canonicalModes ?? undefined + ) + if (typeof active === 'string' && active) addEdge(block.parentId, active) continue } const sourceId = sourceByCustomType.get(block.type) @@ -97,9 +126,14 @@ export function buildReferenceGraph( /** * Expand a direction of the graph into a tree rooted at `rootId`. `adjacency` is - * either the forward (callees) or reverse (callers) map. A node already on the - * current DFS path is emitted as a `cycle: true` leaf and not re-expanded, so - * `A → B → A` terminates. Children are sorted by name for stable rendering. + * either the forward (callees) or reverse (callers) map. + * + * A node already on the current DFS path is emitted as a `cycle: true` leaf and + * not re-expanded, so `A → B → A` (and a self-call `A → A`) terminates. A node + * already fully expanded elsewhere in this tree (reachable via another acyclic + * path — a diamond) is emitted once more as a plain leaf without re-expanding its + * subtree: the edge stays visible, but a densely reconverging graph can't blow up + * exponentially. Children are sorted by name for stable rendering. */ function buildTree( rootId: string, @@ -107,6 +141,7 @@ function buildTree( nameById: Map ): ReferenceNode[] { const path = new Set([rootId]) + const expanded = new Set() const expand = (id: string, depth: number): ReferenceNode[] => { if (depth >= MAX_REFERENCE_DEPTH) return [] @@ -126,6 +161,11 @@ function buildTree( nodes.push({ id: childId, name, cycle: true, children: [] }) continue } + if (expanded.has(childId)) { + nodes.push({ id: childId, name, cycle: false, children: [] }) + continue + } + expanded.add(childId) path.add(childId) nodes.push({ id: childId, name, cycle: false, children: expand(childId, depth + 1) }) path.delete(childId) @@ -185,6 +225,7 @@ export async function getWorkflowReferences( childFromManual: sql< string | null >`${workflowBlocks.subBlocks} -> 'manualWorkflowId' ->> 'value'`, + canonicalModes: sql`${workflowBlocks.data} -> 'canonicalModes'`, }) .from(workflowBlocks) .innerJoin(workflow, eq(workflow.id, workflowBlocks.workflowId))