Skip to content

Commit 0b5da48

Browse files
fix(custom-block): bill nested + failed-run hosted cost; expose real inputs to the agent
1 parent 8c1e003 commit 0b5da48

3 files changed

Lines changed: 73 additions & 8 deletions

File tree

apps/sim/blocks/custom/server-overlay.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { AsyncLocalStorage } from 'node:async_hooks'
2+
import type { WorkflowInputField } from '@/lib/workflows/input-format'
23
import { buildCustomBlockConfig, type CustomBlockRow } from '@/blocks/custom/build-config'
34
import { registerBlockOverlayResolver } from '@/blocks/custom/overlay'
45
import type { BlockConfig, BlockIcon } from '@/blocks/types'
56

7+
/** A row for the overlay, optionally carrying live-derived Start input fields. */
8+
type CustomBlockOverlayRow = CustomBlockRow & { inputFields?: WorkflowInputField[] }
9+
610
/**
711
* Server-side custom-block overlay. Resolves `custom_block_*` types during
812
* serialization + execution from a per-request, per-org map held in
@@ -24,16 +28,23 @@ registerBlockOverlayResolver({
2428
* Run `fn` with the given org's custom blocks resolvable via `getBlock`/
2529
* `getAllBlocks`. Wrap every execution serializer entry point (execute route,
2630
* trigger.dev task, scheduled/webhook runs) at the org-context boundary so a
27-
* workflow containing a custom block can serialize and execute. Input mapping is
28-
* schema-agnostic, so the server needs no per-field editors (`inputFields: []`).
31+
* workflow containing a custom block can serialize and execute.
32+
*
33+
* Execution passes bare rows: `inputMapping` is schema-agnostic, so no per-field
34+
* editors are needed. Agent-facing callers (`get_blocks_metadata`, `edit_workflow`)
35+
* pass rows carrying `inputFields` so `getBlock` exposes the real input sub-blocks —
36+
* matching what the VFS block files show — instead of an empty schema.
2937
*/
3038
export function withCustomBlockOverlay<T>(
31-
rows: CustomBlockRow[],
39+
rows: CustomBlockOverlayRow[],
3240
fn: () => Promise<T>
3341
): Promise<T> {
3442
const map = new Map<string, BlockConfig>()
3543
for (const row of rows) {
36-
map.set(row.type, buildCustomBlockConfig(row, [], { icon: PLACEHOLDER_ICON }))
44+
map.set(
45+
row.type,
46+
buildCustomBlockConfig(row, row.inputFields ?? [], { icon: PLACEHOLDER_ICON })
47+
)
3748
}
3849
return store.run(map, fn)
3950
}

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

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors'
33
import { generateId } from '@sim/utils/id'
44
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
55
import { buildNextCallChain, validateCallChain } from '@/lib/execution/call-chain'
6+
import { calculateCostSummary } from '@/lib/logs/execution/logging-factory'
67
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
78
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
89
import 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+
6196
type 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
}

apps/sim/lib/copilot/tools/server/router.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-cr
5858
import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables'
5959
import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow'
6060
import { queryLogsServerTool } from '@/lib/copilot/tools/server/workflow/query-logs'
61-
import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations'
61+
import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations'
6262
import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay'
6363

6464
export type ExecuteResponseSuccess = z.output<typeof ExecuteResponseSuccessSchema>
@@ -248,7 +248,7 @@ export async function routeExecution(
248248
const result =
249249
CUSTOM_BLOCK_OVERLAY_TOOLS.has(toolName) && context?.workspaceId
250250
? await withCustomBlockOverlay(
251-
await getCustomBlockRowsForWorkspace(context.workspaceId),
251+
await listCustomBlocksWithInputsForWorkspace(context.workspaceId),
252252
runTool
253253
)
254254
: await runTool()

0 commit comments

Comments
 (0)