Skip to content

Commit 3bbf9f2

Browse files
waleedlatif1claude
andcommitted
fix(logs): split summary/detail contracts to make trace tab gate type-safe
The Trace tab was silently missing from the Log Details sidepanel because list and detail rows shared one WorkflowLog type with executionData: z.unknown(). The UI couldn't distinguish a summary row (no spans) from a detail row (with spans), so the tab gate read undefined and hid itself. Splits into WorkflowLogSummary (list) and WorkflowLogDetail (typed executionData with optional traceSpans). Detail and by-execution routes both write through to the same logKeys.detail(id) cache, eliminating the two-key fragmentation that caused the merge memo workaround. List route moves to cursor pagination on (sortValue, id) with proper NULLS LAST handling and SQL-side sort across workflow + job execution tables. Detail route now requires and asserts workspaceId. Deep-link path uses useLogByExecutionId instead of auto-paginating the entire workspace. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5d53847 commit 3bbf9f2

11 files changed

Lines changed: 1131 additions & 905 deletions

File tree

Lines changed: 163 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { db } from '@sim/db'
22
import {
33
jobExecutionLogs,
4+
pausedExecutions,
45
permissions,
56
workflow,
67
workflowDeploymentVersion,
@@ -9,175 +10,195 @@ import {
910
import { createLogger } from '@sim/logger'
1011
import { and, eq } from 'drizzle-orm'
1112
import { type NextRequest, NextResponse } from 'next/server'
12-
import { logIdParamsSchema } from '@/lib/api/contracts/logs'
13+
import { getLogDetailContract } from '@/lib/api/contracts/logs'
14+
import { parseRequest } from '@/lib/api/server'
1315
import { getSession } from '@/lib/auth'
14-
import { generateRequestId } from '@/lib/core/utils/request'
1516
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1617

1718
const logger = createLogger('LogDetailsByIdAPI')
1819

19-
export const revalidate = 0
20-
2120
export const GET = withRouteHandler(
22-
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
23-
const requestId = generateRequestId()
21+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
22+
const session = await getSession()
23+
if (!session?.user?.id) {
24+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
25+
}
2426

25-
try {
26-
const session = await getSession()
27-
if (!session?.user?.id) {
28-
logger.warn(`[${requestId}] Unauthorized log details access attempt`)
29-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
30-
}
27+
const userId = session.user.id
28+
const parsed = await parseRequest(getLogDetailContract, request, context)
29+
if (!parsed.success) return parsed.response
30+
31+
const { id } = parsed.data.params
32+
const { workspaceId } = parsed.data.query
33+
34+
const rows = await db
35+
.select({
36+
id: workflowExecutionLogs.id,
37+
workflowId: workflowExecutionLogs.workflowId,
38+
executionId: workflowExecutionLogs.executionId,
39+
deploymentVersionId: workflowExecutionLogs.deploymentVersionId,
40+
level: workflowExecutionLogs.level,
41+
status: workflowExecutionLogs.status,
42+
trigger: workflowExecutionLogs.trigger,
43+
startedAt: workflowExecutionLogs.startedAt,
44+
endedAt: workflowExecutionLogs.endedAt,
45+
totalDurationMs: workflowExecutionLogs.totalDurationMs,
46+
executionData: workflowExecutionLogs.executionData,
47+
cost: workflowExecutionLogs.cost,
48+
files: workflowExecutionLogs.files,
49+
createdAt: workflowExecutionLogs.createdAt,
50+
workflowName: workflow.name,
51+
workflowDescription: workflow.description,
52+
workflowColor: workflow.color,
53+
workflowFolderId: workflow.folderId,
54+
workflowUserId: workflow.userId,
55+
workflowWorkspaceId: workflow.workspaceId,
56+
workflowCreatedAt: workflow.createdAt,
57+
workflowUpdatedAt: workflow.updatedAt,
58+
deploymentVersion: workflowDeploymentVersion.version,
59+
deploymentVersionName: workflowDeploymentVersion.name,
60+
pausedStatus: pausedExecutions.status,
61+
pausedTotalPauseCount: pausedExecutions.totalPauseCount,
62+
pausedResumedCount: pausedExecutions.resumedCount,
63+
})
64+
.from(workflowExecutionLogs)
65+
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
66+
.leftJoin(
67+
workflowDeploymentVersion,
68+
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
69+
)
70+
.leftJoin(
71+
pausedExecutions,
72+
eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)
73+
)
74+
.innerJoin(
75+
permissions,
76+
and(
77+
eq(permissions.entityType, 'workspace'),
78+
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
79+
eq(permissions.userId, userId)
80+
)
81+
)
82+
.where(
83+
and(eq(workflowExecutionLogs.id, id), eq(workflowExecutionLogs.workspaceId, workspaceId))
84+
)
85+
.limit(1)
3186

32-
const userId = session.user.id
33-
const { id } = logIdParamsSchema.parse(await params)
87+
const log = rows[0]
3488

35-
const rows = await db
89+
if (!log) {
90+
const jobRows = await db
3691
.select({
37-
id: workflowExecutionLogs.id,
38-
workflowId: workflowExecutionLogs.workflowId,
39-
executionId: workflowExecutionLogs.executionId,
40-
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
41-
deploymentVersionId: workflowExecutionLogs.deploymentVersionId,
42-
level: workflowExecutionLogs.level,
43-
status: workflowExecutionLogs.status,
44-
trigger: workflowExecutionLogs.trigger,
45-
startedAt: workflowExecutionLogs.startedAt,
46-
endedAt: workflowExecutionLogs.endedAt,
47-
totalDurationMs: workflowExecutionLogs.totalDurationMs,
48-
executionData: workflowExecutionLogs.executionData,
49-
cost: workflowExecutionLogs.cost,
50-
files: workflowExecutionLogs.files,
51-
createdAt: workflowExecutionLogs.createdAt,
52-
workflowName: workflow.name,
53-
workflowDescription: workflow.description,
54-
workflowColor: workflow.color,
55-
workflowFolderId: workflow.folderId,
56-
workflowUserId: workflow.userId,
57-
workflowWorkspaceId: workflow.workspaceId,
58-
workflowCreatedAt: workflow.createdAt,
59-
workflowUpdatedAt: workflow.updatedAt,
60-
deploymentVersion: workflowDeploymentVersion.version,
61-
deploymentVersionName: workflowDeploymentVersion.name,
92+
id: jobExecutionLogs.id,
93+
executionId: jobExecutionLogs.executionId,
94+
level: jobExecutionLogs.level,
95+
status: jobExecutionLogs.status,
96+
trigger: jobExecutionLogs.trigger,
97+
startedAt: jobExecutionLogs.startedAt,
98+
endedAt: jobExecutionLogs.endedAt,
99+
totalDurationMs: jobExecutionLogs.totalDurationMs,
100+
executionData: jobExecutionLogs.executionData,
101+
cost: jobExecutionLogs.cost,
102+
createdAt: jobExecutionLogs.createdAt,
62103
})
63-
.from(workflowExecutionLogs)
64-
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
65-
.leftJoin(
66-
workflowDeploymentVersion,
67-
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
68-
)
104+
.from(jobExecutionLogs)
69105
.innerJoin(
70106
permissions,
71107
and(
72108
eq(permissions.entityType, 'workspace'),
73-
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
109+
eq(permissions.entityId, jobExecutionLogs.workspaceId),
74110
eq(permissions.userId, userId)
75111
)
76112
)
77-
.where(eq(workflowExecutionLogs.id, id))
113+
.where(and(eq(jobExecutionLogs.id, id), eq(jobExecutionLogs.workspaceId, workspaceId)))
78114
.limit(1)
79115

80-
const log = rows[0]
116+
const jobLog = jobRows[0]
117+
if (!jobLog) {
118+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
119+
}
81120

82-
// Fallback: check job_execution_logs
83-
if (!log) {
84-
const jobRows = await db
85-
.select({
86-
id: jobExecutionLogs.id,
87-
executionId: jobExecutionLogs.executionId,
88-
level: jobExecutionLogs.level,
89-
status: jobExecutionLogs.status,
90-
trigger: jobExecutionLogs.trigger,
91-
startedAt: jobExecutionLogs.startedAt,
92-
endedAt: jobExecutionLogs.endedAt,
93-
totalDurationMs: jobExecutionLogs.totalDurationMs,
94-
executionData: jobExecutionLogs.executionData,
95-
cost: jobExecutionLogs.cost,
96-
createdAt: jobExecutionLogs.createdAt,
97-
})
98-
.from(jobExecutionLogs)
99-
.innerJoin(
100-
permissions,
101-
and(
102-
eq(permissions.entityType, 'workspace'),
103-
eq(permissions.entityId, jobExecutionLogs.workspaceId),
104-
eq(permissions.userId, userId)
105-
)
106-
)
107-
.where(eq(jobExecutionLogs.id, id))
108-
.limit(1)
121+
const execData = (jobLog.executionData as Record<string, unknown> | null) ?? {}
122+
const data = {
123+
id: jobLog.id,
124+
workflowId: null,
125+
executionId: jobLog.executionId,
126+
deploymentVersionId: null,
127+
deploymentVersion: null,
128+
deploymentVersionName: null,
129+
level: jobLog.level,
130+
status: jobLog.status,
131+
duration: jobLog.totalDurationMs ? `${jobLog.totalDurationMs}ms` : null,
132+
trigger: jobLog.trigger,
133+
createdAt: jobLog.startedAt.toISOString(),
134+
workflow: null,
135+
jobTitle:
136+
((execData.trigger as Record<string, unknown> | undefined)?.source as string) ?? null,
137+
cost: jobLog.cost ?? null,
138+
pauseSummary: { status: null, total: 0, resumed: 0 },
139+
hasPendingPause: false,
140+
executionData: {
141+
totalDuration: jobLog.totalDurationMs,
142+
enhanced: true as const,
143+
...execData,
144+
},
145+
files: null,
146+
}
109147

110-
const jobLog = jobRows[0]
111-
if (!jobLog) {
112-
return NextResponse.json({ error: 'Not found' }, { status: 404 })
113-
}
148+
return NextResponse.json({ data })
149+
}
114150

115-
const execData = jobLog.executionData as Record<string, any> | null
116-
const response = {
117-
id: jobLog.id,
118-
workflowId: null,
119-
executionId: jobLog.executionId,
120-
deploymentVersionId: null,
121-
deploymentVersion: null,
122-
deploymentVersionName: null,
123-
level: jobLog.level,
124-
status: jobLog.status,
125-
duration: jobLog.totalDurationMs ? `${jobLog.totalDurationMs}ms` : null,
126-
trigger: jobLog.trigger,
127-
createdAt: jobLog.startedAt.toISOString(),
128-
workflow: null,
129-
jobTitle: (execData?.trigger?.source as string) || null,
130-
executionData: {
131-
totalDuration: jobLog.totalDurationMs,
132-
...execData,
133-
enhanced: true,
134-
},
135-
cost: jobLog.cost as any,
151+
const workflowSummary = log.workflowId
152+
? {
153+
id: log.workflowId,
154+
name: log.workflowName,
155+
description: log.workflowDescription,
156+
color: log.workflowColor,
157+
folderId: log.workflowFolderId,
158+
userId: log.workflowUserId,
159+
workspaceId: log.workflowWorkspaceId,
160+
createdAt: log.workflowCreatedAt?.toISOString() ?? null,
161+
updatedAt: log.workflowUpdatedAt?.toISOString() ?? null,
136162
}
163+
: null
137164

138-
return NextResponse.json({ data: response })
139-
}
165+
const totalPauseCount = Number(log.pausedTotalPauseCount ?? 0)
166+
const resumedCount = Number(log.pausedResumedCount ?? 0)
167+
const hasPendingPause =
168+
(totalPauseCount > 0 && resumedCount < totalPauseCount) ||
169+
(log.pausedStatus !== null && log.pausedStatus !== 'fully_resumed')
140170

141-
const workflowSummary = log.workflowId
142-
? {
143-
id: log.workflowId,
144-
name: log.workflowName,
145-
description: log.workflowDescription,
146-
color: log.workflowColor,
147-
folderId: log.workflowFolderId,
148-
userId: log.workflowUserId,
149-
workspaceId: log.workflowWorkspaceId,
150-
createdAt: log.workflowCreatedAt,
151-
updatedAt: log.workflowUpdatedAt,
152-
}
153-
: null
171+
const data = {
172+
id: log.id,
173+
workflowId: log.workflowId,
174+
executionId: log.executionId,
175+
deploymentVersionId: log.deploymentVersionId,
176+
deploymentVersion: log.deploymentVersion ?? null,
177+
deploymentVersionName: log.deploymentVersionName ?? null,
178+
level: log.level,
179+
status: log.status,
180+
duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null,
181+
trigger: log.trigger,
182+
createdAt: log.startedAt.toISOString(),
183+
workflow: workflowSummary,
184+
jobTitle: null,
185+
cost: log.cost ?? null,
186+
pauseSummary: {
187+
status: log.pausedStatus ?? null,
188+
total: totalPauseCount,
189+
resumed: resumedCount,
190+
},
191+
hasPendingPause,
192+
executionData: {
193+
totalDuration: log.totalDurationMs,
194+
enhanced: true as const,
195+
...((log.executionData as Record<string, unknown> | null) ?? {}),
196+
},
197+
files: log.files ?? null,
198+
}
154199

155-
const response = {
156-
id: log.id,
157-
workflowId: log.workflowId,
158-
executionId: log.executionId,
159-
deploymentVersionId: log.deploymentVersionId,
160-
deploymentVersion: log.deploymentVersion ?? null,
161-
deploymentVersionName: log.deploymentVersionName ?? null,
162-
level: log.level,
163-
status: log.status,
164-
duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null,
165-
trigger: log.trigger,
166-
createdAt: log.startedAt.toISOString(),
167-
files: log.files || undefined,
168-
workflow: workflowSummary,
169-
executionData: {
170-
totalDuration: log.totalDurationMs,
171-
...(log.executionData as any),
172-
enhanced: true,
173-
},
174-
cost: log.cost as any,
175-
}
200+
logger.debug('Fetched log detail', { id, workspaceId })
176201

177-
return NextResponse.json({ data: response })
178-
} catch (error: any) {
179-
logger.error(`[${requestId}] log details fetch error`, error)
180-
return NextResponse.json({ error: error.message }, { status: 500 })
181-
}
202+
return NextResponse.json({ data })
182203
}
183204
)

0 commit comments

Comments
 (0)