feat(custom-blocks): let a publisher decide whether their block's runs reach consumer traces - #6950
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
PR SummaryHigh Risk Overview When off, the executor withholds Read-time Reviewed by Cursor Bugbot for commit 952c22c. Configure here. |
Greptile SummaryThe PR moves custom-block child-trace disclosure to an explicit publisher-controlled policy and extends trace joining to Agent-tool invocations.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/sim/lib/logs/execution/hydrate-child-traces.ts | Replaces reader workspace authorization with a fail-closed, live publisher-policy lookup for every child-trace boundary. |
| apps/sim/lib/logs/execution/trace-spans/span-factory.ts | Promotes custom-block child-trace metadata from Agent tool results onto nested tool spans and removes the plumbing fields from displayed output. |
| apps/sim/executor/handlers/workflow/workflow-handler.ts | Resolves publisher trace policy per invocation and withholds child execution handles when tracing is disabled. |
| apps/sim/ee/custom-blocks/components/custom-block-detail.tsx | Adds publisher-facing state, dirty tracking, save behavior, and UI controls for the custom-block trace policy. |
| packages/db/migrations/0299_remarkable_gateway.sql | Adds the non-null trace_child_runs column with a fail-closed default of false. |
| apps/sim/executor/handlers/workflow/workflow-handler.test.ts | Tests publisher opt-in, consumer non-assertability, live-stream gating, failure references, and typed error narrowing for the new policy cases. |
Sequence Diagram
sequenceDiagram
participant Publisher
participant Settings
participant Executor
participant Log
participant Consumer
Publisher->>Settings: Configure traceChildRuns
Settings->>Executor: Persist publisher policy
Executor->>Executor: Resolve policy for invocation
alt Tracing enabled
Executor->>Log: Persist childExecutionId
else Tracing disabled
Executor->>Log: Persist disabled marker only
end
Consumer->>Log: Open consuming workflow trace
Log->>Settings: Read current publisher policy
alt Handle exists and policy enabled
Log-->>Consumer: Join child spans
else Missing handle or policy disabled
Log-->>Consumer: Show unexpanded boundary
end
Reviews (3): Last reviewed commit: "fix(logs): sum the child-trace drop coun..." | Re-trigger Greptile
…ot 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>
|
@cursor review |
…and-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>
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 952c22c. Configure here.
…s 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>
…ot 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>
…and-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>
…llision 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>
952c22c to
5735a2a
Compare
The problem
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 does 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 change
The decision moves to the party whose data it is.
custom_block.trace_child_runsis set by the publisher in Settings → Enterprise → Custom blocks, applies org-wide, and is the entire policy — nothing downstream re-checks a caller.getCustomBlockAuthorityalready resolves per invocation and is the one lookup both the canvas handler and the Agent-tool runner pass through, so a single column covers both surfaces with no per-surface plumbing, and no consumer input can assert it (there's a test for exactly that).Removed:
canStreamCustomBlockToViewer, thecheckWorkspaceAccesscall inhydrateChildTraces, and the'denied'member ofchildTraceAccess.It defaults to
false— please sanity-check thisWith 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's 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.
Practical effect: existing custom blocks stop joining traces until a publisher opts in. The trace-join feature is days old, so that population should be small.
Closed means no handle at all
Rather than persisting the handle behind a flag, a closed block emits no
childExecutionId— there is nothing for a reader, a migration, or a later refactor to join. What replaces it is a_childTraceDisabledmarker, 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 trace step shows Not traced.The consumer-facing failure
refis untouched either way — it's the only thing that makes an untraced failure reportable.Agent-tool invocations now join too
They never did. The child's handle already reached the agent's persisted
toolCalls[].result(postProcessToolOutputstrips only__-prefixed keys); nothing lifted it onto the tool span. Both span builders now lift and strip it, andhydrateChildTracesneeded no change — its boundary walk already recurses into children.The same handle is stripped from the model-facing copy 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.One condition survives on the live stream
An identified consumer. Not an authorization check — no workspace query — but chat deployments and the public API leave
liveTraceViewerUserIdunset 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. Read-time joining has no such condition.Copilot deliberately cannot set the field, with a comment saying it must never become a parameter.
Migration
0299—ALTER TABLE custom_block ADD COLUMN trace_child_runs boolean DEFAULT false NOT NULL. Pure expand;check:migrationspasses with no annotation needed (safe tier — the deployed app never writes the column and the default fills existing rows).Verification
tscclean,biomecleanapps/sim(0 failures)check:migrations,check:api-validation:strict,check:boundaries,check:client-boundary,check:import-specifiers,check:utils,check:canvas-sentences,check:tool-registry-boundaryall passTests added cover: the publisher policy opening and closing the handle, a consumer input failing to assert it, the live-stream gate in both directions, no access query running at all, the failure
refsurviving a closed policy, the tool-span lift on both provider paths (segments and raw tool calls), and hydration joining a tool span nested under an agent block.Not exercised in a browser. The settings row uses the
SettingRow+Switchpattern already in that form, but nobody has clicked it.🤖 Generated with Claude Code