Skip to content

Commit 9ee1b81

Browse files
feat(custom-blocks): join cross-workspace runs into the caller's trace, and map blocks per environment (#6857)
* feat(custom-blocks): join cross-workspace runs into the caller's trace, and map blocks per environment Teams that orchestrate work across workspaces have two gaps that keep them on HTTP blocks instead of custom blocks: they cannot see what a custom block actually did, and a forked environment silently keeps calling the environment it was forked from. Debugging. A custom block is an invocation boundary — a published block is org-wide, so its internals must not reach every consumer by default. The child already writes its own log row in the source workspace, correlated to the invoking run; the trace existed, it just was not joined. The parent's span now carries only the child's opaque execution id, and `hydrateChildTraces` joins the child's spans at READ time, after authorizing the person reading against the child's workspace. Authorization follows the viewer rather than a flag set at publish time, re-evaluates on every read, and needs no second copy of the spans. Each hop of a nested chain is authorized against its own workspace. Boundaries left unexpanded — no access, no data, past a cap — say so, because a childless boundary span otherwise renders exactly like a leaf and a partial trace reads as a complete one. Live runs stream too, gated on `liveTraceViewerUserId`, which only surfaces with a single known authenticated viewer set. Chat deployments stream through the same callbacks and their consumer may be anonymous, so anything that does not opt in keeps the boundary shut. Child spans handed to such a viewer are projected through the CHILD's session: the invoking run's registry knows nothing about the publisher's secrets, so projecting there would leave a source-owner credential unmasked. They reach the live stream and stop — `createSpanFromLog` still refuses to persist them, which is what keeps read-time hydration the single authorization point. Environments. A fork inherits its parent's organization and `custom_block` is keyed `(organization_id, type)`, so a uat fork resolved to the same row and ran the prod workflow. Custom blocks become a fork-mappable resource, keyed by BLOCK TYPE — the rule every kind follows: key by whatever the workflow references, as `file` does with storage keys and `env-var` with names. A custom block is the only resource referenced by the canvas block's own type rather than a sub-block value, so the rewrite gets its own channel. Unmapped blocks keep the source type, because a type cannot be emptied without deleting the node; they surface as unmapped and block the promote, which is what stops uat from quietly invoking prod. Same-named environment copies now carry their source workspace, so an Access Control allowlist decision between three identical "Invoice Parser" rows is no longer a guess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(workspace-forking): let an explicit identity custom-block mapping resolve `remapForkBlockType` reported a mapping whose target equalled the source as unresolved, conflating "a mapping exists" with "the type string changed". Those are opposite states that produce an identical output `type`, and every caller uses the flag for the former — to decide whether the reference blocks a promote. The org-wide candidate list includes the source block, so binding an environment to the shared block is a normal pick. Under the old flag it raised `unmapped-custom-block` and refused the sync over a choice the user had explicitly made. The flag is now named `resolved` and reports mapping existence; whether the type moved is already visible from `type`. Reported by Cursor Bugbot on #6857. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(custom-blocks): keep a streamed child's spans and markers off the parent's log Two leaks in the live-stream path, both the same mistake: treating a channel as viewer-scoped when it is actually persisted, so gating the stream on an authorized viewer bought nothing. `childTraceSpans` rode the block output to reach the stream. `filterOutputForLog` only dropped a hidden key when the block's own config declared it `hiddenFromDisplay` — true of the workflow block, never of a custom block, whose outputs are publisher-curated. The source run's spans therefore persisted into the parent's `span.output`, readable by anyone with parent-workspace access and never re-checked by `hydrateChildTraces`. A globally hidden key is now dropped at the top level, not only when nested, and `extractDisplayOutput` strips it again so no other producer can reintroduce it. The fan-out also called the invoking run's `onBlockStart`/`onBlockComplete`, which are persist-then-emit composites: they write block names and I/O into the parent's LoggingSession before reaching the stream. Those markers are keyed by the parent execution and outlive the per-viewer check entirely. Custom-block children now go through `liveStreamCallbacks`, the raw emit-only pair, and fail closed when a surface supplies none. Same-workspace workflow children keep the composites — they belong to the same run and their markers are legitimately the parent's. Reported by Cursor Bugbot on #6857. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(custom-blocks): carry the emit-only stream sink into nested executions Routing custom-block events through `liveStreamCallbacks` forwarded the viewer id to the child but not the sink itself, so a nested hop cleared `canStreamCustomBlockToViewer` off the inherited id and then had nothing to stream through — `parentStreamSink` fell back to `{}` and live traces stopped at the first sub-executor. That hit a custom block nested inside a workflow block as readily as one inside another custom block. The sink now travels with the viewer id, and both are withheld together when streaming is not permitted. It is always the INHERITED chain, never `parentStreamSink`: for a same-workspace workflow block that is the persisting composite, so forwarding it would put a custom block nested inside one straight back onto the parent's progress markers — the leak the previous commit closed. Reported by Cursor Bugbot on #6857. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7c8d290 commit 9ee1b81

51 files changed

Lines changed: 21760 additions & 155 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/logs/execution/[executionId]/route.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { executionIdParamsSchema } from '@/lib/api/contracts/logs'
1212
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
1313
import { generateRequestId } from '@/lib/core/utils/request'
1414
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
15+
import { hydrateChildTraces } from '@/lib/logs/execution/hydrate-child-traces'
1516
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
1617
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
1718
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -126,6 +127,14 @@ export const GET = withRouteHandler(
126127
}
127128
)) as WorkflowExecutionLog['executionData']
128129
const traceSpans = (executionData?.traceSpans as TraceSpan[]) || []
130+
131+
// Join any custom-block child runs first: the spans they contribute carry
132+
// their own `childWorkflowSnapshotId`s, so the collection below picks them
133+
// up and canvas drill-down works across the workspace boundary too.
134+
if (traceSpans.length > 0) {
135+
await hydrateChildTraces(traceSpans, { viewerUserId: authenticatedUserId })
136+
}
137+
129138
const childSnapshotIds = new Set<string>()
130139
const collectSnapshotIds = (spans: TraceSpan[]) => {
131140
spans.forEach((span) => {

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ import { BlockTile } from '@/blocks/block-tile'
4949
import { isCustomBlockType } from '@/blocks/custom/build-config'
5050
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
5151

52+
/**
53+
* Why a custom block's steps are not shown under it. `granted` is deliberately absent —
54+
* the joined children are their own evidence, so labelling them would be noise.
55+
*/
56+
const CHILD_TRACE_ACCESS_LABEL: Record<string, string> = {
57+
denied: 'No access to the source workspace',
58+
missing: 'Not available',
59+
truncated: 'Not expanded (nesting limit)',
60+
}
61+
5262
const DEFAULT_TREE_PANE_WIDTH = 240
5363
const MIN_TREE_PANE_WIDTH = 200
5464
const MAX_TREE_PANE_WIDTH = 600
@@ -672,6 +682,13 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
672682
label: 'Type',
673683
value: isCustomBlockType(span.type) ? 'custom block' : span.type,
674684
})
685+
// A custom block runs in another workspace, so its steps are joined in only for a viewer
686+
// authorized there. Say why they are absent — otherwise a boundary span with no children
687+
// is indistinguishable from a block that simply did nothing.
688+
const childRunLabel = span.childTraceAccess
689+
? CHILD_TRACE_ACCESS_LABEL[span.childTraceAccess]
690+
: undefined
691+
if (childRunLabel) metaEntries.push({ label: 'Child run', value: childRunLabel })
675692
metaEntries.push({ label: 'Duration', value: formatDuration(duration, { precision: 2 }) || '—' })
676693
if (span.tries !== undefined) metaEntries.push({ label: 'Tries', value: String(span.tries) })
677694
if (span.provider) metaEntries.push({ label: 'Provider', value: span.provider })

apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function CustomBlocksLoader() {
3636
name: block.name,
3737
description: block.description,
3838
workflowId: block.workflowId,
39+
workspaceName: block.workspaceName,
3940
exposedOutputs: block.exposedOutputs,
4041
},
4142
block.inputFields,

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ vi.mock('@/blocks', () => ({
88
}))
99

1010
vi.mock('@/executor/constants', () => ({
11-
isWorkflowBlockType: vi.fn((blockType: string | undefined) => {
12-
return blockType === 'workflow' || blockType === 'workflow_input'
11+
isSubExecutionBlockType: vi.fn((blockType: string | undefined) => {
12+
return (
13+
blockType === 'workflow' ||
14+
blockType === 'workflow_input' ||
15+
blockType?.startsWith('custom_block_') === true
16+
)
1317
}),
1418
}))
1519

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type React from 'react'
22
import { Ban, CircleX, Repeat, Split, TriangleAlert, Workflow } from '@sim/emcn/icons'
33
import { getBlock } from '@/blocks'
4-
import { isWorkflowBlockType } from '@/executor/constants'
4+
import { isSubExecutionBlockType } from '@/executor/constants'
55
import { TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
66
import type { ConsoleEntry } from '@/stores/terminal'
77

@@ -184,7 +184,7 @@ function collectWorkflowDescendants(
184184
const direct = workflowChildGroups.get(instanceKey) ?? []
185185
const result = [...direct]
186186
for (const entry of direct) {
187-
if (isWorkflowBlockType(entry.blockType)) {
187+
if (isSubExecutionBlockType(entry.blockType)) {
188188
// Use childWorkflowInstanceId when available (unique per-invocation) to correctly
189189
// separate children across loop iterations of the same workflow block.
190190
result.push(
@@ -481,7 +481,7 @@ export function buildEntryTree(entries: ConsoleEntry[], idPrefix = ''): EntryNod
481481
return true
482482
})
483483
.map((block) => {
484-
if (isWorkflowBlockType(block.blockType)) {
484+
if (isSubExecutionBlockType(block.blockType)) {
485485
const instanceKey = block.childWorkflowInstanceId ?? block.blockId
486486
const allDescendants = collectWorkflowDescendants(instanceKey, workflowChildGroups)
487487
const rawChildren = allDescendants.map((c) => ({
@@ -524,7 +524,7 @@ export function buildEntryTree(entries: ConsoleEntry[], idPrefix = ''): EntryNod
524524
const remainingRegularBlocks: ConsoleEntry[] = []
525525

526526
for (const block of regularBlocks) {
527-
if (isWorkflowBlockType(block.blockType)) {
527+
if (isSubExecutionBlockType(block.blockType)) {
528528
const instanceKey = block.childWorkflowInstanceId ?? block.blockId
529529
const allDescendants = collectWorkflowDescendants(instanceKey, workflowChildGroups)
530530
const rawChildren = allDescendants.map((c) => ({

apps/sim/blocks/custom/build-config.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,30 @@ describe('buildCustomBlockConfig', () => {
151151
expect(JSON.parse(json as string)).toEqual({ title: 'Acme', count: 3 })
152152
})
153153
})
154+
155+
describe('sourceWorkspaceName', () => {
156+
const icon = () => null as never
157+
158+
it('carries the source workspace so same-named environment copies stay distinguishable', () => {
159+
// prod/uat/sandbox copies of one block share a name and differ only by an opaque
160+
// `custom_block_<slug>` type. Without the workspace, an allowlist decision in Access
161+
// Control — or any other list of blocks — is a coin flip between three identical rows.
162+
const prod = buildCustomBlockConfig({ ...row, workspaceName: 'Impl (prod)' }, [], { icon })
163+
const uat = buildCustomBlockConfig(
164+
{ ...row, type: 'custom_block_uat999', workspaceName: 'Impl (uat)' },
165+
[],
166+
{ icon }
167+
)
168+
169+
expect(prod.name).toBe(uat.name)
170+
expect(prod.sourceWorkspaceName).toBe('Impl (prod)')
171+
expect(uat.sourceWorkspaceName).toBe('Impl (uat)')
172+
})
173+
174+
it('is omitted when the workspace is unknown, so no empty suffix renders', () => {
175+
expect(buildCustomBlockConfig(row, [], { icon }).sourceWorkspaceName).toBeUndefined()
176+
expect(
177+
buildCustomBlockConfig({ ...row, workspaceName: null }, [], { icon }).sourceWorkspaceName
178+
).toBeUndefined()
179+
})
180+
})

apps/sim/blocks/custom/build-config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export interface CustomBlockRow {
4545
name: string
4646
description: string
4747
workflowId: string
48+
/** Source workflow's home workspace name, to disambiguate same-named env copies. */
49+
workspaceName?: string | null
4850
/** Curated exposed outputs; empty/absent exposes the child's whole `result`. */
4951
exposedOutputs?: CustomBlockOutput[]
5052
}
@@ -154,6 +156,7 @@ export function buildCustomBlockConfig(
154156
name: row.name,
155157
description: row.description,
156158
sourceWorkflowId: row.workflowId,
159+
...(row.workspaceName ? { sourceWorkspaceName: row.workspaceName } : {}),
157160
category: 'tools',
158161
longDescription:
159162
'A published workflow packaged as a reusable, self-contained block. Fill its input ' +

apps/sim/blocks/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,13 @@ export interface BlockConfig<T extends ToolResponse = ToolResponse> {
638638
* (placing it would recurse).
639639
*/
640640
sourceWorkflowId?: string
641+
/**
642+
* For published custom blocks only: the name of the workspace the bound source
643+
* workflow lives in. Display-only, and the sole way to tell two blocks apart when
644+
* an org runs the same block per environment — prod/uat/sandbox copies share a
645+
* name and differ only by an opaque `custom_block_<slug>` type.
646+
*/
647+
sourceWorkspaceName?: string
641648
/**
642649
* Marks an unreleased block. Preview blocks are hidden from every discovery
643650
* surface (toolbar, search, mentions, copilot/VFS, docs) in every environment —

apps/sim/ee/access-control/components/group-detail.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,15 @@ function BlockToolRow({
756756
)}
757757
>
758758
<span className='truncate text-sm'>{block.name}</span>
759+
{/* An org running one custom block per environment has prod/uat/sandbox copies
760+
sharing a name and differing only by an opaque type slug. The source workspace
761+
is the only thing that tells them apart, so an allowlist decision made without
762+
it is a guess. */}
763+
{block.sourceWorkspaceName && (
764+
<span className='flex-shrink-0 text-[var(--text-muted)] text-caption'>
765+
{block.sourceWorkspaceName}
766+
</span>
767+
)}
759768
{isBlockAllowed && deniedCount > 0 && (
760769
<ChipTag variant='gray' className='flex-shrink-0'>
761770
{deniedCount} blocked
@@ -1787,6 +1796,11 @@ export function GroupDetail({
17871796
{BlockIcon && <BlockIcon className='!size-[9px] text-white' />}
17881797
</div>
17891798
<span className='truncate text-sm'>{block.name}</span>
1799+
{block.sourceWorkspaceName && (
1800+
<span className='flex-shrink-0 text-[var(--text-muted)] text-caption'>
1801+
{block.sourceWorkspaceName}
1802+
</span>
1803+
)}
17901804
</label>
17911805
{block.description && (
17921806
<Info side='top' className='flex-shrink-0'>

apps/sim/ee/workspace-forking/components/fork-sync/cleared-refs-list.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export const FORK_RESOURCE_KIND_LABEL: Record<string, string> = {
6969
'knowledge-base': 'knowledge base',
7070
file: 'file',
7171
'custom-tool': 'custom tool',
72+
'custom-block': 'custom block',
7273
skill: 'skill',
7374
'mcp-server': 'MCP server',
7475
credential: 'credential',
@@ -97,5 +98,10 @@ export function forkBlockerResolution(
9798
return `deleted in the source — map it to an existing ${FORK_RESOURCE_KIND_LABEL[ref.kind] ?? 'resource'} in ${targetWorkspaceName}`
9899
case 'workflow-missing':
99100
return `deploy "${ref.sourceLabel}" in the source or remove the reference`
101+
// Phrased as a consequence, not a loss: an unmapped custom block does not empty a field,
102+
// it keeps invoking the SOURCE environment's block. The row renders this as the whole
103+
// clause after the block name (no "would lose" lead-in), so it reads as a sentence.
104+
case 'unmapped-custom-block':
105+
return `still runs the source's block — map it to a custom block published in ${targetWorkspaceName}`
100106
}
101107
}

0 commit comments

Comments
 (0)