Skip to content

Commit 71129cd

Browse files
feat(custom-blocks): let a publisher decide whether their block's runs reach consumer traces (#6950)
* feat(custom-blocks): let a publisher decide whether their block's runs reach consumer traces Joining a custom block's child run into its caller's trace shipped on by default, gated at read time by whether the person reading could already open the source workspace. That gate is doing the wrong job: a custom block's whole point is that consumers need no access to the source, so the check refuses exactly the readers the feature exists for, and it makes the answer depend on who is looking rather than on what the block's owner agreed to publish. The decision moves to the party whose data it is. `custom_block.trace_child_runs` is set by the publisher in Settings, applies org-wide, and is the entire policy — nothing downstream re-checks a caller. `getCustomBlockAuthority` already resolves per invocation and is the one lookup both the canvas handler and the Agent-tool runner pass through, so one column covers both surfaces and no consumer input can assert it. It defaults to FALSE. With the viewer check gone, an opted-in block publishes the source workflow's block names, inputs, outputs, and prompts to anyone who can read a consuming workflow's log. That is the same boundary curated outputs and redacted errors hold, so it opens by an affirmative act of the publisher or not at all — never as the residue of a column default on rows nobody revisited. Closed means the handle is withheld outright rather than persisted behind a flag: with no `childExecutionId` there is nothing for a reader, a migration, or a later refactor to join. What replaces it is a `_childTraceDisabled` marker, because a boundary span with no children renders exactly like a leaf block and an untraced run would otherwise read as one that did nothing. The consumer-facing failure `ref` is untouched either way — it is the only thing that makes an untraced failure reportable. Custom blocks invoked as Agent tools now join too. The child's handle already reached the agent's persisted `toolCalls[].result` (`postProcessToolOutput` strips only `__`-prefixed keys); nothing lifted it onto the tool span. Both span builders lift and strip it, and `hydrateChildTraces` needs no change — its boundary walk already recurses. The same handle is stripped from the model-facing copy of the tool result in `executeProviderTool`, the single point where the raw and model copies diverge: an opaque execution id in a tool result reads to a model like data the tool returned. The live SSE stream keeps one condition beyond the policy: an identified consumer. Not an authorization check — no workspace query — but chat deployments and the public API leave `liveTraceViewerUserId` unset because their consumer may be anonymous, and opting into org-wide tracing is not consent to stream a publisher's raw agent tokens to the internet. Copilot deliberately cannot set the field; exposing a team's internals org-wide is a human decision, not one an agent makes while publishing on their behalf. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(custom-blocks): read the publisher's trace policy at read time, not from the handle's presence Treating a persisted `childExecutionId` as proof of publisher consent is only true for handles this PR's writer produced. Every handle written before it meant something else — "a child ran; authorize the reader" — and the rows carrying them outlive the migration, so removing the reader check turned them into an open door: a consumer could open an old parent log and receive the source workflow's block names, inputs, outputs, and prompts from a block whose publisher never opted in. `hydrateChildTraces` now resolves the policy live, per boundary, from `custom_block.trace_child_runs`. The child log row's `workflowId` is the key — publish enforces one block per workflow — which also covers an Agent-tool boundary, whose span carries no block type to look up. A workflow with no block row (never published, or since deleted) has no publisher left to consent and stays shut, as does a failed policy read. This is not redundant with the write-time withholding. The handler still emits no handle for a block that was closed when the run executed, so such a run stays closed forever even if the block is opened later; this check decides whether the runs that DO carry a handle may still be shown. Turning the policy off therefore also closes what is already recorded, which is what a governance switch has to do to mean anything. Reported by Greptile on #6950. Also drops `any` from the trace-policy tests: outputs read through `Record<string, unknown>` (the handler's declared return does not name these internal keys) and failures narrow through `ChildWorkflowError.isChildWorkflowError`, which pins the failure type as well as its fields. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(logs): sum the child-trace drop counters from the struct, not a hand-listed set `totalDropped` re-listed four of the five counters, so a read whose only drops were policy refusals computed zero and skipped the log entirely. That is the commonest drop there is now — every handle written before the publisher policy existed refuses at that gate — so the one signal telling an operator the live check is closing joins went silent exactly when it started mattering. Summed from the struct instead. A hand-maintained list beside a struct is stale the moment a field is added, which is precisely how `policyClosed` was left out. Reported by Cursor Bugbot on #6950. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(db): renumber the custom-block trace migration around a 0299 collision Staging landed its own 0299 (`table_run_dispatches.heartbeat_at`) while this branch was open. The two migrations are independent — different tables, no shared statement — so only the number and drizzle's snapshot chain collided. Regenerated rather than hand-merged: a drizzle snapshot is a full-schema dump whose `prevId` links it to its parent, so editing one by hand to sit after a migration it was not generated against is how the chain silently stops matching the database. Staging's 0299 and its snapshot are taken verbatim; this is 0300, generated against them, and its SQL is byte-identical to what it replaced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fe0b92b commit 71129cd

26 files changed

Lines changed: 21016 additions & 269 deletions

File tree

apps/docs/content/docs/en/platform/enterprise/custom-blocks.mdx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,15 @@ Pick which of the workflow's outputs consumers can use, and give each one a name
7474

7575
<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} />
7676

77-
### 6. Save
77+
### 6. Choose whether runs are traced
78+
79+
**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.
80+
81+
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.
82+
83+
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.
84+
85+
### 7. Save
7886

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

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

8795
<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} />
8896

89-
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.
97+
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.
9098

9199
<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} />
92100

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

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

99-
- **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.
107+
- **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.
100108
- **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.
101109
- **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.
102110

apps/sim/app/api/custom-blocks/[id]/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
3636
if (authz.error) return authz.error
3737
const { ctx } = authz
3838

39-
const { name, description, enabled, iconUrl, inputs, exposedOutputs } = parsed.data.body
39+
const { name, description, enabled, iconUrl, inputs, exposedOutputs, traceChildRuns } =
40+
parsed.data.body
4041
try {
4142
await updateCustomBlock(id, {
4243
name,
@@ -45,6 +46,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
4546
inputs,
4647
iconUrl,
4748
exposedOutputs,
49+
traceChildRuns,
4850
})
4951
recordAudit({
5052
workspaceId: ctx.sourceWorkspaceId,

apps/sim/app/api/custom-blocks/route.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ function toWire(block: CustomBlockWithInputs) {
3636
description: block.description,
3737
iconUrl: block.iconUrl,
3838
enabled: block.enabled,
39+
traceChildRuns: block.traceChildRuns,
3940
inputFields: block.inputFields,
4041
exposedOutputs: block.exposedOutputs,
4142
}
@@ -82,8 +83,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8283
if (!parsed.success) return parsed.response
8384

8485
const userId = session.user.id
85-
const { workspaceId, workflowId, name, description, iconUrl, inputs, exposedOutputs } =
86-
parsed.data.body
86+
const {
87+
workspaceId,
88+
workflowId,
89+
name,
90+
description,
91+
iconUrl,
92+
inputs,
93+
exposedOutputs,
94+
traceChildRuns,
95+
} = parsed.data.body
8796

8897
const access = await checkWorkspaceAccess(workspaceId, userId)
8998
if (!access.canAdmin) {
@@ -120,6 +129,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
120129
iconUrl,
121130
inputs,
122131
exposedOutputs,
132+
traceChildRuns,
123133
})
124134
recordAudit({
125135
workspaceId,

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
5454
* the joined children are their own evidence, so labelling them would be noise.
5555
*/
5656
const CHILD_TRACE_ACCESS_LABEL: Record<string, string> = {
57-
denied: 'No access to the source workspace',
5857
missing: 'Not available',
5958
truncated: 'Not expanded (nesting limit)',
59+
disabled: 'Not traced',
6060
}
6161

6262
const DEFAULT_TREE_PANE_WIDTH = 240
@@ -682,12 +682,13 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
682682
label: 'Type',
683683
value: isCustomBlockType(span.type) ? 'custom block' : span.type,
684684
})
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
685+
// A custom block runs in another workspace, and its steps are joined in only when its
686+
// publisher opted that block into consumer traces. Say why they are absent — otherwise a
687+
// boundary span with no children is indistinguishable from a block that simply did
688+
// nothing. The read-time verdict wins: a span that carries one was opted in, so
689+
// `disabled` can only describe a span hydration never considered.
690+
const childRunState = span.childTraceAccess ?? (span.childTraceDisabled ? 'disabled' : undefined)
691+
const childRunLabel = childRunState ? CHILD_TRACE_ACCESS_LABEL[childRunState] : undefined
691692
if (childRunLabel) metaEntries.push({ label: 'Child run', value: childRunLabel })
692693
metaEntries.push({ label: 'Duration', value: formatDuration(duration, { precision: 2 }) || '—' })
693694
if (span.tries !== undefined) metaEntries.push({ label: 'Tries', value: String(span.tries) })

apps/sim/ee/custom-blocks/components/custom-block-detail.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
142142
toCustomBlockInputs(existing?.inputFields)
143143
)
144144
const [outputs, setOutputs] = useState<CustomBlockOutput[]>(() => existing?.exposedOutputs ?? [])
145+
const [traceChildRuns, setTraceChildRuns] = useState(existing?.traceChildRuns ?? false)
145146
const [error, setError] = useState<string | null>(null)
146147
const [showDelete, setShowDelete] = useState(false)
147148

@@ -169,6 +170,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
169170
setDescription(existing.description ?? '')
170171
setInputs(toCustomBlockInputs(existing.inputFields))
171172
setOutputs(existing.exposedOutputs ?? [])
173+
setTraceChildRuns(existing.traceChildRuns)
172174
}
173175

174176
const iconUpload = useProfilePictureUpload({
@@ -277,6 +279,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
277279
? name.trim() !== existing.name ||
278280
description.trim() !== (existing.description ?? '') ||
279281
(iconUrl || null) !== (existing.iconUrl ?? null) ||
282+
traceChildRuns !== existing.traceChildRuns ||
280283
JSON.stringify(visibleOutputs) !== JSON.stringify(existing.exposedOutputs) ||
281284
JSON.stringify(normalizeInputsForCompare(visibleInputs)) !==
282285
JSON.stringify(normalizeInputsForCompare(existing.inputFields))
@@ -286,6 +289,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
286289
selectedWorkflowId ||
287290
selectedWorkspaceId !== eligibleDefaultWorkspaceId ||
288291
iconUrl ||
292+
traceChildRuns ||
289293
visibleOutputs.length > 0 ||
290294
visibleInputs.some((i) => i.placeholder?.trim())
291295
)
@@ -343,6 +347,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
343347
}
344348
setName(existing?.name ?? '')
345349
setDescription(existing?.description ?? '')
350+
setTraceChildRuns(existing?.traceChildRuns ?? false)
346351
setInputs(toCustomBlockInputs(existing?.inputFields))
347352
setOutputs(existing?.exposedOutputs ?? [])
348353
iconUpload.reset()
@@ -388,6 +393,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
388393
description: description.trim(),
389394
inputs: inputPlaceholders,
390395
exposedOutputs,
396+
traceChildRuns,
391397
...(iconChanged ? { iconUrl: iconUrl || null } : {}),
392398
})
393399
toast.success('Block updated')
@@ -399,6 +405,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
399405
description: description.trim(),
400406
inputs: inputPlaceholders,
401407
exposedOutputs,
408+
traceChildRuns,
402409
...(iconUrl ? { iconUrl } : {}),
403410
})
404411
toast.success('Block created')
@@ -727,6 +734,19 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
727734
</div>
728735
)}
729736
</SettingRow>
737+
738+
<SettingRow
739+
label='Trace runs in consumer logs'
740+
htmlFor='custom-block-trace-child-runs'
741+
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.'
742+
>
743+
<Switch
744+
id='custom-block-trace-child-runs'
745+
checked={traceChildRuns}
746+
onCheckedChange={setTraceChildRuns}
747+
disabled={!canManageBlock}
748+
/>
749+
</SettingRow>
730750
</div>
731751
</SettingsPanel>
732752

apps/sim/executor/constants.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,10 +323,27 @@ export function isWorkflowBlockType(blockType: string | undefined): boolean {
323323
*/
324324
export const CHILD_EXECUTION_ID_OUTPUT_KEY = '_childExecutionId'
325325

326+
/**
327+
* Internal marker saying a custom block ran a child whose trace it deliberately
328+
* did not publish. Carried instead of {@link CHILD_EXECUTION_ID_OUTPUT_KEY}, never
329+
* beside it: withholding the handle is what makes tracing-off fail closed, and a
330+
* marker that travelled with the handle would be one dropped field away from
331+
* joining a run the caller opted out of. Underscore-prefixed for the same reason.
332+
*
333+
* Recorded because a boundary span with no children renders exactly like a leaf
334+
* block, so an untraced invocation would otherwise read as one that did nothing.
335+
*
336+
* Neither key may become a globally hidden output key: on the Agent-tool path the
337+
* block log's nested `toolCalls[].result` is the only carrier from the tool
338+
* response to the tool span, so hiding them there would silently stop custom
339+
* blocks invoked as tools from joining their child runs at all.
340+
*/
341+
export const CHILD_TRACE_DISABLED_OUTPUT_KEY = '_childTraceDisabled'
342+
326343
/**
327344
* Whether a block runs another workflow underneath it, and therefore owns a
328345
* nested subtree in the trace/terminal — a workflow block, or a custom block
329-
* whose source run the viewer has been authorized to see.
346+
* whose publisher opted its runs into consumer traces.
330347
*
331348
* Deliberately wider than {@link isWorkflowBlockType}, which stays narrow because
332349
* it also gates whether the child workflow's NAME may be attached to an error —

apps/sim/executor/errors/child-workflow-error.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ interface ChildWorkflowErrorOptions {
2222
* started — including for boundary-safe failures, which carry no `ref`.
2323
*/
2424
childExecutionId?: string
25+
/**
26+
* A child run happened but the invocation opted out of publishing it. Mutually
27+
* exclusive with {@link childExecutionId} — see `CHILD_TRACE_DISABLED_OUTPUT_KEY`.
28+
*/
29+
childTraceDisabled?: boolean
2530
cause?: Error
2631
}
2732

@@ -45,6 +50,7 @@ export class ChildWorkflowError extends Error {
4550
readonly rootErrorMessage: string
4651
readonly consumerFacing?: CustomBlockFailure
4752
readonly childExecutionId?: string
53+
readonly childTraceDisabled?: boolean
4854

4955
constructor(options: ChildWorkflowErrorOptions) {
5056
super(options.message, { cause: options.cause })
@@ -58,6 +64,7 @@ export class ChildWorkflowError extends Error {
5864
this.rootErrorMessage = options.rootErrorMessage ?? options.message
5965
this.consumerFacing = options.consumerFacing
6066
this.childExecutionId = options.childExecutionId
67+
this.childTraceDisabled = options.childTraceDisabled
6168
}
6269

6370
static isChildWorkflowError(error: unknown): error is ChildWorkflowError {

apps/sim/executor/execution/block-executor.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
buildResumeApiUrl,
2121
buildResumeUiUrl,
2222
CHILD_EXECUTION_ID_OUTPUT_KEY,
23+
CHILD_TRACE_DISABLED_OUTPUT_KEY,
2324
DEFAULTS,
2425
EDGE,
2526
isSentinelBlockType,
@@ -351,11 +352,15 @@ export class BlockExecutor {
351352
if (typeof childExecutionId === 'string' && childExecutionId) {
352353
blockLog.childExecution = { executionId: childExecutionId }
353354
}
355+
if (normalizedOutput[CHILD_TRACE_DISABLED_OUTPUT_KEY] === true) {
356+
blockLog.childTraceDisabled = true
357+
}
354358
}
355359

356360
const {
357361
childTraceSpans: _traces,
358362
[CHILD_EXECUTION_ID_OUTPUT_KEY]: _childExecutionId,
363+
[CHILD_TRACE_DISABLED_OUTPUT_KEY]: _childTraceDisabled,
359364
...outputForState
360365
} = normalizedOutput
361366
const stateOutput = outputForState as NormalizedBlockOutput
@@ -663,10 +668,14 @@ export class BlockExecutor {
663668
if (ChildWorkflowError.isChildWorkflowError(error) && error.childTraceSpans.length > 0) {
664669
blockLog.childTraceSpans = error.childTraceSpans
665670
}
666-
// A failed custom block still has its own child run to join at read time.
671+
// A failed custom block still has its own child run to join at read time —
672+
// unless the instance opted out, which leaves only the marker.
667673
if (ChildWorkflowError.isChildWorkflowError(error) && error.childExecutionId) {
668674
blockLog.childExecution = { executionId: error.childExecutionId }
669675
}
676+
if (ChildWorkflowError.isChildWorkflowError(error) && error.childTraceDisabled) {
677+
blockLog.childTraceDisabled = true
678+
}
670679
}
671680

672681
const diagnosticRegistry = ctx.errorResolvedSecretTraceRegistry

0 commit comments

Comments
 (0)