Skip to content

Commit 7293b67

Browse files
feat(custom-blocks): log and bill child runs in the publisher's workspace (#6023)
* feat(custom-blocks): log and bill child runs in the publisher's workspace * fix(custom-blocks): sanitize every boundary failure and classify it for consumers * fix(custom-blocks): surface a cancelled child as cancelled, not a generic failure * fix(custom-blocks): share one large-value id list so nested blocks propagate * fix(custom-blocks): add a durable cancel backstop to the child bridge * fix(tools): carry Sim's own status through the tool-response boundary * fix(custom-blocks): stop forwarding the publisher's personal quota to consumers * fix(custom-blocks): correlate agent-tool runs to the real invoking execution * fix(custom-blocks): plumb the invoking execution id and abort signal to agent tools * fix(agent): forward the execution id through the provider payload * fix(executor): stop adopting an upstream target's HTTP status as our own * fix(custom-blocks): drain child log finalization when the parent is cancelled * fix(custom-blocks): require curated outputs instead of exposing the whole result * fix(custom-blocks): track the whole child run, not just its finalization
1 parent 64bcfea commit 7293b67

40 files changed

Lines changed: 2307 additions & 371 deletions

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type React from 'react'
22
import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons'
33
import { formatCreditCost } from '@/lib/billing/credits/conversion'
44
import { perceivedBrightness } from '@/lib/colors'
5+
import { hasUnhandledError } from '@/lib/logs/execution/trace-spans/trace-spans'
56
import type { TraceSpan } from '@/lib/logs/types'
67
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
78
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
@@ -43,10 +44,7 @@ export function hasErrorInTree(span: TraceSpan): boolean {
4344
}
4445

4546
export function hasUnhandledErrorInTree(span: TraceSpan): boolean {
46-
if (span.status === 'error' && !span.errorHandled) return true
47-
if (span.children?.length) return span.children.some(hasUnhandledErrorInTree)
48-
if (span.toolCalls?.length && !span.errorHandled) return span.toolCalls.some((tc) => tc.error)
49-
return false
47+
return hasUnhandledError(span, { includeToolCalls: true })
5048
}
5149

5250
export function getBlockIconAndColor(

apps/sim/app/workspace/[workspaceId]/logs/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ const TRIGGER_VARIANT_MAP: Record<string, React.ComponentProps<typeof Badge>['va
8585
copilot: 'pink',
8686
mothership: 'pink',
8787
workflow: 'blue-secondary',
88+
custom_block: 'blue-secondary',
8889
}
8990

9091
interface StatusBadgeProps {

apps/sim/blocks/custom/build-config.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,18 @@ describe('buildCustomBlockConfig', () => {
9494
expect(findSub(config, 'docs')?.multiple).toBe(true)
9595
})
9696

97-
it('exposes the full result and hides plumbing when no outputs are curated', () => {
97+
it('advertises no data fields — and no whole-result fallback — without curation', () => {
9898
const config = buildCustomBlockConfig(row, fields, { icon })
99-
expect(Object.keys(config.outputs).sort()).toEqual(['error', 'result', 'success'])
99+
// Curation is required at publish, so an uncurated row exposes only the
100+
// system fields. `result` must not come back: it would advertise the child's
101+
// raw terminal state (agent toolCalls/thinking, nested workflow ids).
102+
expect(Object.keys(config.outputs).sort()).toEqual([
103+
'error',
104+
'errorRef',
105+
'errorType',
106+
'success',
107+
])
108+
expect(config.outputs.result).toBeUndefined()
100109
expect(config.outputs.childWorkflowId).toBeUndefined()
101110
expect(config.outputs.childTraceSpans).toBeUndefined()
102111
})

apps/sim/blocks/custom/build-config.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,14 @@ export const RESERVED_PARAMS = new Set([
6363
])
6464

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

7475
/** Whether an exposed-output name collides with a system output field. */
7576
export function isReservedOutputName(name: string): boolean {
@@ -208,13 +209,15 @@ function buildOutputs(exposed: CustomBlockOutput[] | undefined): BlockConfig['ou
208209
const outputs: BlockConfig['outputs'] = {
209210
success: { type: 'boolean', description: 'Execution success status' },
210211
error: { type: 'string', description: 'Error message' },
212+
errorType: { type: 'string', description: 'Machine-readable failure class' },
213+
errorRef: { type: 'string', description: 'Opaque reference to the failed run' },
211214
}
212-
if (exposed && exposed.length > 0) {
213-
for (const out of exposed) {
214-
outputs[out.name] = { type: 'json', description: `Output: ${out.path}` }
215-
}
216-
} else {
217-
outputs.result = { type: 'json', description: 'Workflow execution result' }
215+
// No whole-`result` fallback: curation is required at publish, so every
216+
// consumer-visible field is one the publisher chose. A legacy row with no
217+
// curated outputs advertises no data fields and fails loudly at invocation
218+
// rather than silently reverting to exposing the child's raw terminal state.
219+
for (const out of exposed ?? []) {
220+
outputs[out.name] = { type: 'json', description: `Output: ${out.path}` }
218221
}
219222
return outputs
220223
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Machine-readable class of a custom-block failure. Every member describes a
3+
* fact the CONSUMER already knows or can act on — never the source workflow's
4+
* blocks, tools, or upstream provider responses.
5+
*/
6+
export type CustomBlockErrorType =
7+
| 'missing_inputs'
8+
| 'not_deployed'
9+
| 'unavailable'
10+
| 'depth_limit'
11+
/**
12+
* The payer had no usage headroom, so the child never ran. Safe to surface:
13+
* a custom block always resolves within the consumer's own organization, so
14+
* the exhausted limit is their org's, not a foreign publisher's.
15+
*/
16+
| 'usage_limit'
17+
/** The child run was cancelled (the invoking run aborted or was cancelled). */
18+
| 'cancelled'
19+
| 'execution_failed'
20+
21+
/** What a failed custom block tells its consumer. Leaks nothing about the source run. */
22+
export interface CustomBlockFailure {
23+
/** Stable, machine-readable class of the failure. */
24+
errorType: CustomBlockErrorType
25+
/** Opaque handle to the child run, so the publisher can find the exact failing execution. */
26+
ref?: string
27+
/** Consumer-safe sentence. Generic unless the throw site was a {@link BoundarySafeError}. */
28+
message: string
29+
}
30+
31+
/**
32+
* An error whose `message` names only the CALLER's own artifacts — the block
33+
* they placed, its input labels, its deployment state — so it may cross an
34+
* invocation boundary verbatim.
35+
*
36+
* Every error that is NOT of this class is opaque at the boundary. The default
37+
* is fail-closed, so a `throw new Error(...)` added later anywhere in the
38+
* custom-block path is redacted without anyone remembering to redact it. This
39+
* replaces the older convention of throwing *before* the `try` block to dodge
40+
* the catch's sanitizer, where redaction depended on lexical position.
41+
*/
42+
export class BoundarySafeError extends Error {
43+
readonly errorType: CustomBlockErrorType
44+
45+
constructor(options: { message: string; errorType: CustomBlockErrorType }) {
46+
super(options.message)
47+
this.name = 'BoundarySafeError'
48+
this.errorType = options.errorType
49+
}
50+
}
51+
52+
export function isBoundarySafeError(error: unknown): error is BoundarySafeError {
53+
return error instanceof BoundarySafeError
54+
}

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { TraceSpan } from '@/lib/logs/types'
2+
import type { CustomBlockFailure } from '@/executor/errors/boundary'
23
import type { ExecutionResult } from '@/executor/types'
34

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

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

2541
constructor(options: ChildWorkflowErrorOptions) {
2642
super(options.message, { cause: options.cause })
@@ -30,9 +46,24 @@ export class ChildWorkflowError extends Error {
3046
this.executionResult = options.executionResult
3147
this.childWorkflowSnapshotId = options.childWorkflowSnapshotId
3248
this.childWorkflowInstanceId = options.childWorkflowInstanceId
49+
this.workflowChain = options.workflowChain ?? [options.childWorkflowName]
50+
this.rootErrorMessage = options.rootErrorMessage ?? options.message
51+
this.consumerFacing = options.consumerFacing
3352
}
3453

3554
static isChildWorkflowError(error: unknown): error is ChildWorkflowError {
3655
return error instanceof ChildWorkflowError
3756
}
3857
}
58+
59+
/**
60+
* Renders a nested child-workflow failure for display. Kept byte-identical to
61+
* the format this error used to carry as a raw string, so persisted logs and
62+
* the UI are unaffected by the move to structured fields.
63+
*/
64+
export function formatWorkflowChainMessage(chain: string[], rootError: string): string {
65+
if (chain.length > 1) {
66+
return `Workflow chain: ${chain.join(' → ')} | ${rootError}`
67+
}
68+
return `"${chain[0]}" failed: ${rootError}`
69+
}

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
DEFAULTS,
2222
EDGE,
2323
isSentinelBlockType,
24+
isWorkflowBlockType,
2425
} from '@/executor/constants'
2526
import type { DAGNode } from '@/executor/dag/builder'
2627
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
@@ -450,10 +451,25 @@ export class BlockExecutor {
450451
errorOutput.content = partialContent
451452
}
452453

454+
// Only real workflow blocks surface a child workflow name. A custom block's
455+
// source workflow is never named to its consumer — and before the handler
456+
// resolves the real name this field still holds the source workflow id, so
457+
// an early throw (e.g. the call-chain depth limit) would leak it outright.
453458
if (ChildWorkflowError.isChildWorkflowError(error)) {
454-
errorOutput.childWorkflowName = error.childWorkflowName
455-
if (error.childWorkflowSnapshotId) {
456-
errorOutput.childWorkflowSnapshotId = error.childWorkflowSnapshotId
459+
if (isWorkflowBlockType(block.metadata?.id)) {
460+
errorOutput.childWorkflowName = error.childWorkflowName
461+
if (error.childWorkflowSnapshotId) {
462+
errorOutput.childWorkflowSnapshotId = error.childWorkflowSnapshotId
463+
}
464+
}
465+
// A custom block's consumer gets a machine-readable failure class and an
466+
// opaque handle to the failed run — enough to branch on and to quote in a
467+
// support request, without naming anything inside the source workflow.
468+
if (error.consumerFacing) {
469+
errorOutput.errorType = error.consumerFacing.errorType
470+
if (error.consumerFacing.ref) {
471+
errorOutput.errorRef = error.consumerFacing.ref
472+
}
457473
}
458474
}
459475

apps/sim/executor/handlers/agent/agent-handler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,7 @@ export class AgentBlockHandler implements BlockHandler {
11261126
workflowId: ctx.workflowId,
11271127
workspaceId: ctx.workspaceId,
11281128
userId: ctx.userId,
1129+
executionId: ctx.executionId,
11291130
stream: streaming,
11301131
messages: messages?.map(({ executionId, ...msg }) => msg),
11311132
environmentVariables: normalizeStringRecord(ctx.environmentVariables),
@@ -1209,6 +1210,10 @@ export class AgentBlockHandler implements BlockHandler {
12091210
isDeployedContext: ctx.isDeployedContext,
12101211
callChain: ctx.callChain,
12111212
billingAttribution: ctx.metadata.billingAttribution,
1213+
// Reaches tool `_context` via `prepareToolExecution`, so a tool that starts
1214+
// its own child execution (a custom block) correlates and cancels against
1215+
// this real run instead of minting a phantom id.
1216+
executionId: ctx.executionId,
12121217
reasoningEffort: providerRequest.reasoningEffort,
12131218
verbosity: providerRequest.verbosity,
12141219
thinkingLevel: providerRequest.thinkingLevel,

apps/sim/executor/handlers/generic/generic-handler.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors'
33
import { getBlock } from '@/blocks/index'
44
import { isMcpTool } from '@/executor/constants'
55
import type { BlockHandler, ExecutionContext } from '@/executor/types'
6+
import { readStatusCode } from '@/executor/utils/errors'
67
import type { SerializedBlock } from '@/serializer/types'
78
import { executeTool } from '@/tools'
89
import { getTool } from '@/tools/utils'
@@ -93,6 +94,10 @@ export class GenericBlockHandler implements BlockHandler {
9394
blockName: block.metadata?.name || 'Unnamed Block',
9495
output: result.output || {},
9596
timestamp: new Date().toISOString(),
97+
// `executeTool` flattens a thrown error into a result, so Sim's own
98+
// status (hosted-key 429/503) would be lost here. Carry it onto the
99+
// error so `getExecutionErrorStatus` can still reach the API caller.
100+
...(typeof result.statusCode === 'number' ? { statusCode: result.statusCode } : {}),
96101
})
97102

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

110-
if (error.status) {
111-
errorMessage += ` (Status: ${error.status})`
115+
const statusCode = readStatusCode(error)
116+
if (statusCode !== undefined) {
117+
errorMessage += ` (Status: ${statusCode})`
112118
}
113119

114120
error.message = errorMessage

apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ describe('runCustomBlockTool', () => {
8888
expect(res.error).toContain('not deployed')
8989
})
9090

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

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

105104
it('rejects a missing block type without invoking the handler', async () => {
@@ -108,3 +107,41 @@ describe('runCustomBlockTool', () => {
108107
expect(mockExecute).not.toHaveBeenCalled()
109108
})
110109
})
110+
111+
describe('buildCustomBlockExecutionContext invoker identity', () => {
112+
it("adopts the invoking run's ids so correlation names a real execution", () => {
113+
const ctx = buildCustomBlockExecutionContext({
114+
workspaceId: 'ws-1',
115+
executionId: 'agent-execution-id',
116+
requestId: 'agent-request-id',
117+
})
118+
119+
expect(ctx.executionId).toBe('agent-execution-id')
120+
expect(ctx.metadata.executionId).toBe('agent-execution-id')
121+
expect(ctx.metadata.requestId).toBe('agent-request-id')
122+
})
123+
124+
it('falls back to generated ids when the caller supplies none', () => {
125+
const ctx = buildCustomBlockExecutionContext({ workspaceId: 'ws-1' })
126+
127+
expect(ctx.executionId).toBeTruthy()
128+
expect(ctx.metadata.requestId).toBeTruthy()
129+
expect(ctx.executionId).not.toBe(ctx.metadata.requestId)
130+
})
131+
})
132+
133+
describe('buildCustomBlockExecutionContext cancellation', () => {
134+
it("adopts the agent tool loop's abort signal so the bridge has something to watch", () => {
135+
const controller = new AbortController()
136+
const ctx = buildCustomBlockExecutionContext(
137+
{ workspaceId: 'ws-1' },
138+
{ abortSignal: controller.signal }
139+
)
140+
141+
expect(ctx.abortSignal).toBe(controller.signal)
142+
})
143+
144+
it('leaves the signal undefined when the caller has none', () => {
145+
expect(buildCustomBlockExecutionContext({ workspaceId: 'ws-1' }).abortSignal).toBeUndefined()
146+
})
147+
})

0 commit comments

Comments
 (0)