Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
018873f
feat(custom-blocks): log and bill child runs in the publisher's works…
TheodoreSpeaks Jul 28, 2026
3d2cac2
fix(custom-blocks): sanitize every boundary failure and classify it f…
TheodoreSpeaks Jul 28, 2026
dd9748f
fix(custom-blocks): surface a cancelled child as cancelled, not a gen…
TheodoreSpeaks Jul 28, 2026
57d4e2b
fix(custom-blocks): share one large-value id list so nested blocks pr…
TheodoreSpeaks Jul 28, 2026
c5c5d3e
fix(custom-blocks): add a durable cancel backstop to the child bridge
TheodoreSpeaks Jul 28, 2026
de8eddd
fix(tools): carry Sim's own status through the tool-response boundary
TheodoreSpeaks Jul 28, 2026
1923fe7
fix(custom-blocks): stop forwarding the publisher's personal quota to…
TheodoreSpeaks Jul 28, 2026
0c4e959
fix(custom-blocks): correlate agent-tool runs to the real invoking ex…
TheodoreSpeaks Jul 29, 2026
7b5590a
fix(custom-blocks): plumb the invoking execution id and abort signal …
TheodoreSpeaks Jul 29, 2026
67805cb
fix(agent): forward the execution id through the provider payload
TheodoreSpeaks Jul 29, 2026
891e02e
fix(executor): stop adopting an upstream target's HTTP status as our own
TheodoreSpeaks Jul 29, 2026
8c8ffc1
fix(custom-blocks): drain child log finalization when the parent is c…
TheodoreSpeaks Jul 29, 2026
264bc74
fix(custom-blocks): require curated outputs instead of exposing the w…
TheodoreSpeaks Jul 29, 2026
b345750
fix(custom-blocks): track the whole child run, not just its finalization
TheodoreSpeaks Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type React from 'react'
import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons'
import { formatCreditCost } from '@/lib/billing/credits/conversion'
import { perceivedBrightness } from '@/lib/colors'
import { hasUnhandledError } from '@/lib/logs/execution/trace-spans/trace-spans'
import type { TraceSpan } from '@/lib/logs/types'
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
Expand Down Expand Up @@ -43,10 +44,7 @@ export function hasErrorInTree(span: TraceSpan): boolean {
}

export function hasUnhandledErrorInTree(span: TraceSpan): boolean {
if (span.status === 'error' && !span.errorHandled) return true
if (span.children?.length) return span.children.some(hasUnhandledErrorInTree)
if (span.toolCalls?.length && !span.errorHandled) return span.toolCalls.some((tc) => tc.error)
return false
return hasUnhandledError(span, { includeToolCalls: true })
}

export function getBlockIconAndColor(
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/workspace/[workspaceId]/logs/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const TRIGGER_VARIANT_MAP: Record<string, React.ComponentProps<typeof Badge>['va
copilot: 'pink',
mothership: 'pink',
workflow: 'blue-secondary',
custom_block: 'blue-secondary',
}

interface StatusBadgeProps {
Expand Down
13 changes: 11 additions & 2 deletions apps/sim/blocks/custom/build-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,18 @@ describe('buildCustomBlockConfig', () => {
expect(findSub(config, 'docs')?.multiple).toBe(true)
})

it('exposes the full result and hides plumbing when no outputs are curated', () => {
it('advertises no data fields — and no whole-result fallback — without curation', () => {
const config = buildCustomBlockConfig(row, fields, { icon })
expect(Object.keys(config.outputs).sort()).toEqual(['error', 'result', 'success'])
// Curation is required at publish, so an uncurated row exposes only the
// system fields. `result` must not come back: it would advertise the child's
// raw terminal state (agent toolCalls/thinking, nested workflow ids).
expect(Object.keys(config.outputs).sort()).toEqual([
'error',
'errorRef',
'errorType',
'success',
])
expect(config.outputs.result).toBeUndefined()
expect(config.outputs.childWorkflowId).toBeUndefined()
expect(config.outputs.childTraceSpans).toBeUndefined()
})
Expand Down
25 changes: 14 additions & 11 deletions apps/sim/blocks/custom/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,14 @@ export const RESERVED_PARAMS = new Set([
])

/**
* Output names the block projects itself (`success`/`error` from `buildOutputs`,
* `cost` from the executor's billing aggregation). A user-named exposed output
* must never shadow these — an output literally named `cost` would clobber the
* billed cost. `result` is deliberately NOT reserved: it only exists as a system
* Output names the block projects itself: `success`/`error`/`errorType`/`errorRef`
* from `buildOutputs`, plus `cost` (kept reserved for forward compatibility now
* that the child bills its own run). A user-named exposed output must never
* shadow these. `result` is deliberately NOT reserved: it only exists as a system
* field when no outputs are curated, which cannot co-occur with a named output.
* Compared lowercased, so every entry here must be lowercase.
*/
export const RESERVED_OUTPUT_NAMES = new Set(['success', 'error', 'cost'])
export const RESERVED_OUTPUT_NAMES = new Set(['success', 'error', 'cost', 'errortype', 'errorref'])

/** Whether an exposed-output name collides with a system output field. */
export function isReservedOutputName(name: string): boolean {
Expand Down Expand Up @@ -208,13 +209,15 @@ function buildOutputs(exposed: CustomBlockOutput[] | undefined): BlockConfig['ou
const outputs: BlockConfig['outputs'] = {
success: { type: 'boolean', description: 'Execution success status' },
error: { type: 'string', description: 'Error message' },
errorType: { type: 'string', description: 'Machine-readable failure class' },
errorRef: { type: 'string', description: 'Opaque reference to the failed run' },
}
if (exposed && exposed.length > 0) {
for (const out of exposed) {
outputs[out.name] = { type: 'json', description: `Output: ${out.path}` }
}
} else {
outputs.result = { type: 'json', description: 'Workflow execution result' }
// No whole-`result` fallback: curation is required at publish, so every
// consumer-visible field is one the publisher chose. A legacy row with no
// curated outputs advertises no data fields and fails loudly at invocation
// rather than silently reverting to exposing the child's raw terminal state.
for (const out of exposed ?? []) {
outputs[out.name] = { type: 'json', description: `Output: ${out.path}` }
}
return outputs
}
54 changes: 54 additions & 0 deletions apps/sim/executor/errors/boundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Machine-readable class of a custom-block failure. Every member describes a
* fact the CONSUMER already knows or can act on — never the source workflow's
* blocks, tools, or upstream provider responses.
*/
export type CustomBlockErrorType =
| 'missing_inputs'
| 'not_deployed'
| 'unavailable'
| 'depth_limit'
/**
* The payer had no usage headroom, so the child never ran. Safe to surface:
* a custom block always resolves within the consumer's own organization, so
* the exhausted limit is their org's, not a foreign publisher's.
*/
| 'usage_limit'
/** The child run was cancelled (the invoking run aborted or was cancelled). */
| 'cancelled'
| 'execution_failed'

/** What a failed custom block tells its consumer. Leaks nothing about the source run. */
export interface CustomBlockFailure {
/** Stable, machine-readable class of the failure. */
errorType: CustomBlockErrorType
/** Opaque handle to the child run, so the publisher can find the exact failing execution. */
ref?: string
/** Consumer-safe sentence. Generic unless the throw site was a {@link BoundarySafeError}. */
message: string
}

/**
* An error whose `message` names only the CALLER's own artifacts — the block
* they placed, its input labels, its deployment state — so it may cross an
* invocation boundary verbatim.
*
* Every error that is NOT of this class is opaque at the boundary. The default
* is fail-closed, so a `throw new Error(...)` added later anywhere in the
* custom-block path is redacted without anyone remembering to redact it. This
* replaces the older convention of throwing *before* the `try` block to dodge
* the catch's sanitizer, where redaction depended on lexical position.
*/
export class BoundarySafeError extends Error {
readonly errorType: CustomBlockErrorType

constructor(options: { message: string; errorType: CustomBlockErrorType }) {
super(options.message)
this.name = 'BoundarySafeError'
this.errorType = options.errorType
}
}

export function isBoundarySafeError(error: unknown): error is BoundarySafeError {
return error instanceof BoundarySafeError
}
31 changes: 31 additions & 0 deletions apps/sim/executor/errors/child-workflow-error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { TraceSpan } from '@/lib/logs/types'
import type { CustomBlockFailure } from '@/executor/errors/boundary'
import type { ExecutionResult } from '@/executor/types'

interface ChildWorkflowErrorOptions {
Expand All @@ -8,6 +9,12 @@ interface ChildWorkflowErrorOptions {
executionResult?: ExecutionResult
childWorkflowSnapshotId?: string
childWorkflowInstanceId?: string
/** Child workflow names from this invocation down to the failing run, outermost first. */
workflowChain?: string[]
/** The deepest non-workflow failure, already block-name-prefixed (`"Function 1: boom"`). */
rootErrorMessage?: string
/** Consumer-safe failure descriptor. Set only at a custom-block invocation boundary. */
consumerFacing?: CustomBlockFailure
cause?: Error
}

Expand All @@ -21,6 +28,15 @@ export class ChildWorkflowError extends Error {
readonly childWorkflowSnapshotId?: string
/** Per-invocation unique ID used to correlate child block events with this workflow block. */
readonly childWorkflowInstanceId?: string
/**
* The chain of child workflow names from this invocation down to the failing
* run. Carried structurally so nesting never has to be recovered by parsing
* the formatted message.
*/
readonly workflowChain: string[]
/** The deepest non-workflow failure, without any workflow-chain prefixes. */
readonly rootErrorMessage: string
readonly consumerFacing?: CustomBlockFailure

constructor(options: ChildWorkflowErrorOptions) {
super(options.message, { cause: options.cause })
Expand All @@ -30,9 +46,24 @@ export class ChildWorkflowError extends Error {
this.executionResult = options.executionResult
this.childWorkflowSnapshotId = options.childWorkflowSnapshotId
this.childWorkflowInstanceId = options.childWorkflowInstanceId
this.workflowChain = options.workflowChain ?? [options.childWorkflowName]
this.rootErrorMessage = options.rootErrorMessage ?? options.message
this.consumerFacing = options.consumerFacing
}

static isChildWorkflowError(error: unknown): error is ChildWorkflowError {
return error instanceof ChildWorkflowError
}
}

/**
* Renders a nested child-workflow failure for display. Kept byte-identical to
* the format this error used to carry as a raw string, so persisted logs and
* the UI are unaffected by the move to structured fields.
*/
export function formatWorkflowChainMessage(chain: string[], rootError: string): string {
if (chain.length > 1) {
return `Workflow chain: ${chain.join(' → ')} | ${rootError}`
}
return `"${chain[0]}" failed: ${rootError}`
}
22 changes: 19 additions & 3 deletions apps/sim/executor/execution/block-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
DEFAULTS,
EDGE,
isSentinelBlockType,
isWorkflowBlockType,
} from '@/executor/constants'
import type { DAGNode } from '@/executor/dag/builder'
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
Expand Down Expand Up @@ -450,10 +451,25 @@ export class BlockExecutor {
errorOutput.content = partialContent
}

// Only real workflow blocks surface a child workflow name. A custom block's
// source workflow is never named to its consumer — and before the handler
// resolves the real name this field still holds the source workflow id, so
// an early throw (e.g. the call-chain depth limit) would leak it outright.
if (ChildWorkflowError.isChildWorkflowError(error)) {
errorOutput.childWorkflowName = error.childWorkflowName
if (error.childWorkflowSnapshotId) {
errorOutput.childWorkflowSnapshotId = error.childWorkflowSnapshotId
if (isWorkflowBlockType(block.metadata?.id)) {
errorOutput.childWorkflowName = error.childWorkflowName
if (error.childWorkflowSnapshotId) {
errorOutput.childWorkflowSnapshotId = error.childWorkflowSnapshotId
}
}
// A custom block's consumer gets a machine-readable failure class and an
// opaque handle to the failed run — enough to branch on and to quote in a
// support request, without naming anything inside the source workflow.
if (error.consumerFacing) {
errorOutput.errorType = error.consumerFacing.errorType
if (error.consumerFacing.ref) {
errorOutput.errorRef = error.consumerFacing.ref
}
}
}

Expand Down
5 changes: 5 additions & 0 deletions apps/sim/executor/handlers/agent/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,7 @@ export class AgentBlockHandler implements BlockHandler {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
userId: ctx.userId,
executionId: ctx.executionId,
Comment thread
cursor[bot] marked this conversation as resolved.
stream: streaming,
messages: messages?.map(({ executionId, ...msg }) => msg),
environmentVariables: normalizeStringRecord(ctx.environmentVariables),
Expand Down Expand Up @@ -1028,6 +1029,10 @@ export class AgentBlockHandler implements BlockHandler {
isDeployedContext: ctx.isDeployedContext,
callChain: ctx.callChain,
billingAttribution: ctx.metadata.billingAttribution,
// Reaches tool `_context` via `prepareToolExecution`, so a tool that starts
// its own child execution (a custom block) correlates and cancels against
// this real run instead of minting a phantom id.
executionId: ctx.executionId,
reasoningEffort: providerRequest.reasoningEffort,
verbosity: providerRequest.verbosity,
thinkingLevel: providerRequest.thinkingLevel,
Expand Down
10 changes: 8 additions & 2 deletions apps/sim/executor/handlers/generic/generic-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors'
import { getBlock } from '@/blocks/index'
import { isMcpTool } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { readStatusCode } from '@/executor/utils/errors'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import { getTool } from '@/tools/utils'
Expand Down Expand Up @@ -93,6 +94,10 @@ export class GenericBlockHandler implements BlockHandler {
blockName: block.metadata?.name || 'Unnamed Block',
output: result.output || {},
timestamp: new Date().toISOString(),
// `executeTool` flattens a thrown error into a result, so Sim's own
// status (hosted-key 429/503) would be lost here. Carry it onto the
// error so `getExecutionErrorStatus` can still reach the API caller.
...(typeof result.statusCode === 'number' ? { statusCode: result.statusCode } : {}),
})

throw error
Expand All @@ -107,8 +112,9 @@ export class GenericBlockHandler implements BlockHandler {
errorMessage += `: ${block.metadata.name}`
}

if (error.status) {
errorMessage += ` (Status: ${error.status})`
const statusCode = readStatusCode(error)
if (statusCode !== undefined) {
errorMessage += ` (Status: ${statusCode})`
}

error.message = errorMessage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ describe('runCustomBlockTool', () => {
expect(res.error).toContain('not deployed')
})

it('rolls up already-incurred child cost when the run fails', async () => {
it('reports no cost on failure — the child session billed its own run', async () => {
const err: any = new Error('child blew up')
err.name = 'ChildWorkflowError'
err.childTraceSpans = [{ id: 's1', name: 'child', type: 'agent', cost: { total: 0.25 } }]
Expand All @@ -98,8 +98,7 @@ describe('runCustomBlockTool', () => {
const res = await runCustomBlockTool({ blockType: 'custom_block_abc', _context: {} })

expect(res.success).toBe(false)
// Partial spend must not be recorded as zero-cost.
expect((res.output as any).cost.total).toBeGreaterThan(0)
expect(res.output).toEqual({})
})

it('rejects a missing block type without invoking the handler', async () => {
Expand All @@ -108,3 +107,41 @@ describe('runCustomBlockTool', () => {
expect(mockExecute).not.toHaveBeenCalled()
})
})

describe('buildCustomBlockExecutionContext invoker identity', () => {
it("adopts the invoking run's ids so correlation names a real execution", () => {
const ctx = buildCustomBlockExecutionContext({
workspaceId: 'ws-1',
executionId: 'agent-execution-id',
requestId: 'agent-request-id',
})

expect(ctx.executionId).toBe('agent-execution-id')
expect(ctx.metadata.executionId).toBe('agent-execution-id')
expect(ctx.metadata.requestId).toBe('agent-request-id')
})

it('falls back to generated ids when the caller supplies none', () => {
const ctx = buildCustomBlockExecutionContext({ workspaceId: 'ws-1' })

expect(ctx.executionId).toBeTruthy()
expect(ctx.metadata.requestId).toBeTruthy()
expect(ctx.executionId).not.toBe(ctx.metadata.requestId)
})
})

describe('buildCustomBlockExecutionContext cancellation', () => {
it("adopts the agent tool loop's abort signal so the bridge has something to watch", () => {
const controller = new AbortController()
const ctx = buildCustomBlockExecutionContext(
{ workspaceId: 'ws-1' },
{ abortSignal: controller.signal }
)

expect(ctx.abortSignal).toBe(controller.signal)
})

it('leaves the signal undefined when the caller has none', () => {
expect(buildCustomBlockExecutionContext({ workspaceId: 'ws-1' }).abortSignal).toBeUndefined()
})
})
Loading
Loading