@@ -3,6 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors'
33import { generateId } from '@sim/utils/id'
44import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
55import { buildNextCallChain , validateCallChain } from '@/lib/execution/call-chain'
6+ import { calculateCostSummary } from '@/lib/logs/execution/logging-factory'
67import { snapshotService } from '@/lib/logs/execution/snapshot/service'
78import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
89import type { TraceSpan } from '@/lib/logs/types'
@@ -58,6 +59,40 @@ function remapCustomBlockInputKeys(
5859 return remapped
5960}
6061
62+ /**
63+ * Canonical hosted-key spend of a child run: the model/tool cost the way the
64+ * parent bills it (recursing nested/iteration spans and de-duping model
65+ * breakdowns), minus the base execution charge the parent applies once itself.
66+ * A naive top-level `cost.total` sum undercounts when spend sits on nested children.
67+ */
68+ function aggregateChildCost ( childTraceSpans : TraceSpan [ ] ) : number {
69+ if ( childTraceSpans . length === 0 ) return 0
70+ const summary = calculateCostSummary ( childTraceSpans )
71+ return Math . max ( 0 , summary . totalCost - summary . baseExecutionCharge )
72+ }
73+
74+ /**
75+ * A single cost-only span so a FAILED custom block still bills the hosted-key spend
76+ * its child already consumed (`block-executor` bills `error.childTraceSpans`),
77+ * without exposing any of the source workflow's internal spans. Empty when free.
78+ */
79+ function buildCostCarrierSpans ( childCost : number , blockName : string , type : string ) : TraceSpan [ ] {
80+ if ( childCost <= 0 ) return [ ]
81+ const now = new Date ( ) . toISOString ( )
82+ return [
83+ {
84+ id : generateId ( ) ,
85+ name : blockName ,
86+ type,
87+ duration : 0 ,
88+ startTime : now ,
89+ endTime : now ,
90+ status : 'error' ,
91+ cost : { total : childCost } ,
92+ } ,
93+ ]
94+ }
95+
6196type WorkflowTraceSpan = TraceSpan & {
6297 metadata ?: Record < string , unknown >
6398 children ?: WorkflowTraceSpan [ ]
@@ -328,7 +363,7 @@ export class WorkflowBlockHandler implements BlockHandler {
328363 // of the run's cost into billing — so roll their aggregate cost onto the
329364 // block itself. Custom blocks are org-scoped, so this bills the same org the
330365 // source workflow would bill if run directly, exactly as if it ran the key.
331- const childCost = childTraceSpans . reduce ( ( sum , span ) => sum + ( span . cost ?. total ?? 0 ) , 0 )
366+ const childCost = aggregateChildCost ( childTraceSpans )
332367 return this . projectCustomBlockOutput ( executionResult , exposedOutputs , childCost )
333368 }
334369
@@ -341,10 +376,29 @@ export class WorkflowBlockHandler implements BlockHandler {
341376 // blocks), trace spans, or execution result — the success path hides all of
342377 // these too. The real error is logged above for the publisher/ops; the
343378 // consumer gets only a generic failure attributed to the block they placed.
379+ // But a child that failed AFTER consuming hosted keys still owes that spend,
380+ // so capture the child's spans server-side, distill to the aggregate cost, and
381+ // carry only that (no internals) so `block-executor` still bills it.
344382 if ( isCustomBlock ) {
383+ let failedChildSpans : WorkflowTraceSpan [ ] = [ ]
384+ if ( hasExecutionResult ( error ) && error . executionResult . logs ) {
385+ failedChildSpans = this . captureChildWorkflowLogs (
386+ error . executionResult ,
387+ childWorkflowName ,
388+ ctx
389+ )
390+ } else if ( ChildWorkflowError . isChildWorkflowError ( error ) ) {
391+ failedChildSpans = error . childTraceSpans
392+ }
393+ const blockName = block . metadata ?. name || 'Custom block'
345394 throw new ChildWorkflowError ( {
346395 message : 'Custom block execution failed' ,
347- childWorkflowName : block . metadata ?. name || 'Custom block' ,
396+ childWorkflowName : blockName ,
397+ childTraceSpans : buildCostCarrierSpans (
398+ aggregateChildCost ( failedChildSpans ) ,
399+ blockName ,
400+ block . metadata ?. id ?? 'custom_block'
401+ ) ,
348402 childWorkflowInstanceId : instanceId ,
349403 } )
350404 }
0 commit comments