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
14 changes: 11 additions & 3 deletions apps/docs/content/docs/en/platform/enterprise/custom-blocks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,15 @@ Pick which of the workflow's outputs consumers can use, and give each one a name

<Image src="/static/enterprise/custom-blocks-form.png" alt="Create block form filled in: Workspace and Workflow selectors, an uploaded icon, Name and Description fields, an expanded input with a placeholder, and two selected outputs each given a name" width={900} height={570} />

### 6. Save
### 6. Choose whether runs are traced

**Trace runs in consumer logs** is off by default. Leave it off and your block stays a single step in every workflow that uses it: nothing about the run is recorded anywhere a consumer can reach.

Turn it on and the block's steps appear inside the trace of every workflow that runs it, org-wide. That means anyone who can read those workflows' logs sees your workflow's block names, inputs, outputs, and prompts — including people with no access to this workspace. It is the same information curated outputs and redacted errors otherwise keep on your side of the block, so turn it on when you want consumers to be able to debug your block themselves, and leave it off otherwise.

You can change this at any time; it applies to runs from that point on. Failures always return a reference id either way, so you can find a run in your own logs even with tracing off.

### 7. Save

Click **Save changes**. The block is published immediately and becomes available to everyone in your organization in the workflow editor's block toolbar.

Expand All @@ -86,7 +94,7 @@ In the workflow editor, open the block toolbar. Published custom blocks appear u

<Image src="/static/enterprise/custom-blocks-toolbar.png" alt="Workflow editor block toolbar with a Custom Blocks section listing two published blocks below Core Blocks" width={400} height={476} />

Consumers don't need any access to the source workflow. The block runs on its own, using only the inputs provided, and returns only the outputs you exposed. Internal steps, models, and intermediate values of the source workflow are never visible.
Consumers don't need any access to the source workflow. The block runs on its own, using only the inputs provided, and returns only the outputs you exposed. Its internal steps, models, and intermediate values stay hidden unless the block's publisher turned on **Trace runs in consumer logs**, in which case they appear under the block in the run's trace.

<Image src="/static/enterprise/custom-blocks-canvas.png" alt="A custom block connected to a Start block on the workflow canvas, with its query input filled in and the run output showing the returned fields" width={900} height={570} />

Expand All @@ -96,7 +104,7 @@ Consumers don't need any access to the source workflow. The block runs on its ow

Open a block from **Settings → Enterprise → Custom blocks** to edit or delete it.

- **Editing** changes only the block's presentation and interface — name, description, icon, input placeholders, and exposed outputs. The source workflow can't be re-pointed.
- **Editing** changes only the block's presentation, interface, and trace policy — name, description, icon, input placeholders, exposed outputs, and whether runs are traced in consumer logs. The source workflow can't be re-pointed.
- **Changing what the block does** is done by editing and **redeploying the source workflow**. The block picks up the new deployment automatically; there's nothing to republish.
- **Deleting** a block is permanent. Workflows already using it will have that block removed, so replace it before deleting if it's in active use.

Expand Down
4 changes: 3 additions & 1 deletion apps/sim/app/api/custom-blocks/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
if (authz.error) return authz.error
const { ctx } = authz

const { name, description, enabled, iconUrl, inputs, exposedOutputs } = parsed.data.body
const { name, description, enabled, iconUrl, inputs, exposedOutputs, traceChildRuns } =
parsed.data.body
try {
await updateCustomBlock(id, {
name,
Expand All @@ -45,6 +46,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
inputs,
iconUrl,
exposedOutputs,
traceChildRuns,
})
recordAudit({
workspaceId: ctx.sourceWorkspaceId,
Expand Down
14 changes: 12 additions & 2 deletions apps/sim/app/api/custom-blocks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function toWire(block: CustomBlockWithInputs) {
description: block.description,
iconUrl: block.iconUrl,
enabled: block.enabled,
traceChildRuns: block.traceChildRuns,
inputFields: block.inputFields,
exposedOutputs: block.exposedOutputs,
}
Expand Down Expand Up @@ -82,8 +83,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (!parsed.success) return parsed.response

const userId = session.user.id
const { workspaceId, workflowId, name, description, iconUrl, inputs, exposedOutputs } =
parsed.data.body
const {
workspaceId,
workflowId,
name,
description,
iconUrl,
inputs,
exposedOutputs,
traceChildRuns,
} = parsed.data.body

const access = await checkWorkspaceAccess(workspaceId, userId)
if (!access.canAdmin) {
Expand Down Expand Up @@ -120,6 +129,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
iconUrl,
inputs,
exposedOutputs,
traceChildRuns,
})
recordAudit({
workspaceId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
* 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)',
disabled: 'Not traced',
}

const DEFAULT_TREE_PANE_WIDTH = 240
Expand Down Expand Up @@ -682,12 +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
// A custom block runs in another workspace, and its steps are joined in only when its
// publisher opted that block into consumer traces. Say why they are absent — otherwise a
// boundary span with no children is indistinguishable from a block that simply did
// nothing. The read-time verdict wins: a span that carries one was opted in, so
// `disabled` can only describe a span hydration never considered.
const childRunState = span.childTraceAccess ?? (span.childTraceDisabled ? 'disabled' : undefined)
const childRunLabel = childRunState ? CHILD_TRACE_ACCESS_LABEL[childRunState] : 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) })
Expand Down
20 changes: 20 additions & 0 deletions apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
toCustomBlockInputs(existing?.inputFields)
)
const [outputs, setOutputs] = useState<CustomBlockOutput[]>(() => existing?.exposedOutputs ?? [])
const [traceChildRuns, setTraceChildRuns] = useState(existing?.traceChildRuns ?? false)
const [error, setError] = useState<string | null>(null)
const [showDelete, setShowDelete] = useState(false)

Expand Down Expand Up @@ -169,6 +170,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
setDescription(existing.description ?? '')
setInputs(toCustomBlockInputs(existing.inputFields))
setOutputs(existing.exposedOutputs ?? [])
setTraceChildRuns(existing.traceChildRuns)
}

const iconUpload = useProfilePictureUpload({
Expand Down Expand Up @@ -277,6 +279,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
? name.trim() !== existing.name ||
description.trim() !== (existing.description ?? '') ||
(iconUrl || null) !== (existing.iconUrl ?? null) ||
traceChildRuns !== existing.traceChildRuns ||
JSON.stringify(visibleOutputs) !== JSON.stringify(existing.exposedOutputs) ||
JSON.stringify(normalizeInputsForCompare(visibleInputs)) !==
JSON.stringify(normalizeInputsForCompare(existing.inputFields))
Expand All @@ -286,6 +289,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
selectedWorkflowId ||
selectedWorkspaceId !== eligibleDefaultWorkspaceId ||
iconUrl ||
traceChildRuns ||
visibleOutputs.length > 0 ||
visibleInputs.some((i) => i.placeholder?.trim())
)
Expand Down Expand Up @@ -343,6 +347,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
}
setName(existing?.name ?? '')
setDescription(existing?.description ?? '')
setTraceChildRuns(existing?.traceChildRuns ?? false)
setInputs(toCustomBlockInputs(existing?.inputFields))
setOutputs(existing?.exposedOutputs ?? [])
iconUpload.reset()
Expand Down Expand Up @@ -388,6 +393,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
description: description.trim(),
inputs: inputPlaceholders,
exposedOutputs,
traceChildRuns,
...(iconChanged ? { iconUrl: iconUrl || null } : {}),
})
toast.success('Block updated')
Expand All @@ -399,6 +405,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
description: description.trim(),
inputs: inputPlaceholders,
exposedOutputs,
traceChildRuns,
...(iconUrl ? { iconUrl } : {}),
})
toast.success('Block created')
Expand Down Expand Up @@ -727,6 +734,19 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
</div>
)}
</SettingRow>

<SettingRow
label='Trace runs in consumer logs'
htmlFor='custom-block-trace-child-runs'
description='Shows this block’s steps inside the trace of every workflow that runs it, org-wide. Anyone who can read those logs sees the source workflow’s block names, inputs, outputs, and prompts — including people with no access to this workspace.'
>
<Switch
id='custom-block-trace-child-runs'
checked={traceChildRuns}
onCheckedChange={setTraceChildRuns}
disabled={!canManageBlock}
/>
</SettingRow>
</div>
</SettingsPanel>

Expand Down
19 changes: 18 additions & 1 deletion apps/sim/executor/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,10 +323,27 @@ export function isWorkflowBlockType(blockType: string | undefined): boolean {
*/
export const CHILD_EXECUTION_ID_OUTPUT_KEY = '_childExecutionId'

/**
* Internal marker saying a custom block ran a child whose trace it deliberately
* did not publish. Carried instead of {@link CHILD_EXECUTION_ID_OUTPUT_KEY}, never
* beside it: withholding the handle is what makes tracing-off fail closed, and a
* marker that travelled with the handle would be one dropped field away from
* joining a run the caller opted out of. Underscore-prefixed for the same reason.
*
* Recorded because a boundary span with no children renders exactly like a leaf
* block, so an untraced invocation would otherwise read as one that did nothing.
*
* Neither key may become a globally hidden output key: on the Agent-tool path the
* block log's nested `toolCalls[].result` is the only carrier from the tool
* response to the tool span, so hiding them there would silently stop custom
* blocks invoked as tools from joining their child runs at all.
*/
export const CHILD_TRACE_DISABLED_OUTPUT_KEY = '_childTraceDisabled'

/**
* Whether a block runs another workflow underneath it, and therefore owns a
* nested subtree in the trace/terminal — a workflow block, or a custom block
* whose source run the viewer has been authorized to see.
* whose publisher opted its runs into consumer traces.
*
* Deliberately wider than {@link isWorkflowBlockType}, which stays narrow because
* it also gates whether the child workflow's NAME may be attached to an error —
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/executor/errors/child-workflow-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ interface ChildWorkflowErrorOptions {
* started — including for boundary-safe failures, which carry no `ref`.
*/
childExecutionId?: string
/**
* A child run happened but the invocation opted out of publishing it. Mutually
* exclusive with {@link childExecutionId} — see `CHILD_TRACE_DISABLED_OUTPUT_KEY`.
*/
childTraceDisabled?: boolean
cause?: Error
}

Expand All @@ -45,6 +50,7 @@ export class ChildWorkflowError extends Error {
readonly rootErrorMessage: string
readonly consumerFacing?: CustomBlockFailure
readonly childExecutionId?: string
readonly childTraceDisabled?: boolean

constructor(options: ChildWorkflowErrorOptions) {
super(options.message, { cause: options.cause })
Expand All @@ -58,6 +64,7 @@ export class ChildWorkflowError extends Error {
this.rootErrorMessage = options.rootErrorMessage ?? options.message
this.consumerFacing = options.consumerFacing
this.childExecutionId = options.childExecutionId
this.childTraceDisabled = options.childTraceDisabled
}

static isChildWorkflowError(error: unknown): error is ChildWorkflowError {
Expand Down
11 changes: 10 additions & 1 deletion apps/sim/executor/execution/block-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
buildResumeApiUrl,
buildResumeUiUrl,
CHILD_EXECUTION_ID_OUTPUT_KEY,
CHILD_TRACE_DISABLED_OUTPUT_KEY,
DEFAULTS,
EDGE,
isSentinelBlockType,
Expand Down Expand Up @@ -351,11 +352,15 @@ export class BlockExecutor {
if (typeof childExecutionId === 'string' && childExecutionId) {
blockLog.childExecution = { executionId: childExecutionId }
}
if (normalizedOutput[CHILD_TRACE_DISABLED_OUTPUT_KEY] === true) {
blockLog.childTraceDisabled = true
}
}

const {
childTraceSpans: _traces,
[CHILD_EXECUTION_ID_OUTPUT_KEY]: _childExecutionId,
[CHILD_TRACE_DISABLED_OUTPUT_KEY]: _childTraceDisabled,
...outputForState
} = normalizedOutput
const stateOutput = outputForState as NormalizedBlockOutput
Expand Down Expand Up @@ -663,10 +668,14 @@ export class BlockExecutor {
if (ChildWorkflowError.isChildWorkflowError(error) && error.childTraceSpans.length > 0) {
blockLog.childTraceSpans = error.childTraceSpans
}
// A failed custom block still has its own child run to join at read time.
// A failed custom block still has its own child run to join at read time —
// unless the instance opted out, which leaves only the marker.
if (ChildWorkflowError.isChildWorkflowError(error) && error.childExecutionId) {
blockLog.childExecution = { executionId: error.childExecutionId }
}
if (ChildWorkflowError.isChildWorkflowError(error) && error.childTraceDisabled) {
blockLog.childTraceDisabled = true
}
Comment thread
icecrasher321 marked this conversation as resolved.
}

const diagnosticRegistry = ctx.errorResolvedSecretTraceRegistry
Expand Down
Loading
Loading