Skip to content

Commit e0f1e66

Browse files
feat(child-workflows): nested execution snapshots (#3059)
* feat(child-workflows): nested execution snapshots * cleanup typing * address bugbot comments and fix tests * do not cascade delete logs/snapshots * fix few more inconsitencies * fix external logs route * add fallback color
1 parent 20bb7cd commit e0f1e66

File tree

32 files changed

+10658
-95
lines changed

32 files changed

+10658
-95
lines changed

apps/sim/app/api/logs/[id]/route.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
5656
deploymentVersionName: workflowDeploymentVersion.name,
5757
})
5858
.from(workflowExecutionLogs)
59-
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
59+
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
6060
.leftJoin(
6161
workflowDeploymentVersion,
6262
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
@@ -65,7 +65,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
6565
permissions,
6666
and(
6767
eq(permissions.entityType, 'workspace'),
68-
eq(permissions.entityId, workflow.workspaceId),
68+
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
6969
eq(permissions.userId, userId)
7070
)
7171
)
@@ -77,17 +77,19 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
7777
return NextResponse.json({ error: 'Not found' }, { status: 404 })
7878
}
7979

80-
const workflowSummary = {
81-
id: log.workflowId,
82-
name: log.workflowName,
83-
description: log.workflowDescription,
84-
color: log.workflowColor,
85-
folderId: log.workflowFolderId,
86-
userId: log.workflowUserId,
87-
workspaceId: log.workflowWorkspaceId,
88-
createdAt: log.workflowCreatedAt,
89-
updatedAt: log.workflowUpdatedAt,
90-
}
80+
const workflowSummary = log.workflowId
81+
? {
82+
id: log.workflowId,
83+
name: log.workflowName,
84+
description: log.workflowDescription,
85+
color: log.workflowColor,
86+
folderId: log.workflowFolderId,
87+
userId: log.workflowUserId,
88+
workspaceId: log.workflowWorkspaceId,
89+
createdAt: log.workflowCreatedAt,
90+
updatedAt: log.workflowUpdatedAt,
91+
}
92+
: null
9193

9294
const response = {
9395
id: log.id,

apps/sim/app/api/logs/cleanup/route.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from '@sim/db'
2-
import { subscription, user, workflow, workflowExecutionLogs } from '@sim/db/schema'
2+
import { subscription, user, workflowExecutionLogs, workspace } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq, inArray, lt, sql } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
@@ -40,17 +40,17 @@ export async function GET(request: NextRequest) {
4040

4141
const freeUserIds = freeUsers.map((u) => u.userId)
4242

43-
const workflowsQuery = await db
44-
.select({ id: workflow.id })
45-
.from(workflow)
46-
.where(inArray(workflow.userId, freeUserIds))
43+
const workspacesQuery = await db
44+
.select({ id: workspace.id })
45+
.from(workspace)
46+
.where(inArray(workspace.billedAccountUserId, freeUserIds))
4747

48-
if (workflowsQuery.length === 0) {
49-
logger.info('No workflows found for free users')
50-
return NextResponse.json({ message: 'No workflows found for cleanup' })
48+
if (workspacesQuery.length === 0) {
49+
logger.info('No workspaces found for free users')
50+
return NextResponse.json({ message: 'No workspaces found for cleanup' })
5151
}
5252

53-
const workflowIds = workflowsQuery.map((w) => w.id)
53+
const workspaceIds = workspacesQuery.map((w) => w.id)
5454

5555
const results = {
5656
enhancedLogs: {
@@ -77,7 +77,7 @@ export async function GET(request: NextRequest) {
7777
let batchesProcessed = 0
7878
let hasMoreLogs = true
7979

80-
logger.info(`Starting enhanced logs cleanup for ${workflowIds.length} workflows`)
80+
logger.info(`Starting enhanced logs cleanup for ${workspaceIds.length} workspaces`)
8181

8282
while (hasMoreLogs && batchesProcessed < MAX_BATCHES) {
8383
const oldEnhancedLogs = await db
@@ -99,7 +99,7 @@ export async function GET(request: NextRequest) {
9999
.from(workflowExecutionLogs)
100100
.where(
101101
and(
102-
inArray(workflowExecutionLogs.workflowId, workflowIds),
102+
inArray(workflowExecutionLogs.workspaceId, workspaceIds),
103103
lt(workflowExecutionLogs.createdAt, retentionDate)
104104
)
105105
)
@@ -127,7 +127,7 @@ export async function GET(request: NextRequest) {
127127
customKey: enhancedLogKey,
128128
metadata: {
129129
logId: String(log.id),
130-
workflowId: String(log.workflowId),
130+
workflowId: String(log.workflowId ?? ''),
131131
executionId: String(log.executionId),
132132
logType: 'enhanced',
133133
archivedAt: new Date().toISOString(),

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

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ 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'
13+
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
1314

1415
const logger = createLogger('LogsByExecutionIdAPI')
1516

@@ -48,14 +49,15 @@ export async function GET(
4849
endedAt: workflowExecutionLogs.endedAt,
4950
totalDurationMs: workflowExecutionLogs.totalDurationMs,
5051
cost: workflowExecutionLogs.cost,
52+
executionData: workflowExecutionLogs.executionData,
5153
})
5254
.from(workflowExecutionLogs)
53-
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
55+
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
5456
.innerJoin(
5557
permissions,
5658
and(
5759
eq(permissions.entityType, 'workspace'),
58-
eq(permissions.entityId, workflow.workspaceId),
60+
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
5961
eq(permissions.userId, authenticatedUserId)
6062
)
6163
)
@@ -78,10 +80,42 @@ export async function GET(
7880
return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 })
7981
}
8082

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

apps/sim/app/api/logs/export/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
22
import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, desc, eq } from 'drizzle-orm'
4+
import { and, desc, eq, sql } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { getSession } from '@/lib/auth'
77
import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters'
@@ -41,7 +41,7 @@ export async function GET(request: NextRequest) {
4141
totalDurationMs: workflowExecutionLogs.totalDurationMs,
4242
cost: workflowExecutionLogs.cost,
4343
executionData: workflowExecutionLogs.executionData,
44-
workflowName: workflow.name,
44+
workflowName: sql<string>`COALESCE(${workflow.name}, 'Deleted Workflow')`,
4545
}
4646

4747
const workspaceCondition = eq(workflowExecutionLogs.workspaceId, params.workspaceId)
@@ -74,7 +74,7 @@ export async function GET(request: NextRequest) {
7474
const rows = await db
7575
.select(selectColumns)
7676
.from(workflowExecutionLogs)
77-
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
77+
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
7878
.innerJoin(
7979
permissions,
8080
and(

apps/sim/app/api/logs/route.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export async function GET(request: NextRequest) {
116116
workflowDeploymentVersion,
117117
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
118118
)
119-
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
119+
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
120120
.innerJoin(
121121
permissions,
122122
and(
@@ -190,7 +190,7 @@ export async function GET(request: NextRequest) {
190190
pausedExecutions,
191191
eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)
192192
)
193-
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
193+
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
194194
.innerJoin(
195195
permissions,
196196
and(
@@ -314,17 +314,19 @@ export async function GET(request: NextRequest) {
314314
} catch {}
315315
}
316316

317-
const workflowSummary = {
318-
id: log.workflowId,
319-
name: log.workflowName,
320-
description: log.workflowDescription,
321-
color: log.workflowColor,
322-
folderId: log.workflowFolderId,
323-
userId: log.workflowUserId,
324-
workspaceId: log.workflowWorkspaceId,
325-
createdAt: log.workflowCreatedAt,
326-
updatedAt: log.workflowUpdatedAt,
327-
}
317+
const workflowSummary = log.workflowId
318+
? {
319+
id: log.workflowId,
320+
name: log.workflowName,
321+
description: log.workflowDescription,
322+
color: log.workflowColor,
323+
folderId: log.workflowFolderId,
324+
userId: log.workflowUserId,
325+
workspaceId: log.workflowWorkspaceId,
326+
createdAt: log.workflowCreatedAt,
327+
updatedAt: log.workflowUpdatedAt,
328+
}
329+
: null
328330

329331
return {
330332
id: log.id,

apps/sim/app/api/logs/stats/route.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export async function GET(request: NextRequest) {
7272
maxTime: sql<string>`MAX(${workflowExecutionLogs.startedAt})`,
7373
})
7474
.from(workflowExecutionLogs)
75-
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
75+
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
7676
.innerJoin(
7777
permissions,
7878
and(
@@ -103,8 +103,8 @@ export async function GET(request: NextRequest) {
103103

104104
const statsQuery = await db
105105
.select({
106-
workflowId: workflowExecutionLogs.workflowId,
107-
workflowName: workflow.name,
106+
workflowId: sql<string>`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`,
107+
workflowName: sql<string>`COALESCE(${workflow.name}, 'Deleted Workflow')`,
108108
segmentIndex:
109109
sql<number>`FLOOR(EXTRACT(EPOCH FROM (${workflowExecutionLogs.startedAt} - ${startTimeIso}::timestamp)) * 1000 / ${segmentMs})`.as(
110110
'segment_index'
@@ -120,7 +120,7 @@ export async function GET(request: NextRequest) {
120120
),
121121
})
122122
.from(workflowExecutionLogs)
123-
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
123+
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
124124
.innerJoin(
125125
permissions,
126126
and(
@@ -130,7 +130,11 @@ export async function GET(request: NextRequest) {
130130
)
131131
)
132132
.where(whereCondition)
133-
.groupBy(workflowExecutionLogs.workflowId, workflow.name, sql`segment_index`)
133+
.groupBy(
134+
sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`,
135+
sql`COALESCE(${workflow.name}, 'Deleted Workflow')`,
136+
sql`segment_index`
137+
)
134138

135139
const workflowMap = new Map<
136140
string,

apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
215215
}
216216

217217
for (const log of logs) {
218+
if (!log.workflowId) continue // Skip logs for deleted workflows
218219
const idx = Math.min(
219220
segments - 1,
220221
Math.max(0, Math.floor((log.startedAt.getTime() - start.getTime()) / segmentMs))

apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { memo } from 'react'
22
import { cn } from '@/lib/core/utils/cn'
3+
import {
4+
DELETED_WORKFLOW_COLOR,
5+
DELETED_WORKFLOW_LABEL,
6+
} from '@/app/workspace/[workspaceId]/logs/utils'
37
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
48
import { StatusBar, type StatusBarSegment } from '..'
59

@@ -61,22 +65,32 @@ export function WorkflowsList({
6165
<div>
6266
{filteredExecutions.map((workflow, idx) => {
6367
const isSelected = expandedWorkflowId === workflow.workflowId
68+
const isDeletedWorkflow = workflow.workflowName === DELETED_WORKFLOW_LABEL
69+
const workflowColor = isDeletedWorkflow
70+
? DELETED_WORKFLOW_COLOR
71+
: workflows[workflow.workflowId]?.color || '#64748b'
72+
const canToggle = !isDeletedWorkflow
6473

6574
return (
6675
<div
6776
key={workflow.workflowId}
6877
className={cn(
69-
'flex h-[44px] cursor-pointer items-center gap-[16px] px-[24px] hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]',
78+
'flex h-[44px] items-center gap-[16px] px-[24px] hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]',
79+
canToggle ? 'cursor-pointer' : 'cursor-default',
7080
isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
7181
)}
72-
onClick={() => onToggleWorkflow(workflow.workflowId)}
82+
onClick={() => {
83+
if (canToggle) {
84+
onToggleWorkflow(workflow.workflowId)
85+
}
86+
}}
7387
>
7488
{/* Workflow name with color */}
7589
<div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'>
7690
<div
7791
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
7892
style={{
79-
backgroundColor: workflows[workflow.workflowId]?.color || '#64748b',
93+
backgroundColor: workflowColor,
8094
}}
8195
/>
8296
<span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'>

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}

0 commit comments

Comments
 (0)