mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fe0b92bb6d
commit
71129cd112
@@ -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.
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -45,6 +46,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
|
||||
inputs,
|
||||
iconUrl,
|
||||
exposedOutputs,
|
||||
traceChildRuns,
|
||||
})
|
||||
recordAudit({
|
||||
workspaceId: ctx.sourceWorkspaceId,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -120,6 +129,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
iconUrl,
|
||||
inputs,
|
||||
exposedOutputs,
|
||||
traceChildRuns,
|
||||
})
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
|
||||
+8
-7
@@ -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
|
||||
@@ -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) })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
@@ -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))
|
||||
@@ -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())
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -399,6 +405,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
|
||||
description: description.trim(),
|
||||
inputs: inputPlaceholders,
|
||||
exposedOutputs,
|
||||
traceChildRuns,
|
||||
...(iconUrl ? { iconUrl } : {}),
|
||||
})
|
||||
toast.success('Block created')
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
@@ -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 {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
buildResumeApiUrl,
|
||||
buildResumeUiUrl,
|
||||
CHILD_EXECUTION_ID_OUTPUT_KEY,
|
||||
CHILD_TRACE_DISABLED_OUTPUT_KEY,
|
||||
DEFAULTS,
|
||||
EDGE,
|
||||
isSentinelBlockType,
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
const diagnosticRegistry = ctx.errorResolvedSecretTraceRegistry
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createTimeoutAbortController, getExecutionDeadlineAt } from '@/lib/core
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import { BoundarySafeError } from '@/executor/errors/boundary'
|
||||
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
|
||||
import {
|
||||
findMissingRequiredCustomBlockInputs,
|
||||
remapCustomBlockInputKeys,
|
||||
@@ -1172,6 +1173,9 @@ describe('WorkflowBlockHandler', () => {
|
||||
ownerUserId: 'owner-9',
|
||||
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }],
|
||||
requiredInputIds: [],
|
||||
// Publisher opted this block's runs into consumer traces; the tests that
|
||||
// exercise the closed path override it.
|
||||
traceChildRuns: true,
|
||||
})
|
||||
mockGetPersonalAndWorkspaceEnv.mockResolvedValue({
|
||||
personalDecrypted: {},
|
||||
@@ -1214,18 +1218,27 @@ describe('WorkflowBlockHandler', () => {
|
||||
mockProjectTraceSpansForLiveDisplay.mockResolvedValue([projectedSpan])
|
||||
})
|
||||
|
||||
it('emits NOTHING when there is no authorized live viewer', async () => {
|
||||
it('emits NOTHING when there is no identified live consumer', async () => {
|
||||
const output: any = await handler.execute(customBlockContext(), customBlock(), {})
|
||||
|
||||
expect(output.childTraceSpans).toBeUndefined()
|
||||
expect(mockProjectTraceSpansForLiveDisplay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits nothing when the viewer cannot read the source workspace', async () => {
|
||||
mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: false })
|
||||
it('emits nothing when the publisher has not opted the block in', async () => {
|
||||
// The viewer's own access is deliberately not consulted; this is the only
|
||||
// thing that closes the stream for an identified consumer.
|
||||
mockGetCustomBlockAuthority.mockResolvedValue({
|
||||
workflowId: 'source-workflow-id',
|
||||
organizationId: 'org-1',
|
||||
ownerUserId: 'owner-9',
|
||||
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }],
|
||||
requiredInputIds: [],
|
||||
traceChildRuns: false,
|
||||
})
|
||||
|
||||
const output: any = await handler.execute(
|
||||
customBlockContext({ liveTraceViewerUserId: 'outsider' }),
|
||||
customBlockContext({ liveTraceViewerUserId: 'consumer-1' }),
|
||||
customBlock(),
|
||||
{}
|
||||
)
|
||||
@@ -1234,11 +1247,10 @@ describe('WorkflowBlockHandler', () => {
|
||||
expect(mockProjectTraceSpansForLiveDisplay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('projects through the CHILD session before handing spans to an authorized viewer', async () => {
|
||||
it('projects through the CHILD session before handing spans to a live consumer', async () => {
|
||||
// The invoking run's registry knows nothing about the publisher's secrets, so
|
||||
// projecting there would leave a source-owner credential unmasked in the consumer's
|
||||
// stream. Only the child's own session can mask them.
|
||||
mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true })
|
||||
|
||||
const output: any = await handler.execute(
|
||||
customBlockContext({ liveTraceViewerUserId: 'viewer-1' }),
|
||||
@@ -1260,7 +1272,6 @@ describe('WorkflowBlockHandler', () => {
|
||||
// Those markers are keyed by the parent execution and readable by anyone with
|
||||
// parent-workspace access, long after the per-viewer gate this stream passed — so
|
||||
// the source workflow's blocks must reach the emit half only.
|
||||
mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true })
|
||||
const persistingStart = vi.fn()
|
||||
const persistingComplete = vi.fn()
|
||||
const emitOnlyStart = vi.fn()
|
||||
@@ -1288,10 +1299,9 @@ describe('WorkflowBlockHandler', () => {
|
||||
})
|
||||
|
||||
it('forwards the emit-only sink to the child, so nested hops still stream', async () => {
|
||||
// The viewer id alone is not enough: a nested custom block clears
|
||||
// `canStreamCustomBlockToViewer` off the inherited id and then needs a sink to
|
||||
// stream through. Without this the live trace stops at the first sub-executor.
|
||||
mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true })
|
||||
// The viewer id alone is not enough: a nested custom block re-derives its own
|
||||
// stream permission and then needs a sink to stream through. Without this the
|
||||
// live trace stops at the first sub-executor.
|
||||
const emitOnly = { onBlockStart: vi.fn(), onBlockComplete: vi.fn() }
|
||||
|
||||
await handler.execute(
|
||||
@@ -1306,11 +1316,21 @@ describe('WorkflowBlockHandler', () => {
|
||||
})
|
||||
|
||||
it('withholds both the viewer id and the sink when streaming is not permitted', async () => {
|
||||
mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: false })
|
||||
mockGetCustomBlockAuthority.mockResolvedValue({
|
||||
workflowId: 'source-workflow-id',
|
||||
organizationId: 'org-1',
|
||||
ownerUserId: 'owner-9',
|
||||
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }],
|
||||
requiredInputIds: [],
|
||||
traceChildRuns: false,
|
||||
})
|
||||
const emitOnly = { onBlockStart: vi.fn(), onBlockComplete: vi.fn() }
|
||||
|
||||
await handler.execute(
|
||||
customBlockContext({ liveTraceViewerUserId: 'outsider', liveStreamCallbacks: emitOnly }),
|
||||
customBlockContext({
|
||||
liveTraceViewerUserId: 'consumer-1',
|
||||
liveStreamCallbacks: emitOnly,
|
||||
}),
|
||||
customBlock(),
|
||||
{}
|
||||
)
|
||||
@@ -1321,7 +1341,6 @@ describe('WorkflowBlockHandler', () => {
|
||||
})
|
||||
|
||||
it('keeps the boundary shut when the surface offers no emit-only sink', async () => {
|
||||
mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true })
|
||||
const persistingStart = vi.fn()
|
||||
|
||||
await handler.execute(
|
||||
@@ -1337,8 +1356,6 @@ describe('WorkflowBlockHandler', () => {
|
||||
})
|
||||
|
||||
it('still exposes only curated outputs alongside the spans', async () => {
|
||||
mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true })
|
||||
|
||||
const output: any = await handler.execute(
|
||||
customBlockContext({ liveTraceViewerUserId: 'viewer-1' }),
|
||||
customBlock(),
|
||||
@@ -1351,6 +1368,141 @@ describe('WorkflowBlockHandler', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("the publisher's trace policy", () => {
|
||||
/** Republish the block with tracing closed, the shipped default. */
|
||||
function closeTracePolicy() {
|
||||
mockGetCustomBlockAuthority.mockResolvedValue({
|
||||
workflowId: 'source-workflow-id',
|
||||
organizationId: 'org-1',
|
||||
ownerUserId: 'owner-9',
|
||||
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }],
|
||||
requiredInputIds: [],
|
||||
traceChildRuns: false,
|
||||
})
|
||||
}
|
||||
|
||||
it('publishes the child run handle when the publisher opted in', async () => {
|
||||
const output = (await handler.execute(customBlockContext(), customBlock(), {})) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
expect(typeof output._childExecutionId).toBe('string')
|
||||
expect(output._childExecutionId).not.toBe('parent-execution-id')
|
||||
expect(output._childTraceDisabled).toBeUndefined()
|
||||
})
|
||||
|
||||
it('withholds the handle outright when the publisher has not', async () => {
|
||||
// Withheld rather than persisted behind a flag: with no handle there is
|
||||
// nothing for a reader to join, so the decision cannot be undone downstream.
|
||||
closeTracePolicy()
|
||||
|
||||
const output = (await handler.execute(customBlockContext(), customBlock(), {})) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
expect(output._childExecutionId).toBeUndefined()
|
||||
expect(output._childTraceDisabled).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a consumer input trying to assert the policy', async () => {
|
||||
// The policy belongs to the publisher and is read from the DB. A caller's
|
||||
// workflow must not be able to open its own view of another team's internals
|
||||
// by placing a param the executor happens to read.
|
||||
closeTracePolicy()
|
||||
|
||||
const output = (await handler.execute(customBlockContext(), customBlock(), {
|
||||
traceChildRun: true,
|
||||
traceChildRuns: true,
|
||||
})) as Record<string, unknown>
|
||||
|
||||
expect(output._childExecutionId).toBeUndefined()
|
||||
expect(output._childTraceDisabled).toBe(true)
|
||||
})
|
||||
|
||||
it('does not stream to a live viewer when the publisher has not opted in', async () => {
|
||||
closeTracePolicy()
|
||||
const emitOnly = { onBlockStart: vi.fn(), onBlockComplete: vi.fn() }
|
||||
|
||||
const output = (await handler.execute(
|
||||
customBlockContext({ liveTraceViewerUserId: 'viewer-1', liveStreamCallbacks: emitOnly }),
|
||||
customBlock(),
|
||||
{}
|
||||
)) as Record<string, unknown>
|
||||
|
||||
expect(output.childTraceSpans).toBeUndefined()
|
||||
expect(output._childWorkflowInstanceId).toBeUndefined()
|
||||
const forwarded = executorOptions[0].contextExtensions
|
||||
expect(forwarded.liveTraceViewerUserId).toBeUndefined()
|
||||
expect(forwarded.liveStreamCallbacks).toBeUndefined()
|
||||
})
|
||||
|
||||
it('streams to an opted-in block without checking the viewer at all', async () => {
|
||||
// The publisher's decision is the whole policy — no workspace-access query
|
||||
// stands between an opted-in block and the run's live trace.
|
||||
const emitOnly = { onBlockStart: vi.fn(), onBlockComplete: vi.fn() }
|
||||
|
||||
await handler.execute(
|
||||
customBlockContext({ liveTraceViewerUserId: 'viewer-1', liveStreamCallbacks: emitOnly }),
|
||||
customBlock(),
|
||||
{}
|
||||
)
|
||||
|
||||
expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled()
|
||||
const forwarded = executorOptions[0].contextExtensions
|
||||
expect(forwarded.liveTraceViewerUserId).toBe('viewer-1')
|
||||
expect(forwarded.liveStreamCallbacks).toBe(emitOnly)
|
||||
})
|
||||
|
||||
it('keeps the boundary shut for a surface with no identified consumer', async () => {
|
||||
// Chat deployments and the public API leave `liveTraceViewerUserId` unset —
|
||||
// their consumer may be an anonymous visitor. Opting into org-wide tracing is
|
||||
// not consent to stream a publisher's raw agent tokens to the internet.
|
||||
const output = (await handler.execute(customBlockContext(), customBlock(), {})) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
expect(output.childTraceSpans).toBeUndefined()
|
||||
expect(output._childWorkflowInstanceId).toBeUndefined()
|
||||
// The persisted handle still rides along: reading a log is not the same
|
||||
// surface as streaming one, and that is the publisher's opt-in working.
|
||||
expect(typeof output._childExecutionId).toBe('string')
|
||||
})
|
||||
|
||||
it('keeps the failure ref while withholding the trace handle', async () => {
|
||||
// The ref is the one thing that makes an untraced failure reportable — the
|
||||
// consumer quotes it and the publisher finds the run. Only the trace join goes.
|
||||
closeTracePolicy()
|
||||
mockExecutorExecute.mockRejectedValue(new Error('child blew up'))
|
||||
|
||||
const thrown = await handler
|
||||
.execute(customBlockContext(), customBlock(), {})
|
||||
.catch((e: unknown) => e)
|
||||
|
||||
expect(ChildWorkflowError.isChildWorkflowError(thrown)).toBe(true)
|
||||
const error = thrown as ChildWorkflowError
|
||||
expect(error.consumerFacing?.ref).toBeTruthy()
|
||||
expect(error.message).toContain(error.consumerFacing?.ref as string)
|
||||
expect(error.childExecutionId).toBeUndefined()
|
||||
expect(error.childTraceDisabled).toBe(true)
|
||||
})
|
||||
|
||||
it('carries the handle on a failure from an opted-in block', async () => {
|
||||
mockExecutorExecute.mockRejectedValue(new Error('child blew up'))
|
||||
|
||||
const thrown = await handler
|
||||
.execute(customBlockContext(), customBlock(), {})
|
||||
.catch((e: unknown) => e)
|
||||
|
||||
expect(ChildWorkflowError.isChildWorkflowError(thrown)).toBe(true)
|
||||
const error = thrown as ChildWorkflowError
|
||||
expect(typeof error.childExecutionId).toBe('string')
|
||||
expect(error.childTraceDisabled).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a session on the source workflow with a fresh id and no base charge', async () => {
|
||||
await handler.execute(customBlockContext(), customBlock(), {})
|
||||
|
||||
@@ -1873,62 +2025,6 @@ describe('WorkflowBlockHandler', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('canStreamCustomBlockToViewer', () => {
|
||||
// Streaming a custom block's child into the parent's live trace reveals the SOURCE
|
||||
// workflow's block names, inputs, outputs, and raw agent tokens. It is allowed only
|
||||
// when the run has a single known, authenticated viewer who can already read that
|
||||
// workspace — every other combination must keep the boundary shut.
|
||||
it('streams when an authenticated viewer can read the source workspace', async () => {
|
||||
mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true })
|
||||
|
||||
const allowed = await (handler as any).canStreamCustomBlockToViewer(
|
||||
{ liveTraceViewerUserId: 'user-1' },
|
||||
'ws-source'
|
||||
)
|
||||
|
||||
expect(allowed).toBe(true)
|
||||
expect(mockCheckWorkspaceAccess).toHaveBeenCalledWith('ws-source', 'user-1')
|
||||
})
|
||||
|
||||
it('refuses when the viewer cannot read the source workspace', async () => {
|
||||
mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: false })
|
||||
|
||||
expect(
|
||||
await (handler as any).canStreamCustomBlockToViewer(
|
||||
{ liveTraceViewerUserId: 'outsider' },
|
||||
'ws-source'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses when there is no identified live viewer', async () => {
|
||||
// Chat deployments, the public API, webhooks, and schedules all leave
|
||||
// `liveTraceViewerUserId` unset — their stream consumer may be anonymous.
|
||||
expect(await (handler as any).canStreamCustomBlockToViewer({}, 'ws-source')).toBe(false)
|
||||
expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses when the source workspace is unknown', async () => {
|
||||
expect(
|
||||
await (handler as any).canStreamCustomBlockToViewer(
|
||||
{ liveTraceViewerUserId: 'user-1' },
|
||||
null
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when the access check throws', async () => {
|
||||
mockCheckWorkspaceAccess.mockRejectedValue(new Error('permissions backend down'))
|
||||
|
||||
expect(
|
||||
await (handler as any).canStreamCustomBlockToViewer(
|
||||
{ liveTraceViewerUserId: 'user-1' },
|
||||
'ws-source'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectCustomBlockOutput', () => {
|
||||
const childResult = {
|
||||
success: true,
|
||||
|
||||
@@ -19,11 +19,16 @@ import {
|
||||
} from '@/lib/workflows/custom-blocks/child-execution'
|
||||
import { getCustomBlockAuthority } from '@/lib/workflows/custom-blocks/operations'
|
||||
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { type CustomBlockOutput, isCustomBlockType } from '@/blocks/custom/build-config'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { Executor } from '@/executor'
|
||||
import { BlockType, CHILD_EXECUTION_ID_OUTPUT_KEY, DEFAULTS, HTTP } from '@/executor/constants'
|
||||
import {
|
||||
BlockType,
|
||||
CHILD_EXECUTION_ID_OUTPUT_KEY,
|
||||
CHILD_TRACE_DISABLED_OUTPUT_KEY,
|
||||
DEFAULTS,
|
||||
HTTP,
|
||||
} from '@/executor/constants'
|
||||
import {
|
||||
BoundarySafeError,
|
||||
type CustomBlockErrorType,
|
||||
@@ -147,6 +152,22 @@ export function remapCustomBlockInputKeys(
|
||||
return remapped
|
||||
}
|
||||
|
||||
/**
|
||||
* What a finished custom-block invocation tells the caller about its child run:
|
||||
* the handle that joins the child's spans at read time, or — when the publisher has
|
||||
* not opened this block — only that a child ran and was not traced. Never both, so
|
||||
* an unopened block has no handle for anything downstream to join.
|
||||
*/
|
||||
function buildChildTraceHandle(
|
||||
childExecutionId: string | undefined,
|
||||
traceChildRuns: boolean
|
||||
): Record<string, unknown> {
|
||||
if (!childExecutionId) return {}
|
||||
return traceChildRuns
|
||||
? { [CHILD_EXECUTION_ID_OUTPUT_KEY]: childExecutionId }
|
||||
: { [CHILD_TRACE_DISABLED_OUTPUT_KEY]: true }
|
||||
}
|
||||
|
||||
type WorkflowTraceSpan = TraceSpan & {
|
||||
metadata?: Record<string, unknown>
|
||||
children?: WorkflowTraceSpan[]
|
||||
@@ -193,6 +214,18 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
const blockTypeId = block.metadata?.id
|
||||
const isCustomBlock = isCustomBlockType(blockTypeId)
|
||||
|
||||
// Whether this invocation publishes its child run to the caller: streams the
|
||||
// source workflow's block events live, and persists the handle that joins the
|
||||
// child's spans into the caller's trace at read time.
|
||||
//
|
||||
// The PUBLISHER decides, per block, org-wide — it is their workflow's internals
|
||||
// being exposed — and that decision is the whole policy: nothing downstream
|
||||
// re-checks who is reading. Resolved from `getCustomBlockAuthority` below, so it
|
||||
// is a server-side property of the block rather than anything a consumer's
|
||||
// workflow can assert. A regular workflow block is not a boundary at all; its
|
||||
// child is the same run and always belongs in the trace.
|
||||
let traceChildRuns = !isCustomBlock
|
||||
|
||||
// Custom (deploy-as-block) blocks are an invocation boundary: resolve the bound
|
||||
// workflow + authority from the DB (never trust the serialized value) and run the
|
||||
// source workflow's LATEST deployment under its OWNER's authority — the same
|
||||
@@ -220,13 +253,15 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
}),
|
||||
block,
|
||||
instanceId,
|
||||
undefined
|
||||
undefined,
|
||||
traceChildRuns
|
||||
)
|
||||
}
|
||||
workflowId = authority.workflowId
|
||||
loadUserId = authority.ownerUserId
|
||||
exposedOutputs = authority.exposedOutputs
|
||||
requiredInputIds = authority.requiredInputIds
|
||||
traceChildRuns = authority.traceChildRuns
|
||||
|
||||
// Curation is required at publish, so this only trips on a row that
|
||||
// predates that rule. Fail loudly rather than fall back to exposing the
|
||||
@@ -240,7 +275,8 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
}),
|
||||
block,
|
||||
instanceId,
|
||||
undefined
|
||||
undefined,
|
||||
traceChildRuns
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -401,15 +437,16 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
const withinSseChildDepth = childDepth <= DEFAULTS.MAX_SSE_CHILD_DEPTH
|
||||
// Forwarding the consumer's SSE callbacks into a custom block's source run
|
||||
// streams the publisher's block names, inputs, outputs, and raw agent tokens
|
||||
// to whoever holds the stream. That is only safe when the stream has exactly
|
||||
// one known, authenticated viewer AND that viewer can already read the source
|
||||
// workspace — which is what `canStreamCustomBlockToViewer` establishes. Every
|
||||
// surface whose consumer may be anonymous (chat deployments, public API)
|
||||
// leaves `liveTraceViewerUserId` unset, so the boundary holds by default.
|
||||
// to whoever holds the stream. The publisher's `traceChildRuns` is what permits
|
||||
// that, and it is checked against no viewer — but it is a decision about what
|
||||
// the ORG may see, so it still requires a stream with a known, authenticated
|
||||
// consumer. `liveTraceViewerUserId` is set only by surfaces whose consumer is a
|
||||
// signed-in Sim user; chat deployments and the public API leave it unset, and a
|
||||
// publisher opting in has not thereby opted into an anonymous visitor on the
|
||||
// internet receiving their agent's raw tokens.
|
||||
const shouldPropagateCallbacks =
|
||||
withinSseChildDepth &&
|
||||
(!isCustomBlock ||
|
||||
(await this.canStreamCustomBlockToViewer(ctx, childWorkflow.workspaceId)))
|
||||
(!isCustomBlock || (traceChildRuns && Boolean(ctx.liveTraceViewerUserId)))
|
||||
|
||||
if (!withinSseChildDepth && !isCustomBlock) {
|
||||
logger.info('Dropping SSE callbacks beyond max child depth', {
|
||||
@@ -608,7 +645,7 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
|
||||
const activeSession = childSession
|
||||
const emitsSessionMarkers = Boolean(activeSession && childSessionStarted)
|
||||
// A custom block streaming to an authorized viewer needs BOTH sinks: its own
|
||||
// A custom block that is streaming needs BOTH sinks: its own
|
||||
// logging session's progress markers, and the parent's live stream. They are
|
||||
// composed into one fan-out rather than spread into the options object twice
|
||||
// — two spreads of the same keys silently keep only the last, which would
|
||||
@@ -729,8 +766,8 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
// A custom block's own session markers and the parent's live stream, fanned
|
||||
// out together — see `childCallbacks` above for why this is not two spreads.
|
||||
...childCallbacks,
|
||||
// A live viewer of a custom block is authorized against the source
|
||||
// workspace, so the child may name it. Deeper hops inherit the same gate.
|
||||
// The publisher opened this block's runs to the org, so the child may name
|
||||
// the source. Deeper hops inherit the same gate.
|
||||
liveTraceViewerUserId: shouldPropagateCallbacks ? ctx.liveTraceViewerUserId : undefined,
|
||||
// The emit-only sink travels WITH the viewer id, or a nested hop would clear the
|
||||
// access check and then have nothing to stream through — live traces would stop
|
||||
@@ -770,15 +807,16 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
}
|
||||
|
||||
// A custom block's spans are never PERSISTED into the parent's log — they belong to
|
||||
// the child's own row in the source workspace and are joined at read time, per viewer
|
||||
// (`hydrateChildTraces`). `createSpanFromLog` enforces that: it only calls
|
||||
// `attachChildWorkflowSpans` for `isWorkflowBlockType`, which excludes custom blocks.
|
||||
// the child's own row in the source workspace and are joined at read time from the
|
||||
// opaque handle (`hydrateChildTraces`). `createSpanFromLog` enforces that: it only
|
||||
// calls `attachChildWorkflowSpans` for `isWorkflowBlockType`, which excludes custom
|
||||
// blocks.
|
||||
//
|
||||
// They ARE handed to a live viewer who has already been authorized against the source
|
||||
// workspace, so the terminal can reconcile a child row whose `block:completed` event
|
||||
// was dropped. 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 in the consumer's stream.
|
||||
// They ARE handed to a live stream the publisher's policy has opened, so the terminal
|
||||
// can reconcile a child row whose `block:completed` event was dropped. 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 in the consumer's stream.
|
||||
let childTraceSpans: WorkflowTraceSpan[] = []
|
||||
if (!isCustomBlock) {
|
||||
childTraceSpans = this.captureChildWorkflowLogs(executionResult, childWorkflowName, ctx)
|
||||
@@ -819,10 +857,15 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
// curated payload. The block executor lifts `_childExecutionId` onto the
|
||||
// block log and strips it before the output reaches workflow state, so it
|
||||
// never becomes referenceable from the consumer's own blocks.
|
||||
//
|
||||
// With tracing off the handle is withheld outright rather than persisted
|
||||
// behind a flag: there is then nothing to join, so the opt-out cannot be
|
||||
// undone by a reader, a later migration, or a dropped field. The marker
|
||||
// that replaces it says a child ran, which the span already says.
|
||||
return {
|
||||
...exposedOutput,
|
||||
...(childExecutionId ? { [CHILD_EXECUTION_ID_OUTPUT_KEY]: childExecutionId } : {}),
|
||||
// Both are only set while the child is streaming to an authorized viewer. The
|
||||
...buildChildTraceHandle(childExecutionId, traceChildRuns),
|
||||
// Both are only set while the child is streaming to an identified consumer. The
|
||||
// instance id is how the terminal correlates the child's live rows back to this
|
||||
// invocation; the spans let it reconcile a row whose completion event was lost.
|
||||
// The block executor lifts them onto the block log and strips them from state.
|
||||
@@ -851,7 +894,7 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
// `buildBoundaryFailure` preserves an already-attached `consumerFacing`, so the
|
||||
// depth guard keeps its own classification.
|
||||
if (isCustomBlock) {
|
||||
throw this.buildBoundaryFailure(error, block, instanceId, childExecutionId)
|
||||
throw this.buildBoundaryFailure(error, block, instanceId, childExecutionId, traceChildRuns)
|
||||
}
|
||||
|
||||
// An error this same invocation already attributed (e.g. the depth guard, or
|
||||
@@ -961,37 +1004,6 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a custom block may stream the SOURCE workflow's block events into the
|
||||
* invoking run's live trace.
|
||||
*
|
||||
* Two conditions, both required. First, the run must have a single known,
|
||||
* authenticated viewer: `liveTraceViewerUserId` is set only by surfaces whose SSE
|
||||
* consumer is a Sim user (an editor/manual run), never by chat deployments or the
|
||||
* public API, whose consumer may be an anonymous visitor. Second, that viewer must
|
||||
* already be able to read the source workspace — streaming reveals exactly what
|
||||
* opening the source workspace's own logs would.
|
||||
*
|
||||
* Fail-closed on every uncertain path: no viewer, no source workspace, or an
|
||||
* access check that throws all keep the boundary shut.
|
||||
*/
|
||||
private async canStreamCustomBlockToViewer(
|
||||
ctx: ExecutionContext,
|
||||
sourceWorkspaceId: string | null | undefined
|
||||
): Promise<boolean> {
|
||||
const viewerUserId = ctx.liveTraceViewerUserId
|
||||
if (!viewerUserId || !sourceWorkspaceId) return false
|
||||
try {
|
||||
const access = await checkWorkspaceAccess(sourceWorkspaceId, viewerUserId)
|
||||
return access.hasAccess === true
|
||||
} catch (error) {
|
||||
logger.warn('Custom block live-trace access check failed; keeping boundary closed', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The consumer-facing failure for a custom block. The invocation boundary means
|
||||
* the consumer must never see the source workflow's name, its nested error text
|
||||
@@ -1003,14 +1015,25 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
* crosses verbatim. Everything else collapses to a generic failure: the default
|
||||
* is fail-closed, so a `throw` added later is redacted automatically. Deliberately
|
||||
* sets no `cause` — that is what severs the error chain at the trust boundary.
|
||||
*
|
||||
* `traceChildRuns` governs only the trace handle. The consumer-facing `ref` is
|
||||
* unaffected: it is an opaque id the consumer is already given so a publisher can
|
||||
* find the failing run, and suppressing it would take away the one thing that
|
||||
* makes an untraced failure reportable.
|
||||
*/
|
||||
private buildBoundaryFailure(
|
||||
error: unknown,
|
||||
block: SerializedBlock,
|
||||
instanceId: string,
|
||||
childExecutionId: string | undefined
|
||||
childExecutionId: string | undefined,
|
||||
traceChildRuns: boolean
|
||||
): ChildWorkflowError {
|
||||
const blockName = block.metadata?.name || 'Custom block'
|
||||
const traceHandle = childExecutionId
|
||||
? traceChildRuns
|
||||
? { childExecutionId }
|
||||
: { childTraceDisabled: true }
|
||||
: {}
|
||||
|
||||
// An error this invocation already classified for the consumer (the depth
|
||||
// guard) keeps its own type and message rather than collapsing to generic.
|
||||
@@ -1024,7 +1047,7 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
childWorkflowName: blockName,
|
||||
childWorkflowInstanceId: instanceId,
|
||||
consumerFacing: alreadyClassified,
|
||||
...(childExecutionId ? { childExecutionId } : {}),
|
||||
...traceHandle,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1047,7 +1070,7 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
// Carried even when `ref` is withheld (boundary-safe failures such as
|
||||
// `cancelled` set no ref), so the parent's log always keeps the handle
|
||||
// needed to join the child's own run at read time.
|
||||
...(childExecutionId ? { childExecutionId } : {}),
|
||||
...traceHandle,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+19
-11
@@ -293,11 +293,18 @@ export interface BlockLog {
|
||||
/**
|
||||
* A custom block's child run, which executes under its own execution id against
|
||||
* the SOURCE workspace. Only the opaque id crosses the invocation boundary — the
|
||||
* child's spans stay on its own log row and are joined at READ time, once the
|
||||
* viewer has been authorized against the source workspace. Kept off `output` for
|
||||
* the same reason {@link childTraceSpans} is.
|
||||
* child's spans stay on its own log row and are joined at READ time. Written only
|
||||
* for a block whose publisher opted its runs into consumer traces — the presence of
|
||||
* this id IS that permission. Kept off `output` for the same reason
|
||||
* {@link childTraceSpans} is.
|
||||
*/
|
||||
childExecution?: { executionId: string }
|
||||
/**
|
||||
* A custom block ran a child whose publisher has not opened it to consumers, so no
|
||||
* `childExecution` handle exists to join. Recorded because a boundary span with
|
||||
* no children is otherwise indistinguishable from a leaf block.
|
||||
*/
|
||||
childTraceDisabled?: boolean
|
||||
/** Internal encrypted sidecar used only for causal display projection. */
|
||||
displayResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
|
||||
}
|
||||
@@ -543,10 +550,11 @@ export interface ExecutionContext {
|
||||
* run. Deliberately UNSET on chat deployments, public API, webhook, and schedule
|
||||
* runs, whose stream consumer may be an anonymous external visitor.
|
||||
*
|
||||
* Used to decide whether a custom block may stream the SOURCE workflow's block
|
||||
* events across the invocation boundary: only if this viewer has access to the
|
||||
* source workspace. Absent means the boundary holds, so every surface that does
|
||||
* not opt in is fail-closed by default.
|
||||
* Whether a custom block may stream the SOURCE workflow's block events is the
|
||||
* publisher's decision, not this viewer's — but that decision covers the ORG, so it
|
||||
* still requires a stream with an identified consumer. This field is the proof of
|
||||
* one; absent, the boundary holds and every anonymous-consumer surface is
|
||||
* fail-closed by default.
|
||||
*/
|
||||
liveTraceViewerUserId?: string
|
||||
|
||||
@@ -557,10 +565,10 @@ export interface ExecutionContext {
|
||||
* `LoggingSession` before reaching the stream.
|
||||
*
|
||||
* A custom block's child must reach the emit half and never the persist half. The
|
||||
* stream is gated per viewer against the source workspace, but a persisted marker is
|
||||
* keyed by the PARENT execution and is readable by anyone with parent-workspace access
|
||||
* long after that check — so persisting the source workflow's block names there would
|
||||
* leak them past the boundary the gate exists to hold.
|
||||
* stream is gated on the publisher's trace policy AND an identified consumer, but a
|
||||
* persisted marker is keyed by the PARENT execution and is readable by anyone with
|
||||
* parent-workspace access on any surface — so persisting the source workflow's block
|
||||
* names there would leak them past the gate entirely.
|
||||
*/
|
||||
liveStreamCallbacks?: Pick<ExecutionCallbacks, 'onBlockStart' | 'onBlockComplete'>
|
||||
|
||||
|
||||
@@ -68,6 +68,8 @@ export const customBlockSchema = z.object({
|
||||
/** Uploaded icon image URL, or null for the default icon. */
|
||||
iconUrl: z.string().nullable(),
|
||||
enabled: z.boolean(),
|
||||
/** Whether this block's runs are joined into consumers' traces, org-wide. */
|
||||
traceChildRuns: z.boolean(),
|
||||
inputFields: z.array(inputFieldSchema),
|
||||
/** Curated outputs exposed to consumers; empty = expose the child's whole result. */
|
||||
exposedOutputs: z.array(exposedOutputSchema),
|
||||
@@ -113,6 +115,12 @@ export const publishCustomBlockBodySchema = z.object({
|
||||
.array(exposedOutputWriteSchema)
|
||||
.min(1, 'Select at least one output to expose to consumers')
|
||||
.max(50),
|
||||
/**
|
||||
* Joins this block's runs into consumers' traces, org-wide. Defaults FALSE: it is
|
||||
* the only policy guarding the source workflow's internals, so publishing must
|
||||
* never open them as a side effect of omitting a field.
|
||||
*/
|
||||
traceChildRuns: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export type PublishCustomBlockBody = z.input<typeof publishCustomBlockBodySchema>
|
||||
@@ -135,6 +143,8 @@ export const updateCustomBlockBodySchema = z
|
||||
.min(1, 'Select at least one output to expose to consumers')
|
||||
.max(50)
|
||||
.optional(),
|
||||
/** Omit to leave the trace policy unchanged. */
|
||||
traceChildRuns: z.boolean().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field is required' })
|
||||
|
||||
|
||||
@@ -276,6 +276,10 @@ export async function executeDeployCustomBlock(
|
||||
}
|
||||
}
|
||||
|
||||
// `traceChildRuns` is deliberately not passed and must never become a
|
||||
// parameter here: opening a block's runs to every consumer in the org exposes
|
||||
// the source workflow's internals, and that is a decision for a human
|
||||
// publisher in the settings UI, not one an agent makes on their behalf.
|
||||
const block = await publishCustomBlock({
|
||||
organizationId,
|
||||
workspaceId,
|
||||
|
||||
@@ -4,16 +4,24 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
|
||||
const { mockSelect, mockCheckWorkspaceAccess, mockMaterialize } = vi.hoisted(() => ({
|
||||
mockSelect: vi.fn(),
|
||||
mockCheckWorkspaceAccess: vi.fn(),
|
||||
mockMaterialize: vi.fn(),
|
||||
}))
|
||||
const { mockSelect, mockSelectPolicies, mockCheckWorkspaceAccess, mockMaterialize } = vi.hoisted(
|
||||
() => ({
|
||||
mockSelect: vi.fn(),
|
||||
mockSelectPolicies: vi.fn(),
|
||||
mockCheckWorkspaceAccess: vi.fn(),
|
||||
mockMaterialize: vi.fn(),
|
||||
})
|
||||
)
|
||||
|
||||
// Two queries per depth now — the child log rows, then the publisher policy for the
|
||||
// blocks they belong to. Routed by table so neither test has to know the call order.
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => ({ where: (...args: unknown[]) => mockSelect(...args) }),
|
||||
select: (columns: Record<string, unknown>) => ({
|
||||
from: () => ({
|
||||
where: (...args: unknown[]) =>
|
||||
'traceChildRuns' in columns ? mockSelectPolicies(...args) : mockSelect(...args),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
@@ -71,11 +79,18 @@ describe('hydrateChildTraces', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSelect.mockResolvedValue([])
|
||||
// Publishers of every source workflow the tests reference have opted in; the
|
||||
// closed-policy cases override this.
|
||||
mockSelectPolicies.mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
['wf-source', 'wf-b', 'wf-c'].map((workflowId) => ({ workflowId, traceChildRuns: true }))
|
||||
)
|
||||
)
|
||||
mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockMaterialize.mockResolvedValue({ traceSpans: [childSpan()] })
|
||||
})
|
||||
|
||||
it('splices the child run in when the viewer can read the source workspace', async () => {
|
||||
it('splices the child run in under the boundary span', async () => {
|
||||
mockSelect.mockResolvedValue([
|
||||
{
|
||||
executionId: 'child-exec-1',
|
||||
@@ -93,11 +108,14 @@ describe('hydrateChildTraces', () => {
|
||||
expect(spans[0].childTraceAccess).toBe('granted')
|
||||
expect(spans[0].children).toHaveLength(1)
|
||||
expect(spans[0].children?.[0].name).toBe('Agent 1')
|
||||
// Drill-down into the child's canvas only becomes available once authorized.
|
||||
// The child's graph snapshot rides along, so canvas drill-down works too.
|
||||
expect(spans[0].childWorkflowSnapshotId).toBe('snap-1')
|
||||
})
|
||||
|
||||
it('leaves the boundary shut when the viewer has no access to the source workspace', async () => {
|
||||
it('joins an opted-in run without consulting the reader at all', async () => {
|
||||
// The publisher's policy is the whole answer and is the same for everyone. Adding
|
||||
// a reader check — against a consumer who by design has no access to the source
|
||||
// workspace — would refuse every read the feature exists to serve.
|
||||
mockSelect.mockResolvedValue([
|
||||
{
|
||||
executionId: 'child-exec-1',
|
||||
@@ -107,21 +125,44 @@ describe('hydrateChildTraces', () => {
|
||||
executionData: {},
|
||||
},
|
||||
])
|
||||
mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: false })
|
||||
const spans = [boundarySpan('child-exec-1')]
|
||||
|
||||
const result = await hydrateChildTraces(spans, { viewerUserId: 'outsider' })
|
||||
const result = await hydrateChildTraces(spans, { viewerUserId: 'outsider-with-no-access' })
|
||||
|
||||
expect(result.hydrated).toBe(0)
|
||||
expect(result.dropped.denied).toBe(1)
|
||||
expect(spans[0].childTraceAccess).toBe('denied')
|
||||
expect(spans[0].children).toEqual([])
|
||||
// Nothing about the source may leak on a denied read.
|
||||
expect(spans[0].childWorkflowSnapshotId).toBeUndefined()
|
||||
expect(mockMaterialize).not.toHaveBeenCalled()
|
||||
expect(result.hydrated).toBe(1)
|
||||
expect(spans[0].childTraceAccess).toBe('granted')
|
||||
expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when the access check throws', async () => {
|
||||
it('refuses a handle whose block is not opted in, however it got there', async () => {
|
||||
// The decisive case: handles persisted before this policy existed meant "a child
|
||||
// ran, authorize the reader", not "the publisher consented". Reading presence as
|
||||
// consent would hand out the internals of every block that never opted in.
|
||||
mockSelect.mockResolvedValue([
|
||||
{
|
||||
executionId: 'child-exec-1',
|
||||
workspaceId: 'ws-source',
|
||||
workflowId: 'wf-source',
|
||||
stateSnapshotId: 'snap-1',
|
||||
executionData: {},
|
||||
},
|
||||
])
|
||||
mockSelectPolicies.mockResolvedValue([{ workflowId: 'wf-source', traceChildRuns: false }])
|
||||
const spans = [boundarySpan('child-exec-1')]
|
||||
|
||||
const result = await hydrateChildTraces(spans, { viewerUserId: 'user-1' })
|
||||
|
||||
expect(result.hydrated).toBe(0)
|
||||
expect(result.dropped.policyClosed).toBe(1)
|
||||
expect(spans[0].childTraceAccess).toBe('disabled')
|
||||
expect(spans[0].children).toEqual([])
|
||||
// Nothing about the source may be materialized for a closed block.
|
||||
expect(mockMaterialize).not.toHaveBeenCalled()
|
||||
expect(spans[0].childWorkflowSnapshotId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails closed when the block behind a handle no longer exists', async () => {
|
||||
// Unpublished, or deleted: there is no publisher left to have consented.
|
||||
mockSelect.mockResolvedValue([
|
||||
{
|
||||
executionId: 'child-exec-1',
|
||||
@@ -131,13 +172,59 @@ describe('hydrateChildTraces', () => {
|
||||
executionData: {},
|
||||
},
|
||||
])
|
||||
mockCheckWorkspaceAccess.mockRejectedValue(new Error('permissions backend down'))
|
||||
mockSelectPolicies.mockResolvedValue([])
|
||||
const spans = [boundarySpan('child-exec-1')]
|
||||
|
||||
const result = await hydrateChildTraces(spans, { viewerUserId: 'user-1' })
|
||||
|
||||
expect(result.dropped.denied).toBe(1)
|
||||
expect(spans[0].childTraceAccess).toBe('denied')
|
||||
expect(result.dropped.policyClosed).toBe(1)
|
||||
expect(spans[0].childTraceAccess).toBe('disabled')
|
||||
})
|
||||
|
||||
it('fails closed when the policy read itself throws', async () => {
|
||||
mockSelect.mockResolvedValue([
|
||||
{
|
||||
executionId: 'child-exec-1',
|
||||
workspaceId: 'ws-source',
|
||||
workflowId: 'wf-source',
|
||||
stateSnapshotId: null,
|
||||
executionData: {},
|
||||
},
|
||||
])
|
||||
mockSelectPolicies.mockRejectedValue(new Error('policy read failed'))
|
||||
const spans = [boundarySpan('child-exec-1')]
|
||||
|
||||
const result = await hydrateChildTraces(spans, { viewerUserId: 'user-1' })
|
||||
|
||||
expect(result.dropped.policyClosed).toBe(1)
|
||||
expect(spans[0].childTraceAccess).toBe('disabled')
|
||||
})
|
||||
|
||||
it('reads each block policy once no matter how many boundaries resolve to it', async () => {
|
||||
mockSelect.mockResolvedValue([
|
||||
{
|
||||
executionId: 'child-exec-1',
|
||||
workspaceId: 'ws-source',
|
||||
workflowId: 'wf-source',
|
||||
stateSnapshotId: null,
|
||||
executionData: {},
|
||||
},
|
||||
{
|
||||
executionId: 'child-exec-2',
|
||||
workspaceId: 'ws-source',
|
||||
workflowId: 'wf-source',
|
||||
stateSnapshotId: null,
|
||||
executionData: {},
|
||||
},
|
||||
])
|
||||
const spans = [
|
||||
boundarySpan('child-exec-1'),
|
||||
{ ...boundarySpan('child-exec-2'), id: 'span-2', blockId: 'blk-2' },
|
||||
]
|
||||
|
||||
await hydrateChildTraces(spans, { viewerUserId: 'user-1' })
|
||||
|
||||
expect(mockSelectPolicies).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('marks a missing child log row rather than failing the parent read', async () => {
|
||||
@@ -170,33 +257,6 @@ describe('hydrateChildTraces', () => {
|
||||
expect(spans[0].children?.[0].cost).toBeUndefined()
|
||||
})
|
||||
|
||||
it('checks each source workspace once no matter how many boundaries resolve to it', async () => {
|
||||
mockSelect.mockResolvedValue([
|
||||
{
|
||||
executionId: 'child-exec-1',
|
||||
workspaceId: 'ws-source',
|
||||
workflowId: 'wf-source',
|
||||
stateSnapshotId: null,
|
||||
executionData: {},
|
||||
},
|
||||
{
|
||||
executionId: 'child-exec-2',
|
||||
workspaceId: 'ws-source',
|
||||
workflowId: 'wf-source',
|
||||
stateSnapshotId: null,
|
||||
executionData: {},
|
||||
},
|
||||
])
|
||||
const spans = [
|
||||
boundarySpan('child-exec-1'),
|
||||
{ ...boundarySpan('child-exec-2'), id: 'span-2', blockId: 'blk-2' },
|
||||
]
|
||||
|
||||
await hydrateChildTraces(spans, { viewerUserId: 'user-1' })
|
||||
|
||||
expect(mockCheckWorkspaceAccess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports boundaries dropped by the row cap instead of silently truncating', async () => {
|
||||
mockSelect.mockResolvedValue([])
|
||||
const spans = [
|
||||
@@ -254,9 +314,9 @@ describe('hydrateChildTraces', () => {
|
||||
expect(spans[1].childTraceAccess).toBe('truncated')
|
||||
})
|
||||
|
||||
it('authorizes each hop of a NESTED custom block against its own workspace', async () => {
|
||||
// Orchestrator -> impl (ws-b) -> sub-impl (ws-c). Access to the middle workspace must
|
||||
// never imply access to the innermost one, so each hop is checked separately.
|
||||
it('follows a NESTED custom block into its own source workspace', async () => {
|
||||
// Orchestrator -> impl (ws-b) -> sub-impl (ws-c). Each hop's handle was written by
|
||||
// its own publisher's opt-in, so the walk simply follows what is there.
|
||||
mockSelect
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -276,9 +336,6 @@ describe('hydrateChildTraces', () => {
|
||||
executionData: {},
|
||||
},
|
||||
])
|
||||
mockCheckWorkspaceAccess.mockImplementation((workspaceId: string) =>
|
||||
Promise.resolve({ hasAccess: workspaceId === 'ws-b' })
|
||||
)
|
||||
// The child's own span carries the next boundary handle, exactly as `createBaseSpan`
|
||||
// stamps it when the middle workflow's log row is written.
|
||||
mockMaterialize.mockResolvedValue({
|
||||
@@ -288,13 +345,10 @@ describe('hydrateChildTraces', () => {
|
||||
|
||||
const result = await hydrateChildTraces(spans, { viewerUserId: 'user-1' })
|
||||
|
||||
expect(result.hydrated).toBe(1)
|
||||
expect(result.hydrated).toBe(2)
|
||||
expect(spans[0].childTraceAccess).toBe('granted')
|
||||
// The nested boundary was reached and independently refused.
|
||||
expect(spans[0].children?.[0].childTraceAccess).toBe('denied')
|
||||
expect(result.dropped.denied).toBe(1)
|
||||
expect(mockCheckWorkspaceAccess).toHaveBeenCalledWith('ws-b', 'user-1')
|
||||
expect(mockCheckWorkspaceAccess).toHaveBeenCalledWith('ws-c', 'user-1')
|
||||
expect(spans[0].children?.[0].childTraceAccess).toBe('granted')
|
||||
expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when no span carries a boundary handle', async () => {
|
||||
@@ -305,4 +359,61 @@ describe('hydrateChildTraces', () => {
|
||||
expect(result.hydrated).toBe(0)
|
||||
expect(mockSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('joins a custom block invoked as an Agent tool, nested under the agent span', async () => {
|
||||
// The handle sits on a tool child span rather than a top-level block span. Nothing
|
||||
// here special-cases that — the boundary walk already recurses — so this pins the
|
||||
// property the Agent-tool path depends on.
|
||||
mockSelect.mockResolvedValue([
|
||||
{
|
||||
executionId: 'child-exec-1',
|
||||
workspaceId: 'ws-source',
|
||||
workflowId: 'wf-source',
|
||||
stateSnapshotId: 'snap-1',
|
||||
executionData: {},
|
||||
},
|
||||
])
|
||||
const toolSpan: TraceSpan = {
|
||||
id: 'agent-1-tool-0',
|
||||
name: 'Invoice Parser',
|
||||
type: 'tool',
|
||||
duration: 8,
|
||||
startTime: '2026-01-01T00:00:00.001Z',
|
||||
endTime: '2026-01-01T00:00:00.009Z',
|
||||
childExecutionId: 'child-exec-1',
|
||||
}
|
||||
const agentSpan: TraceSpan = {
|
||||
id: 'agent-1',
|
||||
name: 'Agent 1',
|
||||
type: 'agent',
|
||||
duration: 10,
|
||||
startTime: '2026-01-01T00:00:00.000Z',
|
||||
endTime: '2026-01-01T00:00:00.010Z',
|
||||
blockId: 'agent-blk-1',
|
||||
children: [toolSpan],
|
||||
}
|
||||
|
||||
const result = await hydrateChildTraces([agentSpan], { viewerUserId: 'user-1' })
|
||||
|
||||
expect(result.hydrated).toBe(1)
|
||||
expect(toolSpan.childTraceAccess).toBe('granted')
|
||||
expect(toolSpan.children?.[0].name).toBe('Agent 1')
|
||||
})
|
||||
|
||||
it('never joins an untraced boundary, since it carries no handle at all', async () => {
|
||||
// The opt-out is enforced at write time: with no `childExecutionId` there is nothing
|
||||
// for a reader to look up, whatever access they hold.
|
||||
const spans: TraceSpan[] = [
|
||||
{ ...boundarySpan('unused'), childExecutionId: undefined, childTraceDisabled: true },
|
||||
]
|
||||
|
||||
const result = await hydrateChildTraces(spans, { viewerUserId: 'user-1' })
|
||||
|
||||
expect(result.hydrated).toBe(0)
|
||||
expect(mockSelect).not.toHaveBeenCalled()
|
||||
expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled()
|
||||
// Untouched by hydration: the marker stays the only thing the UI reads.
|
||||
expect(spans[0].childTraceAccess).toBeUndefined()
|
||||
expect(spans[0].childTraceDisabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { customBlock, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { inArray } from 'drizzle-orm'
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
stripSpanCosts,
|
||||
} from '@/lib/logs/execution/trace-store'
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('HydrateChildTraces')
|
||||
|
||||
@@ -24,20 +23,24 @@ const DEFAULT_MAX_ROWS = 25
|
||||
|
||||
/** Why a child run was not joined into the parent's trace. */
|
||||
export interface ChildTraceDropCounts {
|
||||
/** Viewer has no access to the child's source workspace. */
|
||||
denied: number
|
||||
/** No child log row, or it carries no spans (in flight, pruned, never written). */
|
||||
missing: number
|
||||
/** Boundary sat deeper than `maxDepth`. */
|
||||
depthLimited: number
|
||||
/** `maxRows` was already exhausted by shallower boundaries. */
|
||||
rowLimited: number
|
||||
/** The child-log lookup itself failed, so access was never determined. */
|
||||
/** The block's publisher has not opened its runs to consumers. */
|
||||
policyClosed: number
|
||||
/** The child-log lookup itself failed, so nothing could be joined. */
|
||||
failed: number
|
||||
}
|
||||
|
||||
export interface HydrateChildTracesOptions {
|
||||
/** The user reading the log. Every child workspace is authorized against them. */
|
||||
/**
|
||||
* The user reading the log. NOT an authorization input — the only policy is the
|
||||
* publisher's, and it is the same answer for every reader. Carried so large-value
|
||||
* materialization and secret projection have an owner to attribute their reads to.
|
||||
*/
|
||||
viewerUserId: string
|
||||
maxDepth?: number
|
||||
maxRows?: number
|
||||
@@ -77,14 +80,27 @@ function collectBoundarySpans(spans: TraceSpan[], into: TraceSpan[]): void {
|
||||
* Joins a custom block's child run into its parent's trace, so an orchestrator
|
||||
* workflow's log shows the whole cross-workspace execution in one waterfall.
|
||||
*
|
||||
* The custom-block boundary deliberately keeps the child's spans out of the
|
||||
* parent's persisted log — they belong to the child's own log row in the SOURCE
|
||||
* workspace, and a custom block is org-wide, so a consumer must not be handed
|
||||
* another team's block internals by default. Only the child's opaque execution id
|
||||
* is persisted on the parent span; the join happens HERE, at read time, and only
|
||||
* after the *viewer* has been authorized against the child's workspace. The
|
||||
* authorization decision therefore follows the person reading rather than a flag
|
||||
* set when the block was published, and it re-evaluates on every read.
|
||||
* There is deliberately no check on the READER: whether a block's runs may be shown
|
||||
* to consumers is the publisher's org-wide decision and is the same answer for
|
||||
* everyone. That decision is read here, live, per boundary — not inferred from the
|
||||
* handle's presence.
|
||||
*
|
||||
* Reading it live rather than trusting the handle is what makes the policy safe to
|
||||
* apply to logs that already exist. A handle persisted before the policy existed
|
||||
* meant something else entirely ("a child ran; authorize the reader"), and treating
|
||||
* it as consent would hand out the internals of every block whose publisher never
|
||||
* opted in. It also means turning the policy OFF closes the runs already recorded,
|
||||
* which is what a governance switch has to do to be worth anything.
|
||||
*
|
||||
* The two halves are not redundant. The handler withholds the handle at write time,
|
||||
* so a run executed while the block was closed stays closed forever, even if the
|
||||
* publisher opens the block later; this check then decides whether the runs that DO
|
||||
* carry a handle may still be shown.
|
||||
*
|
||||
* The consequence is deliberate and worth stating: for an opted-in block, anyone who
|
||||
* can read the consuming workflow's log sees the source workflow's block names,
|
||||
* inputs, outputs, and prompts — including consumers with no access to the source
|
||||
* workspace. That is what the publisher turned on.
|
||||
*
|
||||
* Mutates `spans` in place — callers pass a freshly materialized, request-local
|
||||
* tree, and copying it would double the peak memory of a large trace.
|
||||
@@ -99,10 +115,10 @@ export async function hydrateChildTraces(
|
||||
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH
|
||||
const maxRows = options.maxRows ?? DEFAULT_MAX_ROWS
|
||||
const dropped: ChildTraceDropCounts = {
|
||||
denied: 0,
|
||||
missing: 0,
|
||||
depthLimited: 0,
|
||||
rowLimited: 0,
|
||||
policyClosed: 0,
|
||||
failed: 0,
|
||||
}
|
||||
let hydrated = 0
|
||||
@@ -111,9 +127,9 @@ export async function hydrateChildTraces(
|
||||
// Execution ids already joined in this read. Guards against a malformed tree
|
||||
// pointing back at an ancestor run and looping forever.
|
||||
const seen = new Set<string>()
|
||||
// One access decision per workspace, reused across every boundary that resolves
|
||||
// to it — a parent with 30 custom-block spans usually spans 1-2 workspaces.
|
||||
const accessByWorkspace = new Map<string, boolean>()
|
||||
// One policy read per source workflow, reused across every boundary that resolves
|
||||
// to it — a parent with 30 custom-block spans usually spans 1-2 blocks.
|
||||
const policyByWorkflowId = new Map<string, boolean>()
|
||||
|
||||
let frontier = spans
|
||||
for (let depth = 0; ; depth++) {
|
||||
@@ -162,38 +178,55 @@ export async function hydrateChildTraces(
|
||||
|
||||
const rowByExecutionId = new Map(rows.map((row) => [row.executionId, row]))
|
||||
|
||||
const workspacesToCheck = Array.from(
|
||||
// `custom_block.workflow_id` is unique (publish enforces one block per workflow),
|
||||
// so the child run's own workflow identifies the block whose policy governs it.
|
||||
// This works for an Agent-tool boundary too, whose span carries no block type.
|
||||
const workflowIdsToRead = Array.from(
|
||||
new Set(
|
||||
rows
|
||||
.map((row) => row.workspaceId)
|
||||
.filter((id): id is string => typeof id === 'string' && !accessByWorkspace.has(id))
|
||||
.map((row) => row.workflowId)
|
||||
.filter((id): id is string => typeof id === 'string' && !policyByWorkflowId.has(id))
|
||||
)
|
||||
)
|
||||
await Promise.all(
|
||||
workspacesToCheck.map(async (workspaceId) => {
|
||||
try {
|
||||
const access = await checkWorkspaceAccess(workspaceId, options.viewerUserId)
|
||||
accessByWorkspace.set(workspaceId, access.hasAccess === true)
|
||||
} catch {
|
||||
// Fail closed: an unresolvable access decision keeps the boundary shut.
|
||||
accessByWorkspace.set(workspaceId, false)
|
||||
}
|
||||
})
|
||||
)
|
||||
if (workflowIdsToRead.length > 0) {
|
||||
try {
|
||||
const blocks = await db
|
||||
.select({
|
||||
workflowId: customBlock.workflowId,
|
||||
traceChildRuns: customBlock.traceChildRuns,
|
||||
})
|
||||
.from(customBlock)
|
||||
.where(inArray(customBlock.workflowId, workflowIdsToRead))
|
||||
// Seeded closed first, so a workflow with no custom block row — unpublished,
|
||||
// or a block since deleted — has no publisher left to consent and stays shut.
|
||||
for (const id of workflowIdsToRead) policyByWorkflowId.set(id, false)
|
||||
for (const block of blocks) policyByWorkflowId.set(block.workflowId, block.traceChildRuns)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read custom-block trace policy; leaving boundaries unexpanded', {
|
||||
count: workflowIdsToRead.length,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
for (const id of workflowIdsToRead) policyByWorkflowId.set(id, false)
|
||||
}
|
||||
}
|
||||
|
||||
const nextFrontier: TraceSpan[] = []
|
||||
await Promise.all(
|
||||
admitted.map(async (span) => {
|
||||
const executionId = span.childExecutionId as string
|
||||
const row = rowByExecutionId.get(executionId)
|
||||
if (!row) {
|
||||
// No row, or a row whose workspace is gone: large-value storage keys are
|
||||
// workspace-scoped, so an orphaned row has nothing materializable either way.
|
||||
if (!row?.workspaceId) {
|
||||
span.childTraceAccess = 'missing'
|
||||
dropped.missing++
|
||||
return
|
||||
}
|
||||
if (!row.workspaceId || accessByWorkspace.get(row.workspaceId) !== true) {
|
||||
span.childTraceAccess = 'denied'
|
||||
dropped.denied++
|
||||
if (!row.workflowId || policyByWorkflowId.get(row.workflowId) !== true) {
|
||||
// Same verdict the write path records on a closed block, so a reader cannot
|
||||
// tell whether the policy shut at run time or since.
|
||||
span.childTraceAccess = 'disabled'
|
||||
dropped.policyClosed++
|
||||
return
|
||||
}
|
||||
|
||||
@@ -233,9 +266,8 @@ export async function hydrateChildTraces(
|
||||
|
||||
span.children = children
|
||||
span.childTraceAccess = 'granted'
|
||||
// Only now that the viewer is authorized does the child's graph snapshot
|
||||
// become theirs to see. Stamping it at write time would have exposed the
|
||||
// source workflow's structure to every consumer.
|
||||
// Carried alongside the joined spans so canvas drill-down works for the same
|
||||
// reader, on the same permission.
|
||||
if (row.stateSnapshotId) span.childWorkflowSnapshotId = row.stateSnapshotId
|
||||
hydrated++
|
||||
nextFrontier.push(...children)
|
||||
@@ -246,8 +278,11 @@ export async function hydrateChildTraces(
|
||||
frontier = nextFrontier
|
||||
}
|
||||
|
||||
const totalDropped =
|
||||
dropped.denied + dropped.missing + dropped.depthLimited + dropped.rowLimited + dropped.failed
|
||||
// Summed from the struct rather than a hand-listed set of fields, which silently
|
||||
// goes stale the moment a counter is added — `policyClosed` was already missing
|
||||
// from such a list, and it is the commonest drop of all now that a handle written
|
||||
// before the policy existed refuses here.
|
||||
const totalDropped = Object.values(dropped).reduce((sum, count) => sum + count, 0)
|
||||
if (totalDropped > 0) {
|
||||
logger.info('Custom-block child runs not joined into parent trace', { hydrated, ...dropped })
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createLogger } from '@sim/logger'
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import type { ProviderTiming, TraceSpan } from '@/lib/logs/types'
|
||||
import {
|
||||
CHILD_EXECUTION_ID_OUTPUT_KEY,
|
||||
CHILD_TRACE_DISABLED_OUTPUT_KEY,
|
||||
isConditionBlockType,
|
||||
isWorkflowBlockType,
|
||||
stripCustomToolPrefix,
|
||||
@@ -24,6 +26,39 @@ function normalizeTraceOutput(value: unknown): Record<string, unknown> | undefin
|
||||
return isRecordLike(value) ? value : { value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifts a custom block's child-run handle off a tool result onto the tool span,
|
||||
* the way {@link createBaseSpan} lifts it off a block log.
|
||||
*
|
||||
* A custom block invoked as an Agent tool produces a real child run, and the
|
||||
* handle rides its tool output — but the tool span is where a reader can act on
|
||||
* it: `hydrateChildTraces` walks nested children, so a tool span carrying
|
||||
* `childExecutionId` joins its child exactly like a canvas block's span does.
|
||||
* The keys are stripped from the displayed output because they are plumbing, and
|
||||
* a raw execution id shown in a tool result reads like data the tool returned.
|
||||
*/
|
||||
function liftChildTraceHandle(output: Record<string, unknown> | undefined): {
|
||||
output: Record<string, unknown> | undefined
|
||||
handle: Pick<TraceSpan, 'childExecutionId' | 'childTraceDisabled'>
|
||||
} {
|
||||
if (!output) return { output, handle: {} }
|
||||
const hasExecutionId = typeof output[CHILD_EXECUTION_ID_OUTPUT_KEY] === 'string'
|
||||
const hasDisabledMarker = output[CHILD_TRACE_DISABLED_OUTPUT_KEY] === true
|
||||
if (!hasExecutionId && !hasDisabledMarker) return { output, handle: {} }
|
||||
|
||||
const {
|
||||
[CHILD_EXECUTION_ID_OUTPUT_KEY]: executionId,
|
||||
[CHILD_TRACE_DISABLED_OUTPUT_KEY]: _disabled,
|
||||
...rest
|
||||
} = output
|
||||
return {
|
||||
output: rest,
|
||||
handle: hasExecutionId
|
||||
? { childExecutionId: executionId as string }
|
||||
: { childTraceDisabled: true },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a TraceSpan from a BlockLog. Returns null for invalid logs.
|
||||
*
|
||||
@@ -77,6 +112,7 @@ function createBaseSpan(log: ValidBlockLog): TraceSpan {
|
||||
output,
|
||||
...(childIds ?? {}),
|
||||
...(log.childExecution ? { childExecutionId: log.childExecution.executionId } : {}),
|
||||
...(log.childTraceDisabled ? { childTraceDisabled: true } : {}),
|
||||
...(log.errorHandled && { errorHandled: true }),
|
||||
...(log.tries !== undefined && { tries: log.tries }),
|
||||
...(log.loopId && { loopId: log.loopId }),
|
||||
@@ -97,7 +133,7 @@ function extractDisplayOutput(log: ValidBlockLog): Record<string, unknown> {
|
||||
// `childTraceSpans` is dropped defensively as well as by `filterOutputForLog`: the spans
|
||||
// ride a block's output to reach the live stream, and a producer whose block config does
|
||||
// not declare them hidden would otherwise persist another workspace's spans here, where
|
||||
// no per-viewer authorization ever runs again.
|
||||
// nothing downstream would ever strip them again.
|
||||
const {
|
||||
childWorkflowSnapshotId,
|
||||
childWorkflowId,
|
||||
@@ -211,7 +247,9 @@ function buildChildrenFromTimeSegments(
|
||||
const currentIndex = toolCallIndices.get(normalizedName) ?? 0
|
||||
const match = callsForName[currentIndex]
|
||||
toolCallIndices.set(normalizedName, currentIndex + 1)
|
||||
const output = normalizeTraceOutput(match?.result ?? match?.output)
|
||||
const { output, handle } = liftChildTraceHandle(
|
||||
normalizeTraceOutput(match?.result ?? match?.output)
|
||||
)
|
||||
|
||||
const toolChild: TraceSpan = {
|
||||
id: `${span.id}-segment-${index}`,
|
||||
@@ -223,6 +261,7 @@ function buildChildrenFromTimeSegments(
|
||||
status: match?.error || segment.errorMessage ? 'error' : 'success',
|
||||
input: match?.arguments ?? match?.input,
|
||||
output: match?.error ? { error: match.error, ...output } : output,
|
||||
...handle,
|
||||
}
|
||||
if (segment.toolCallId) toolChild.toolCallId = segment.toolCallId
|
||||
if (segment.errorType) toolChild.errorType = segment.errorType
|
||||
@@ -289,7 +328,7 @@ function buildChildrenFromToolCalls(span: TraceSpan, log: ValidBlockLog): TraceS
|
||||
return toolCalls.map((tc, index) => {
|
||||
const startTime = tc.startTime ?? log.startedAt
|
||||
const endTime = tc.endTime ?? log.endedAt
|
||||
const output = normalizeTraceOutput(tc.result ?? tc.output)
|
||||
const { output, handle } = liftChildTraceHandle(normalizeTraceOutput(tc.result ?? tc.output))
|
||||
return {
|
||||
id: `${span.id}-tool-${index}`,
|
||||
name: stripCustomToolPrefix(tc.name ?? 'unnamed-tool'),
|
||||
@@ -300,6 +339,7 @@ function buildChildrenFromToolCalls(span: TraceSpan, log: ValidBlockLog): TraceS
|
||||
status: tc.error ? 'error' : 'success',
|
||||
input: tc.arguments ?? tc.input,
|
||||
output: tc.error ? { error: tc.error, ...output } : output,
|
||||
...handle,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2397,10 +2397,10 @@ describe('custom-block boundary spans', () => {
|
||||
}
|
||||
|
||||
it("never PERSISTS a custom block's child spans into the parent trace", () => {
|
||||
// The child's spans are handed to an authorized LIVE viewer for terminal
|
||||
// reconciliation, and they ride the block log to get there — but they must not
|
||||
// land in the parent's stored trace. Persisting them would bake a publish-time
|
||||
// decision into the row, where read-time hydration re-checks the viewer instead.
|
||||
// The child's spans are handed to a LIVE consumer for terminal reconciliation, and
|
||||
// they ride the block log to get there — but they must not land in the parent's
|
||||
// stored trace. The persisted row keeps only the opaque handle, which is what read
|
||||
// time joins from.
|
||||
const result: ExecutionResult = {
|
||||
success: true,
|
||||
output: {},
|
||||
@@ -2462,4 +2462,150 @@ describe('custom-block boundary spans', () => {
|
||||
|
||||
expect(boundary.childExecutionId).toBe('child-exec-1')
|
||||
})
|
||||
|
||||
it('marks an untraced boundary so it cannot be mistaken for a leaf block', () => {
|
||||
// A boundary span with no children and no marker renders exactly like a block
|
||||
// that did nothing, which would make a deliberately partial trace read as complete.
|
||||
const result: ExecutionResult = {
|
||||
success: true,
|
||||
output: {},
|
||||
logs: [{ ...customBlockLog, childTraceDisabled: true }],
|
||||
} as unknown as ExecutionResult
|
||||
|
||||
const { traceSpans } = buildTraceSpans(result)
|
||||
const boundary = traceSpans[0].children?.find((s) => s.blockId === 'cb-1') ?? traceSpans[0]
|
||||
|
||||
expect(boundary.childTraceDisabled).toBe(true)
|
||||
expect(boundary.childExecutionId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom block invoked as an Agent tool', () => {
|
||||
const agentLog = {
|
||||
blockId: 'agent-1',
|
||||
blockName: 'Agent 1',
|
||||
blockType: 'agent',
|
||||
startedAt: '2024-01-01T10:00:00.000Z',
|
||||
endedAt: '2024-01-01T10:00:02.000Z',
|
||||
durationMs: 2000,
|
||||
success: true,
|
||||
executionOrder: 1,
|
||||
input: {},
|
||||
}
|
||||
|
||||
function toolSpanFor(result: Record<string, unknown>, output?: Record<string, unknown>) {
|
||||
const executionResult: ExecutionResult = {
|
||||
success: true,
|
||||
output: {},
|
||||
logs: [
|
||||
{
|
||||
...agentLog,
|
||||
output: {
|
||||
content: 'done',
|
||||
...output,
|
||||
toolCalls: {
|
||||
list: [
|
||||
{
|
||||
name: 'Published Block',
|
||||
arguments: { topic: 'q3' },
|
||||
result,
|
||||
startTime: '2024-01-01T10:00:00.500Z',
|
||||
endTime: '2024-01-01T10:00:01.500Z',
|
||||
duration: 1000,
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as ExecutionResult
|
||||
|
||||
const { traceSpans } = buildTraceSpans(executionResult)
|
||||
const agentSpan = traceSpans[0].children?.find((s) => s.blockId === 'agent-1') ?? traceSpans[0]
|
||||
return agentSpan.children?.find((s) => s.type === 'tool')
|
||||
}
|
||||
|
||||
it('lifts the child run handle onto the tool span so hydration can join it', () => {
|
||||
// A custom block run as a tool produces a real child run, but the handle arrives
|
||||
// inside the tool result. The tool span is where a reader can act on it —
|
||||
// `hydrateChildTraces` walks nested children, so this is all the join needs.
|
||||
const toolSpan = toolSpanFor({ answer: 42, _childExecutionId: 'child-exec-2' })
|
||||
|
||||
expect(toolSpan?.childExecutionId).toBe('child-exec-2')
|
||||
})
|
||||
|
||||
it('strips the handle from what the tool span displays', () => {
|
||||
// Plumbing, not data: an opaque execution id sitting in a tool result reads like
|
||||
// something the tool returned.
|
||||
const toolSpan = toolSpanFor({ answer: 42, _childExecutionId: 'child-exec-2' })
|
||||
|
||||
expect(toolSpan?.output).not.toHaveProperty('_childExecutionId')
|
||||
expect(toolSpan?.output?.answer).toBe(42)
|
||||
})
|
||||
|
||||
it('marks an untraced tool invocation instead of joining it', () => {
|
||||
const toolSpan = toolSpanFor({ answer: 42, _childTraceDisabled: true })
|
||||
|
||||
expect(toolSpan?.childExecutionId).toBeUndefined()
|
||||
expect(toolSpan?.childTraceDisabled).toBe(true)
|
||||
expect(toolSpan?.output).not.toHaveProperty('_childTraceDisabled')
|
||||
})
|
||||
|
||||
it('leaves an ordinary tool call untouched', () => {
|
||||
const toolSpan = toolSpanFor({ items: ['a'] })
|
||||
|
||||
expect(toolSpan?.childExecutionId).toBeUndefined()
|
||||
expect(toolSpan?.childTraceDisabled).toBeUndefined()
|
||||
expect(toolSpan?.output).toEqual({ items: ['a'] })
|
||||
})
|
||||
|
||||
it('lifts the handle on the provider-segment path too', () => {
|
||||
// Providers that emit `timeSegments` build tool children from the segments rather
|
||||
// than the raw tool-call list, so the lift has to happen on both paths or the join
|
||||
// works for some models and silently not for others.
|
||||
const executionResult: ExecutionResult = {
|
||||
success: true,
|
||||
output: {},
|
||||
logs: [
|
||||
{
|
||||
...agentLog,
|
||||
output: {
|
||||
content: 'done',
|
||||
providerTiming: {
|
||||
duration: 2000,
|
||||
startTime: '2024-01-01T10:00:00.000Z',
|
||||
endTime: '2024-01-01T10:00:02.000Z',
|
||||
timeSegments: [
|
||||
{
|
||||
type: 'tool',
|
||||
name: 'Published Block',
|
||||
startTime: new Date('2024-01-01T10:00:00.500Z').getTime(),
|
||||
endTime: new Date('2024-01-01T10:00:01.500Z').getTime(),
|
||||
duration: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
toolCalls: {
|
||||
list: [
|
||||
{
|
||||
name: 'Published Block',
|
||||
arguments: {},
|
||||
result: { answer: 42, _childExecutionId: 'child-exec-3' },
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as ExecutionResult
|
||||
|
||||
const { traceSpans } = buildTraceSpans(executionResult)
|
||||
const agentSpan = traceSpans[0].children?.find((s) => s.blockId === 'agent-1') ?? traceSpans[0]
|
||||
const toolSpan = agentSpan.children?.find((s) => s.type === 'tool')
|
||||
|
||||
expect(toolSpan?.childExecutionId).toBe('child-exec-3')
|
||||
expect(toolSpan?.output).not.toHaveProperty('_childExecutionId')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -185,7 +185,7 @@ export async function fetchLogDetail({
|
||||
)
|
||||
|
||||
// A custom block's child ran in another workspace and kept its spans on its
|
||||
// own log row. Join them in now, for the workspaces this viewer can read.
|
||||
// own log row. Join in the ones whose publisher opened them to consumers.
|
||||
if (Array.isArray(executionData?.traceSpans)) {
|
||||
await hydrateChildTraces(executionData.traceSpans as TraceSpan[], { viewerUserId: userId })
|
||||
}
|
||||
|
||||
+20
-10
@@ -290,20 +290,30 @@ export interface TraceSpan {
|
||||
/**
|
||||
* For a custom-block span: the child run's own execution id, in the SOURCE
|
||||
* workspace. Only this opaque handle is persisted — the child's spans are
|
||||
* joined at read time by `hydrateChildTraces`, after the viewer has been
|
||||
* authorized against that workspace.
|
||||
* joined at read time by `hydrateChildTraces`. Written only for a block whose
|
||||
* publisher opted its runs into consumer traces, so its presence IS the permission
|
||||
* and no check runs at read time.
|
||||
*/
|
||||
childExecutionId?: string
|
||||
/**
|
||||
* Set by read-time hydration on a span carrying {@link childExecutionId}:
|
||||
* whether the viewer was allowed to see the child run, whether it still exists,
|
||||
* and — for `truncated` — whether hydration simply never attempted it (past the
|
||||
* nesting/row cap, or the lookup failed). `truncated` must never be conflated
|
||||
* with an empty child: a boundary span with no children and no marker is
|
||||
* indistinguishable from a leaf block, which would render a partial trace as a
|
||||
* complete one. Never persisted — it describes one viewer's read, not the run.
|
||||
* A custom block ran a child whose publisher has not opened it to consumers, so
|
||||
* no {@link childExecutionId} was ever written and there is nothing to join.
|
||||
* Persisted, unlike {@link childTraceAccess}: it describes the run, not a read.
|
||||
* Without it an untraced boundary renders exactly like a leaf block.
|
||||
*/
|
||||
childTraceAccess?: 'granted' | 'denied' | 'missing' | 'truncated'
|
||||
childTraceDisabled?: boolean
|
||||
/**
|
||||
* Set by read-time hydration on a span carrying {@link childExecutionId}: whether
|
||||
* the child run was joined, whether the block's publisher currently allows it
|
||||
* (`disabled`), whether the run still exists, and — for `truncated` — whether
|
||||
* hydration simply never attempted it (past the nesting/row cap, or the lookup
|
||||
* failed). It carries no verdict about the READER; the only policy is the
|
||||
* publisher's. `truncated` must never be conflated with an empty child: a boundary
|
||||
* span with no children and no marker is indistinguishable from a leaf block, which
|
||||
* would render a partial trace as a complete one. Never persisted — it describes
|
||||
* one read, not the run.
|
||||
*/
|
||||
childTraceAccess?: 'granted' | 'disabled' | 'missing' | 'truncated'
|
||||
model?: string
|
||||
cost?: {
|
||||
input?: number
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface CustomBlockWithInputs {
|
||||
description: string
|
||||
iconUrl: string | null
|
||||
enabled: boolean
|
||||
/** Publisher's org-wide decision on joining this block's runs into consumer traces. */
|
||||
traceChildRuns: boolean
|
||||
inputFields: WorkflowInputField[]
|
||||
exposedOutputs: CustomBlockOutput[]
|
||||
}
|
||||
@@ -217,6 +219,7 @@ async function hydrateCustomBlockRow(joined: {
|
||||
description: row.description,
|
||||
iconUrl: row.iconUrl,
|
||||
enabled: row.enabled,
|
||||
traceChildRuns: row.traceChildRuns,
|
||||
inputFields: applyInputPlaceholders(row.inputs, await deriveInputFields(row.workflowId)),
|
||||
exposedOutputs: row.outputs ?? [],
|
||||
}
|
||||
@@ -312,6 +315,13 @@ export async function getCustomBlockAuthority(
|
||||
exposedOutputs: CustomBlockOutput[]
|
||||
/** Start-field ids (form keys) the publisher marked required. May reference removed fields. */
|
||||
requiredInputIds: string[]
|
||||
/**
|
||||
* Whether this invocation may publish its child run to the caller's trace. The
|
||||
* publisher's decision is the whole policy, and it is resolved HERE so both the
|
||||
* canvas handler and the Agent-tool runner — which reach execution through this
|
||||
* same lookup — read one value that no consumer input can influence.
|
||||
*/
|
||||
traceChildRuns: boolean
|
||||
} | null> {
|
||||
// Scope resolution to the consumer's org: `(organizationId, type)` is the unique
|
||||
// key, so without the org filter a `custom_block_*` type smuggled in from another
|
||||
@@ -330,6 +340,7 @@ export async function getCustomBlockAuthority(
|
||||
enabled: customBlock.enabled,
|
||||
outputs: customBlock.outputs,
|
||||
inputs: customBlock.inputs,
|
||||
traceChildRuns: customBlock.traceChildRuns,
|
||||
ownerUserId: workflow.userId,
|
||||
})
|
||||
.from(customBlock)
|
||||
@@ -346,6 +357,7 @@ export async function getCustomBlockAuthority(
|
||||
ownerUserId: row.ownerUserId,
|
||||
exposedOutputs: row.outputs ?? [],
|
||||
requiredInputIds: (row.inputs ?? []).filter((i) => i.required).map((i) => i.id),
|
||||
traceChildRuns: row.traceChildRuns,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +452,8 @@ export async function publishCustomBlock(params: {
|
||||
inputs?: InputPlaceholder[]
|
||||
/** Required — see {@link assertCuratedOutputs}. */
|
||||
exposedOutputs: CustomBlockOutput[]
|
||||
/** Omitted means off — publishing a block never opens its runs to consumers by default. */
|
||||
traceChildRuns?: boolean
|
||||
}): Promise<CustomBlockWithInputs> {
|
||||
const {
|
||||
organizationId,
|
||||
@@ -451,6 +465,7 @@ export async function publishCustomBlock(params: {
|
||||
iconUrl,
|
||||
inputs,
|
||||
exposedOutputs,
|
||||
traceChildRuns = false,
|
||||
} = params
|
||||
|
||||
assertNoReservedOutputNames(exposedOutputs)
|
||||
@@ -510,6 +525,7 @@ export async function publishCustomBlock(params: {
|
||||
inputs: inputs ?? [],
|
||||
outputs: exposedOutputs ?? [],
|
||||
enabled: true,
|
||||
traceChildRuns,
|
||||
createdBy: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -529,6 +545,7 @@ export async function publishCustomBlock(params: {
|
||||
description,
|
||||
iconUrl: iconUrl ?? null,
|
||||
enabled: true,
|
||||
traceChildRuns,
|
||||
inputFields: applyInputPlaceholders(inputs ?? null, await deriveInputFields(workflowId)),
|
||||
exposedOutputs: exposedOutputs ?? [],
|
||||
}
|
||||
@@ -548,6 +565,7 @@ export async function updateCustomBlock(
|
||||
iconUrl?: string | null
|
||||
inputs?: InputPlaceholder[]
|
||||
exposedOutputs?: CustomBlockOutput[]
|
||||
traceChildRuns?: boolean
|
||||
}
|
||||
): Promise<void> {
|
||||
if (updates.exposedOutputs !== undefined) {
|
||||
@@ -561,6 +579,7 @@ export async function updateCustomBlock(
|
||||
if (updates.inputs !== undefined) patch.inputs = updates.inputs
|
||||
if (updates.exposedOutputs !== undefined) patch.outputs = updates.exposedOutputs
|
||||
if (updates.iconUrl !== undefined) patch.iconUrl = updates.iconUrl
|
||||
if (updates.traceChildRuns !== undefined) patch.traceChildRuns = updates.traceChildRuns
|
||||
|
||||
await db.update(customBlock).set(patch).where(eq(customBlock.id, id))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { isRecordLike, omit } from '@sim/utils/object'
|
||||
import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result'
|
||||
import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types'
|
||||
import {
|
||||
CHILD_EXECUTION_ID_OUTPUT_KEY,
|
||||
CHILD_TRACE_DISABLED_OUTPUT_KEY,
|
||||
} from '@/executor/constants'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
import { getPreparedProviderToolInputProvenance } from '@/providers/tool-input-provenance'
|
||||
@@ -46,6 +51,31 @@ function toProviderModelResponse(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a custom block's child-run handle from the copy bound for the model.
|
||||
*
|
||||
* The handle has to survive on `rawResponse`, which is what the tool-call record
|
||||
* (and therefore the trace span) is built from — but it is trace plumbing, and an
|
||||
* opaque execution id in a tool result reads to a model like data the tool
|
||||
* returned, which it may then quote back to the user. Applied at this single
|
||||
* split point because every provider's tool loop goes through here; there is no
|
||||
* other place both copies exist.
|
||||
*/
|
||||
function withoutChildTraceHandle(response: ToolResponse): ToolResponse {
|
||||
const output = response.output
|
||||
if (!isRecordLike(output)) return response
|
||||
if (
|
||||
!Object.hasOwn(output, CHILD_EXECUTION_ID_OUTPUT_KEY) &&
|
||||
!Object.hasOwn(output, CHILD_TRACE_DISABLED_OUTPUT_KEY)
|
||||
) {
|
||||
return response
|
||||
}
|
||||
return {
|
||||
...response,
|
||||
output: omit(output, [CHILD_EXECUTION_ID_OUTPUT_KEY, CHILD_TRACE_DISABLED_OUTPUT_KEY]),
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeProviderTool(
|
||||
toolId: string,
|
||||
params: Parameters<typeof executeTool>[1],
|
||||
@@ -78,12 +108,11 @@ export async function executeProviderTool(
|
||||
resolvedSecretTraceRegistry: toolCallRegistry,
|
||||
})
|
||||
if (!registry || !toolCallRegistry) {
|
||||
return { rawResponse: result, modelResponse: result }
|
||||
return { rawResponse: result, modelResponse: withoutChildTraceHandle(result) }
|
||||
}
|
||||
|
||||
const modelResponse = toProviderModelResponse(
|
||||
result,
|
||||
projectToolResultForCopilot(result, toolCallRegistry)
|
||||
const modelResponse = withoutChildTraceHandle(
|
||||
toProviderModelResponse(result, projectToolResultForCopilot(result, toolCallRegistry))
|
||||
)
|
||||
registry.mergeToolCallRegistry(toolCallRegistry)
|
||||
return { rawResponse: result, modelResponse }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "custom_block" ADD COLUMN "trace_child_runs" boolean DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2094,6 +2094,13 @@
|
||||
"when": 1787338729668,
|
||||
"tag": "0299_bright_nemesis",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 300,
|
||||
"version": "7",
|
||||
"when": 1787360221351,
|
||||
"tag": "0300_hesitant_war_machine",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3727,6 +3727,18 @@ export const customBlock = pgTable(
|
||||
*/
|
||||
outputs: json('outputs').$type<Array<{ blockId: string; path: string; name: string }>>(),
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
/**
|
||||
* The publisher's org-wide decision on whether this block's runs are joined into
|
||||
* a consumer's trace. It is the ONLY policy — no viewer check runs downstream —
|
||||
* so turning it on publishes the source workflow's block names, inputs, outputs,
|
||||
* and prompts to anyone who can read a consuming workflow's logs, including
|
||||
* consumers with no access to the source workspace.
|
||||
*
|
||||
* Defaults false because of that: this is the same boundary curated outputs and
|
||||
* redacted errors exist to hold, and it may only open by an affirmative act of
|
||||
* the publisher, never by a column default applied to rows nobody revisited.
|
||||
*/
|
||||
traceChildRuns: boolean('trace_child_runs').notNull().default(false),
|
||||
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
|
||||
@@ -473,6 +473,7 @@ export const schemaMock = {
|
||||
inputs: 'customBlock.inputs',
|
||||
outputs: 'customBlock.outputs',
|
||||
enabled: 'customBlock.enabled',
|
||||
traceChildRuns: 'customBlock.traceChildRuns',
|
||||
createdBy: 'customBlock.createdBy',
|
||||
createdAt: 'customBlock.createdAt',
|
||||
updatedAt: 'customBlock.updatedAt',
|
||||
|
||||
Reference in New Issue
Block a user