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..d6ef6045816 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/references/route.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockAuthorizeWorkflow, mockGetWorkflowReferences } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockAuthorizeWorkflow: vi.fn(), + mockGetWorkflowReferences: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, +})) + +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') { + const url = `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' } }) + mockAuthorizeWorkflow.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'wf-1', workspaceId: 'ws-1' }, + workspacePermission: '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 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 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 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 new file mode 100644 index 00000000000..c2e5505e391 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/references/route.ts @@ -0,0 +1,37 @@ +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' +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' + +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 { id } = parsed.data.params + + 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.allowed ? 403 : auth.status } + ) + } + + 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/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..ae4def07afa --- /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,68 @@ +'use client' + +import { Workflow } from '@sim/emcn/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 row padding: lands depth-0 content on the modal's px-4 text gutter. */ + BASE_INDENT: 8, +} 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..e507111c956 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal.tsx @@ -0,0 +1,74 @@ +'use client' + +import { useState } from 'react' +import { ChipModal, ChipModalBody, ChipModalHeader, ChipModalTabs } from '@sim/emcn' +import { useRouter } from 'next/navigation' +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 { + 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. Mounted on + * demand by the owning row, so state initializes fresh per open. + */ +export function ReferencesModal({ + onClose, + workspaceId, + workflowId, + workflowName, +}: ReferencesModalProps) { + const router = useRouter() + const [activeTab, setActiveTab] = useState('callers') + + const { data, isPending, isError } = useWorkflowReferences(workflowId) + + const handleNavigate = (targetId: string) => { + router.push(`/workspace/${workspaceId}/w/${targetId}`) + onClose() + } + + const nodes = 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..372d093b014 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) @@ -398,6 +400,10 @@ export const WorkflowItem = memo(function WorkflowItem({ [shouldPreventClickRef, workflow.id, onWorkflowClick, isEditing] ) + const handleFindReferences = useCallback(() => { + setIsReferencesOpen(true) + }, []) + return ( <> + + {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 new file mode 100644 index 00000000000..6ae68da75b4 --- /dev/null +++ b/apps/sim/hooks/queries/workflow-references.ts @@ -0,0 +1,39 @@ +import { useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + getWorkflowReferencesContract, + type WorkflowReferencesResponse, +} from '@/lib/api/contracts/workflow-references' + +/** + * Zero — the graph reflects live editor state, and no workflow-edit mutation + * invalidates this key (edits arrive over the socket, not through React Query). + * The modal mounts on demand, so every open refetches; a reopen paints the + * cached tree instantly while the background refetch reconciles it. + */ +export const WORKFLOW_REFERENCES_STALE_TIME = 0 + +export const workflowReferenceKeys = { + all: ['workflow-references'] as const, + details: () => [...workflowReferenceKeys.all, 'detail'] as const, + detail: (workflowId?: string) => [...workflowReferenceKeys.details(), workflowId ?? ''] as const, +} + +async function fetchWorkflowReferences( + workflowId: string, + signal?: AbortSignal +): Promise { + return requestJson(getWorkflowReferencesContract, { + params: { id: workflowId }, + signal, + }) +} + +export function useWorkflowReferences(workflowId?: string) { + return useQuery({ + 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 new file mode 100644 index 00000000000..c298c4f6bec --- /dev/null +++ b/apps/sim/lib/api/contracts/workflow-references.ts @@ -0,0 +1,54 @@ +import { z } from 'zod' +import { nonEmptyIdSchema } 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. */ + 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 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, + 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..02ad33e67c9 --- /dev/null +++ b/apps/sim/lib/workflows/references/operations.test.ts @@ -0,0 +1,309 @@ +/** + * @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, + canonicalModes: null, + toolInputValues: null, + } +} + +describe('resolveWorkflowReferences', () => { + it('resolves direct callers and callees', () => { + 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', () => { + 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('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' }, + toolInputValues: null, + }, + ] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.id)).toEqual(['c']) + }) + + it('marks cycles as leaves and stops recursing', () => { + 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('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(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 → E. D reconverges; it must appear under both + // B and C but expand its subtree (E) only under the first-visited branch. + const workflowsWithE = [...workflows, { id: 'e', name: 'E' }] + const blocks = [ + workflowBlock('a', 'b'), + workflowBlock('a', 'c'), + workflowBlock('b', 'd'), + workflowBlock('c', 'd'), + workflowBlock('d', 'e'), + ] + const { callees } = resolveWorkflowReferences('a', workflowsWithE, 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 collapsed leaf under C. + expect(b?.children).toEqual([ + { + id: 'd', + name: 'D', + cycle: false, + children: [{ id: 'e', name: 'E', cycle: false, children: [] }], + }, + ]) + expect(c?.children).toEqual([{ id: 'd', name: 'D', cycle: false, children: [] }]) + }) + + it('truncates expansion at the depth ceiling', () => { + // A linear chain longer than MAX_REFERENCE_DEPTH (25): w0 → w1 → … → w29. + const chain = Array.from({ length: 30 }, (_, i) => ({ id: `w${i}`, name: `W${i}` })) + const blocks = Array.from({ length: 29 }, (_, i) => workflowBlock(`w${i}`, `w${i + 1}`)) + const { callees } = resolveWorkflowReferences('w0', chain, blocks, []) + let depth = 0 + let node = callees[0] + while (node) { + depth += 1 + node = node.children[0] + } + expect(depth).toBe(25) + }) + + it('re-expands a depth-truncated node when a shallower path reaches it', () => { + // Root fans out to a 25-deep chain (visited first by name sort: "A…") whose + // tail X gets truncated at the ceiling, and a direct edge (via "Z") to X. + // The shallow path must still show X's child Y instead of a collapsed leaf. + const nodes = [ + { id: 'root', name: 'Root' }, + ...Array.from({ length: 24 }, (_, i) => ({ + id: `a${i}`, + name: `A${String(i).padStart(2, '0')}`, + })), + { id: 'x', name: 'X' }, + { id: 'y', name: 'Y' }, + { id: 'z', name: 'Z' }, + ] + const blocks = [ + workflowBlock('root', 'a0'), + ...Array.from({ length: 23 }, (_, i) => workflowBlock(`a${i}`, `a${i + 1}`)), + workflowBlock('a23', 'x'), + workflowBlock('root', 'z'), + workflowBlock('z', 'x'), + workflowBlock('x', 'y'), + ] + const { callees } = resolveWorkflowReferences('root', nodes, blocks, []) + const z = callees.find((n) => n.id === 'z') + const xUnderZ = z?.children.find((n) => n.id === 'x') + expect(xUnderZ?.children.map((n) => n.id)).toEqual(['y']) + }) + + 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', () => { + const blocks: ReferenceBlockRow[] = [ + { + parentId: 'd', + type: 'custom_block_x', + childFromSelector: null, + childFromManual: null, + canonicalModes: null, + toolInputValues: 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, + canonicalModes: null, + toolInputValues: 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']) + }) + + 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 a workflow tool to its active advanced-mode value', () => { + // Tool 0 is toggled to advanced via the index-scoped canonicalModes key; the + // stale basic selector (`b`) must not mask the live manual value (`c`). + const blocks: ReferenceBlockRow[] = [ + { + parentId: 'a', + type: 'agent', + childFromSelector: null, + childFromManual: null, + canonicalModes: { '0:workflowId': 'advanced' }, + toolInputValues: [ + [{ type: 'workflow_input', params: { workflowId: 'b', manualWorkflowId: 'c' } }], + ], + }, + ] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.id)).toEqual(['c']) + }) + + it('resolves legacy workflow-typed tools and isolates index-scoped modes per tool', () => { + // Tool 0 is a legacy `workflow`-typed entry (still rendered/executed by the + // editor); tool 1 is advanced-mode via its own index-scoped key. Tool 0 must + // stay basic (`b`) — tool 1's override must not bleed into it. + const blocks: ReferenceBlockRow[] = [ + { + parentId: 'a', + type: 'agent', + childFromSelector: null, + childFromManual: null, + canonicalModes: { '1:workflowId': 'advanced' }, + toolInputValues: [ + [ + { type: 'workflow', params: { workflowId: 'b' } }, + { type: 'workflow_input', params: { workflowId: 'c', manualWorkflowId: 'd' } }, + ], + ], + }, + ] + const { callees } = resolveWorkflowReferences('a', workflows, blocks, []) + expect(callees.map((n) => n.id)).toEqual(['b', 'd']) + }) + + 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 new file mode 100644 index 00000000000..768539b286d --- /dev/null +++ b/apps/sim/lib/workflows/references/operations.ts @@ -0,0 +1,327 @@ +import { db } from '@sim/db' +import { workflow, workflowBlocks } from '@sim/db/schema' +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, + resolveActiveCanonicalValue, + scopeCanonicalModesForTool, +} from '@/lib/workflows/subblocks/visibility' +import { CUSTOM_BLOCK_TYPE_PREFIX } from '@/blocks/custom/build-config' +import { BlockType, isWorkflowBlockType } from '@/executor/constants' + +/** + * 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 = 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', + basicId: 'workflowId', + 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 + 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 + /** + * 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 + /** + * 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` (basic) or `params.manualWorkflowId` (advanced). + * Null when the block has no tool-input sub-blocks. + */ + toolInputValues: unknown[] | 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> +} + +/** + * Callee workflow ids referenced by a block's tool-input values: workflow tools + * (`workflow_input`, plus legacy stored entries typed `workflow`) resolved to + * their ACTIVE canonical member — the basic `params.workflowId` selector or the + * advanced `params.manualWorkflowId` input, per the tool's index-scoped + * `canonicalModes` override ({@link scopeCanonicalModesForTool}) — mirroring how + * execution picks the live value. + */ +function toolInputCallees( + toolInputValues: unknown[] | null, + canonicalModes: CanonicalModeOverrides | null +): string[] { + if (!toolInputValues) return [] + const callees: string[] = [] + for (const value of toolInputValues) { + const { array } = coerceObjectArray(value) + if (!array) continue + array.forEach((tool, toolIndex) => { + if ( + !isRecord(tool) || + typeof tool.type !== 'string' || + !isWorkflowBlockType(tool.type) || + !isRecord(tool.params) + ) { + return + } + const scoped = scopeCanonicalModesForTool(canonicalModes ?? undefined, toolIndex, tool.type) + const active = resolveActiveCanonicalValue( + WORKFLOW_ID_CANONICAL_GROUP, + { + workflowId: typeof tool.params.workflowId === 'string' ? tool.params.workflowId : null, + manualWorkflowId: + typeof tool.params.manualWorkflowId === 'string' ? tool.params.manualWorkflowId : null, + }, + scoped + ) + if (typeof active === 'string' && active) callees.push(active) + }) + } + return callees +} + +/** + * Build the directed reference graph from raw workspace rows. Pure (no I/O) so it + * 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; + * - **custom blocks** (`custom_block_`), whose type slug maps to a bound + * 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 + * 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. + */ +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) return + if (!nameById.has(parentId) || !nameById.has(childId)) return + 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) { + if (isWorkflowBlockType(block.type)) { + 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) + } else { + const sourceId = sourceByCustomType.get(block.type) + if (sourceId) addEdge(block.parentId, sourceId) + } + for (const calleeId of toolInputCallees(block.toolInputValues, block.canonicalModes)) { + addEdge(block.parentId, calleeId) + } + } + + 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` (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, + adjacency: Map>, + nameById: Map +): 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) => nameOf(a).localeCompare(nameOf(b))) + + const nodes: ReferenceNode[] = [] + for (const childId of sorted) { + const name = nameOf(childId) + if (path.has(childId)) { + 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) + // A depth-capped expansion is incomplete — allow a shallower path to retry + // it in full instead of collapsing to a leaf. Each retry starts strictly + // shallower, so this stays bounded. + if (depth + 1 >= MAX_REFERENCE_DEPTH) expanded.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 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 }) + .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'`, + 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)) + .where( + and( + eq(workflow.workspaceId, workspaceId), + isNull(workflow.archivedAt), + or( + inArray(workflowBlocks.type, [BlockType.WORKFLOW, BlockType.WORKFLOW_INPUT]), + sql`${workflowBlocks.type} LIKE ${`${CUSTOM_BLOCK_LIKE_PREFIX}%`} ESCAPE '\\'`, + hasToolInput + ) + ) + ), + getCustomBlockRowsForWorkspace(workspaceId), + ]) + + return resolveWorkflowReferences(workflowId, workflowRows, blockRows, customBlockRows) +} 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