Skip to content

Commit dc04767

Browse files
committed
feat(child-workflows): nested execution snapshots
1 parent 06d7ce7 commit dc04767

File tree

10 files changed

+125
-6
lines changed

10 files changed

+125
-6
lines changed

apps/sim/app/api/logs/execution/[executionId]/route.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
workflowExecutionSnapshots,
77
} from '@sim/db/schema'
88
import { createLogger } from '@sim/logger'
9-
import { and, eq } from 'drizzle-orm'
9+
import { and, eq, inArray } from 'drizzle-orm'
1010
import { type NextRequest, NextResponse } from 'next/server'
1111
import { checkHybridAuth } from '@/lib/auth/hybrid'
1212
import { generateRequestId } from '@/lib/core/utils/request'
@@ -48,6 +48,7 @@ export async function GET(
4848
endedAt: workflowExecutionLogs.endedAt,
4949
totalDurationMs: workflowExecutionLogs.totalDurationMs,
5050
cost: workflowExecutionLogs.cost,
51+
executionData: workflowExecutionLogs.executionData,
5152
})
5253
.from(workflowExecutionLogs)
5354
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
@@ -78,10 +79,44 @@ export async function GET(
7879
return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 })
7980
}
8081

82+
const traceSpans =
83+
(workflowLog.executionData as { traceSpans?: Array<{ [key: string]: unknown }> })
84+
?.traceSpans || []
85+
const childSnapshotIds = new Set<string>()
86+
const collectSnapshotIds = (spans: Array<{ [key: string]: unknown }>) => {
87+
spans.forEach((span) => {
88+
const snapshotId = span.childWorkflowSnapshotId
89+
if (typeof snapshotId === 'string') {
90+
childSnapshotIds.add(snapshotId)
91+
}
92+
const children = span.children
93+
if (Array.isArray(children)) {
94+
collectSnapshotIds(children as Array<{ [key: string]: unknown }>)
95+
}
96+
})
97+
}
98+
if (traceSpans.length > 0) {
99+
collectSnapshotIds(traceSpans)
100+
}
101+
102+
const childWorkflowSnapshots =
103+
childSnapshotIds.size > 0
104+
? await db
105+
.select()
106+
.from(workflowExecutionSnapshots)
107+
.where(inArray(workflowExecutionSnapshots.id, Array.from(childSnapshotIds)))
108+
: []
109+
110+
const childSnapshotMap = childWorkflowSnapshots.reduce<Record<string, unknown>>((acc, snap) => {
111+
acc[snap.id] = snap.stateData
112+
return acc
113+
}, {})
114+
81115
const response = {
82116
executionId,
83117
workflowId: workflowLog.workflowId,
84118
workflowState: snapshot.stateData,
119+
childWorkflowSnapshots: childSnapshotMap,
85120
executionMetadata: {
86121
trigger: workflowLog.trigger,
87122
startedAt: workflowLog.startedAt.toISOString(),

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ export function ExecutionSnapshot({
8080
}, [executionId, closeMenu])
8181

8282
const workflowState = data?.workflowState as WorkflowState | undefined
83+
const childWorkflowSnapshots = data?.childWorkflowSnapshots as
84+
| Record<string, WorkflowState>
85+
| undefined
8386

8487
const renderContent = () => {
8588
if (isLoading) {
@@ -148,6 +151,7 @@ export function ExecutionSnapshot({
148151
key={executionId}
149152
workflowState={workflowState}
150153
traceSpans={traceSpans}
154+
childWorkflowSnapshots={childWorkflowSnapshots}
151155
className={className}
152156
height={height}
153157
width={width}

apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,7 @@ interface ExecutionData {
690690
output?: unknown
691691
status?: string
692692
durationMs?: number
693+
childWorkflowSnapshotId?: string
693694
}
694695

695696
interface WorkflowVariable {
@@ -714,6 +715,8 @@ interface PreviewEditorProps {
714715
parallels?: Record<string, Parallel>
715716
/** When true, shows "Not Executed" badge if no executionData is provided */
716717
isExecutionMode?: boolean
718+
/** Child workflow snapshots keyed by snapshot ID (execution mode only) */
719+
childWorkflowSnapshots?: Record<string, WorkflowState>
717720
/** Optional close handler - if not provided, no close button is shown */
718721
onClose?: () => void
719722
/** Callback to drill down into a nested workflow block */
@@ -739,6 +742,7 @@ function PreviewEditorContent({
739742
loops,
740743
parallels,
741744
isExecutionMode = false,
745+
childWorkflowSnapshots,
742746
onClose,
743747
onDrillDown,
744748
}: PreviewEditorProps) {
@@ -768,17 +772,31 @@ function PreviewEditorContent({
768772
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } = useWorkflowState(
769773
childWorkflowId ?? undefined
770774
)
775+
const childWorkflowSnapshotId = executionData?.childWorkflowSnapshotId
776+
const childWorkflowSnapshotState = childWorkflowSnapshotId
777+
? childWorkflowSnapshots?.[childWorkflowSnapshotId]
778+
: undefined
771779

772780
/** Drills down into the child workflow or opens it in a new tab */
773781
const handleExpandChildWorkflow = useCallback(() => {
774-
if (!childWorkflowId || !childWorkflowState) return
782+
if (!childWorkflowId) return
775783

776784
if (isExecutionMode && onDrillDown) {
777-
onDrillDown(block.id, childWorkflowState)
785+
const resolvedChildState = childWorkflowSnapshotState ?? childWorkflowState
786+
if (!resolvedChildState) return
787+
onDrillDown(block.id, resolvedChildState)
778788
} else if (workspaceId) {
779789
window.open(`/workspace/${workspaceId}/w/${childWorkflowId}`, '_blank', 'noopener,noreferrer')
780790
}
781-
}, [childWorkflowId, childWorkflowState, isExecutionMode, onDrillDown, block.id, workspaceId])
791+
}, [
792+
childWorkflowId,
793+
childWorkflowSnapshotState,
794+
childWorkflowState,
795+
isExecutionMode,
796+
onDrillDown,
797+
block.id,
798+
workspaceId,
799+
])
782800

783801
const contentRef = useRef<HTMLDivElement>(null)
784802
const subBlocksRef = useRef<HTMLDivElement>(null)

apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ interface TraceSpan {
1919
status?: string
2020
duration?: number
2121
children?: TraceSpan[]
22+
childWorkflowSnapshotId?: string
23+
childWorkflowId?: string
2224
}
2325

2426
interface BlockExecutionData {
@@ -28,6 +30,7 @@ interface BlockExecutionData {
2830
durationMs: number
2931
/** Child trace spans for nested workflow blocks */
3032
children?: TraceSpan[]
33+
childWorkflowSnapshotId?: string
3134
}
3235

3336
/** Represents a level in the workflow navigation stack */
@@ -89,6 +92,7 @@ export function buildBlockExecutions(spans: TraceSpan[]): Record<string, BlockEx
8992
status: span.status || 'unknown',
9093
durationMs: span.duration || 0,
9194
children: span.children,
95+
childWorkflowSnapshotId: span.childWorkflowSnapshotId,
9296
}
9397
}
9498
}
@@ -103,6 +107,8 @@ interface PreviewProps {
103107
traceSpans?: TraceSpan[]
104108
/** Pre-computed block executions (optional - will be built from traceSpans if not provided) */
105109
blockExecutions?: Record<string, BlockExecutionData>
110+
/** Child workflow snapshots keyed by snapshot ID (execution mode only) */
111+
childWorkflowSnapshots?: Record<string, WorkflowState>
106112
/** Additional CSS class names */
107113
className?: string
108114
/** Height of the component */
@@ -135,6 +141,7 @@ export function Preview({
135141
workflowState: rootWorkflowState,
136142
traceSpans: rootTraceSpans,
137143
blockExecutions: providedBlockExecutions,
144+
childWorkflowSnapshots,
138145
className,
139146
height = '100%',
140147
width = '100%',
@@ -284,6 +291,7 @@ export function Preview({
284291
loops={workflowState.loops}
285292
parallels={workflowState.parallels}
286293
isExecutionMode={isExecutionMode}
294+
childWorkflowSnapshots={childWorkflowSnapshots}
287295
onClose={handleEditorClose}
288296
onDrillDown={handleDrillDown}
289297
/>

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ interface ChildWorkflowErrorOptions {
66
childWorkflowName: string
77
childTraceSpans?: TraceSpan[]
88
executionResult?: ExecutionResult
9+
childWorkflowSnapshotId?: string
910
cause?: Error
1011
}
1112

@@ -16,13 +17,15 @@ export class ChildWorkflowError extends Error {
1617
readonly childTraceSpans: TraceSpan[]
1718
readonly childWorkflowName: string
1819
readonly executionResult?: ExecutionResult
20+
readonly childWorkflowSnapshotId?: string
1921

2022
constructor(options: ChildWorkflowErrorOptions) {
2123
super(options.message, { cause: options.cause })
2224
this.name = 'ChildWorkflowError'
2325
this.childWorkflowName = options.childWorkflowName
2426
this.childTraceSpans = options.childTraceSpans ?? []
2527
this.executionResult = options.executionResult
28+
this.childWorkflowSnapshotId = options.childWorkflowSnapshotId
2629
}
2730

2831
static isChildWorkflowError(error: unknown): error is ChildWorkflowError {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ export class BlockExecutor {
237237
if (ChildWorkflowError.isChildWorkflowError(error)) {
238238
errorOutput.childTraceSpans = error.childTraceSpans
239239
errorOutput.childWorkflowName = error.childWorkflowName
240+
if (error.childWorkflowSnapshotId) {
241+
errorOutput.childWorkflowSnapshotId = error.childWorkflowSnapshotId
242+
}
240243
}
241244

242245
this.state.setBlockOutput(node.id, errorOutput, duration)

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
23
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
34
import type { TraceSpan } from '@/lib/logs/types'
45
import type { BlockOutput } from '@/blocks/types'
@@ -57,6 +58,7 @@ export class WorkflowBlockHandler implements BlockHandler {
5758
const workflowMetadata = workflows[workflowId]
5859
let childWorkflowName = workflowMetadata?.name || workflowId
5960

61+
let childWorkflowSnapshotId: string | undefined
6062
try {
6163
const currentDepth = (ctx.workflowId?.split('_sub_').length || 1) - 1
6264
if (currentDepth >= DEFAULTS.MAX_WORKFLOW_DEPTH) {
@@ -107,6 +109,12 @@ export class WorkflowBlockHandler implements BlockHandler {
107109
childWorkflowInput = inputs.input
108110
}
109111

112+
const childSnapshotResult = await snapshotService.createSnapshotWithDeduplication(
113+
workflowId,
114+
childWorkflow.workflowState
115+
)
116+
childWorkflowSnapshotId = childSnapshotResult.snapshot.id
117+
110118
const subExecutor = new Executor({
111119
workflow: childWorkflow.serializedState,
112120
workflowInput: childWorkflowInput,
@@ -139,7 +147,8 @@ export class WorkflowBlockHandler implements BlockHandler {
139147
workflowId,
140148
childWorkflowName,
141149
duration,
142-
childTraceSpans
150+
childTraceSpans,
151+
childWorkflowSnapshotId
143152
)
144153

145154
return mappedResult
@@ -172,6 +181,7 @@ export class WorkflowBlockHandler implements BlockHandler {
172181
childWorkflowName,
173182
childTraceSpans,
174183
executionResult,
184+
childWorkflowSnapshotId,
175185
cause: error instanceof Error ? error : undefined,
176186
})
177187
}
@@ -279,6 +289,10 @@ export class WorkflowBlockHandler implements BlockHandler {
279289
)
280290

281291
const workflowVariables = (workflowData.variables as Record<string, any>) || {}
292+
const workflowStateWithVariables = {
293+
...workflowState,
294+
variables: workflowVariables,
295+
}
282296

283297
if (Object.keys(workflowVariables).length > 0) {
284298
logger.info(
@@ -290,6 +304,7 @@ export class WorkflowBlockHandler implements BlockHandler {
290304
name: workflowData.name,
291305
serializedState: serializedWorkflow,
292306
variables: workflowVariables,
307+
workflowState: workflowStateWithVariables,
293308
rawBlocks: workflowState.blocks,
294309
}
295310
}
@@ -358,11 +373,16 @@ export class WorkflowBlockHandler implements BlockHandler {
358373
)
359374

360375
const workflowVariables = (wfData?.variables as Record<string, any>) || {}
376+
const workflowStateWithVariables = {
377+
...deployedState,
378+
variables: workflowVariables,
379+
}
361380

362381
return {
363382
name: wfData?.name || DEFAULTS.WORKFLOW_NAME,
364383
serializedState: serializedWorkflow,
365384
variables: workflowVariables,
385+
workflowState: workflowStateWithVariables,
366386
rawBlocks: deployedState.blocks,
367387
}
368388
}
@@ -504,7 +524,8 @@ export class WorkflowBlockHandler implements BlockHandler {
504524
childWorkflowId: string,
505525
childWorkflowName: string,
506526
duration: number,
507-
childTraceSpans?: WorkflowTraceSpan[]
527+
childTraceSpans?: WorkflowTraceSpan[],
528+
childWorkflowSnapshotId?: string
508529
): BlockOutput {
509530
const success = childResult.success !== false
510531
const result = childResult.output || {}
@@ -521,6 +542,8 @@ export class WorkflowBlockHandler implements BlockHandler {
521542
return {
522543
success: true,
523544
childWorkflowName,
545+
childWorkflowId,
546+
childWorkflowSnapshotId,
524547
result,
525548
childTraceSpans: childTraceSpans || [],
526549
} as Record<string, any>

apps/sim/hooks/queries/logs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ export interface ExecutionSnapshotData {
210210
executionId: string
211211
workflowId: string
212212
workflowState: Record<string, unknown>
213+
childWorkflowSnapshots?: Record<string, Record<string, unknown>>
213214
executionMetadata: {
214215
trigger: string
215216
startedAt: string

apps/sim/lib/logs/execution/trace-spans/trace-spans.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,26 @@ export function buildTraceSpans(result: ExecutionResult): {
112112
const duration = log.durationMs || 0
113113

114114
let output = log.output || {}
115+
let childWorkflowSnapshotId: string | undefined
116+
let childWorkflowId: string | undefined
117+
118+
if (output && typeof output === 'object') {
119+
const outputRecord = output as Record<string, unknown>
120+
childWorkflowSnapshotId =
121+
typeof outputRecord.childWorkflowSnapshotId === 'string'
122+
? outputRecord.childWorkflowSnapshotId
123+
: undefined
124+
childWorkflowId =
125+
typeof outputRecord.childWorkflowId === 'string' ? outputRecord.childWorkflowId : undefined
126+
if (childWorkflowSnapshotId || childWorkflowId) {
127+
const {
128+
childWorkflowSnapshotId: _childSnapshotId,
129+
childWorkflowId: _childWorkflowId,
130+
...outputRest
131+
} = outputRecord
132+
output = outputRest
133+
}
134+
}
115135

116136
if (log.error) {
117137
output = {
@@ -134,6 +154,8 @@ export function buildTraceSpans(result: ExecutionResult): {
134154
blockId: log.blockId,
135155
input: log.input || {},
136156
output: output,
157+
...(childWorkflowSnapshotId ? { childWorkflowSnapshotId } : {}),
158+
...(childWorkflowId ? { childWorkflowId } : {}),
137159
...(log.loopId && { loopId: log.loopId }),
138160
...(log.parallelId && { parallelId: log.parallelId }),
139161
...(log.iterationIndex !== undefined && { iterationIndex: log.iterationIndex }),

apps/sim/lib/logs/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ export interface TraceSpan {
178178
blockId?: string
179179
input?: Record<string, unknown>
180180
output?: Record<string, unknown>
181+
childWorkflowSnapshotId?: string
182+
childWorkflowId?: string
181183
model?: string
182184
cost?: {
183185
input?: number

0 commit comments

Comments
 (0)