From f11c3d27e91391ca5fa7c33403dee76e31431a06 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 22 Jul 2026 11:08:41 -0700 Subject: [PATCH 1/6] 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/6] 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)) From 65aa1a194c8153726bb42a73b6e8bc814d209ee9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 22 Jul 2026 13:06:47 -0700 Subject: [PATCH 3/6] improvement(workflows): align reference viewer auth, coverage, and UI with platform conventions - authorize via authorizeWorkflowByWorkspacePermission and derive the workspace server-side (404/403 semantics; drops the client-supplied workspaceId query param from the contract, hook, and modal) - add workflow-tool call edges: workflow_input tools inside tool-input sub-blocks now appear in both trees; non-call selector shapes stay deliberately excluded (documented against remap-internal-ids) - restore native cmd/ctrl+click open-in-new-tab on sidebar workflow rows; references stay reachable from the context menu - mount ReferencesModal on demand per row, deleting the prevIsOpen reset, the enabled knob, and the staleTime-0 workaround (now 30s) - align the tree with design tokens (--text-icon, --surface-hover, px-4 text gutter) and drop the hardcoded brand hex - escape LIKE wildcards in the custom_block_ prefix match; import MAX_CALL_CHAIN_DEPTH instead of mirroring it; remove dead fallbacks, the duplicate not-found scan, and the redundant custom-block row map --- .../workflows/[id]/references/route.test.ts | 60 ++++++--- .../api/workflows/[id]/references/route.ts | 19 ++- .../reference-tree/reference-tree.tsx | 14 +- .../references-modal/references-modal.tsx | 26 ++-- .../workflow-item/workflow-item.tsx | 18 +-- apps/sim/hooks/queries/workflow-references.ts | 27 ++-- .../lib/api/contracts/workflow-references.ts | 9 +- .../workflows/references/operations.test.ts | 50 +++++++- .../lib/workflows/references/operations.ts | 121 ++++++++++++------ 9 files changed, 214 insertions(+), 130 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/references/route.test.ts b/apps/sim/app/api/workflows/[id]/references/route.test.ts index 29fba71ef84..d6ef6045816 100644 --- a/apps/sim/app/api/workflows/[id]/references/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/references/route.test.ts @@ -4,20 +4,18 @@ 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(), - }) -) +const { mockGetSession, mockAuthorizeWorkflow, mockGetWorkflowReferences } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockAuthorizeWorkflow: vi.fn(), + mockGetWorkflowReferences: vi.fn(), +})) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, })) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, +vi.mock('@sim/platform-authz/workflow', () => ({ + authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, })) vi.mock('@/lib/workflows/references/operations', () => ({ @@ -31,10 +29,8 @@ const REFERENCES = { 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` +function callRoute(id = 'wf-1') { + const url = `http://localhost:3000/api/workflows/${id}/references` return GET(createMockRequest('GET', undefined, {}, url), { params: Promise.resolve({ id }) }) } @@ -42,7 +38,12 @@ describe('GET /api/workflows/[id]/references', () => { beforeEach(() => { vi.clearAllMocks() mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - mockGetUserEntityPermissions.mockResolvedValue('read') + mockAuthorizeWorkflow.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'wf-1', workspaceId: 'ws-1' }, + workspacePermission: 'read', + }) mockGetWorkflowReferences.mockResolvedValue(REFERENCES) }) @@ -53,22 +54,41 @@ describe('GET /api/workflows/[id]/references', () => { 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 404 when the workflow does not exist', async () => { + mockAuthorizeWorkflow.mockResolvedValue({ + allowed: false, + status: 404, + message: 'Workflow not found', + workflow: null, + workspacePermission: null, + }) + const response = await callRoute() + expect(response.status).toBe(404) + expect(mockGetWorkflowReferences).not.toHaveBeenCalled() }) - it('returns 403 when the user cannot access the workspace', async () => { - mockGetUserEntityPermissions.mockResolvedValue(null) + it('returns 403 when the user cannot read the workflow', async () => { + mockAuthorizeWorkflow.mockResolvedValue({ + allowed: false, + status: 403, + message: 'Unauthorized: Access denied to read this workflow', + workflow: { id: 'wf-1', workspaceId: 'ws-1' }, + workspacePermission: null, + }) const response = await callRoute() expect(response.status).toBe(403) expect(mockGetWorkflowReferences).not.toHaveBeenCalled() }) - it('returns the reference trees for the workflow', async () => { + it('returns the reference trees scoped to the workflow workspace', async () => { const response = await callRoute() expect(response.status).toBe(200) expect(await response.json()).toEqual(REFERENCES) + expect(mockAuthorizeWorkflow).toHaveBeenCalledWith({ + workflowId: 'wf-1', + userId: 'user-1', + action: 'read', + }) 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 index 4a611ed1498..28ef6d764a1 100644 --- a/apps/sim/app/api/workflows/[id]/references/route.ts +++ b/apps/sim/app/api/workflows/[id]/references/route.ts @@ -1,3 +1,4 @@ +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { getWorkflowReferencesContract } from '@/lib/api/contracts/workflow-references' @@ -5,7 +6,6 @@ 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 }> } @@ -18,13 +18,20 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC const parsed = await parseRequest(getWorkflowReferencesContract, request, context) if (!parsed.success) return parsed.response - const { params, query } = parsed.data + const { id } = parsed.data.params - const permission = await getUserEntityPermissions(session.user.id, 'workspace', query.workspaceId) - if (permission === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + const auth = await authorizeWorkflowByWorkspacePermission({ + workflowId: id, + userId: session.user.id, + action: 'read', + }) + if (!auth.allowed || !auth.workflow?.workspaceId) { + return NextResponse.json( + { error: auth.message ?? 'Access denied' }, + { status: auth.status || 403 } + ) } - const references = await getWorkflowReferences(query.workspaceId, params.id) + const references = await getWorkflowReferences(auth.workflow.workspaceId, id) return NextResponse.json(references) }) 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 index 32d84ebd90f..626c0ae844c 100644 --- 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 @@ -7,10 +7,8 @@ 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', + /** Base row padding: lands depth-0 content on the modal's px-4 text gutter. */ + BASE_INDENT: 8, } as const interface ReferenceTreeProps { @@ -34,20 +32,20 @@ function ReferenceTreeItem({ node, depth, onNavigate }: ReferenceTreeItemProps) style={{ paddingLeft: CONFIG.BASE_INDENT + depth * CONFIG.INDENT_PER_LEVEL }} className={cn( 'flex w-full min-w-0 items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors', - 'hover:bg-[var(--surface-active)]' + 'hover:bg-[var(--surface-hover)]' )} > - + {node.name} {node.cycle && ( - (cycle) + (cycle) )} {node.children.length > 0 && (
{node.children.map((child) => ( ('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 { data, isPending, isError } = useWorkflowReferences(workflowId) - const handleNavigate = useCallback( - (targetId: string) => { - router.push(`/workspace/${workspaceId}/w/${targetId}`) - onClose() - }, - [router, workspaceId, onClose] - ) + const handleNavigate = (targetId: string) => { + router.push(`/workspace/${workspaceId}/w/${targetId}`) + onClose() + } const nodes: ReferenceNode[] = data ? data[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 23ee3eae416..bb63ae441a2 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 @@ -388,8 +388,6 @@ export const WorkflowItem = memo(function WorkflowItem({ } if (e.metaKey || e.ctrlKey) { - e.preventDefault() - setIsReferencesOpen(true) return } @@ -525,13 +523,15 @@ export const WorkflowItem = memo(function WorkflowItem({ itemName={deleteModalNames} /> - setIsReferencesOpen(false)} - workspaceId={workspaceId} - workflowId={workflow.id} - workflowName={workflow.name} - /> + {isReferencesOpen && ( + 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 index 0083fc526ff..69083603ccd 100644 --- a/apps/sim/hooks/queries/workflow-references.ts +++ b/apps/sim/hooks/queries/workflow-references.ts @@ -6,42 +6,33 @@ import { } from '@/lib/api/contracts/workflow-references' /** - * 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. + * Short — the graph reflects live editor state (workflow blocks/names can change + * between opens). The modal mounts on demand, so each open refetches once the + * window lapses while still absorbing rapid open/close flapping. */ -export const WORKFLOW_REFERENCES_STALE_TIME = 0 +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, + detail: (workflowId?: string) => [...workflowReferenceKeys.details(), 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 } -) { +export function useWorkflowReferences(workflowId?: string) { return useQuery({ - queryKey: workflowReferenceKeys.detail(workspaceId, workflowId), - queryFn: ({ signal }) => - fetchWorkflowReferences(workspaceId as string, workflowId as string, signal), - enabled: Boolean(workspaceId && workflowId) && (options?.enabled ?? true), + queryKey: workflowReferenceKeys.detail(workflowId), + queryFn: ({ signal }) => fetchWorkflowReferences(workflowId as string, signal), + enabled: Boolean(workflowId), 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 index 918b1ea69a5..c298c4f6bec 100644 --- a/apps/sim/lib/api/contracts/workflow-references.ts +++ b/apps/sim/lib/api/contracts/workflow-references.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { nonEmptyIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' /** @@ -11,7 +11,7 @@ import { defineRouteContract } from '@/lib/api/contracts/types' export interface ReferenceNode { /** Referenced workflow id. */ id: string - /** Referenced workflow name (falls back to the id if unresolved). */ + /** Referenced workflow name. */ name: string /** * True when this node closes a cycle already on the current path (e.g. the @@ -34,10 +34,6 @@ 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), @@ -51,7 +47,6 @@ 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 index 0244f0397ef..a37ae489c79 100644 --- a/apps/sim/lib/workflows/references/operations.test.ts +++ b/apps/sim/lib/workflows/references/operations.test.ts @@ -28,12 +28,12 @@ function workflowBlock( childFromSelector: mode === 'basic' ? childId : null, childFromManual: mode === 'manual' ? childId : null, canonicalModes: null, + toolInputValues: 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, []) @@ -46,7 +46,6 @@ describe('resolveWorkflowReferences', () => { }) 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'), @@ -76,6 +75,7 @@ describe('resolveWorkflowReferences', () => { childFromSelector: 'b', childFromManual: 'c', canonicalModes: { workflowId: 'advanced' }, + toolInputValues: null, }, ] const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) @@ -83,7 +83,6 @@ describe('resolveWorkflowReferences', () => { }) 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, []) @@ -134,6 +133,7 @@ describe('resolveWorkflowReferences', () => { childFromSelector: null, childFromManual: null, canonicalModes: null, + toolInputValues: null, }, ] const customBlocks: CustomBlockLink[] = [{ type: 'custom_block_x', workflowId: 'c' }] @@ -153,6 +153,7 @@ describe('resolveWorkflowReferences', () => { childFromSelector: null, childFromManual: null, canonicalModes: null, + toolInputValues: null, }, ] const { callees } = resolveWorkflowReferences('d', workflows, blocks, []) @@ -171,4 +172,47 @@ describe('resolveWorkflowReferences', () => { const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) expect(callees.map((n) => n.name)).toEqual(['B', 'C']) }) + + it('resolves workflow tools inside tool-input sub-blocks', () => { + // Agent block on A carrying a workflow_input tool that calls B; a non-workflow + // tool and a malformed entry must be ignored. + const blocks: ReferenceBlockRow[] = [ + { + parentId: 'a', + type: 'agent', + childFromSelector: null, + childFromManual: null, + canonicalModes: null, + toolInputValues: [ + [ + { type: 'workflow_input', params: { workflowId: 'b' } }, + { type: 'function', params: {} }, + { type: 'workflow_input' }, + ], + ], + }, + ] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.id)).toEqual(['b']) + + const bResult = resolveWorkflowReferences('b', workflows, blocks, []) + expect(bResult.callers.map((n) => n.id)).toEqual(['a']) + }) + + it('resolves workflow tools from a JSON-stringified tool-input value', () => { + const blocks: ReferenceBlockRow[] = [ + { + parentId: 'a', + type: 'agent', + childFromSelector: null, + childFromManual: null, + canonicalModes: null, + toolInputValues: [ + JSON.stringify([{ type: 'workflow_input', params: { workflowId: 'c' } }]), + ], + }, + ] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.id)).toEqual(['c']) + }) }) diff --git a/apps/sim/lib/workflows/references/operations.ts b/apps/sim/lib/workflows/references/operations.ts index ed1b1972d17..7ec89fd68e2 100644 --- a/apps/sim/lib/workflows/references/operations.ts +++ b/apps/sim/lib/workflows/references/operations.ts @@ -1,9 +1,10 @@ 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 { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import type { ReferenceNode } from '@/lib/api/contracts/workflow-references' +import { MAX_CALL_CHAIN_DEPTH } from '@/lib/execution/call-chain' import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' +import { coerceObjectArray, isRecord } from '@/lib/workflows/persistence/remap-internal-ids' import { type CanonicalGroup, type CanonicalModeOverrides, @@ -12,20 +13,22 @@ import { 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. + * Depth ceiling for a reference tree — the runtime call-chain bound; a display + * tree never needs to show more than the executor allows. 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 +const MAX_REFERENCE_DEPTH = MAX_CALL_CHAIN_DEPTH /** * 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. + * (`remapWorkflowReferencesInSubBlocks` in + * `@/lib/workflows/persistence/remap-internal-ids` builds the same pair from + * positional keys — keep the two in sync if the pair ever changes.) */ const WORKFLOW_ID_CANONICAL_GROUP: CanonicalGroup = { canonicalId: 'workflowId', @@ -33,6 +36,9 @@ const WORKFLOW_ID_CANONICAL_GROUP: CanonicalGroup = { advancedIds: ['manualWorkflowId'], } +/** `custom_block_` with LIKE wildcards (`_`) escaped, for the SQL prefix match. */ +const CUSTOM_BLOCK_LIKE_PREFIX = CUSTOM_BLOCK_TYPE_PREFIX.replace(/[\\%_]/g, '\\$&') + /** A workspace-local, non-archived workflow node. */ export interface WorkflowNode { id: string @@ -52,6 +58,13 @@ export interface ReferenceBlockRow { * used to pick the active `workflowId` value. Absent for most blocks. */ canonicalModes: CanonicalModeOverrides | null + /** + * Aggregated `tool-input` sub-block values on this block (agent-style tool + * lists). Each entry is one sub-block's raw value — an array of tool objects, + * or that array JSON-stringified. `workflow_input` tools carry the callee id + * in `params.workflowId`. Null when the block has no tool-input sub-blocks. + */ + toolInputValues: unknown[] | null } /** A custom-block type slug bound to its source workflow. */ @@ -70,13 +83,40 @@ interface ReferenceGraph { reverse: Map> } +/** + * Callee workflow ids referenced by a block's tool-input values: `workflow_input` + * tools whose `params.workflowId` was picked from the workflow selector. + */ +function toolInputCallees(toolInputValues: unknown[] | null): string[] { + if (!toolInputValues) return [] + const callees: string[] = [] + for (const value of toolInputValues) { + const { array } = coerceObjectArray(value) + if (!array) continue + for (const tool of array) { + if (!isRecord(tool) || tool.type !== BlockType.WORKFLOW_INPUT || !isRecord(tool.params)) { + continue + } + if (typeof tool.params.workflowId === 'string') callees.push(tool.params.workflowId) + } + } + return callees +} + /** * Build the directed reference graph from raw workspace rows. Pure (no I/O) so it - * can be unit-tested directly. Resolves two reference shapes: + * can be unit-tested directly. Resolves three call-edge shapes: * - direct **workflow blocks** (`workflow` and `workflow_input`), whose child id - * is the `workflowId` (basic) or `manualWorkflowId` (advanced) sub-block value; and + * is the `workflowId` (basic) or `manualWorkflowId` (advanced) sub-block value; * - **custom blocks** (`custom_block_`), whose type slug maps to a bound - * source workflow via `customBlocks`. + * source workflow via `customBlocks`; and + * - **workflow tools** — `workflow_input` entries inside a block's `tool-input` + * sub-blocks (an agent invoking another workflow as a tool). + * + * Non-call reference shapes (the logs block's `workflowSelector` monitor list and + * the workspace-event trigger's `workflowIds`; see + * `remapWorkflowReferencesInSubBlocks`) are deliberately excluded — the viewer + * shows call relationships. * * The workflow-block child is the value of the block's ACTIVE mode * ({@link resolveActiveCanonicalValue}), so a dormant basic/advanced value can't @@ -84,7 +124,7 @@ interface ReferenceGraph { * 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( +function buildReferenceGraph( workflows: WorkflowNode[], blocks: ReferenceBlockRow[], customBlocks: CustomBlockLink[] @@ -101,10 +141,12 @@ export function buildReferenceGraph( const addEdge = (parentId: string, childId: string) => { 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) - if (!reverse.has(childId)) reverse.set(childId, new Set()) - reverse.get(childId)?.add(parentId) + let callees = forward.get(parentId) + if (!callees) forward.set(parentId, (callees = new Set())) + callees.add(childId) + let callers = reverse.get(childId) + if (!callers) reverse.set(childId, (callers = new Set())) + callers.add(parentId) } for (const block of blocks) { @@ -115,10 +157,13 @@ export function buildReferenceGraph( block.canonicalModes ?? undefined ) if (typeof active === 'string' && active) addEdge(block.parentId, active) - continue + } else { + const sourceId = sourceByCustomType.get(block.type) + if (sourceId) addEdge(block.parentId, sourceId) + } + for (const calleeId of toolInputCallees(block.toolInputValues)) { + addEdge(block.parentId, calleeId) } - const sourceId = sourceByCustomType.get(block.type) - if (sourceId) addEdge(block.parentId, sourceId) } return { nameById, forward, reverse } @@ -142,21 +187,18 @@ function buildTree( ): ReferenceNode[] { const path = new Set([rootId]) const expanded = new Set() + const nameOf = (id: string) => nameById.get(id) as string 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 sorted = [...neighbors].sort((a, b) => nameOf(a).localeCompare(nameOf(b))) const nodes: ReferenceNode[] = [] for (const childId of sorted) { - const name = nameById.get(childId) ?? childId + const name = nameOf(childId) if (path.has(childId)) { nodes.push({ id: childId, name, cycle: true, children: [] }) continue @@ -210,6 +252,11 @@ export async function getWorkflowReferences( workspaceId: string, workflowId: string ): Promise<{ callers: ReferenceNode[]; callees: ReferenceNode[] }> { + const hasToolInput = sql`EXISTS ( + SELECT 1 FROM jsonb_each(${workflowBlocks.subBlocks}) AS kv + WHERE kv.value ->> 'type' = 'tool-input' + )` + const [workflowRows, blockRows, customBlockRows] = await Promise.all([ db .select({ id: workflow.id, name: workflow.name }) @@ -226,6 +273,11 @@ export async function getWorkflowReferences( string | null >`${workflowBlocks.subBlocks} -> 'manualWorkflowId' ->> 'value'`, canonicalModes: sql`${workflowBlocks.data} -> 'canonicalModes'`, + toolInputValues: sql`( + SELECT jsonb_agg(kv.value -> 'value') + FROM jsonb_each(${workflowBlocks.subBlocks}) AS kv + WHERE kv.value ->> 'type' = 'tool-input' + )`, }) .from(workflowBlocks) .innerJoin(workflow, eq(workflow.id, workflowBlocks.workflowId)) @@ -235,26 +287,13 @@ export async function getWorkflowReferences( isNull(workflow.archivedAt), or( inArray(workflowBlocks.type, [BlockType.WORKFLOW, BlockType.WORKFLOW_INPUT]), - like(workflowBlocks.type, `${CUSTOM_BLOCK_TYPE_PREFIX}%`) + sql`${workflowBlocks.type} LIKE ${`${CUSTOM_BLOCK_LIKE_PREFIX}%`} ESCAPE '\\'`, + hasToolInput ) ) ), 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 + return resolveWorkflowReferences(workflowId, workflowRows, blockRows, customBlockRows) } From 35bda2302f6efd3671a5e5e7893b925c982367a7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 22 Jul 2026 13:11:41 -0700 Subject: [PATCH 4/6] improvement(workflows): final polish on the reference viewer - drop the vestigial isOpen prop (conditional mount owns visibility) - unify on the emcn Workflow icon in tree rows - inline the static className and derive nodes without an annotation - remove one restating test comment --- .../components/reference-tree/reference-tree.tsx | 10 +++------- .../components/references-modal/references-modal.tsx | 7 ++----- .../components/workflow-item/workflow-item.tsx | 1 - apps/sim/lib/workflows/references/operations.test.ts | 1 - 4 files changed, 5 insertions(+), 14 deletions(-) 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 index 626c0ae844c..cf642dd8dff 100644 --- 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 @@ -1,7 +1,6 @@ 'use client' -import { cn } from '@sim/emcn' -import { WorkflowIcon } from '@/components/icons' +import { Workflow } from '@sim/emcn/icons' import type { ReferenceNode } from '@/lib/api/contracts/workflow-references' const CONFIG = { @@ -30,12 +29,9 @@ function ReferenceTreeItem({ node, depth, onNavigate }: ReferenceTreeItemProps) type='button' onClick={() => onNavigate(node.id)} style={{ paddingLeft: CONFIG.BASE_INDENT + depth * CONFIG.INDENT_PER_LEVEL }} - className={cn( - 'flex w-full min-w-0 items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors', - 'hover:bg-[var(--surface-hover)]' - )} + className='flex w-full min-w-0 items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-[var(--surface-hover)]' > - + {node.name} {node.cycle && ( (cycle) 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 index 258a9ebe442..d9ec16e50d6 100644 --- 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 @@ -3,7 +3,6 @@ import { 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' @@ -20,7 +19,6 @@ const EMPTY_MESSAGE: Record = { } interface ReferencesModalProps { - isOpen: boolean onClose: () => void workspaceId: string workflowId: string @@ -34,7 +32,6 @@ interface ReferencesModalProps { * demand by the owning row, so state initializes fresh per open. */ export function ReferencesModal({ - isOpen, onClose, workspaceId, workflowId, @@ -50,10 +47,10 @@ export function ReferencesModal({ onClose() } - const nodes: ReferenceNode[] = data ? data[activeTab] : [] + const nodes = data?.[activeTab] ?? [] return ( - !next && onClose()} srTitle='References'> + !next && onClose()} srTitle='References'> References · {workflowName} setIsReferencesOpen(false)} workspaceId={workspaceId} workflowId={workflow.id} diff --git a/apps/sim/lib/workflows/references/operations.test.ts b/apps/sim/lib/workflows/references/operations.test.ts index a37ae489c79..6f970617167 100644 --- a/apps/sim/lib/workflows/references/operations.test.ts +++ b/apps/sim/lib/workflows/references/operations.test.ts @@ -125,7 +125,6 @@ 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', From bc09b5675cbd7104febad6c109e632bddcb3020d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 22 Jul 2026 13:21:45 -0700 Subject: [PATCH 5/6] fix(workflows): resolve tool references by active canonical mode and keep the reference cache live - workflow_input tools inside tool-input now resolve basic/advanced via the index-scoped canonicalModes override, mirroring execution (Cursor finding) - staleTime back to 0: no mutation invalidates this key, so a reopen must background-refetch; on-demand mounting keeps the cached tree painting instantly (Greptile P1) - modal header uses the em-dash label-entity convention; tree items carry aria-level instead of a static aria-selected --- .../reference-tree/reference-tree.tsx | 2 +- .../references-modal/references-modal.tsx | 2 +- apps/sim/hooks/queries/workflow-references.ts | 9 ++--- .../workflows/references/operations.test.ts | 19 ++++++++++ .../lib/workflows/references/operations.ts | 35 +++++++++++++++---- 5 files changed, 54 insertions(+), 13 deletions(-) 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 index cf642dd8dff..ae4def07afa 100644 --- 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 @@ -24,7 +24,7 @@ interface ReferenceTreeItemProps { function ReferenceTreeItem({ node, depth, onNavigate }: ReferenceTreeItemProps) { return ( -
+