Skip to content

Commit 6f33a94

Browse files
feat(workflows): IDE-style reference viewer for workflows (#5854)
* 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). * 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. * 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 * 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 * 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 * fix(workflows): cover legacy workflow-typed tools and retry depth-truncated expansions - toolInputCallees matches both workflow tool type spellings via isWorkflowBlockType and passes the tool's own type as the legacy canonicalModes fallback, matching providers/utils resolution - a depth-capped expansion no longer poisons the expanded set, so a shallower path re-expands the node in full (Cursor finding) - the allowed-but-workspaceless auth branch now returns 403, not the authz result's 200 - tests: legacy tool type + per-tool index-scope isolation, diamond re-expansion with a real subtree, depth ceiling, shallow-path retry --------- Co-authored-by: Marcus Chandra <mzxchandra@gmail.com>
1 parent 5338485 commit 6f33a94

11 files changed

Lines changed: 1039 additions & 3 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockGetSession, mockAuthorizeWorkflow, mockGetWorkflowReferences } = vi.hoisted(() => ({
8+
mockGetSession: vi.fn(),
9+
mockAuthorizeWorkflow: vi.fn(),
10+
mockGetWorkflowReferences: vi.fn(),
11+
}))
12+
13+
vi.mock('@/lib/auth', () => ({
14+
getSession: mockGetSession,
15+
}))
16+
17+
vi.mock('@sim/platform-authz/workflow', () => ({
18+
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow,
19+
}))
20+
21+
vi.mock('@/lib/workflows/references/operations', () => ({
22+
getWorkflowReferences: mockGetWorkflowReferences,
23+
}))
24+
25+
import { GET } from '@/app/api/workflows/[id]/references/route'
26+
27+
const REFERENCES = {
28+
callers: [{ id: 'b', name: 'B', cycle: false, children: [] }],
29+
callees: [],
30+
}
31+
32+
function callRoute(id = 'wf-1') {
33+
const url = `http://localhost:3000/api/workflows/${id}/references`
34+
return GET(createMockRequest('GET', undefined, {}, url), { params: Promise.resolve({ id }) })
35+
}
36+
37+
describe('GET /api/workflows/[id]/references', () => {
38+
beforeEach(() => {
39+
vi.clearAllMocks()
40+
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
41+
mockAuthorizeWorkflow.mockResolvedValue({
42+
allowed: true,
43+
status: 200,
44+
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
45+
workspacePermission: 'read',
46+
})
47+
mockGetWorkflowReferences.mockResolvedValue(REFERENCES)
48+
})
49+
50+
it('returns 401 without a session', async () => {
51+
mockGetSession.mockResolvedValue(null)
52+
const response = await callRoute()
53+
expect(response.status).toBe(401)
54+
expect(mockGetWorkflowReferences).not.toHaveBeenCalled()
55+
})
56+
57+
it('returns 404 when the workflow does not exist', async () => {
58+
mockAuthorizeWorkflow.mockResolvedValue({
59+
allowed: false,
60+
status: 404,
61+
message: 'Workflow not found',
62+
workflow: null,
63+
workspacePermission: null,
64+
})
65+
const response = await callRoute()
66+
expect(response.status).toBe(404)
67+
expect(mockGetWorkflowReferences).not.toHaveBeenCalled()
68+
})
69+
70+
it('returns 403 when the user cannot read the workflow', async () => {
71+
mockAuthorizeWorkflow.mockResolvedValue({
72+
allowed: false,
73+
status: 403,
74+
message: 'Unauthorized: Access denied to read this workflow',
75+
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
76+
workspacePermission: null,
77+
})
78+
const response = await callRoute()
79+
expect(response.status).toBe(403)
80+
expect(mockGetWorkflowReferences).not.toHaveBeenCalled()
81+
})
82+
83+
it('returns the reference trees scoped to the workflow workspace', async () => {
84+
const response = await callRoute()
85+
expect(response.status).toBe(200)
86+
expect(await response.json()).toEqual(REFERENCES)
87+
expect(mockAuthorizeWorkflow).toHaveBeenCalledWith({
88+
workflowId: 'wf-1',
89+
userId: 'user-1',
90+
action: 'read',
91+
})
92+
expect(mockGetWorkflowReferences).toHaveBeenCalledWith('ws-1', 'wf-1')
93+
})
94+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
2+
import type { NextRequest } from 'next/server'
3+
import { NextResponse } from 'next/server'
4+
import { getWorkflowReferencesContract } from '@/lib/api/contracts/workflow-references'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { getSession } from '@/lib/auth'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { getWorkflowReferences } from '@/lib/workflows/references/operations'
9+
10+
type RouteContext = { params: Promise<{ id: string }> }
11+
12+
export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
13+
const session = await getSession()
14+
if (!session?.user?.id) {
15+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
16+
}
17+
18+
const parsed = await parseRequest(getWorkflowReferencesContract, request, context)
19+
if (!parsed.success) return parsed.response
20+
21+
const { id } = parsed.data.params
22+
23+
const auth = await authorizeWorkflowByWorkspacePermission({
24+
workflowId: id,
25+
userId: session.user.id,
26+
action: 'read',
27+
})
28+
if (!auth.allowed || !auth.workflow?.workspaceId) {
29+
return NextResponse.json(
30+
{ error: auth.message ?? 'Access denied' },
31+
{ status: auth.allowed ? 403 : auth.status }
32+
)
33+
}
34+
35+
const references = await getWorkflowReferences(auth.workflow.workspaceId, id)
36+
return NextResponse.json(references)
37+
})

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
SquareArrowUpRight,
2323
Trash,
2424
Unlock,
25+
Workflow,
2526
} from '@sim/emcn/icons'
2627
import { Pin, PinOff } from 'lucide-react'
2728

@@ -31,6 +32,7 @@ interface ContextMenuProps {
3132
menuRef: React.RefObject<HTMLDivElement | null>
3233
onClose: () => void
3334
onOpenInNewTab?: () => void
35+
onFindReferences?: () => void
3436
onMarkAsRead?: () => void
3537
onMarkAsUnread?: () => void
3638
onTogglePin?: () => void
@@ -53,6 +55,7 @@ interface ContextMenuProps {
5355
onExport?: () => void
5456
onDelete: () => void
5557
showOpenInNewTab?: boolean
58+
showFindReferences?: boolean
5659
showMarkAsRead?: boolean
5760
showMarkAsUnread?: boolean
5861
showPin?: boolean
@@ -93,6 +96,7 @@ export function ContextMenu({
9396
menuRef,
9497
onClose,
9598
onOpenInNewTab,
99+
onFindReferences,
96100
onMarkAsRead,
97101
onMarkAsUnread,
98102
onTogglePin,
@@ -104,6 +108,7 @@ export function ContextMenu({
104108
onExport,
105109
onDelete,
106110
showOpenInNewTab = false,
111+
showFindReferences = false,
107112
showMarkAsRead = false,
108113
showMarkAsUnread = false,
109114
showPin = false,
@@ -133,7 +138,8 @@ export function ContextMenu({
133138
showUploadLogo = false,
134139
disableUploadLogo = false,
135140
}: ContextMenuProps) {
136-
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
141+
const hasNavigationSection =
142+
(showOpenInNewTab && onOpenInNewTab) || (showFindReferences && onFindReferences)
137143
const hasStatusSection =
138144
(showMarkAsRead && onMarkAsRead) ||
139145
(showMarkAsUnread && onMarkAsUnread) ||
@@ -195,6 +201,17 @@ export function ContextMenu({
195201
Open in new tab
196202
</DropdownMenuItem>
197203
)}
204+
{showFindReferences && onFindReferences && (
205+
<DropdownMenuItem
206+
onSelect={() => {
207+
onFindReferences()
208+
onClose()
209+
}}
210+
>
211+
<Workflow />
212+
Show references
213+
</DropdownMenuItem>
214+
)}
198215
{hasNavigationSection && (hasStatusSection || hasEditSection || hasCopySection) && (
199216
<DropdownMenuSeparator />
200217
)}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
'use client'
2+
3+
import { Workflow } from '@sim/emcn/icons'
4+
import type { ReferenceNode } from '@/lib/api/contracts/workflow-references'
5+
6+
const CONFIG = {
7+
/** Horizontal indent added per tree level, in pixels. */
8+
INDENT_PER_LEVEL: 16,
9+
/** Base row padding: lands depth-0 content on the modal's px-4 text gutter. */
10+
BASE_INDENT: 8,
11+
} as const
12+
13+
interface ReferenceTreeProps {
14+
nodes: ReferenceNode[]
15+
/** Invoked with a workflow id when a row is activated. */
16+
onNavigate: (workflowId: string) => void
17+
}
18+
19+
interface ReferenceTreeItemProps {
20+
node: ReferenceNode
21+
depth: number
22+
onNavigate: (workflowId: string) => void
23+
}
24+
25+
function ReferenceTreeItem({ node, depth, onNavigate }: ReferenceTreeItemProps) {
26+
return (
27+
<div role='treeitem' aria-level={depth + 1}>
28+
<button
29+
type='button'
30+
onClick={() => onNavigate(node.id)}
31+
style={{ paddingLeft: CONFIG.BASE_INDENT + depth * CONFIG.INDENT_PER_LEVEL }}
32+
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)]'
33+
>
34+
<Workflow className='size-[14px] shrink-0 text-[var(--text-icon)]' />
35+
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>{node.name}</span>
36+
{node.cycle && (
37+
<span className='shrink-0 text-[var(--text-muted)] text-caption'>(cycle)</span>
38+
)}
39+
</button>
40+
{node.children.length > 0 && (
41+
<div role='group'>
42+
{node.children.map((child) => (
43+
<ReferenceTreeItem
44+
key={child.id}
45+
node={child}
46+
depth={depth + 1}
47+
onNavigate={onNavigate}
48+
/>
49+
))}
50+
</div>
51+
)}
52+
</div>
53+
)
54+
}
55+
56+
/**
57+
* Read-only recursive tree of workflow references. Each row navigates to its
58+
* workflow on click; cyclic leaves are marked and render no children.
59+
*/
60+
export function ReferenceTree({ nodes, onNavigate }: ReferenceTreeProps) {
61+
return (
62+
<div role='tree' className='flex flex-col'>
63+
{nodes.map((node) => (
64+
<ReferenceTreeItem key={node.id} node={node} depth={0} onNavigate={onNavigate} />
65+
))}
66+
</div>
67+
)
68+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { ChipModal, ChipModalBody, ChipModalHeader, ChipModalTabs } from '@sim/emcn'
5+
import { useRouter } from 'next/navigation'
6+
import { ReferenceTree } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree'
7+
import { useWorkflowReferences } from '@/hooks/queries/workflow-references'
8+
9+
type ReferencesTab = 'callers' | 'callees'
10+
11+
const TABS = [
12+
{ value: 'callers', label: 'Used by' },
13+
{ value: 'callees', label: 'Uses' },
14+
] as const
15+
16+
const EMPTY_MESSAGE: Record<ReferencesTab, string> = {
17+
callers: 'No workflows call this workflow.',
18+
callees: "This workflow doesn't call any other workflows.",
19+
}
20+
21+
interface ReferencesModalProps {
22+
onClose: () => void
23+
workspaceId: string
24+
workflowId: string
25+
workflowName: string
26+
}
27+
28+
/**
29+
* IDE-style reference viewer for a workflow. "Used by" lists the workflows that
30+
* call it (inbound); "Uses" lists the workflows it calls (outbound). Both are
31+
* recursive trees whose rows navigate to the referenced workflow. Mounted on
32+
* demand by the owning row, so state initializes fresh per open.
33+
*/
34+
export function ReferencesModal({
35+
onClose,
36+
workspaceId,
37+
workflowId,
38+
workflowName,
39+
}: ReferencesModalProps) {
40+
const router = useRouter()
41+
const [activeTab, setActiveTab] = useState<ReferencesTab>('callers')
42+
43+
const { data, isPending, isError } = useWorkflowReferences(workflowId)
44+
45+
const handleNavigate = (targetId: string) => {
46+
router.push(`/workspace/${workspaceId}/w/${targetId}`)
47+
onClose()
48+
}
49+
50+
const nodes = data?.[activeTab] ?? []
51+
52+
return (
53+
<ChipModal open onOpenChange={(next) => !next && onClose()} srTitle='References'>
54+
<ChipModalHeader onClose={onClose}>References — {workflowName}</ChipModalHeader>
55+
<ChipModalBody>
56+
<ChipModalTabs
57+
tabs={TABS}
58+
value={activeTab}
59+
onChange={(value) => setActiveTab(value as ReferencesTab)}
60+
aria-label='Reference direction'
61+
/>
62+
{isPending ? (
63+
<p className='px-2 text-[var(--text-muted)] text-sm'>Loading references…</p>
64+
) : isError ? (
65+
<p className='px-2 text-[var(--text-error)] text-sm'>Failed to load references.</p>
66+
) : nodes.length === 0 ? (
67+
<p className='px-2 text-[var(--text-muted)] text-sm'>{EMPTY_MESSAGE[activeTab]}</p>
68+
) : (
69+
<ReferenceTree nodes={nodes} onNavigate={handleNavigate} />
70+
)}
71+
</ChipModalBody>
72+
</ChipModal>
73+
)
74+
}

0 commit comments

Comments
 (0)