Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/sim/app/api/logs/execution/[executionId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { executionIdParamsSchema } from '@/lib/api/contracts/logs'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { hydrateChildTraces } from '@/lib/logs/execution/hydrate-child-traces'
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
Expand Down Expand Up @@ -126,6 +127,14 @@ export const GET = withRouteHandler(
}
)) as WorkflowExecutionLog['executionData']
const traceSpans = (executionData?.traceSpans as TraceSpan[]) || []

// Join any custom-block child runs first: the spans they contribute carry
// their own `childWorkflowSnapshotId`s, so the collection below picks them
// up and canvas drill-down works across the workspace boundary too.
if (traceSpans.length > 0) {
await hydrateChildTraces(traceSpans, { viewerUserId: authenticatedUserId })
}

const childSnapshotIds = new Set<string>()
const collectSnapshotIds = (spans: TraceSpan[]) => {
spans.forEach((span) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ import { BlockTile } from '@/blocks/block-tile'
import { isCustomBlockType } from '@/blocks/custom/build-config'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'

/**
* Why a custom block's steps are not shown under it. `granted` is deliberately absent —
* the joined children are their own evidence, so labelling them would be noise.
*/
const CHILD_TRACE_ACCESS_LABEL: Record<string, string> = {
denied: 'No access to the source workspace',
missing: 'Not available',
truncated: 'Not expanded (nesting limit)',
}

const DEFAULT_TREE_PANE_WIDTH = 240
const MIN_TREE_PANE_WIDTH = 200
const MAX_TREE_PANE_WIDTH = 600
Expand Down Expand Up @@ -672,6 +682,13 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
label: 'Type',
value: isCustomBlockType(span.type) ? 'custom block' : span.type,
})
// A custom block runs in another workspace, so its steps are joined in only for a viewer
// authorized there. Say why they are absent — otherwise a boundary span with no children
// is indistinguishable from a block that simply did nothing.
const childRunLabel = span.childTraceAccess
? CHILD_TRACE_ACCESS_LABEL[span.childTraceAccess]
: undefined
if (childRunLabel) metaEntries.push({ label: 'Child run', value: childRunLabel })
metaEntries.push({ label: 'Duration', value: formatDuration(duration, { precision: 2 }) || '—' })
if (span.tries !== undefined) metaEntries.push({ label: 'Tries', value: String(span.tries) })
if (span.provider) metaEntries.push({ label: 'Provider', value: span.provider })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function CustomBlocksLoader() {
name: block.name,
description: block.description,
workflowId: block.workflowId,
workspaceName: block.workspaceName,
exposedOutputs: block.exposedOutputs,
},
block.inputFields,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ vi.mock('@/blocks', () => ({
}))

vi.mock('@/executor/constants', () => ({
isWorkflowBlockType: vi.fn((blockType: string | undefined) => {
return blockType === 'workflow' || blockType === 'workflow_input'
isSubExecutionBlockType: vi.fn((blockType: string | undefined) => {
return (
blockType === 'workflow' ||
blockType === 'workflow_input' ||
blockType?.startsWith('custom_block_') === true
)
}),
}))

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type React from 'react'
import { Ban, CircleX, Repeat, Split, TriangleAlert, Workflow } from '@sim/emcn/icons'
import { getBlock } from '@/blocks'
import { isWorkflowBlockType } from '@/executor/constants'
import { isSubExecutionBlockType } from '@/executor/constants'
import { TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
import type { ConsoleEntry } from '@/stores/terminal'

Expand Down Expand Up @@ -184,7 +184,7 @@ function collectWorkflowDescendants(
const direct = workflowChildGroups.get(instanceKey) ?? []
const result = [...direct]
for (const entry of direct) {
if (isWorkflowBlockType(entry.blockType)) {
if (isSubExecutionBlockType(entry.blockType)) {
// Use childWorkflowInstanceId when available (unique per-invocation) to correctly
// separate children across loop iterations of the same workflow block.
result.push(
Expand Down Expand Up @@ -481,7 +481,7 @@ export function buildEntryTree(entries: ConsoleEntry[], idPrefix = ''): EntryNod
return true
})
.map((block) => {
if (isWorkflowBlockType(block.blockType)) {
if (isSubExecutionBlockType(block.blockType)) {
const instanceKey = block.childWorkflowInstanceId ?? block.blockId
const allDescendants = collectWorkflowDescendants(instanceKey, workflowChildGroups)
const rawChildren = allDescendants.map((c) => ({
Expand Down Expand Up @@ -524,7 +524,7 @@ export function buildEntryTree(entries: ConsoleEntry[], idPrefix = ''): EntryNod
const remainingRegularBlocks: ConsoleEntry[] = []

for (const block of regularBlocks) {
if (isWorkflowBlockType(block.blockType)) {
if (isSubExecutionBlockType(block.blockType)) {
const instanceKey = block.childWorkflowInstanceId ?? block.blockId
const allDescendants = collectWorkflowDescendants(instanceKey, workflowChildGroups)
const rawChildren = allDescendants.map((c) => ({
Expand Down
27 changes: 27 additions & 0 deletions apps/sim/blocks/custom/build-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,30 @@ describe('buildCustomBlockConfig', () => {
expect(JSON.parse(json as string)).toEqual({ title: 'Acme', count: 3 })
})
})

describe('sourceWorkspaceName', () => {
const icon = () => null as never

it('carries the source workspace so same-named environment copies stay distinguishable', () => {
// prod/uat/sandbox copies of one block share a name and differ only by an opaque
// `custom_block_<slug>` type. Without the workspace, an allowlist decision in Access
// Control — or any other list of blocks — is a coin flip between three identical rows.
const prod = buildCustomBlockConfig({ ...row, workspaceName: 'Impl (prod)' }, [], { icon })
const uat = buildCustomBlockConfig(
{ ...row, type: 'custom_block_uat999', workspaceName: 'Impl (uat)' },
[],
{ icon }
)

expect(prod.name).toBe(uat.name)
expect(prod.sourceWorkspaceName).toBe('Impl (prod)')
expect(uat.sourceWorkspaceName).toBe('Impl (uat)')
})

it('is omitted when the workspace is unknown, so no empty suffix renders', () => {
expect(buildCustomBlockConfig(row, [], { icon }).sourceWorkspaceName).toBeUndefined()
expect(
buildCustomBlockConfig({ ...row, workspaceName: null }, [], { icon }).sourceWorkspaceName
).toBeUndefined()
})
})
3 changes: 3 additions & 0 deletions apps/sim/blocks/custom/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export interface CustomBlockRow {
name: string
description: string
workflowId: string
/** Source workflow's home workspace name, to disambiguate same-named env copies. */
workspaceName?: string | null
/** Curated exposed outputs; empty/absent exposes the child's whole `result`. */
exposedOutputs?: CustomBlockOutput[]
}
Expand Down Expand Up @@ -154,6 +156,7 @@ export function buildCustomBlockConfig(
name: row.name,
description: row.description,
sourceWorkflowId: row.workflowId,
...(row.workspaceName ? { sourceWorkspaceName: row.workspaceName } : {}),
category: 'tools',
longDescription:
'A published workflow packaged as a reusable, self-contained block. Fill its input ' +
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/blocks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,13 @@ export interface BlockConfig<T extends ToolResponse = ToolResponse> {
* (placing it would recurse).
*/
sourceWorkflowId?: string
/**
* For published custom blocks only: the name of the workspace the bound source
* workflow lives in. Display-only, and the sole way to tell two blocks apart when
* an org runs the same block per environment — prod/uat/sandbox copies share a
* name and differ only by an opaque `custom_block_<slug>` type.
*/
sourceWorkspaceName?: string
/**
* Marks an unreleased block. Preview blocks are hidden from every discovery
* surface (toolbar, search, mentions, copilot/VFS, docs) in every environment —
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/ee/access-control/components/group-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,15 @@ function BlockToolRow({
)}
>
<span className='truncate text-sm'>{block.name}</span>
{/* An org running one custom block per environment has prod/uat/sandbox copies
sharing a name and differing only by an opaque type slug. The source workspace
is the only thing that tells them apart, so an allowlist decision made without
it is a guess. */}
{block.sourceWorkspaceName && (
<span className='flex-shrink-0 text-[var(--text-muted)] text-caption'>
{block.sourceWorkspaceName}
</span>
)}
{isBlockAllowed && deniedCount > 0 && (
<ChipTag variant='gray' className='flex-shrink-0'>
{deniedCount} blocked
Expand Down Expand Up @@ -1787,6 +1796,11 @@ export function GroupDetail({
{BlockIcon && <BlockIcon className='!size-[9px] text-white' />}
</div>
<span className='truncate text-sm'>{block.name}</span>
{block.sourceWorkspaceName && (
<span className='flex-shrink-0 text-[var(--text-muted)] text-caption'>
{block.sourceWorkspaceName}
</span>
)}
</label>
{block.description && (
<Info side='top' className='flex-shrink-0'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const FORK_RESOURCE_KIND_LABEL: Record<string, string> = {
'knowledge-base': 'knowledge base',
file: 'file',
'custom-tool': 'custom tool',
'custom-block': 'custom block',
skill: 'skill',
'mcp-server': 'MCP server',
credential: 'credential',
Expand Down Expand Up @@ -97,5 +98,10 @@ export function forkBlockerResolution(
return `deleted in the source — map it to an existing ${FORK_RESOURCE_KIND_LABEL[ref.kind] ?? 'resource'} in ${targetWorkspaceName}`
case 'workflow-missing':
return `deploy "${ref.sourceLabel}" in the source or remove the reference`
// Phrased as a consequence, not a loss: an unmapped custom block does not empty a field,
// it keeps invoking the SOURCE environment's block. The row renders this as the whole
// clause after the block name (no "would lose" lead-in), so it reads as a sentence.
case 'unmapped-custom-block':
return `still runs the source's block — map it to a custom block published in ${targetWorkspaceName}`
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -959,9 +959,19 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
className='flex min-w-0 items-start justify-between gap-3 text-[var(--text-secondary)] text-small'
>
<span className='min-w-0'>
<span className='text-[var(--text-body)]'>{ref.blockLabel}</span> would lose{' '}
<span className='text-[var(--text-body)]'>{ref.fieldLabel}</span> in{' '}
{ref.workflowName} —{' '}
<span className='text-[var(--text-body)]'>{ref.blockLabel}</span>
{/* A custom block blocks for the opposite reason to everything else here:
nothing is lost, the block keeps invoking the SOURCE environment. Saying
"would lose" would contradict its own resolution line. */}
{ref.kind === 'custom-block' ? (
<> in {ref.workflowName} </>
) : (
<>
{' '}
would lose <span className='text-[var(--text-body)]'>{ref.fieldLabel}</span>{' '}
in {ref.workflowName} —{' '}
</>
)}
{forkBlockerResolution(ref, controller.targetWorkspaceName)}
</span>
{/* Only a source-deleted reference can be dropped: an unmapped copyable can still
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ const MAPPING_SECTION: Record<MappableMappingKind, { label: string; order: numbe
file: { label: 'Files', order: 4 },
'mcp-server': { label: 'MCP servers', order: 5 },
'custom-tool': { label: 'Custom tools', order: 6 },
skill: { label: 'Skills', order: 7 },
'custom-block': { label: 'Custom blocks', order: 7 },
skill: { label: 'Skills', order: 8 },
}

/** Shared empty owners map for the pull direction so the options mapper never re-allocates. */
Expand Down
11 changes: 11 additions & 0 deletions apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,13 @@ export interface CopyWorkflowStateParams {
folderIdMap: Map<string, string>
/** Optional resource-reference remap applied to every block's subBlocks. */
transformSubBlocks?: SubBlockTransform
/**
* Optional remap of a block's own `type`. Only custom blocks use it: their reference IS
* the type, so unlike every other resource there is no sub-block value to rewrite. Returns
* the type unchanged when nothing is mapped, which deliberately leaves the copy pointing at
* the source block rather than deleting the node — see {@link remapForkBlockType}.
*/
transformBlockType?: (blockType: string, block: { id: string; name: string }) => string
/**
* The target workflow's current draft subBlocks (block id -> subBlocks), for
* `replace` mode only. When present, required dependents that the sync left empty
Expand Down Expand Up @@ -424,6 +431,7 @@ export async function copyWorkflowStateIntoTarget(
workflowIdMap,
folderIdMap,
transformSubBlocks,
transformBlockType,
targetCurrentBlocks,
dependentOverrides,
nameRegistry,
Expand Down Expand Up @@ -546,6 +554,9 @@ export async function copyWorkflowStateIntoTarget(
newBlocks[newBlockId] = {
...block,
id: newBlockId,
type: transformBlockType
? transformBlockType(block.type, { id: oldBlockId, name: block.name })
: block.type,
// double-cast-allowed: remap helpers return SubBlockRecord; the entries retain the SubBlockState shape this block requires
subBlocks: subBlocks as unknown as Record<string, SubBlockState>,
data: updatedData,
Expand Down
1 change: 1 addition & 0 deletions apps/sim/ee/workspace-forking/lib/create-fork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
}))
vi.mock('@/ee/workspace-forking/lib/remap/fork-bootstrap', () => ({
createForkBootstrapTransform: vi.fn(() => (subBlocks: unknown) => subBlocks),
createForkBlockTypeTransform: vi.fn(() => (blockType: string) => blockType),
}))
vi.mock('@/ee/workspace-forking/lib/remap/reference-scan', () => ({
collectReferencedDocumentIds: vi.fn(() => new Set<string>()),
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/ee/workspace-forking/lib/create-fork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,10 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
return resourceResult.idMap.get(resourceType)?.get(sourceId) ?? null
}
const transform = createForkBootstrapTransform(resolveCopied)
// No block-type transform here: custom blocks are never copied into a fork and a fresh
// fork has no mappings yet, so a placed custom block necessarily keeps the parent's type.
// It surfaces as an unmapped reference in the sync view (via `scanWorkflowReferences`) and
// blocks the first promote until the environment's own block is mapped to it.

// The child is brand new, so this loads an empty registry; name collisions can only
// arise among the copied workflows themselves, which the in-loop claims resolve.
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/ee/workspace-forking/lib/mapping/mapping-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const RESOURCE_TYPE_TO_FORK_KIND: Record<ForkResourceType, ForkRemapKind | null>
// Identity-only, like `workflow`: nothing in a subblock references a workflow-publishing
// server, so these rows never participate in reference remapping.
workflow_mcp_server: null,
custom_block: 'custom-block',
custom_tool: 'custom-tool',
skill: 'skill',
}
Expand All @@ -77,6 +78,7 @@ const NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE = {
file: 'file',
'mcp-server': 'mcp_server',
'custom-tool': 'custom_tool',
'custom-block': 'custom_block',
skill: 'skill',
} as const satisfies Record<
Exclude<ForkRemapKind, 'credential'>,
Expand Down
45 changes: 43 additions & 2 deletions apps/sim/ee/workspace-forking/lib/mapping/resources.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import {
dbChainMock,
dbChainMockFns,
queueTableRows,
resetDbChainMock,
schemaMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it } from 'vitest'
import type { DbOrTx } from '@/lib/db/types'
import {
Expand All @@ -20,7 +26,8 @@ describe('listForkResourceCandidates', () => {
it('populates file candidates keyed by storage key and leaves knowledge-document empty', async () => {
// The grouped queries resolve in Promise.all array order, each ending in `.limit()`:
// credentials, workspace env, tables, knowledge bases, MCP servers, custom tools, skills,
// files. Queue the eight results in that exact order.
// files, custom blocks. Queue the first eight in that exact order; the custom-block query
// resolves its workspace's organization first, finds nothing queued, and returns [].
dbChainMockFns.limit
.mockResolvedValueOnce([
{ id: 'cred-1', displayName: 'Cred One', providerId: 'google-email' },
Expand Down Expand Up @@ -50,6 +57,40 @@ describe('listForkResourceCandidates', () => {
})
})

describe('custom-block mapping candidates', () => {
beforeEach(() => {
resetDbChainMock()
})

it('keys candidates by BLOCK TYPE and labels them with the source workspace', async () => {
// A placed block references `custom_block_<slug>`, not `custom_block.id`, so the mapping
// must key by type — the same rule `file` follows with storage keys. Both environments'
// blocks usually share a name, so the workspace suffix is what makes the pick legible.
queueTableRows(schemaMock.workspace, [{ organizationId: 'org-1' }])
queueTableRows(schemaMock.customBlock, [
{ id: 'custom_block_prod01', name: 'Invoice Parser', sourceWorkspaceName: 'Impl (prod)' },
{ id: 'custom_block_uat001', name: 'Invoice Parser', sourceWorkspaceName: 'Impl (uat)' },
])

const result = await listForkResourceCandidates(executor, 'ws-1')

expect(result['custom-block']).toEqual([
{ id: 'custom_block_prod01', label: 'Invoice Parser (Impl (prod))' },
{ id: 'custom_block_uat001', label: 'Invoice Parser (Impl (uat))' },
])
})

it('returns no candidates for a workspace with no organization', async () => {
// Custom blocks are org-scoped; a personal workspace can never place one, so offering
// candidates there would let a mapping be saved that can never resolve at execution.
queueTableRows(schemaMock.workspace, [{ organizationId: null }])

const result = await listForkResourceCandidates(executor, 'ws-personal')

expect(result['custom-block']).toEqual([])
})
})

describe('listForkCopyableSourceResources', () => {
beforeEach(() => {
resetDbChainMock()
Expand Down
Loading
Loading