From 3bbf9f25705dccecc35a6f72522692b0ab581022 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 4 May 2026 11:00:33 -0700 Subject: [PATCH 01/13] 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 --- apps/sim/app/api/logs/[id]/route.ts | 305 +++--- .../logs/by-execution/[executionId]/route.ts | 212 ++++ apps/sim/app/api/logs/route.ts | 934 ++++++++---------- .../add-resource-dropdown.tsx | 2 + .../resource-content/resource-content.tsx | 9 +- .../components/log-details/log-details.tsx | 21 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 163 ++- .../user-input/hooks/use-mention-data.ts | 2 +- apps/sim/hooks/queries/logs.ts | 142 ++- apps/sim/lib/api/contracts/logs.ts | 242 ++++- scripts/check-api-validation-contracts.ts | 4 +- 11 files changed, 1131 insertions(+), 905 deletions(-) create mode 100644 apps/sim/app/api/logs/by-execution/[executionId]/route.ts diff --git a/apps/sim/app/api/logs/[id]/route.ts b/apps/sim/app/api/logs/[id]/route.ts index 575b0867b1a..7f32666b050 100644 --- a/apps/sim/app/api/logs/[id]/route.ts +++ b/apps/sim/app/api/logs/[id]/route.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { jobExecutionLogs, + pausedExecutions, permissions, workflow, workflowDeploymentVersion, @@ -9,175 +10,195 @@ import { import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { logIdParamsSchema } from '@/lib/api/contracts/logs' +import { getLogDetailContract } from '@/lib/api/contracts/logs' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('LogDetailsByIdAPI') -export const revalidate = 0 - export const GET = withRouteHandler( - async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized log details access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const userId = session.user.id + const parsed = await parseRequest(getLogDetailContract, request, context) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + level: workflowExecutionLogs.level, + status: workflowExecutionLogs.status, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + cost: workflowExecutionLogs.cost, + files: workflowExecutionLogs.files, + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowColor: workflow.color, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + deploymentVersion: workflowDeploymentVersion.version, + deploymentVersionName: workflowDeploymentVersion.name, + pausedStatus: pausedExecutions.status, + pausedTotalPauseCount: pausedExecutions.totalPauseCount, + pausedResumedCount: pausedExecutions.resumedCount, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .leftJoin( + workflowDeploymentVersion, + eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) + ) + .leftJoin( + pausedExecutions, + eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) + ) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflowExecutionLogs.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where( + and(eq(workflowExecutionLogs.id, id), eq(workflowExecutionLogs.workspaceId, workspaceId)) + ) + .limit(1) - const userId = session.user.id - const { id } = logIdParamsSchema.parse(await params) + const log = rows[0] - const rows = await db + if (!log) { + const jobRows = await db .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - executionId: workflowExecutionLogs.executionId, - stateSnapshotId: workflowExecutionLogs.stateSnapshotId, - deploymentVersionId: workflowExecutionLogs.deploymentVersionId, - level: workflowExecutionLogs.level, - status: workflowExecutionLogs.status, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - cost: workflowExecutionLogs.cost, - files: workflowExecutionLogs.files, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowColor: workflow.color, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - deploymentVersion: workflowDeploymentVersion.version, - deploymentVersionName: workflowDeploymentVersion.name, + id: jobExecutionLogs.id, + executionId: jobExecutionLogs.executionId, + level: jobExecutionLogs.level, + status: jobExecutionLogs.status, + trigger: jobExecutionLogs.trigger, + startedAt: jobExecutionLogs.startedAt, + endedAt: jobExecutionLogs.endedAt, + totalDurationMs: jobExecutionLogs.totalDurationMs, + executionData: jobExecutionLogs.executionData, + cost: jobExecutionLogs.cost, + createdAt: jobExecutionLogs.createdAt, }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .leftJoin( - workflowDeploymentVersion, - eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) - ) + .from(jobExecutionLogs) .innerJoin( permissions, and( eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), + eq(permissions.entityId, jobExecutionLogs.workspaceId), eq(permissions.userId, userId) ) ) - .where(eq(workflowExecutionLogs.id, id)) + .where(and(eq(jobExecutionLogs.id, id), eq(jobExecutionLogs.workspaceId, workspaceId))) .limit(1) - const log = rows[0] + const jobLog = jobRows[0] + if (!jobLog) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } - // Fallback: check job_execution_logs - if (!log) { - const jobRows = await db - .select({ - id: jobExecutionLogs.id, - executionId: jobExecutionLogs.executionId, - level: jobExecutionLogs.level, - status: jobExecutionLogs.status, - trigger: jobExecutionLogs.trigger, - startedAt: jobExecutionLogs.startedAt, - endedAt: jobExecutionLogs.endedAt, - totalDurationMs: jobExecutionLogs.totalDurationMs, - executionData: jobExecutionLogs.executionData, - cost: jobExecutionLogs.cost, - createdAt: jobExecutionLogs.createdAt, - }) - .from(jobExecutionLogs) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, jobExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(eq(jobExecutionLogs.id, id)) - .limit(1) + const execData = (jobLog.executionData as Record | null) ?? {} + const data = { + id: jobLog.id, + workflowId: null, + executionId: jobLog.executionId, + deploymentVersionId: null, + deploymentVersion: null, + deploymentVersionName: null, + level: jobLog.level, + status: jobLog.status, + duration: jobLog.totalDurationMs ? `${jobLog.totalDurationMs}ms` : null, + trigger: jobLog.trigger, + createdAt: jobLog.startedAt.toISOString(), + workflow: null, + jobTitle: + ((execData.trigger as Record | undefined)?.source as string) ?? null, + cost: jobLog.cost ?? null, + pauseSummary: { status: null, total: 0, resumed: 0 }, + hasPendingPause: false, + executionData: { + totalDuration: jobLog.totalDurationMs, + enhanced: true as const, + ...execData, + }, + files: null, + } - const jobLog = jobRows[0] - if (!jobLog) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } + return NextResponse.json({ data }) + } - const execData = jobLog.executionData as Record | null - const response = { - id: jobLog.id, - workflowId: null, - executionId: jobLog.executionId, - deploymentVersionId: null, - deploymentVersion: null, - deploymentVersionName: null, - level: jobLog.level, - status: jobLog.status, - duration: jobLog.totalDurationMs ? `${jobLog.totalDurationMs}ms` : null, - trigger: jobLog.trigger, - createdAt: jobLog.startedAt.toISOString(), - workflow: null, - jobTitle: (execData?.trigger?.source as string) || null, - executionData: { - totalDuration: jobLog.totalDurationMs, - ...execData, - enhanced: true, - }, - cost: jobLog.cost as any, + const workflowSummary = log.workflowId + ? { + id: log.workflowId, + name: log.workflowName, + description: log.workflowDescription, + color: log.workflowColor, + folderId: log.workflowFolderId, + userId: log.workflowUserId, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt?.toISOString() ?? null, + updatedAt: log.workflowUpdatedAt?.toISOString() ?? null, } + : null - return NextResponse.json({ data: response }) - } + const totalPauseCount = Number(log.pausedTotalPauseCount ?? 0) + const resumedCount = Number(log.pausedResumedCount ?? 0) + const hasPendingPause = + (totalPauseCount > 0 && resumedCount < totalPauseCount) || + (log.pausedStatus !== null && log.pausedStatus !== 'fully_resumed') - const workflowSummary = log.workflowId - ? { - id: log.workflowId, - name: log.workflowName, - description: log.workflowDescription, - color: log.workflowColor, - folderId: log.workflowFolderId, - userId: log.workflowUserId, - workspaceId: log.workflowWorkspaceId, - createdAt: log.workflowCreatedAt, - updatedAt: log.workflowUpdatedAt, - } - : null + const data = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + deploymentVersionId: log.deploymentVersionId, + deploymentVersion: log.deploymentVersion ?? null, + deploymentVersionName: log.deploymentVersionName ?? null, + level: log.level, + status: log.status, + duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, + trigger: log.trigger, + createdAt: log.startedAt.toISOString(), + workflow: workflowSummary, + jobTitle: null, + cost: log.cost ?? null, + pauseSummary: { + status: log.pausedStatus ?? null, + total: totalPauseCount, + resumed: resumedCount, + }, + hasPendingPause, + executionData: { + totalDuration: log.totalDurationMs, + enhanced: true as const, + ...((log.executionData as Record | null) ?? {}), + }, + files: log.files ?? null, + } - const response = { - id: log.id, - workflowId: log.workflowId, - executionId: log.executionId, - deploymentVersionId: log.deploymentVersionId, - deploymentVersion: log.deploymentVersion ?? null, - deploymentVersionName: log.deploymentVersionName ?? null, - level: log.level, - status: log.status, - duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, - trigger: log.trigger, - createdAt: log.startedAt.toISOString(), - files: log.files || undefined, - workflow: workflowSummary, - executionData: { - totalDuration: log.totalDurationMs, - ...(log.executionData as any), - enhanced: true, - }, - cost: log.cost as any, - } + logger.debug('Fetched log detail', { id, workspaceId }) - return NextResponse.json({ data: response }) - } catch (error: any) { - logger.error(`[${requestId}] log details fetch error`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) - } + return NextResponse.json({ data }) } ) diff --git a/apps/sim/app/api/logs/by-execution/[executionId]/route.ts b/apps/sim/app/api/logs/by-execution/[executionId]/route.ts new file mode 100644 index 00000000000..2b1e288f8d1 --- /dev/null +++ b/apps/sim/app/api/logs/by-execution/[executionId]/route.ts @@ -0,0 +1,212 @@ +import { db } from '@sim/db' +import { + jobExecutionLogs, + pausedExecutions, + permissions, + workflow, + workflowDeploymentVersion, + workflowExecutionLogs, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getLogByExecutionIdContract } from '@/lib/api/contracts/logs' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('LogDetailsByExecutionAPI') + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + const parsed = await parseRequest(getLogByExecutionIdContract, request, context) + if (!parsed.success) return parsed.response + + const { executionId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + level: workflowExecutionLogs.level, + status: workflowExecutionLogs.status, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + cost: workflowExecutionLogs.cost, + files: workflowExecutionLogs.files, + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowColor: workflow.color, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + deploymentVersion: workflowDeploymentVersion.version, + deploymentVersionName: workflowDeploymentVersion.name, + pausedStatus: pausedExecutions.status, + pausedTotalPauseCount: pausedExecutions.totalPauseCount, + pausedResumedCount: pausedExecutions.resumedCount, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .leftJoin( + workflowDeploymentVersion, + eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) + ) + .leftJoin( + pausedExecutions, + eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) + ) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflowExecutionLogs.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where( + and( + eq(workflowExecutionLogs.executionId, executionId), + eq(workflowExecutionLogs.workspaceId, workspaceId) + ) + ) + .limit(1) + + const log = rows[0] + + if (!log) { + const jobRows = await db + .select({ + id: jobExecutionLogs.id, + executionId: jobExecutionLogs.executionId, + level: jobExecutionLogs.level, + status: jobExecutionLogs.status, + trigger: jobExecutionLogs.trigger, + startedAt: jobExecutionLogs.startedAt, + endedAt: jobExecutionLogs.endedAt, + totalDurationMs: jobExecutionLogs.totalDurationMs, + executionData: jobExecutionLogs.executionData, + cost: jobExecutionLogs.cost, + createdAt: jobExecutionLogs.createdAt, + }) + .from(jobExecutionLogs) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, jobExecutionLogs.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where( + and( + eq(jobExecutionLogs.executionId, executionId), + eq(jobExecutionLogs.workspaceId, workspaceId) + ) + ) + .limit(1) + + const jobLog = jobRows[0] + if (!jobLog) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const execData = (jobLog.executionData as Record | null) ?? {} + const data = { + id: jobLog.id, + workflowId: null, + executionId: jobLog.executionId, + deploymentVersionId: null, + deploymentVersion: null, + deploymentVersionName: null, + level: jobLog.level, + status: jobLog.status, + duration: jobLog.totalDurationMs ? `${jobLog.totalDurationMs}ms` : null, + trigger: jobLog.trigger, + createdAt: jobLog.startedAt.toISOString(), + workflow: null, + jobTitle: + ((execData.trigger as Record | undefined)?.source as string) ?? null, + cost: jobLog.cost ?? null, + pauseSummary: { status: null, total: 0, resumed: 0 }, + hasPendingPause: false, + executionData: { + totalDuration: jobLog.totalDurationMs, + enhanced: true as const, + ...execData, + }, + files: null, + } + + return NextResponse.json({ data }) + } + + const workflowSummary = log.workflowId + ? { + id: log.workflowId, + name: log.workflowName, + description: log.workflowDescription, + color: log.workflowColor, + folderId: log.workflowFolderId, + userId: log.workflowUserId, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt?.toISOString() ?? null, + updatedAt: log.workflowUpdatedAt?.toISOString() ?? null, + } + : null + + const totalPauseCount = Number(log.pausedTotalPauseCount ?? 0) + const resumedCount = Number(log.pausedResumedCount ?? 0) + const hasPendingPause = + (totalPauseCount > 0 && resumedCount < totalPauseCount) || + (log.pausedStatus !== null && log.pausedStatus !== 'fully_resumed') + + const data = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + deploymentVersionId: log.deploymentVersionId, + deploymentVersion: log.deploymentVersion ?? null, + deploymentVersionName: log.deploymentVersionName ?? null, + level: log.level, + status: log.status, + duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, + trigger: log.trigger, + createdAt: log.startedAt.toISOString(), + workflow: workflowSummary, + jobTitle: null, + cost: log.cost ?? null, + pauseSummary: { + status: log.pausedStatus ?? null, + total: totalPauseCount, + resumed: resumedCount, + }, + hasPendingPause, + executionData: { + totalDuration: log.totalDurationMs, + enhanced: true as const, + ...((log.executionData as Record | null) ?? {}), + }, + files: log.files ?? null, + } + + logger.debug('Fetched log by execution id', { executionId, workspaceId }) + + return NextResponse.json({ data }) + } +) diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index 27b071be0f3..9725f468b03 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -10,6 +10,7 @@ import { import { createLogger } from '@sim/logger' import { and, + asc, desc, eq, gt, @@ -24,582 +25,431 @@ import { type SQL, sql, } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { listLogsQuerySchema } from '@/lib/api/contracts/logs' -import { isZodError } from '@/lib/api/server' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { listLogsContract, type WorkflowLogSummary } from '@/lib/api/contracts/logs' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildFilterConditions } from '@/lib/logs/filters' const logger = createLogger('LogsAPI') -export const revalidate = 0 +type SortBy = 'date' | 'duration' | 'cost' | 'status' +type SortOrder = 'asc' | 'desc' -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() +interface CursorData { + v: string | number | null + id: string +} + +function encodeCursor(data: CursorData): string { + return Buffer.from(JSON.stringify(data)).toString('base64') +} +function decodeCursor(cursor: string): CursorData | null { try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized logs access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString()) + if (typeof parsed?.id !== 'string') return null + return parsed as CursorData + } catch { + return null + } +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = session.user.id + + const parsed = await parseRequest(listLogsContract, request, {}) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + const sortBy = params.sortBy as SortBy + const sortOrder = params.sortOrder as SortOrder + const cursor = params.cursor ? decodeCursor(params.cursor) : null + + const workflowSortExpr: SQL = (() => { + switch (sortBy) { + case 'duration': + return sql`${workflowExecutionLogs.totalDurationMs}` + case 'cost': + return sql`(${workflowExecutionLogs.cost}->>'total')::numeric` + case 'status': + return sql`${workflowExecutionLogs.status}` + default: + return sql`${workflowExecutionLogs.startedAt}` } + })() + + const jobSortExpr: SQL = (() => { + switch (sortBy) { + case 'duration': + return sql`${jobExecutionLogs.totalDurationMs}` + case 'cost': + return sql`(${jobExecutionLogs.cost}->>'total')::numeric` + case 'status': + return sql`${jobExecutionLogs.status}` + default: + return sql`${jobExecutionLogs.startedAt}` + } + })() + + const dir = sortOrder === 'asc' ? asc : desc + const nullsLast = sql`NULLS LAST` + const orderByClause = (expr: SQL): SQL => sql`${dir(expr)} ${nullsLast}` + + const buildCursorCondition = (sortExpr: unknown, idCol: unknown): SQL | undefined => { + if (!cursor) return undefined + const v = cursor.v + const id = cursor.id + const cmp = sortOrder === 'asc' ? sql`>` : sql`<` + if (v === null) { + return sql`(${sortExpr} IS NULL AND ${idCol} ${cmp} ${id})` + } + return sql`((${sortExpr} IS NOT NULL AND ${sortExpr} ${cmp} ${v}) OR (${sortExpr} = ${v} AND ${idCol} ${cmp} ${id}) OR ${sortExpr} IS NULL)` + } - const userId = session.user.id - - try { - const { searchParams } = new URL(request.url) - const params = listLogsQuerySchema.parse(Object.fromEntries(searchParams.entries())) - - const selectColumns = - params.details === 'full' - ? { - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - executionId: workflowExecutionLogs.executionId, - stateSnapshotId: workflowExecutionLogs.stateSnapshotId, - deploymentVersionId: workflowExecutionLogs.deploymentVersionId, - level: workflowExecutionLogs.level, - status: workflowExecutionLogs.status, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - cost: workflowExecutionLogs.cost, - files: workflowExecutionLogs.files, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowColor: workflow.color, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - pausedStatus: pausedExecutions.status, - pausedTotalPauseCount: pausedExecutions.totalPauseCount, - pausedResumedCount: pausedExecutions.resumedCount, - deploymentVersion: workflowDeploymentVersion.version, - deploymentVersionName: workflowDeploymentVersion.name, - } - : { - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - executionId: workflowExecutionLogs.executionId, - stateSnapshotId: workflowExecutionLogs.stateSnapshotId, - deploymentVersionId: workflowExecutionLogs.deploymentVersionId, - level: workflowExecutionLogs.level, - status: workflowExecutionLogs.status, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: sql`NULL`, - cost: workflowExecutionLogs.cost, - files: sql`NULL`, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowColor: workflow.color, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - pausedStatus: pausedExecutions.status, - pausedTotalPauseCount: pausedExecutions.totalPauseCount, - pausedResumedCount: pausedExecutions.resumedCount, - deploymentVersion: workflowDeploymentVersion.version, - deploymentVersionName: sql`NULL`, - } - - const workspaceFilter = eq(workflowExecutionLogs.workspaceId, params.workspaceId) - - const baseQuery = db - .select(selectColumns) - .from(workflowExecutionLogs) - .leftJoin( - pausedExecutions, - eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) - ) - .leftJoin( - workflowDeploymentVersion, - eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) - ) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) + const fetchSize = params.limit + 1 - let conditions: SQL | undefined + // Build workflow log conditions + const workflowConditions: SQL[] = [eq(workflowExecutionLogs.workspaceId, params.workspaceId)] - if (params.level && params.level !== 'all') { - const levels = params.level.split(',').filter(Boolean) - const levelConditions: SQL[] = [] + if (params.level && params.level !== 'all') { + const levels = params.level.split(',').filter(Boolean) + const levelConditions: SQL[] = [] - for (const level of levels) { - if (level === 'error') { - levelConditions.push(eq(workflowExecutionLogs.level, 'error')) - } else if (level === 'info') { - const condition = and( - eq(workflowExecutionLogs.level, 'info'), - isNotNull(workflowExecutionLogs.endedAt) - ) - if (condition) levelConditions.push(condition) - } else if (level === 'running') { - const condition = and( - eq(workflowExecutionLogs.level, 'info'), - isNull(workflowExecutionLogs.endedAt) - ) - if (condition) levelConditions.push(condition) - } else if (level === 'pending') { - const condition = and( - eq(workflowExecutionLogs.level, 'info'), - or( - sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`, - and( - isNotNull(pausedExecutions.status), - sql`${pausedExecutions.status} != 'fully_resumed'` - ) - ) + for (const level of levels) { + if (level === 'error') { + levelConditions.push(eq(workflowExecutionLogs.level, 'error')) + } else if (level === 'info') { + const c = and( + eq(workflowExecutionLogs.level, 'info'), + isNotNull(workflowExecutionLogs.endedAt) + ) + if (c) levelConditions.push(c) + } else if (level === 'running') { + const c = and( + eq(workflowExecutionLogs.level, 'info'), + isNull(workflowExecutionLogs.endedAt) + ) + if (c) levelConditions.push(c) + } else if (level === 'pending') { + const c = and( + eq(workflowExecutionLogs.level, 'info'), + or( + sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`, + and( + isNotNull(pausedExecutions.status), + sql`${pausedExecutions.status} != 'fully_resumed'` ) - if (condition) levelConditions.push(condition) - } - } - - if (levelConditions.length > 0) { - conditions = and( - conditions, - levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions) ) - } - } - - // Apply common filters (workflowIds, folderIds, triggers, dates, search, cost, duration) - // Level filtering is handled above with advanced running/pending state logic - const commonFilters = buildFilterConditions(params, { useSimpleLevelFilter: false }) - if (commonFilters) { - conditions = and(conditions, commonFilters) + ) + if (c) levelConditions.push(c) } + } - // Workflow-specific filters exclude job logs entirely - const hasWorkflowSpecificFilters = !!( - params.workflowIds || - params.folderIds || - params.workflowName || - params.folderName + if (levelConditions.length > 0) { + workflowConditions.push( + levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)! ) - // If triggers filter is set and doesn't include 'mothership', skip job logs - const triggersList = params.triggers?.split(',').filter(Boolean) || [] - const triggersExcludeJobs = - triggersList.length > 0 && - !triggersList.includes('all') && - !triggersList.includes('mothership') - const includeJobLogs = !hasWorkflowSpecificFilters && !triggersExcludeJobs - - const fetchSize = params.limit + params.offset - - const workflowLogs = await baseQuery - .where(and(workspaceFilter, conditions)) - .orderBy(desc(workflowExecutionLogs.startedAt)) - .limit(fetchSize) + } + } - const workflowCountQuery = db - .select({ count: sql`count(*)` }) - .from(workflowExecutionLogs) - .leftJoin( - pausedExecutions, - eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) - ) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(and(eq(workflowExecutionLogs.workspaceId, params.workspaceId), conditions)) - - // Build job log filters (subset of filters that apply to job logs) - let jobLogs: Array<{ - id: string - executionId: string - level: string - status: string - trigger: string - startedAt: Date - endedAt: Date | null - totalDurationMs: number | null - executionData: unknown - cost: unknown - createdAt: Date - jobTitle: string | null - }> = [] - let jobCount = 0 - - if (includeJobLogs) { - const jobConditions: SQL[] = [eq(jobExecutionLogs.workspaceId, params.workspaceId)] - - // Permission check + const commonFilters = buildFilterConditions(params, { useSimpleLevelFilter: false }) + if (commonFilters) workflowConditions.push(commonFilters) + + const workflowCursorCond = buildCursorCondition(workflowSortExpr, workflowExecutionLogs.id) + if (workflowCursorCond) workflowConditions.push(workflowCursorCond) + + // Decide whether to include job logs + const hasWorkflowSpecificFilters = !!( + params.workflowIds || + params.folderIds || + params.workflowName || + params.folderName + ) + const triggersList = params.triggers?.split(',').filter(Boolean) || [] + const triggersExcludeJobs = + triggersList.length > 0 && !triggersList.includes('all') && !triggersList.includes('mothership') + const includeJobLogs = !hasWorkflowSpecificFilters && !triggersExcludeJobs + + const workflowQuery = db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + level: workflowExecutionLogs.level, + status: workflowExecutionLogs.status, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + cost: workflowExecutionLogs.cost, + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowColor: workflow.color, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + pausedStatus: pausedExecutions.status, + pausedTotalPauseCount: pausedExecutions.totalPauseCount, + pausedResumedCount: pausedExecutions.resumedCount, + deploymentVersion: workflowDeploymentVersion.version, + deploymentVersionName: workflowDeploymentVersion.name, + sortValue: sql`${workflowSortExpr}`.as('sort_value'), + }) + .from(workflowExecutionLogs) + .leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)) + .leftJoin( + workflowDeploymentVersion, + eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) + ) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflowExecutionLogs.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(and(...workflowConditions)) + .orderBy(orderByClause(workflowSortExpr), dir(workflowExecutionLogs.id)) + .limit(fetchSize) + + const jobConditions: SQL[] = [eq(jobExecutionLogs.workspaceId, params.workspaceId)] + + if (includeJobLogs) { + jobConditions.push( + sql`EXISTS (SELECT 1 FROM ${permissions} WHERE ${permissions.entityType} = 'workspace' AND ${permissions.entityId} = ${jobExecutionLogs.workspaceId} AND ${permissions.userId} = ${userId})` + ) + + if (params.level && params.level !== 'all') { + const levels = params.level.split(',').filter(Boolean) + const jobLevelConditions: SQL[] = [] + for (const level of levels) { + if (level === 'error') { + jobLevelConditions.push(eq(jobExecutionLogs.level, 'error')) + } else if (level === 'info') { + const c = and(eq(jobExecutionLogs.level, 'info'), isNotNull(jobExecutionLogs.endedAt)) + if (c) jobLevelConditions.push(c) + } + } + if (jobLevelConditions.length > 0) { jobConditions.push( - sql`EXISTS (SELECT 1 FROM ${permissions} WHERE ${permissions.entityType} = 'workspace' AND ${permissions.entityId} = ${jobExecutionLogs.workspaceId} AND ${permissions.userId} = ${userId})` + jobLevelConditions.length === 1 ? jobLevelConditions[0] : or(...jobLevelConditions)! ) + } + } - // Level filter - if (params.level && params.level !== 'all') { - const levels = params.level.split(',').filter(Boolean) - const jobLevelConditions: SQL[] = [] - for (const level of levels) { - if (level === 'error') { - jobLevelConditions.push(eq(jobExecutionLogs.level, 'error')) - } else if (level === 'info') { - const c = and(eq(jobExecutionLogs.level, 'info'), isNotNull(jobExecutionLogs.endedAt)) - if (c) jobLevelConditions.push(c) - } - // 'running' and 'pending' don't apply to job logs (they complete synchronously) - } - if (jobLevelConditions.length > 0) { - jobConditions.push( - jobLevelConditions.length === 1 ? jobLevelConditions[0] : or(...jobLevelConditions)! - ) - } - } - - // Trigger filter - if (triggersList.length > 0 && !triggersList.includes('all')) { - jobConditions.push(inArray(jobExecutionLogs.trigger, triggersList)) - } - - // Date filters - if (params.startDate) { - jobConditions.push(gte(jobExecutionLogs.startedAt, new Date(params.startDate))) - } - if (params.endDate) { - jobConditions.push(lte(jobExecutionLogs.startedAt, new Date(params.endDate))) - } + if (triggersList.length > 0 && !triggersList.includes('all')) { + jobConditions.push(inArray(jobExecutionLogs.trigger, triggersList)) + } - // Search by executionId - if (params.search) { - jobConditions.push(sql`${jobExecutionLogs.executionId} ILIKE ${`%${params.search}%`}`) - } - if (params.executionId) { - jobConditions.push(eq(jobExecutionLogs.executionId, params.executionId)) - } + if (params.startDate) { + jobConditions.push(gte(jobExecutionLogs.startedAt, new Date(params.startDate))) + } + if (params.endDate) { + jobConditions.push(lte(jobExecutionLogs.startedAt, new Date(params.endDate))) + } - // Cost filter - if (params.costOperator && params.costValue !== undefined) { - const costField = sql`(${jobExecutionLogs.cost}->>'total')::numeric` - const ops = { - '=': sql`=`, - '>': sql`>`, - '<': sql`<`, - '>=': sql`>=`, - '<=': sql`<=`, - '!=': sql`!=`, - } as const - jobConditions.push(sql`${costField} ${ops[params.costOperator]} ${params.costValue}`) - } + if (params.search) { + jobConditions.push(sql`${jobExecutionLogs.executionId} ILIKE ${`%${params.search}%`}`) + } + if (params.executionId) { + jobConditions.push(eq(jobExecutionLogs.executionId, params.executionId)) + } - // Duration filter - if (params.durationOperator && params.durationValue !== undefined) { - const durationOps: Record< - string, - (field: typeof jobExecutionLogs.totalDurationMs, val: number) => SQL | undefined - > = { - '=': (f, v) => eq(f, v), - '>': (f, v) => gt(f, v), - '<': (f, v) => lt(f, v), - '>=': (f, v) => gte(f, v), - '<=': (f, v) => lte(f, v), - '!=': (f, v) => ne(f, v), - } - const durationCond = durationOps[params.durationOperator]?.( - jobExecutionLogs.totalDurationMs, - params.durationValue - ) - if (durationCond) jobConditions.push(durationCond) - } + if (params.costOperator && params.costValue !== undefined) { + const costField = sql`(${jobExecutionLogs.cost}->>'total')::numeric` + const ops = { + '=': sql`=`, + '>': sql`>`, + '<': sql`<`, + '>=': sql`>=`, + '<=': sql`<=`, + '!=': sql`!=`, + } as const + jobConditions.push(sql`${costField} ${ops[params.costOperator]} ${params.costValue}`) + } - const jobWhere = and(...jobConditions) - - const [jobLogResults, jobCountResult] = await Promise.all([ - db - .select({ - id: jobExecutionLogs.id, - executionId: jobExecutionLogs.executionId, - level: jobExecutionLogs.level, - status: jobExecutionLogs.status, - trigger: jobExecutionLogs.trigger, - startedAt: jobExecutionLogs.startedAt, - endedAt: jobExecutionLogs.endedAt, - totalDurationMs: jobExecutionLogs.totalDurationMs, - executionData: - params.details === 'full' ? jobExecutionLogs.executionData : sql`NULL`, - cost: jobExecutionLogs.cost, - createdAt: jobExecutionLogs.createdAt, - jobTitle: sql`${jobExecutionLogs.executionData}->'trigger'->>'source'`, - }) - .from(jobExecutionLogs) - .where(jobWhere) - .orderBy(desc(jobExecutionLogs.startedAt)) - .limit(fetchSize), - db.select({ count: sql`count(*)` }).from(jobExecutionLogs).where(jobWhere), - ]) - - jobLogs = jobLogResults as typeof jobLogs - jobCount = Number(jobCountResult[0]?.count || 0) + if (params.durationOperator && params.durationValue !== undefined) { + const durationOps: Record< + string, + (field: typeof jobExecutionLogs.totalDurationMs, val: number) => SQL | undefined + > = { + '=': (f, v) => eq(f, v), + '>': (f, v) => gt(f, v), + '<': (f, v) => lt(f, v), + '>=': (f, v) => gte(f, v), + '<=': (f, v) => lte(f, v), + '!=': (f, v) => ne(f, v), } + const durationCond = durationOps[params.durationOperator]?.( + jobExecutionLogs.totalDurationMs, + params.durationValue + ) + if (durationCond) jobConditions.push(durationCond) + } - const workflowCountResult = await workflowCountQuery - const workflowCount = Number(workflowCountResult[0]?.count || 0) - const totalCount = workflowCount + jobCount - - // Transform workflow logs to the unified shape - const blockExecutionsByExecution: Record = {} - - const createTraceSpans = (blockExecutions: any[]) => { - return blockExecutions.map((block, index) => { - let output = block.outputData - if (block.status === 'error' && block.errorMessage) { - output = { - ...output, - error: block.errorMessage, - stackTrace: block.errorStackTrace, - } - } - - return { - id: block.id, - name: `Block ${block.blockName || block.blockType} (${block.blockType})`, - type: block.blockType, - duration: block.durationMs, - startTime: block.startedAt, - endTime: block.endedAt, - status: block.status === 'success' ? 'success' : 'error', - blockId: block.blockId, - input: block.inputData, - output, - tokens: block.cost?.tokens?.total || 0, - relativeStartMs: index * 100, - children: [], - toolCalls: [], - } - }) - } + const jobCursorCond = buildCursorCondition(jobSortExpr, jobExecutionLogs.id) + if (jobCursorCond) jobConditions.push(jobCursorCond) + } - const extractCostSummary = (blockExecutions: any[]) => { - let totalCost = 0 - let totalInputCost = 0 - let totalOutputCost = 0 - let totalTokens = 0 - let totalPromptTokens = 0 - let totalCompletionTokens = 0 - const models = new Map() - - blockExecutions.forEach((block) => { - if (block.cost) { - totalCost += Number(block.cost.total) || 0 - totalInputCost += Number(block.cost.input) || 0 - totalOutputCost += Number(block.cost.output) || 0 - totalTokens += block.cost.tokens?.total || 0 - totalPromptTokens += block.cost.tokens?.prompt || 0 - totalCompletionTokens += block.cost.tokens?.completion || 0 - - if (block.cost.model) { - if (!models.has(block.cost.model)) { - models.set(block.cost.model, { - input: 0, - output: 0, - total: 0, - tokens: { input: 0, output: 0, total: 0 }, - }) - } - const modelCost = models.get(block.cost.model) - modelCost.input += Number(block.cost.input) || 0 - modelCost.output += Number(block.cost.output) || 0 - modelCost.total += Number(block.cost.total) || 0 - modelCost.tokens.input += block.cost.tokens?.input || block.cost.tokens?.prompt || 0 - modelCost.tokens.output += - block.cost.tokens?.output || block.cost.tokens?.completion || 0 - modelCost.tokens.total += block.cost.tokens?.total || 0 - } - } + const jobQuery = includeJobLogs + ? db + .select({ + id: jobExecutionLogs.id, + executionId: jobExecutionLogs.executionId, + level: jobExecutionLogs.level, + status: jobExecutionLogs.status, + trigger: jobExecutionLogs.trigger, + startedAt: jobExecutionLogs.startedAt, + endedAt: jobExecutionLogs.endedAt, + totalDurationMs: jobExecutionLogs.totalDurationMs, + cost: jobExecutionLogs.cost, + createdAt: jobExecutionLogs.createdAt, + jobTitle: sql`${jobExecutionLogs.executionData}->'trigger'->>'source'`, + sortValue: sql`${jobSortExpr}`.as('sort_value'), }) + .from(jobExecutionLogs) + .where(and(...jobConditions)) + .orderBy(orderByClause(jobSortExpr), dir(jobExecutionLogs.id)) + .limit(fetchSize) + : Promise.resolve([]) - return { - total: totalCost, - input: totalInputCost, - output: totalOutputCost, - tokens: { - total: totalTokens, - input: totalPromptTokens, - output: totalCompletionTokens, - }, - models: Object.fromEntries(models), - } - } + const [workflowRows, jobRows] = await Promise.all([workflowQuery, jobQuery]) - const transformedWorkflowLogs = workflowLogs.map((log) => { - const blockExecutions = blockExecutionsByExecution[log.executionId] || [] - - let traceSpans = [] - let finalOutput: any - let costSummary = (log.cost as any) || { total: 0 } - - if (params.details === 'full' && log.executionData) { - const storedTraceSpans = (log.executionData as any)?.traceSpans - traceSpans = - storedTraceSpans && Array.isArray(storedTraceSpans) && storedTraceSpans.length > 0 - ? storedTraceSpans - : createTraceSpans(blockExecutions) - - costSummary = - log.cost && Object.keys(log.cost as any).length > 0 - ? (log.cost as any) - : extractCostSummary(blockExecutions) - - try { - const fo = (log.executionData as any)?.finalOutput - if (fo !== undefined) finalOutput = fo - } catch {} - } + type RowWithSort = { + id: string + sortValue: unknown + summary: WorkflowLogSummary + } - const workflowSummary = log.workflowId - ? { - id: log.workflowId, - name: log.workflowName, - description: log.workflowDescription, - color: log.workflowColor, - folderId: log.workflowFolderId, - userId: log.workflowUserId, - workspaceId: log.workflowWorkspaceId, - createdAt: log.workflowCreatedAt, - updatedAt: log.workflowUpdatedAt, - } - : null - - return { - id: log.id, - workflowId: log.workflowId, - executionId: log.executionId, - deploymentVersionId: log.deploymentVersionId, - deploymentVersion: log.deploymentVersion ?? null, - deploymentVersionName: log.deploymentVersionName ?? null, - level: log.level, - status: log.status, - duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, - trigger: log.trigger, - createdAt: log.startedAt.toISOString(), - files: params.details === 'full' ? log.files || undefined : undefined, - workflow: workflowSummary, - pauseSummary: { - status: log.pausedStatus ?? null, - total: log.pausedTotalPauseCount ?? 0, - resumed: log.pausedResumedCount ?? 0, - }, - executionData: - params.details === 'full' - ? { - totalDuration: log.totalDurationMs, - traceSpans, - blockExecutions, - finalOutput, - enhanced: true, - } - : undefined, - cost: - params.details === 'full' - ? (costSummary as any) - : { total: (costSummary as any)?.total || 0 }, - hasPendingPause: - (Number(log.pausedTotalPauseCount ?? 0) > 0 && - Number(log.pausedResumedCount ?? 0) < Number(log.pausedTotalPauseCount ?? 0)) || - (log.pausedStatus && log.pausedStatus !== 'fully_resumed'), - } - }) - - // Transform job logs to the same shape - const transformedJobLogs = jobLogs.map((log) => { - const execData = log.executionData as any - const costSummary = (log.cost as any) || { total: 0 } - - return { - id: log.id, - workflowId: null as string | null, - executionId: log.executionId, - deploymentVersionId: null as string | null, - deploymentVersion: null as number | null, - deploymentVersionName: null as string | null, - level: log.level, - status: log.status, - duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, - trigger: log.trigger, - createdAt: log.startedAt.toISOString(), - files: undefined as any, - workflow: null as any, - jobTitle: log.jobTitle, - pauseSummary: { - status: null as string | null, - total: 0, - resumed: 0, - }, - executionData: - params.details === 'full' && execData - ? { - totalDuration: log.totalDurationMs, - traceSpans: execData.traceSpans || [], - blockExecutions: [], - finalOutput: execData.finalOutput, - enhanced: true, - trigger: execData.trigger, - } - : undefined, - cost: params.details === 'full' ? costSummary : { total: costSummary?.total || 0 }, - hasPendingPause: false, - } - }) - - // Merge, sort by createdAt (which is startedAt ISO string) desc, paginate - const allLogs = [...transformedWorkflowLogs, ...transformedJobLogs] - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - .slice(params.offset, params.offset + params.limit) - - return NextResponse.json( - { - data: allLogs, - total: totalCount, - page: Math.floor(params.offset / params.limit) + 1, - pageSize: params.limit, - totalPages: Math.ceil(totalCount / params.limit), - }, - { status: 200 } - ) - } catch (validationError) { - if (isZodError(validationError)) { - logger.warn(`[${requestId}] Invalid logs request parameters`, { - errors: validationError.issues, - }) - return NextResponse.json( - { - error: 'Invalid request parameters', - details: validationError.issues, - }, - { status: 400 } - ) - } - throw validationError + const workflowMapped: RowWithSort[] = workflowRows.map((log) => { + const totalPauseCount = Number(log.pausedTotalPauseCount ?? 0) + const resumedCount = Number(log.pausedResumedCount ?? 0) + const hasPendingPause = + (totalPauseCount > 0 && resumedCount < totalPauseCount) || + (log.pausedStatus !== null && log.pausedStatus !== 'fully_resumed') + + const summary: WorkflowLogSummary = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + deploymentVersionId: log.deploymentVersionId, + deploymentVersion: log.deploymentVersion ?? null, + deploymentVersionName: log.deploymentVersionName ?? null, + level: log.level, + status: log.status, + duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, + trigger: log.trigger, + createdAt: log.startedAt.toISOString(), + workflow: log.workflowId + ? { + id: log.workflowId, + name: log.workflowName, + description: log.workflowDescription, + color: log.workflowColor, + folderId: log.workflowFolderId, + userId: log.workflowUserId, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt?.toISOString() ?? null, + updatedAt: log.workflowUpdatedAt?.toISOString() ?? null, + } + : null, + jobTitle: null, + cost: (log.cost as WorkflowLogSummary['cost']) ?? null, + pauseSummary: { + status: log.pausedStatus ?? null, + total: totalPauseCount, + resumed: resumedCount, + }, + hasPendingPause, } - } catch (error: any) { - logger.error(`[${requestId}] logs fetch error`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) + return { id: log.id, sortValue: log.sortValue, summary } + }) + + const jobMapped: RowWithSort[] = (jobRows as Awaited).map((log) => { + const summary: WorkflowLogSummary = { + id: log.id, + workflowId: null, + executionId: log.executionId, + deploymentVersionId: null, + deploymentVersion: null, + deploymentVersionName: null, + level: log.level, + status: log.status, + duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, + trigger: log.trigger, + createdAt: log.startedAt.toISOString(), + workflow: null, + jobTitle: log.jobTitle ?? null, + cost: (log.cost as WorkflowLogSummary['cost']) ?? null, + pauseSummary: { status: null, total: 0, resumed: 0 }, + hasPendingPause: false, + } + return { id: log.id, sortValue: log.sortValue, summary } + }) + + const compareSortValues = (a: unknown, b: unknown): number => { + if (a === null || a === undefined) return b === null || b === undefined ? 0 : 1 + if (b === null || b === undefined) return -1 + if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime() + if (typeof a === 'number' && typeof b === 'number') return a - b + const aStr = String(a) + const bStr = String(b) + if (sortBy === 'date') { + return new Date(aStr).getTime() - new Date(bStr).getTime() + } + const aNum = Number(aStr) + const bNum = Number(bStr) + if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) return aNum - bNum + return aStr.localeCompare(bStr) } + + const merged = [...workflowMapped, ...jobMapped].sort((a, b) => { + const cmp = compareSortValues(a.sortValue, b.sortValue) + if (cmp !== 0) return sortOrder === 'asc' ? cmp : -cmp + const idCmp = a.id.localeCompare(b.id) + return sortOrder === 'asc' ? idCmp : -idCmp + }) + + const page = merged.slice(0, params.limit) + const hasMore = merged.length > params.limit + let nextCursor: string | null = null + if (hasMore && page.length > 0) { + const last = page[page.length - 1] + const v = last.sortValue + const cursorV = + v instanceof Date + ? v.toISOString() + : typeof v === 'number' || typeof v === 'string' + ? v + : v == null + ? null + : String(v) + nextCursor = encodeCursor({ v: cursorV, id: last.id }) + } + + logger.debug('Listed logs', { + workspaceId: params.workspaceId, + count: page.length, + hasMore, + sortBy, + sortOrder, + }) + + return NextResponse.json({ + data: page.map((row) => row.summary), + nextCursor, + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index 302b3bce4c2..d51078e47af 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -59,6 +59,8 @@ const LOG_DROPDOWN_FILTERS = { triggers: [] as string[], searchQuery: '', limit: LOG_DROPDOWN_LIMIT, + sortBy: 'date' as const, + sortOrder: 'desc' as const, } export function useAvailableResources( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 46f78e1f89e..cbc4ac7f62b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -179,7 +179,7 @@ export const ResourceContent = memo(function ResourceContent({ return case 'log': - return + return case 'generic': return ( @@ -617,11 +617,12 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) { } interface EmbeddedLogProps { + workspaceId: string logId: string } -function EmbeddedLog({ logId }: EmbeddedLogProps) { - const { data: log, isLoading } = useLogDetail(logId) +function EmbeddedLog({ workspaceId, logId }: EmbeddedLogProps) { + const { data: log, isLoading } = useLogDetail(logId, workspaceId) if (isLoading) return LOADING_SKELETON @@ -653,7 +654,7 @@ interface EmbeddedLogActionsProps { export function EmbeddedLogActions({ workspaceId, logId }: EmbeddedLogActionsProps) { const router = useRouter() - const { data: log } = useLogDetail(logId) + const { data: log } = useLogDetail(logId, workspaceId) const handleOpenInLogs = () => { const param = log?.executionId ? `?executionId=${log.executionId}` : '' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index c1cd8c78b91..db5e21bc0d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -297,9 +297,9 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP } }, [log.id]) + const isLikelyExecution = !!log.executionId && log.trigger !== 'mothership' const isWorkflowExecutionLog = - (log.trigger === 'manual' && !!log.duration) || - !!(log.executionData?.enhanced && log.executionData?.traceSpans) + (log.trigger === 'manual' && !!log.duration) || !!log.executionData?.traceSpans const hasCostInfo = !!(isWorkflowExecutionLog && log.cost) const showWorkflowState = @@ -307,8 +307,9 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP !!log.executionId && log.trigger !== 'mothership' && !permissionConfig.hideTraceSpans - const showTraceTab = - isWorkflowExecutionLog && !!log.executionData?.traceSpans && !permissionConfig.hideTraceSpans + + const showTraceTab = !permissionConfig.hideTraceSpans && isLikelyExecution + const traceSpans = log.executionData?.traceSpans const resolvedTab: LogDetailsTab = activeTab === 'trace' && !showTraceTab ? 'overview' : activeTab @@ -594,12 +595,20 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP {/* Trace Tab */} - {showTraceTab && log.executionData?.traceSpans && ( + {showTraceTab && ( - + {traceSpans?.length ? ( + + ) : ( +
+ + No trace data available for this run + +
+ )}
)} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index de2dce93250..3480d15c856 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -50,12 +50,14 @@ import type { Suggestion } from '@/app/workspace/[workspaceId]/logs/types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { getBlock } from '@/blocks/registry' import { useFolderMap, useFolders } from '@/hooks/queries/folders' +import type { LogSortBy, LogSortOrder } from '@/hooks/queries/logs' import { fetchLogDetail, logKeys, prefetchLogDetail, useCancelExecution, useDashboardStats, + useLogByExecutionId, useLogDetail, useLogsList, useRetryExecution, @@ -268,14 +270,11 @@ export default function Logs() { selectedLogId: null, isSidebarOpen: false, }) - const isInitialized = useRef(false) - const pendingExecutionIdRef = useRef(undefined) - if (pendingExecutionIdRef.current === undefined) { - pendingExecutionIdRef.current = - typeof window !== 'undefined' - ? new URLSearchParams(window.location.search).get('executionId') - : null - } + const [pendingExecutionId, setPendingExecutionId] = useState(() => + typeof window !== 'undefined' + ? new URLSearchParams(window.location.search).get('executionId') + : null + ) const [searchQuery, setSearchQuery] = useState(() => { if (typeof window === 'undefined') return '' @@ -308,17 +307,28 @@ export default function Logs() { const [previewLogId, setPreviewLogId] = useState(null) - const activeLogId = previewLogId ?? selectedLogId const queryClient = useQueryClient() - const activeLogQuery = useLogDetail(activeLogId ?? undefined, { - refetchInterval: (query: { state: { data?: WorkflowLog } }) => { + const refetchInterval = useCallback( + (query: { state: { data?: WorkflowLog } }) => { if (!isLive) return false const status = query.state.data?.status return status === 'running' || status === 'pending' ? 3000 : false }, + [isLive] + ) + + const selectedDetailQuery = useLogDetail(selectedLogId ?? undefined, workspaceId, { + refetchInterval, + }) + + const previewDetailQuery = useLogDetail(previewLogId ?? undefined, workspaceId, { + refetchInterval, }) + const sortBy: LogSortBy = (activeSort?.column as LogSortBy | undefined) ?? 'date' + const sortOrder: LogSortOrder = activeSort?.direction ?? 'desc' + const logFilters = useMemo( () => ({ timeRange, @@ -330,12 +340,24 @@ export default function Logs() { triggers, searchQuery: debouncedSearchQuery, limit: LOGS_PER_PAGE, + sortBy, + sortOrder, }), - [timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, debouncedSearchQuery] + [ + timeRange, + startDate, + endDate, + level, + workflowIds, + folderIds, + triggers, + debouncedSearchQuery, + sortBy, + sortOrder, + ] ) const logsQuery = useLogsList(workspaceId, logFilters, { - enabled: Boolean(workspaceId) && isInitialized.current, refetchInterval: isLive ? 3000 : false, }) @@ -354,7 +376,6 @@ export default function Logs() { ) const dashboardStatsQuery = useDashboardStats(workspaceId, dashboardFilters, { - enabled: Boolean(workspaceId) && isInitialized.current, refetchInterval: isLive ? 3000 : false, }) @@ -362,80 +383,42 @@ export default function Logs() { return logsQuery.data?.pages?.flatMap((page) => page.logs) ?? [] }, [logsQuery.data?.pages]) - const sortedLogs = useMemo(() => { - if (!activeSort) return logs - - const { column, direction } = activeSort - return [...logs].sort((a, b) => { - let cmp = 0 - switch (column) { - case 'date': - cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - break - case 'duration': { - const aDuration = parseDuration({ duration: a.duration ?? undefined }) ?? -1 - const bDuration = parseDuration({ duration: b.duration ?? undefined }) ?? -1 - cmp = aDuration - bDuration - break - } - case 'cost': { - const aCost = typeof a.cost?.total === 'number' ? a.cost.total : -1 - const bCost = typeof b.cost?.total === 'number' ? b.cost.total : -1 - cmp = aCost - bCost - break - } - case 'status': - cmp = (a.status ?? '').localeCompare(b.status ?? '') - break - default: - break - } - return direction === 'asc' ? cmp : -cmp - }) - }, [logs, activeSort]) - - const selectedLogIndex = selectedLogId ? sortedLogs.findIndex((l) => l.id === selectedLogId) : -1 - const selectedLogFromList = selectedLogIndex >= 0 ? sortedLogs[selectedLogIndex] : null - - const selectedLog = useMemo(() => { - if (!selectedLogFromList) return null - if (!activeLogQuery.data || previewLogId !== null) return selectedLogFromList - return { ...selectedLogFromList, ...activeLogQuery.data } - }, [selectedLogFromList, activeLogQuery.data, previewLogId]) + const selectedLogIndex = selectedLogId ? logs.findIndex((l) => l.id === selectedLogId) : -1 + const selectedLogFromList = selectedLogIndex >= 0 ? logs[selectedLogIndex] : null + const selectedLog = selectedDetailQuery.data ?? selectedLogFromList ?? null const handleLogHover = useCallback( (rowId: string) => { - prefetchLogDetail(queryClient, rowId) + prefetchLogDetail(queryClient, rowId, workspaceId) }, - [queryClient] + [queryClient, workspaceId] ) useFolders(workspaceId) - logsRef.current = sortedLogs + logsRef.current = logs selectedLogIndexRef.current = selectedLogIndex selectedLogIdRef.current = selectedLogId logsRefetchRef.current = logsQuery.refetch - activeLogRefetchRef.current = activeLogQuery.refetch + activeLogRefetchRef.current = selectedDetailQuery.refetch logsQueryRef.current = { isFetching: logsQuery.isFetching, hasNextPage: logsQuery.hasNextPage ?? false, fetchNextPage: logsQuery.fetchNextPage, } + const deepLinkQuery = useLogByExecutionId(workspaceId, pendingExecutionId) + useEffect(() => { - if (!pendingExecutionIdRef.current) return - const targetExecutionId = pendingExecutionIdRef.current - const found = sortedLogs.find((l) => l.executionId === targetExecutionId) - if (found) { - pendingExecutionIdRef.current = null - dispatch({ type: 'TOGGLE_LOG', logId: found.id }) - } else if (!logsQuery.hasNextPage && logsQuery.status === 'success') { - pendingExecutionIdRef.current = null - } else if (!logsQuery.isFetching && logsQuery.status === 'success') { - logsQueryRef.current.fetchNextPage() + if (!pendingExecutionId) return + const resolvedId = deepLinkQuery.data?.id + if (resolvedId) { + dispatch({ type: 'TOGGLE_LOG', logId: resolvedId }) + setPendingExecutionId(null) + } else if (deepLinkQuery.isError) { + setPendingExecutionId(null) } - }, [sortedLogs, logsQuery.hasNextPage, logsQuery.isFetching, logsQuery.status]) + }, [pendingExecutionId, deepLinkQuery.data, deepLinkQuery.isError]) useEffect(() => { const timers = refreshTimersRef.current @@ -446,9 +429,7 @@ export default function Logs() { }, []) useEffect(() => { - if (isInitialized.current) { - setStoreSearchQuery(debouncedSearchQuery) - } + setStoreSearchQuery(debouncedSearchQuery) }, [debouncedSearchQuery, setStoreSearchQuery]) const handleLogClick = useCallback((rowId: string) => { @@ -484,12 +465,12 @@ export default function Logs() { const handleLogContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { e.preventDefault() - const log = sortedLogs.find((l) => l.id === rowId) ?? null + const log = logs.find((l) => l.id === rowId) ?? null setContextMenuPosition({ x: e.clientX, y: e.clientY }) setContextMenuLog(log) setContextMenuOpen(true) }, - [sortedLogs] + [logs] ) const handleCopyExecutionId = useCallback(() => { @@ -555,7 +536,7 @@ export default function Logs() { try { const detailLog = await queryClient.fetchQuery({ queryKey: logKeys.detail(logId), - queryFn: ({ signal }) => fetchLogDetail(logId, signal), + queryFn: ({ signal }) => fetchLogDetail(logId, workspaceId, signal), staleTime: 30 * 1000, }) const input = extractRetryInput(detailLog) @@ -600,7 +581,8 @@ export default function Logs() { } }, [selectedLogId, selectedLogIndex]) - const effectiveSidebarOpen = isSidebarOpen && selectedLogIndex !== -1 + const effectiveSidebarOpen = + isSidebarOpen && (selectedLogIndex !== -1 || !!selectedDetailQuery.data) const triggerVisualRefresh = useCallback(() => { setIsVisuallyRefreshing(true) @@ -677,11 +659,9 @@ export default function Logs() { ]) useEffect(() => { - if (!isInitialized.current) { - isInitialized.current = true - initializeFromURL() - } - }, [initializeFromURL]) + initializeFromURL() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) useEffect(() => { const handlePopState = () => { @@ -695,12 +675,11 @@ export default function Logs() { }, [initializeFromURL]) const loadMoreLogs = useCallback(() => { - if (activeSort) return const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current if (!isFetching && hasNextPage) { fetchNextPage() } - }, [activeSort]) + }, []) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -753,7 +732,7 @@ export default function Logs() { const rows: ResourceRow[] = useMemo( () => - sortedLogs.map((log) => { + logs.map((log) => { const formattedDate = formatDate(log.createdAt) const displayStatus = getDisplayStatus(log.status) const isMothershipJob = log.trigger === 'mothership' @@ -804,7 +783,7 @@ export default function Logs() { }, } }), - [sortedLogs] + [logs] ) const sidebarOverlay = ( @@ -814,7 +793,7 @@ export default function Logs() { onClose={handleCloseSidebar} onNavigateNext={handleNavigateNext} onNavigatePrev={handleNavigatePrev} - hasNext={selectedLogIndex < sortedLogs.length - 1} + hasNext={selectedLogIndex < logs.length - 1} hasPrev={selectedLogIndex > 0} onRetryExecution={handleRetrySidebarExecution} isRetryPending={retryExecution.isPending} @@ -1121,7 +1100,7 @@ export default function Logs() { label: 'Export', icon: Download, onClick: handleExport, - disabled: !userPermissions.canEdit || isExporting || sortedLogs.length === 0, + disabled: !userPermissions.canEdit || isExporting || logs.length === 0, }, { label: 'Notifications', @@ -1154,7 +1133,7 @@ export default function Logs() { handleExport, userPermissions.canEdit, isExporting, - sortedLogs.length, + logs.length, handleOpenNotificationSettings, ] ) @@ -1192,7 +1171,7 @@ export default function Logs() { onRowContextMenu={handleLogContextMenu} isLoading={!logsQuery.data} onLoadMore={loadMoreLogs} - hasMore={!activeSort && (logsQuery.hasNextPage ?? false)} + hasMore={logsQuery.hasNextPage ?? false} isLoadingMore={logsQuery.isFetchingNextPage} emptyMessage='No logs found' overlay={sidebarOverlay} @@ -1224,10 +1203,10 @@ export default function Logs() { hasActiveFilters={filtersActive} /> - {previewLogId !== null && activeLogQuery.data?.executionId && ( + {previewLogId !== null && previewDetailQuery.data?.executionId && ( ({ diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index bd5b0e5e695..a3b0710d13b 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -14,10 +14,12 @@ import { type ExecutionSnapshotData, getDashboardStatsContract, getExecutionSnapshotContract, + getLogByExecutionIdContract, getLogDetailContract, listLogsContract, type SegmentStats, - type WorkflowLogData, + type WorkflowLogDetail, + type WorkflowLogSummary, type WorkflowStats, } from '@/lib/api/contracts/logs' import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters' @@ -26,10 +28,13 @@ import type { TimeRange, WorkflowLog } from '@/stores/logs/filters/types' export type { DashboardStatsResponse, SegmentStats, WorkflowStats } +export type LogSortBy = 'date' | 'duration' | 'cost' | 'status' +export type LogSortOrder = 'asc' | 'desc' + export const logKeys = { all: ['logs'] as const, lists: () => [...logKeys.all, 'list'] as const, - list: (workspaceId: string | undefined, filters: Omit) => + list: (workspaceId: string | undefined, filters: LogFilters) => [...logKeys.lists(), workspaceId ?? '', filters] as const, details: () => [...logKeys.all, 'detail'] as const, detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const, @@ -44,7 +49,7 @@ export const logKeys = { [...logKeys.executionSnapshots(), executionId ?? ''] as const, } -interface LogFilters { +export interface LogFilters { timeRange: TimeRange startDate?: string endDate?: string @@ -54,15 +59,19 @@ interface LogFilters { triggers: string[] searchQuery: string limit: number + sortBy: LogSortBy + sortOrder: LogSortOrder } -const toWorkflowLog = (log: WorkflowLogData): WorkflowLog => log as WorkflowLog +// double-cast-allowed: bridge from contract type to legacy WorkflowLog used by stores/components +const summaryToWorkflowLog = (log: WorkflowLogSummary): WorkflowLog => log as unknown as WorkflowLog +// double-cast-allowed: bridge from contract type to legacy WorkflowLog used by stores/components +const detailToWorkflowLog = (log: WorkflowLogDetail): WorkflowLog => log as unknown as WorkflowLog -/** - * Applies common filter parameters to a URLSearchParams object. - * Shared between paginated and non-paginated log fetches. - */ -function applyFilterParams(params: URLSearchParams, filters: Omit): void { +function applyFilterParams( + params: URLSearchParams, + filters: Omit +): void { if (filters.level !== 'all') { params.set('level', filters.level) } @@ -99,61 +108,53 @@ function applyFilterParams(params: URLSearchParams, filters: Omit { +): Promise { const apiData = await requestJson(listLogsContract, { - query: buildQueryParams(workspaceId, filters, page), + query: buildListQuery(workspaceId, filters, cursor), signal, }) - const hasMore = apiData.data.length === filters.limit && apiData.page < apiData.totalPages return { - logs: apiData.data.map(toWorkflowLog), - hasMore, - nextPage: hasMore ? page + 1 : undefined, + logs: apiData.data.map(summaryToWorkflowLog), + nextCursor: apiData.nextCursor, } } -export async function fetchLogDetail(logId: string, signal?: AbortSignal): Promise { - const { data } = await requestJson(getLogDetailContract, { - params: { id: logId }, - signal, - }) - return toWorkflowLog(data) -} - -async function fetchLogByExecutionId( +export async function fetchLogDetail( + logId: string, workspaceId: string, - executionId: string, signal?: AbortSignal -): Promise { - const apiData = await requestJson(listLogsContract, { - query: { - workspaceId, - executionId, - details: 'full', - limit: 1, - }, +): Promise { + const { data } = await requestJson(getLogDetailContract, { + params: { id: logId }, + query: { workspaceId }, signal, }) - return apiData.data?.[0] ? toWorkflowLog(apiData.data[0]) : null + return detailToWorkflowLog(data) } interface UseLogsListOptions { @@ -172,10 +173,10 @@ export function useLogsList( fetchLogsPage(workspaceId as string, filters, pageParam, signal), enabled: Boolean(workspaceId) && (options?.enabled ?? true), refetchInterval: options?.refetchInterval ?? false, - staleTime: 0, + staleTime: 30 * 1000, placeholderData: keepPreviousData, - initialPageParam: 1, - getNextPageParam: (lastPage) => lastPage.nextPage, + initialPageParam: null as string | null, + getNextPageParam: (lastPage) => lastPage.nextCursor, }) } @@ -187,52 +188,58 @@ interface UseLogDetailOptions { | ((query: { state: { data?: WorkflowLog } }) => number | false | undefined) } -export function useLogDetail(logId: string | undefined, options?: UseLogDetailOptions) { +export function useLogDetail( + logId: string | undefined, + workspaceId: string | undefined, + options?: UseLogDetailOptions +) { return useQuery({ queryKey: logKeys.detail(logId), - queryFn: ({ signal }) => fetchLogDetail(logId as string, signal), - enabled: Boolean(logId) && (options?.enabled ?? true), + queryFn: ({ signal }) => fetchLogDetail(logId as string, workspaceId as string, signal), + enabled: Boolean(logId) && Boolean(workspaceId) && (options?.enabled ?? true), refetchInterval: options?.refetchInterval ?? false, staleTime: 30 * 1000, }) } /** - * Looks up a workflow log by its `executionId` (the id stored on table workflow cells). - * Returns the full log shape so the LogDetails sidebar can render directly without - * an extra detail fetch. + * Looks up a workflow log by its `executionId`. Writes the resulting detail + * through to the canonical `detail(id)` cache so subsequent `useLogDetail` + * reads hit instantly. */ export function useLogByExecutionId( workspaceId: string | undefined, executionId: string | null | undefined ) { + const queryClient = useQueryClient() return useQuery({ queryKey: logKeys.byExecution(workspaceId, executionId ?? undefined), - queryFn: ({ signal }) => - fetchLogByExecutionId(workspaceId as string, executionId as string, signal), + queryFn: async ({ signal }) => { + const { data } = await requestJson(getLogByExecutionIdContract, { + params: { executionId: executionId as string }, + query: { workspaceId: workspaceId as string }, + signal, + }) + const log = detailToWorkflowLog(data) + queryClient.setQueryData(logKeys.detail(log.id), log) + return log + }, enabled: Boolean(workspaceId) && Boolean(executionId), staleTime: 30 * 1000, }) } -/** - * Prefetches log detail data on hover for instant panel rendering on click. - */ -export function prefetchLogDetail(queryClient: QueryClient, logId: string) { +export function prefetchLogDetail(queryClient: QueryClient, logId: string, workspaceId: string) { queryClient.prefetchQuery({ queryKey: logKeys.detail(logId), - queryFn: ({ signal }) => fetchLogDetail(logId, signal), + queryFn: ({ signal }) => fetchLogDetail(logId, workspaceId, signal), staleTime: 30 * 1000, }) } -/** - * Fetches dashboard stats from the server-side aggregation endpoint. - * Uses SQL aggregation for efficient computation without arbitrary limits. - */ async function fetchDashboardStats( workspaceId: string, - filters: Omit, + filters: Omit, signal?: AbortSignal ): Promise { const params = new URLSearchParams() @@ -252,13 +259,9 @@ interface UseDashboardStatsOptions { refetchInterval?: number | false } -/** - * Hook for fetching dashboard stats using server-side aggregation. - * No arbitrary limits - uses SQL aggregation for accurate metrics. - */ export function useDashboardStats( workspaceId: string | undefined, - filters: Omit, + filters: Omit, options?: UseDashboardStatsOptions ) { return useQuery({ @@ -266,7 +269,7 @@ export function useDashboardStats( queryFn: ({ signal }) => fetchDashboardStats(workspaceId as string, filters, signal), enabled: Boolean(workspaceId) && (options?.enabled ?? true), refetchInterval: options?.refetchInterval ?? false, - staleTime: 0, + staleTime: 30 * 1000, placeholderData: keepPreviousData, }) } @@ -293,12 +296,10 @@ export function useExecutionSnapshot(executionId: string | undefined) { queryKey: logKeys.executionSnapshot(executionId), queryFn: ({ signal }) => fetchExecutionSnapshot(executionId as string, signal), enabled: Boolean(executionId), - staleTime: 5 * 60 * 1000, // 5 minutes - execution snapshots don't change + staleTime: 5 * 60 * 1000, }) } -type LogsPage = { logs: WorkflowLog[]; hasMore: boolean; nextPage: number | undefined } - export function useCancelExecution() { const queryClient = useQueryClient() return useMutation({ @@ -345,7 +346,6 @@ export function useCancelExecution() { onSettled: () => { queryClient.invalidateQueries({ queryKey: logKeys.lists() }) queryClient.invalidateQueries({ queryKey: logKeys.details() }) - queryClient.invalidateQueries({ queryKey: logKeys.stats() }) }, }) } @@ -364,9 +364,6 @@ export function useRetryExecution() { const data = await res.json().catch(() => ({})) throw new Error(data.error || 'Failed to retry execution') } - // The ReadableStream is lazy — start() only runs when read. - // Read one chunk to trigger execution, then cancel. Execution continues - // server-side after client disconnect. const reader = res.body?.getReader() if (reader) { await reader.read() @@ -377,7 +374,6 @@ export function useRetryExecution() { onSettled: () => { queryClient.invalidateQueries({ queryKey: logKeys.lists() }) queryClient.invalidateQueries({ queryKey: logKeys.details() }) - queryClient.invalidateQueries({ queryKey: logKeys.stats() }) }, }) } diff --git a/apps/sim/lib/api/contracts/logs.ts b/apps/sim/lib/api/contracts/logs.ts index b0298e349ec..64477c7ebcc 100644 --- a/apps/sim/lib/api/contracts/logs.ts +++ b/apps/sim/lib/api/contracts/logs.ts @@ -34,10 +34,18 @@ const logFilterQuerySchema = z.object({ durationValue: z.coerce.number().optional(), }) +export const logSortBySchema = z.enum(['date', 'duration', 'cost', 'status']).default('date') +export const logSortOrderSchema = z.enum(['asc', 'desc']).default('desc') + export const listLogsQuerySchema = logFilterQuerySchema.extend({ - details: z.enum(['basic', 'full']).optional().default('basic'), - limit: z.coerce.number().optional().default(100), - offset: z.coerce.number().optional().default(0), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(200).optional().default(100), + sortBy: logSortBySchema, + sortOrder: logSortOrderSchema, +}) + +export const logDetailQuerySchema = z.object({ + workspaceId: z.string().min(1), }) export const statsQueryParamsSchema = logFilterQuerySchema.extend({ @@ -58,55 +66,189 @@ const workflowSummarySchema = z }) .partial() -const fileSchema = z +const fileSchema = z.object({ + id: z.string(), + name: z.string(), + size: z.number(), + type: z.string(), + url: z.string(), + key: z.string(), + uploadedAt: z.string(), + expiresAt: z.string(), + storageProvider: z.enum(['s3', 'blob', 'local']).optional(), + bucketName: z.string().optional(), +}) + +const tokenBreakdownSchema = z .object({ - id: z.string(), - name: z.string(), - size: z.number(), - type: z.string(), - url: z.string(), - key: z.string(), - uploadedAt: z.string(), - expiresAt: z.string(), - storageProvider: z.enum(['s3', 'blob', 'local']).optional(), - bucketName: z.string().optional(), + total: z.number().optional(), + input: z.number().optional(), + output: z.number().optional(), + prompt: z.number().optional(), + completion: z.number().optional(), + }) + .partial() + +const modelCostSchema = z + .object({ + input: z.number().optional(), + output: z.number().optional(), + total: z.number().optional(), + tokens: tokenBreakdownSchema.optional(), + }) + .partial() + +const costSummarySchema = z + .object({ + total: z.number().optional(), + input: z.number().optional(), + output: z.number().optional(), + tokens: tokenBreakdownSchema.optional(), + models: z.record(z.string(), modelCostSchema).optional(), + pricing: z + .object({ + input: z.number(), + output: z.number(), + cachedInput: z.number().optional(), + updatedAt: z.string(), + }) + .optional(), + }) + .partial() + +const pauseSummarySchema = z.object({ + status: z.string().nullable(), + total: z.number(), + resumed: z.number(), +}) + +const blockExecutionSchema = z.object({ + id: z.string(), + blockId: z.string(), + blockName: z.string(), + blockType: z.string(), + startedAt: z.string(), + endedAt: z.string(), + durationMs: z.number(), + status: z.enum(['success', 'error', 'skipped']), + errorMessage: z.string().optional(), + errorStackTrace: z.string().optional(), + inputData: z.unknown(), + outputData: z.unknown(), + cost: costSummarySchema.optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}) + +const toolCallSchema = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + arguments: z.unknown().optional(), + result: z.unknown().optional(), + error: z.string().optional(), + startTime: z.string().optional(), + endTime: z.string().optional(), + duration: z.number().optional(), }) .passthrough() -export const workflowLogSchema = z +type TraceSpan = { + id: string + name: string + type: string + duration?: number + durationMs?: number + startTime?: string + endTime?: string + status?: string + blockId?: string + input?: unknown + output?: unknown + tokens?: number | { total?: number; input?: number; output?: number } + relativeStartMs?: number + toolCalls?: Array> + children?: TraceSpan[] +} + +const traceSpanSchema: z.ZodType = z.lazy(() => + z + .object({ + id: z.string(), + name: z.string(), + type: z.string(), + duration: z.number().optional(), + durationMs: z.number().optional(), + startTime: z.string().optional(), + endTime: z.string().optional(), + status: z.string().optional(), + blockId: z.string().optional(), + input: z.unknown().optional(), + output: z.unknown().optional(), + tokens: z + .union([ + z.number(), + z + .object({ + total: z.number().optional(), + input: z.number().optional(), + output: z.number().optional(), + }) + .partial(), + ]) + .optional(), + relativeStartMs: z.number().optional(), + toolCalls: z.array(toolCallSchema).optional(), + children: z.array(traceSpanSchema).optional(), + }) + .passthrough() +) + +const executionDataDetailSchema = z .object({ - id: z.string(), - workflowId: z.string().nullable(), - executionId: z.string().nullable().optional(), - deploymentVersionId: z.string().nullable().optional(), - deploymentVersion: z.number().nullable().optional(), - deploymentVersionName: z.string().nullable().optional(), - level: z.string(), - status: z.string().nullable().optional(), - duration: z.string().nullable(), - trigger: z.string().nullable(), - createdAt: z.string(), - workflow: workflowSummarySchema.nullable().optional(), - jobTitle: z.string().nullable().optional(), - files: z.array(fileSchema).optional(), - cost: z.unknown().optional(), - hasPendingPause: z.boolean().nullable().optional(), - pauseSummary: z.unknown().optional(), - executionData: z.unknown().optional(), + totalDuration: z.number().nullable().optional(), + enhanced: z.literal(true).optional(), + traceSpans: z.array(traceSpanSchema).optional(), + blockExecutions: z.array(blockExecutionSchema).optional(), + finalOutput: z.unknown().optional(), + workflowInput: z.unknown().optional(), + blockInput: z.record(z.string(), z.unknown()).optional(), + trigger: z.unknown().optional(), }) .passthrough() -export type WorkflowLogData = z.output +export const workflowLogSummarySchema = z.object({ + id: z.string(), + workflowId: z.string().nullable(), + executionId: z.string().nullable(), + deploymentVersionId: z.string().nullable(), + deploymentVersion: z.number().nullable(), + deploymentVersionName: z.string().nullable(), + level: z.string(), + status: z.string().nullable(), + duration: z.string().nullable(), + trigger: z.string().nullable(), + createdAt: z.string(), + workflow: workflowSummarySchema.nullable(), + jobTitle: z.string().nullable(), + cost: costSummarySchema.nullable(), + pauseSummary: pauseSummarySchema, + hasPendingPause: z.boolean(), +}) -export const logsResponseSchema = z.object({ - data: z.array(workflowLogSchema), - total: z.number(), - page: z.number(), - pageSize: z.number(), - totalPages: z.number(), +export const workflowLogDetailSchema = workflowLogSummarySchema.extend({ + executionData: executionDataDetailSchema, + files: z.array(fileSchema).nullable(), }) -export type LogsResponse = z.output +export type WorkflowLogSummary = z.output +export type WorkflowLogDetail = z.output + +export const listLogsResponseSchema = z.object({ + data: z.array(workflowLogSummarySchema), + nextCursor: z.string().nullable(), +}) + +export type ListLogsResponse = z.output export const segmentStatsSchema = z.object({ timestamp: z.string(), @@ -179,7 +321,7 @@ export const listLogsContract = defineRouteContract({ query: listLogsQuerySchema, response: { mode: 'json', - schema: logsResponseSchema, + schema: listLogsResponseSchema, }, }) @@ -187,10 +329,24 @@ export const getLogDetailContract = defineRouteContract({ method: 'GET', path: '/api/logs/[id]', params: logIdParamsSchema, + query: logDetailQuerySchema, + response: { + mode: 'json', + schema: z.object({ + data: workflowLogDetailSchema, + }), + }, +}) + +export const getLogByExecutionIdContract = defineRouteContract({ + method: 'GET', + path: '/api/logs/by-execution/[executionId]', + params: executionIdParamsSchema, + query: logDetailQuerySchema, response: { mode: 'json', schema: z.object({ - data: workflowLogSchema, + data: workflowLogDetailSchema, }), }, }) diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 34cbacb0f6e..14a57e05fad 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 725, - zodRoutes: 725, + totalRoutes: 726, + zodRoutes: 726, nonZodRoutes: 0, } as const From b0c1862b4b1995fe13d9669e4b375e71e4506800 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 4 May 2026 11:18:39 -0700 Subject: [PATCH 02/13] =?UTF-8?q?fix(logs):=20audit=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20render=20side-effect,=20stats=20invalidation,=20enh?= =?UTF-8?q?anced=20spread=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move onActiveTabChange call from render into useEffect to avoid side-effects during render (StrictMode safety). - Re-add logKeys.stats() invalidation to cancel/retry mutations so the dashboard reflects status flips immediately. - Reorder enhanced: true after ...execData spread in detail and by-execution routes so the literal discriminator is never overwritten by stale execData. Co-Authored-By: Claude Opus 4.7 --- apps/sim/app/api/logs/[id]/route.ts | 4 ++-- apps/sim/app/api/logs/by-execution/[executionId]/route.ts | 4 ++-- .../logs/components/log-details/log-details.tsx | 6 ++---- apps/sim/hooks/queries/logs.ts | 2 ++ 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/api/logs/[id]/route.ts b/apps/sim/app/api/logs/[id]/route.ts index 7f32666b050..db1368a7902 100644 --- a/apps/sim/app/api/logs/[id]/route.ts +++ b/apps/sim/app/api/logs/[id]/route.ts @@ -139,8 +139,8 @@ export const GET = withRouteHandler( hasPendingPause: false, executionData: { totalDuration: jobLog.totalDurationMs, - enhanced: true as const, ...execData, + enhanced: true as const, }, files: null, } @@ -191,8 +191,8 @@ export const GET = withRouteHandler( hasPendingPause, executionData: { totalDuration: log.totalDurationMs, - enhanced: true as const, ...((log.executionData as Record | null) ?? {}), + enhanced: true as const, }, files: log.files ?? null, } diff --git a/apps/sim/app/api/logs/by-execution/[executionId]/route.ts b/apps/sim/app/api/logs/by-execution/[executionId]/route.ts index 2b1e288f8d1..7925e6f71ef 100644 --- a/apps/sim/app/api/logs/by-execution/[executionId]/route.ts +++ b/apps/sim/app/api/logs/by-execution/[executionId]/route.ts @@ -147,8 +147,8 @@ export const GET = withRouteHandler( hasPendingPause: false, executionData: { totalDuration: jobLog.totalDurationMs, - enhanced: true as const, ...execData, + enhanced: true as const, }, files: null, } @@ -199,8 +199,8 @@ export const GET = withRouteHandler( hasPendingPause, executionData: { totalDuration: log.totalDurationMs, - enhanced: true as const, ...((log.executionData as Record | null) ?? {}), + enhanced: true as const, }, files: log.files ?? null, } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index db5e21bc0d8..cfc433d0f82 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -313,11 +313,9 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP const resolvedTab: LogDetailsTab = activeTab === 'trace' && !showTraceTab ? 'overview' : activeTab - const prevResolvedTabRef = useRef(resolvedTab) - if (prevResolvedTabRef.current !== resolvedTab) { - prevResolvedTabRef.current = resolvedTab + useEffect(() => { onActiveTabChange?.(resolvedTab) - } + }, [resolvedTab, onActiveTabChange]) const workflowOutput = useMemo(() => { const executionData = log.executionData as { finalOutput?: Record } | undefined diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index a3b0710d13b..c66ffd9d3c0 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -346,6 +346,7 @@ export function useCancelExecution() { onSettled: () => { queryClient.invalidateQueries({ queryKey: logKeys.lists() }) queryClient.invalidateQueries({ queryKey: logKeys.details() }) + queryClient.invalidateQueries({ queryKey: logKeys.stats() }) }, }) } @@ -374,6 +375,7 @@ export function useRetryExecution() { onSettled: () => { queryClient.invalidateQueries({ queryKey: logKeys.lists() }) queryClient.invalidateQueries({ queryKey: logKeys.details() }) + queryClient.invalidateQueries({ queryKey: logKeys.stats() }) }, }) } From 824f00a3038aecfc4eb6fc6581fda60b7cc10704 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 4 May 2026 11:29:01 -0700 Subject: [PATCH 03/13] fix(logs): mirror SQL NULLS LAST in JS merge for cursor consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-memory merge of workflow + job pages negated the comparator for DESC, which placed null sort values at the start. SQL orders both ASC and DESC with NULLS LAST, so DESC pages emitted a cursor {v: , id: ...} while null rows still satisfied the cursor predicate (OR sort_expr IS NULL) on the next page — producing duplicate null rows across pages on cost/duration sorts. Handle nulls explicitly in the JS comparator so they always sort last regardless of direction, matching the SQL ordering. Co-Authored-By: Claude Opus 4.7 --- apps/sim/app/api/logs/route.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index 9725f468b03..c43ec9745dd 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -401,8 +401,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) const compareSortValues = (a: unknown, b: unknown): number => { - if (a === null || a === undefined) return b === null || b === undefined ? 0 : 1 - if (b === null || b === undefined) return -1 if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime() if (typeof a === 'number' && typeof b === 'number') return a - b const aStr = String(a) @@ -417,8 +415,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const merged = [...workflowMapped, ...jobMapped].sort((a, b) => { - const cmp = compareSortValues(a.sortValue, b.sortValue) - if (cmp !== 0) return sortOrder === 'asc' ? cmp : -cmp + const aNull = a.sortValue === null || a.sortValue === undefined + const bNull = b.sortValue === null || b.sortValue === undefined + // Mirror SQL's NULLS LAST for both ASC and DESC so the cursor stays consistent. + if (aNull && !bNull) return 1 + if (!aNull && bNull) return -1 + if (!aNull && !bNull) { + const cmp = compareSortValues(a.sortValue, b.sortValue) + if (cmp !== 0) return sortOrder === 'asc' ? cmp : -cmp + } const idCmp = a.id.localeCompare(b.id) return sortOrder === 'asc' ? idCmp : -idCmp }) From fd7ea59c921fbd14f248514e072ce7dc1c307a47 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 4 May 2026 11:41:23 -0700 Subject: [PATCH 04/13] =?UTF-8?q?fix(logs):=20final-audit=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20stable=20tab=20callback,=20byExecution=20invalidati?= =?UTF-8?q?on,=20optimistic=20detail=20patch,=20trace=20loading=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap LogDetails -> LogDetailsContent onActiveTabChange in useCallback so the child useEffect doesn't refire on every parent render. - Add logKeys.byExecutionAll() to cancel + retry invalidation so the table-embedded sidebar picks up status changes immediately. - Optimistic write-through to logKeys.detail in useCancelExecution so the open sidebar reflects 'cancelling' instantly; rolls back on error. - Distinguish trace loading from trace-empty: when log.executionData is not yet fetched, render "Loading trace…" instead of the empty state. Co-Authored-By: Claude Opus 4.7 --- .../components/log-details/log-details.tsx | 26 +++++++++++------- apps/sim/hooks/queries/logs.ts | 27 ++++++++++++++++--- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index cfc433d0f82..02be2974623 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -1,6 +1,6 @@ 'use client' -import { memo, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { formatDuration } from '@sim/utils/formatting' import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Eye, Search, X } from 'lucide-react' import { createPortal } from 'react-dom' @@ -600,12 +600,18 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP > {traceSpans?.length ? ( - ) : ( + ) : log.executionData ? (
No trace data available for this run
+ ) : ( +
+ + Loading trace… + +
)} )} @@ -667,6 +673,14 @@ export const LogDetails = memo(function LogDetails({ }: LogDetailsProps) { const activeTabRef = useRef('overview') + const handleActiveTabChange = useCallback( + (tab: LogDetailsTab) => { + activeTabRef.current = tab + onActiveTabChange?.(tab) + }, + [onActiveTabChange] + ) + const panelWidth = useLogDetailsUIStore((state) => state.panelWidth) const { handleMouseDown } = useLogDetailsResize() @@ -773,13 +787,7 @@ export const LogDetails = memo(function LogDetails({ - { - activeTabRef.current = tab - onActiveTabChange?.(tab) - }} - /> + )} diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index c66ffd9d3c0..2bfcf718aff 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -323,29 +323,47 @@ export function useCancelExecution() { queryKey: logKeys.lists(), }) + let affectedLogId: string | null = null queryClient.setQueriesData>({ queryKey: logKeys.lists() }, (old) => { if (!old) return old return { ...old, pages: old.pages.map((page) => ({ ...page, - logs: page.logs.map((log) => - log.executionId === executionId ? { ...log, status: 'cancelling' } : log - ), + logs: page.logs.map((log) => { + if (log.executionId !== executionId) return log + affectedLogId = log.id + return { ...log, status: 'cancelling' } + }), })), } }) - return { previousQueries } + let previousDetail: WorkflowLog | undefined + if (affectedLogId) { + previousDetail = queryClient.getQueryData(logKeys.detail(affectedLogId)) + if (previousDetail) { + queryClient.setQueryData(logKeys.detail(affectedLogId), { + ...previousDetail, + status: 'cancelling', + }) + } + } + + return { previousQueries, affectedLogId, previousDetail } }, onError: (_err, _variables, context) => { for (const [queryKey, data] of context?.previousQueries ?? []) { queryClient.setQueryData(queryKey, data) } + if (context?.affectedLogId && context.previousDetail !== undefined) { + queryClient.setQueryData(logKeys.detail(context.affectedLogId), context.previousDetail) + } }, onSettled: () => { queryClient.invalidateQueries({ queryKey: logKeys.lists() }) queryClient.invalidateQueries({ queryKey: logKeys.details() }) + queryClient.invalidateQueries({ queryKey: logKeys.byExecutionAll() }) queryClient.invalidateQueries({ queryKey: logKeys.stats() }) }, }) @@ -375,6 +393,7 @@ export function useRetryExecution() { onSettled: () => { queryClient.invalidateQueries({ queryKey: logKeys.lists() }) queryClient.invalidateQueries({ queryKey: logKeys.details() }) + queryClient.invalidateQueries({ queryKey: logKeys.byExecutionAll() }) queryClient.invalidateQueries({ queryKey: logKeys.stats() }) }, }) From 8d86023325870e27cfcffc44a23e098cf8fd6ccd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 4 May 2026 11:54:14 -0700 Subject: [PATCH 05/13] refactor(logs): migrate stores/components to contract types Replace the legacy `WorkflowLog` / `LogsResponse` / `WorkflowData` / `CostMetadata` / `ToolCallMetadata` shapes in `stores/logs/filters/types.ts` with direct use of the contract types `WorkflowLogSummary`, `WorkflowLogDetail`, and a new `WorkflowLogRow` alias for surfaces that render either form. Removes the `summaryToWorkflowLog` / `detailToWorkflowLog` bridge in the React Query layer along with their double-cast annotations. Co-Authored-By: Claude Opus 4.7 --- .../components/log-details/log-details.tsx | 12 +- .../log-row-context-menu.tsx | 4 +- .../logs/components/logs-list/logs-list.tsx | 28 ++--- .../app/workspace/[workspaceId]/logs/logs.tsx | 14 ++- .../app/workspace/[workspaceId]/logs/utils.ts | 8 +- apps/sim/hooks/queries/logs.ts | 28 ++--- apps/sim/lib/api/contracts/logs.ts | 7 ++ apps/sim/stores/logs/filters/types.ts | 111 +----------------- 8 files changed, 55 insertions(+), 157 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 02be2974623..d8fc881ec69 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -22,9 +22,11 @@ import { SModalTabsTrigger, Tooltip, } from '@/components/emcn' +import type { WorkflowLogRow } from '@/lib/api/contracts/logs' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import { cn } from '@/lib/core/utils/cn' import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans' +import type { TraceSpan } from '@/lib/logs/types' import { workflowBorderColor } from '@/lib/workspaces/colors' import { ExecutionSnapshot, @@ -43,7 +45,6 @@ import { import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' import { usePermissionConfig } from '@/hooks/use-permission-config' import { formatCost } from '@/providers/utils' -import type { WorkflowLog } from '@/stores/logs/filters/types' import { useLogDetailsUIStore } from '@/stores/logs/store' import { MAX_LOG_DETAILS_WIDTH_RATIO, MIN_LOG_DETAILS_WIDTH } from '@/stores/logs/utils' @@ -258,7 +259,7 @@ export type LogDetailsTab = 'overview' | 'trace' interface LogDetailsContentProps { /** The log to display */ - log: WorkflowLog + log: WorkflowLogRow /** Fires when the active tab changes, so embedders can gate their own keyboard handlers */ onActiveTabChange?: (tab: LogDetailsTab) => void } @@ -309,7 +310,8 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP !permissionConfig.hideTraceSpans const showTraceTab = !permissionConfig.hideTraceSpans && isLikelyExecution - const traceSpans = log.executionData?.traceSpans + // double-cast-allowed: contract trace span schema is intentionally permissive (optional duration/startTime/endTime to tolerate legacy persisted JSON); the canonical TraceSpan used by TraceView/ExecutionSnapshot requires them, and runtime data from the executor always supplies them. + const traceSpans = log.executionData?.traceSpans as unknown as TraceSpan[] | undefined const resolvedTab: LogDetailsTab = activeTab === 'trace' && !showTraceTab ? 'overview' : activeTab @@ -621,7 +623,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP {log.executionId && ( setIsExecutionSnapshotOpen(false)} @@ -633,7 +635,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP interface LogDetailsProps { /** The log to display details for */ - log: WorkflowLog | null + log: WorkflowLogRow | null /** Whether the sidebar is open */ isOpen: boolean /** Callback when closing the sidebar */ diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx index 0ce7aafa9f2..d8435907db8 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx @@ -15,13 +15,13 @@ import { SquareArrowUpRight, X, } from '@/components/emcn' -import type { WorkflowLog } from '@/stores/logs/filters/types' +import type { WorkflowLogSummary } from '@/lib/api/contracts/logs' interface LogRowContextMenuProps { isOpen: boolean position: { x: number; y: number } onClose: () => void - log: WorkflowLog | null + log: WorkflowLogSummary | null onCopyExecutionId: () => void onCopyLink: () => void onOpenWorkflow: () => void diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx index e8dd1d912be..7bf398a99ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx @@ -6,6 +6,7 @@ import { ArrowUpRight } from 'lucide-react' import Link from 'next/link' import { List, type RowComponentProps, useListRef } from 'react-window' import { Badge, buttonVariants, Loader } from '@/components/emcn' +import type { WorkflowLogSummary } from '@/lib/api/contracts/logs' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { cn } from '@/lib/core/utils/cn' import { workflowBorderColor } from '@/lib/workspaces/colors' @@ -18,16 +19,15 @@ import { StatusBadge, TriggerBadge, } from '@/app/workspace/[workspaceId]/logs/utils' -import type { WorkflowLog } from '@/stores/logs/filters/types' const LOG_ROW_HEIGHT = 44 as const interface LogRowProps { - log: WorkflowLog + log: WorkflowLogSummary isSelected: boolean - onClick: (log: WorkflowLog) => void - onHover?: (log: WorkflowLog) => void - onContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void + onClick: (log: WorkflowLogSummary) => void + onHover?: (log: WorkflowLogSummary) => void + onContextMenu?: (e: React.MouseEvent, log: WorkflowLogSummary) => void selectedRowRef: React.RefObject | null } @@ -56,7 +56,7 @@ const LogRow = memo( ? '#ec4899' : isDeletedWorkflow ? DELETED_WORKFLOW_COLOR - : log.workflow?.color + : (log.workflow?.color ?? undefined) const handleClick = () => onClick(log) const handleMouseEnter = () => onHover?.(log) @@ -164,11 +164,11 @@ const LogRow = memo( ) interface RowProps { - logs: WorkflowLog[] + logs: WorkflowLogSummary[] selectedLogId: string | null - onLogClick: (log: WorkflowLog) => void - onLogHover?: (log: WorkflowLog) => void - onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void + onLogClick: (log: WorkflowLogSummary) => void + onLogHover?: (log: WorkflowLogSummary) => void + onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLogSummary) => void selectedRowRef: React.RefObject isFetchingNextPage: boolean loaderRef: React.RefObject @@ -225,11 +225,11 @@ function Row({ } export interface LogsListProps { - logs: WorkflowLog[] + logs: WorkflowLogSummary[] selectedLogId: string | null - onLogClick: (log: WorkflowLog) => void - onLogHover?: (log: WorkflowLog) => void - onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void + onLogClick: (log: WorkflowLogSummary) => void + onLogHover?: (log: WorkflowLogSummary) => void + onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLogSummary) => void selectedRowRef: React.RefObject hasNextPage: boolean isFetchingNextPage: boolean diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 3480d15c856..0be70630107 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -16,6 +16,11 @@ import { RefreshCw, toast, } from '@/components/emcn' +import type { + WorkflowLogDetail, + WorkflowLogRow, + WorkflowLogSummary, +} from '@/lib/api/contracts/logs' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { cn } from '@/lib/core/utils/cn' import { @@ -65,7 +70,6 @@ import { import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows' import { useDebounce } from '@/hooks/use-debounce' import { useFilterStore } from '@/stores/logs/filters/store' -import type { WorkflowLog } from '@/stores/logs/filters/types' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' import { Dashboard, @@ -286,7 +290,7 @@ export default function Logs() { const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false) const [isExporting, setIsExporting] = useState(false) const refreshTimersRef = useRef(new Set()) - const logsRef = useRef([]) + const logsRef = useRef([]) const selectedLogIndexRef = useRef(-1) const selectedLogIdRef = useRef(null) const shouldScrollIntoViewRef = useRef(false) @@ -303,14 +307,14 @@ export default function Logs() { const [contextMenuOpen, setContextMenuOpen] = useState(false) const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) - const [contextMenuLog, setContextMenuLog] = useState(null) + const [contextMenuLog, setContextMenuLog] = useState(null) const [previewLogId, setPreviewLogId] = useState(null) const queryClient = useQueryClient() const refetchInterval = useCallback( - (query: { state: { data?: WorkflowLog } }) => { + (query: { state: { data?: WorkflowLogDetail } }) => { if (!isLive) return false const status = query.state.data?.status return status === 'running' || status === 'pending' ? 3000 : false @@ -528,7 +532,7 @@ export default function Logs() { }, [contextMenuLog]) const retryLog = useCallback( - async (log: WorkflowLog | null) => { + async (log: WorkflowLogRow | null) => { const workflowId = log?.workflow?.id || log?.workflowId const logId = log?.id if (!workflowId || !logId) return diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index bfca8a90b5a..55415e65692 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -2,9 +2,9 @@ import React from 'react' import { formatDuration } from '@sim/utils/formatting' import { format } from 'date-fns' import { Badge } from '@/components/emcn' +import type { WorkflowLogDetail } from '@/lib/api/contracts/logs' import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options' import { getBlock } from '@/blocks/registry' -import type { WorkflowLog } from '@/stores/logs/filters/types' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' export const LOG_COLUMNS = { @@ -449,15 +449,15 @@ export const formatDate = (dateString: string) => { * Prefers the persisted `workflowInput` field (new logs), falls back to * reconstructing from `executionState.blockStates` (old logs). */ -export function extractRetryInput(log: WorkflowLog): unknown | undefined { - const execData = log.executionData as Record | undefined +export function extractRetryInput(log: WorkflowLogDetail): unknown | undefined { + const execData = log.executionData if (!execData) return undefined if (execData.workflowInput !== undefined) { return execData.workflowInput } - const executionState = execData.executionState as + const executionState = (execData as Record).executionState as | { blockStates?: Record< string, diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index 2bfcf718aff..fddb9cc25ae 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -24,7 +24,7 @@ import { } from '@/lib/api/contracts/logs' import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' -import type { TimeRange, WorkflowLog } from '@/stores/logs/filters/types' +import type { TimeRange } from '@/stores/logs/filters/types' export type { DashboardStatsResponse, SegmentStats, WorkflowStats } @@ -63,11 +63,6 @@ export interface LogFilters { sortOrder: LogSortOrder } -// double-cast-allowed: bridge from contract type to legacy WorkflowLog used by stores/components -const summaryToWorkflowLog = (log: WorkflowLogSummary): WorkflowLog => log as unknown as WorkflowLog -// double-cast-allowed: bridge from contract type to legacy WorkflowLog used by stores/components -const detailToWorkflowLog = (log: WorkflowLogDetail): WorkflowLog => log as unknown as WorkflowLog - function applyFilterParams( params: URLSearchParams, filters: Omit @@ -123,7 +118,7 @@ function buildListQuery(workspaceId: string, filters: LogFilters, cursor: string } interface LogsPage { - logs: WorkflowLog[] + logs: WorkflowLogSummary[] nextCursor: string | null } @@ -139,7 +134,7 @@ async function fetchLogsPage( }) return { - logs: apiData.data.map(summaryToWorkflowLog), + logs: apiData.data, nextCursor: apiData.nextCursor, } } @@ -148,13 +143,13 @@ export async function fetchLogDetail( logId: string, workspaceId: string, signal?: AbortSignal -): Promise { +): Promise { const { data } = await requestJson(getLogDetailContract, { params: { id: logId }, query: { workspaceId }, signal, }) - return detailToWorkflowLog(data) + return data } interface UseLogsListOptions { @@ -185,7 +180,7 @@ interface UseLogDetailOptions { refetchInterval?: | number | false - | ((query: { state: { data?: WorkflowLog } }) => number | false | undefined) + | ((query: { state: { data?: WorkflowLogDetail } }) => number | false | undefined) } export function useLogDetail( @@ -220,9 +215,8 @@ export function useLogByExecutionId( query: { workspaceId: workspaceId as string }, signal, }) - const log = detailToWorkflowLog(data) - queryClient.setQueryData(logKeys.detail(log.id), log) - return log + queryClient.setQueryData(logKeys.detail(data.id), data) + return data }, enabled: Boolean(workspaceId) && Boolean(executionId), staleTime: 30 * 1000, @@ -339,11 +333,11 @@ export function useCancelExecution() { } }) - let previousDetail: WorkflowLog | undefined + let previousDetail: WorkflowLogDetail | undefined if (affectedLogId) { - previousDetail = queryClient.getQueryData(logKeys.detail(affectedLogId)) + previousDetail = queryClient.getQueryData(logKeys.detail(affectedLogId)) if (previousDetail) { - queryClient.setQueryData(logKeys.detail(affectedLogId), { + queryClient.setQueryData(logKeys.detail(affectedLogId), { ...previousDetail, status: 'cancelling', }) diff --git a/apps/sim/lib/api/contracts/logs.ts b/apps/sim/lib/api/contracts/logs.ts index 64477c7ebcc..6e94720f91a 100644 --- a/apps/sim/lib/api/contracts/logs.ts +++ b/apps/sim/lib/api/contracts/logs.ts @@ -243,6 +243,13 @@ export const workflowLogDetailSchema = workflowLogSummarySchema.extend({ export type WorkflowLogSummary = z.output export type WorkflowLogDetail = z.output +/** + * A row that may be either a list-view summary or a fully loaded detail. Used by + * UI surfaces that render the same log before and after its detail query resolves. + */ +export type WorkflowLogRow = WorkflowLogSummary & + Partial> + export const listLogsResponseSchema = z.object({ data: z.array(workflowLogSummarySchema), nextCursor: z.string().nullable(), diff --git a/apps/sim/stores/logs/filters/types.ts b/apps/sim/stores/logs/filters/types.ts index 3fbd85bfaee..cf95d3bee3e 100644 --- a/apps/sim/stores/logs/filters/types.ts +++ b/apps/sim/stores/logs/filters/types.ts @@ -1,113 +1,3 @@ -import type { ProviderTiming, TokenInfo, ToolCall, TraceSpan } from '@/lib/logs/types' - -export type { ProviderTiming, TokenInfo, ToolCall, TraceSpan } - -export interface WorkflowData { - id: string - name: string - description: string | null - color: string - state: any -} - -export interface ToolCallMetadata { - toolCalls?: ToolCall[] -} - -export interface CostMetadata { - models?: Record< - string, - { - input: number - output: number - total: number - tokens?: { - input?: number - output?: number - prompt?: number - completion?: number - total?: number - } - } - > - input?: number - output?: number - total?: number - tokens?: { - input?: number - output?: number - prompt?: number - completion?: number - total?: number - } - pricing?: { - input: number - output: number - cachedInput?: number - updatedAt: string - } -} - -export interface WorkflowLog { - id: string - workflowId: string | null - executionId?: string | null - deploymentVersion?: number | null - deploymentVersionName?: string | null - level: string - status?: string | null - duration: string | null - trigger: string | null - createdAt: string - workflow?: WorkflowData | null - jobTitle?: string | null - files?: Array<{ - id: string - name: string - size: number - type: string - url: string - key: string - uploadedAt: string - expiresAt: string - storageProvider?: 's3' | 'blob' | 'local' - bucketName?: string - }> - cost?: CostMetadata - hasPendingPause?: boolean - executionData?: ToolCallMetadata & { - traceSpans?: TraceSpan[] - totalDuration?: number - blockInput?: Record - enhanced?: boolean - - blockExecutions?: Array<{ - id: string - blockId: string - blockName: string - blockType: string - startedAt: string - endedAt: string - durationMs: number - status: 'success' | 'error' | 'skipped' - errorMessage?: string - errorStackTrace?: string - inputData: unknown - outputData: unknown - cost?: CostMetadata - metadata: Record - }> - } -} - -export interface LogsResponse { - data: WorkflowLog[] - total: number - page: number - pageSize: number - totalPages: number -} - export type TimeRange = | 'Past 30 minutes' | 'Past hour' @@ -129,6 +19,7 @@ export type LogLevel = | 'cancelled' | 'all' | (string & {}) + /** Core trigger types for workflow execution */ export const CORE_TRIGGER_TYPES = [ 'manual', From ff938c7ab44b518268dca7a172692ad3e1e3d1fd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 4 May 2026 12:10:29 -0700 Subject: [PATCH 06/13] refactor(logs): address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Whitelist sort columns against logSortBy enum to prevent client crash when non-sortable headers (workflow, trigger) reach the contract parser. - Extract fetchLogDetail helper shared by /api/logs/[id] and /api/logs/by-execution/[executionId] — collapses ~360 duplicated lines to a single source of truth keyed on lookup column. --- apps/sim/app/api/logs/[id]/route.ts | 184 +--------------- .../logs/by-execution/[executionId]/route.ts | 192 +---------------- .../app/workspace/[workspaceId]/logs/logs.tsx | 6 +- apps/sim/lib/logs/fetch-log-detail.ts | 197 ++++++++++++++++++ 4 files changed, 218 insertions(+), 361 deletions(-) create mode 100644 apps/sim/lib/logs/fetch-log-detail.ts diff --git a/apps/sim/app/api/logs/[id]/route.ts b/apps/sim/app/api/logs/[id]/route.ts index db1368a7902..75f7378db20 100644 --- a/apps/sim/app/api/logs/[id]/route.ts +++ b/apps/sim/app/api/logs/[id]/route.ts @@ -1,19 +1,10 @@ -import { db } from '@sim/db' -import { - jobExecutionLogs, - pausedExecutions, - permissions, - workflow, - workflowDeploymentVersion, - workflowExecutionLogs, -} from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getLogDetailContract } from '@/lib/api/contracts/logs' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { fetchLogDetail } from '@/lib/logs/fetch-log-detail' const logger = createLogger('LogDetailsByIdAPI') @@ -24,181 +15,22 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const userId = session.user.id const parsed = await parseRequest(getLogDetailContract, request, context) if (!parsed.success) return parsed.response const { id } = parsed.data.params const { workspaceId } = parsed.data.query - const rows = await db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - executionId: workflowExecutionLogs.executionId, - deploymentVersionId: workflowExecutionLogs.deploymentVersionId, - level: workflowExecutionLogs.level, - status: workflowExecutionLogs.status, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - cost: workflowExecutionLogs.cost, - files: workflowExecutionLogs.files, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowColor: workflow.color, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - deploymentVersion: workflowDeploymentVersion.version, - deploymentVersionName: workflowDeploymentVersion.name, - pausedStatus: pausedExecutions.status, - pausedTotalPauseCount: pausedExecutions.totalPauseCount, - pausedResumedCount: pausedExecutions.resumedCount, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .leftJoin( - workflowDeploymentVersion, - eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) - ) - .leftJoin( - pausedExecutions, - eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) - ) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where( - and(eq(workflowExecutionLogs.id, id), eq(workflowExecutionLogs.workspaceId, workspaceId)) - ) - .limit(1) + const data = await fetchLogDetail({ + userId: session.user.id, + workspaceId, + lookupColumn: 'id', + lookupValue: id, + }) - const log = rows[0] - - if (!log) { - const jobRows = await db - .select({ - id: jobExecutionLogs.id, - executionId: jobExecutionLogs.executionId, - level: jobExecutionLogs.level, - status: jobExecutionLogs.status, - trigger: jobExecutionLogs.trigger, - startedAt: jobExecutionLogs.startedAt, - endedAt: jobExecutionLogs.endedAt, - totalDurationMs: jobExecutionLogs.totalDurationMs, - executionData: jobExecutionLogs.executionData, - cost: jobExecutionLogs.cost, - createdAt: jobExecutionLogs.createdAt, - }) - .from(jobExecutionLogs) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, jobExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(and(eq(jobExecutionLogs.id, id), eq(jobExecutionLogs.workspaceId, workspaceId))) - .limit(1) - - const jobLog = jobRows[0] - if (!jobLog) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const execData = (jobLog.executionData as Record | null) ?? {} - const data = { - id: jobLog.id, - workflowId: null, - executionId: jobLog.executionId, - deploymentVersionId: null, - deploymentVersion: null, - deploymentVersionName: null, - level: jobLog.level, - status: jobLog.status, - duration: jobLog.totalDurationMs ? `${jobLog.totalDurationMs}ms` : null, - trigger: jobLog.trigger, - createdAt: jobLog.startedAt.toISOString(), - workflow: null, - jobTitle: - ((execData.trigger as Record | undefined)?.source as string) ?? null, - cost: jobLog.cost ?? null, - pauseSummary: { status: null, total: 0, resumed: 0 }, - hasPendingPause: false, - executionData: { - totalDuration: jobLog.totalDurationMs, - ...execData, - enhanced: true as const, - }, - files: null, - } - - return NextResponse.json({ data }) - } - - const workflowSummary = log.workflowId - ? { - id: log.workflowId, - name: log.workflowName, - description: log.workflowDescription, - color: log.workflowColor, - folderId: log.workflowFolderId, - userId: log.workflowUserId, - workspaceId: log.workflowWorkspaceId, - createdAt: log.workflowCreatedAt?.toISOString() ?? null, - updatedAt: log.workflowUpdatedAt?.toISOString() ?? null, - } - : null - - const totalPauseCount = Number(log.pausedTotalPauseCount ?? 0) - const resumedCount = Number(log.pausedResumedCount ?? 0) - const hasPendingPause = - (totalPauseCount > 0 && resumedCount < totalPauseCount) || - (log.pausedStatus !== null && log.pausedStatus !== 'fully_resumed') - - const data = { - id: log.id, - workflowId: log.workflowId, - executionId: log.executionId, - deploymentVersionId: log.deploymentVersionId, - deploymentVersion: log.deploymentVersion ?? null, - deploymentVersionName: log.deploymentVersionName ?? null, - level: log.level, - status: log.status, - duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, - trigger: log.trigger, - createdAt: log.startedAt.toISOString(), - workflow: workflowSummary, - jobTitle: null, - cost: log.cost ?? null, - pauseSummary: { - status: log.pausedStatus ?? null, - total: totalPauseCount, - resumed: resumedCount, - }, - hasPendingPause, - executionData: { - totalDuration: log.totalDurationMs, - ...((log.executionData as Record | null) ?? {}), - enhanced: true as const, - }, - files: log.files ?? null, - } + if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 }) logger.debug('Fetched log detail', { id, workspaceId }) - return NextResponse.json({ data }) } ) diff --git a/apps/sim/app/api/logs/by-execution/[executionId]/route.ts b/apps/sim/app/api/logs/by-execution/[executionId]/route.ts index 7925e6f71ef..172a77506cc 100644 --- a/apps/sim/app/api/logs/by-execution/[executionId]/route.ts +++ b/apps/sim/app/api/logs/by-execution/[executionId]/route.ts @@ -1,19 +1,10 @@ -import { db } from '@sim/db' -import { - jobExecutionLogs, - pausedExecutions, - permissions, - workflow, - workflowDeploymentVersion, - workflowExecutionLogs, -} from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getLogByExecutionIdContract } from '@/lib/api/contracts/logs' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { fetchLogDetail } from '@/lib/logs/fetch-log-detail' const logger = createLogger('LogDetailsByExecutionAPI') @@ -24,189 +15,22 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const userId = session.user.id const parsed = await parseRequest(getLogByExecutionIdContract, request, context) if (!parsed.success) return parsed.response const { executionId } = parsed.data.params const { workspaceId } = parsed.data.query - const rows = await db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - executionId: workflowExecutionLogs.executionId, - deploymentVersionId: workflowExecutionLogs.deploymentVersionId, - level: workflowExecutionLogs.level, - status: workflowExecutionLogs.status, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - cost: workflowExecutionLogs.cost, - files: workflowExecutionLogs.files, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowColor: workflow.color, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - deploymentVersion: workflowDeploymentVersion.version, - deploymentVersionName: workflowDeploymentVersion.name, - pausedStatus: pausedExecutions.status, - pausedTotalPauseCount: pausedExecutions.totalPauseCount, - pausedResumedCount: pausedExecutions.resumedCount, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .leftJoin( - workflowDeploymentVersion, - eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) - ) - .leftJoin( - pausedExecutions, - eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) - ) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where( - and( - eq(workflowExecutionLogs.executionId, executionId), - eq(workflowExecutionLogs.workspaceId, workspaceId) - ) - ) - .limit(1) + const data = await fetchLogDetail({ + userId: session.user.id, + workspaceId, + lookupColumn: 'executionId', + lookupValue: executionId, + }) - const log = rows[0] - - if (!log) { - const jobRows = await db - .select({ - id: jobExecutionLogs.id, - executionId: jobExecutionLogs.executionId, - level: jobExecutionLogs.level, - status: jobExecutionLogs.status, - trigger: jobExecutionLogs.trigger, - startedAt: jobExecutionLogs.startedAt, - endedAt: jobExecutionLogs.endedAt, - totalDurationMs: jobExecutionLogs.totalDurationMs, - executionData: jobExecutionLogs.executionData, - cost: jobExecutionLogs.cost, - createdAt: jobExecutionLogs.createdAt, - }) - .from(jobExecutionLogs) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, jobExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where( - and( - eq(jobExecutionLogs.executionId, executionId), - eq(jobExecutionLogs.workspaceId, workspaceId) - ) - ) - .limit(1) - - const jobLog = jobRows[0] - if (!jobLog) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const execData = (jobLog.executionData as Record | null) ?? {} - const data = { - id: jobLog.id, - workflowId: null, - executionId: jobLog.executionId, - deploymentVersionId: null, - deploymentVersion: null, - deploymentVersionName: null, - level: jobLog.level, - status: jobLog.status, - duration: jobLog.totalDurationMs ? `${jobLog.totalDurationMs}ms` : null, - trigger: jobLog.trigger, - createdAt: jobLog.startedAt.toISOString(), - workflow: null, - jobTitle: - ((execData.trigger as Record | undefined)?.source as string) ?? null, - cost: jobLog.cost ?? null, - pauseSummary: { status: null, total: 0, resumed: 0 }, - hasPendingPause: false, - executionData: { - totalDuration: jobLog.totalDurationMs, - ...execData, - enhanced: true as const, - }, - files: null, - } - - return NextResponse.json({ data }) - } - - const workflowSummary = log.workflowId - ? { - id: log.workflowId, - name: log.workflowName, - description: log.workflowDescription, - color: log.workflowColor, - folderId: log.workflowFolderId, - userId: log.workflowUserId, - workspaceId: log.workflowWorkspaceId, - createdAt: log.workflowCreatedAt?.toISOString() ?? null, - updatedAt: log.workflowUpdatedAt?.toISOString() ?? null, - } - : null - - const totalPauseCount = Number(log.pausedTotalPauseCount ?? 0) - const resumedCount = Number(log.pausedResumedCount ?? 0) - const hasPendingPause = - (totalPauseCount > 0 && resumedCount < totalPauseCount) || - (log.pausedStatus !== null && log.pausedStatus !== 'fully_resumed') - - const data = { - id: log.id, - workflowId: log.workflowId, - executionId: log.executionId, - deploymentVersionId: log.deploymentVersionId, - deploymentVersion: log.deploymentVersion ?? null, - deploymentVersionName: log.deploymentVersionName ?? null, - level: log.level, - status: log.status, - duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, - trigger: log.trigger, - createdAt: log.startedAt.toISOString(), - workflow: workflowSummary, - jobTitle: null, - cost: log.cost ?? null, - pauseSummary: { - status: log.pausedStatus ?? null, - total: totalPauseCount, - resumed: resumedCount, - }, - hasPendingPause, - executionData: { - totalDuration: log.totalDurationMs, - ...((log.executionData as Record | null) ?? {}), - enhanced: true as const, - }, - files: log.files ?? null, - } + if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 }) logger.debug('Fetched log by execution id', { executionId, workspaceId }) - return NextResponse.json({ data }) } ) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 0be70630107..4e819ba3e0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -92,6 +92,7 @@ import { } from './utils' const LOGS_PER_PAGE = 50 as const +const SORTABLE_COLUMNS: readonly LogSortBy[] = ['date', 'duration', 'cost', 'status'] as const const REFRESH_SPINNER_DURATION_MS = 1000 as const const LOG_COLUMNS: ResourceColumn[] = [ @@ -330,7 +331,10 @@ export default function Logs() { refetchInterval, }) - const sortBy: LogSortBy = (activeSort?.column as LogSortBy | undefined) ?? 'date' + const sortBy: LogSortBy = + activeSort && SORTABLE_COLUMNS.includes(activeSort.column as LogSortBy) + ? (activeSort.column as LogSortBy) + : 'date' const sortOrder: LogSortOrder = activeSort?.direction ?? 'desc' const logFilters = useMemo( diff --git a/apps/sim/lib/logs/fetch-log-detail.ts b/apps/sim/lib/logs/fetch-log-detail.ts new file mode 100644 index 00000000000..1a5aea4dc26 --- /dev/null +++ b/apps/sim/lib/logs/fetch-log-detail.ts @@ -0,0 +1,197 @@ +import { db } from '@sim/db' +import { + jobExecutionLogs, + pausedExecutions, + permissions, + workflow, + workflowDeploymentVersion, + workflowExecutionLogs, +} from '@sim/db/schema' +import { and, eq, type SQL } from 'drizzle-orm' + +type LookupColumn = 'id' | 'executionId' + +interface FetchLogDetailArgs { + userId: string + workspaceId: string + lookupColumn: LookupColumn + lookupValue: string +} + +/** + * Shared loader for the workflow-log detail shape returned by the by-id and + * by-execution routes. Returns `null` when no matching row exists in either + * the workflow-execution or job-execution tables for this user + workspace. + */ +export async function fetchLogDetail({ + userId, + workspaceId, + lookupColumn, + lookupValue, +}: FetchLogDetailArgs) { + const workflowMatch: SQL = + lookupColumn === 'id' + ? eq(workflowExecutionLogs.id, lookupValue) + : eq(workflowExecutionLogs.executionId, lookupValue) + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + level: workflowExecutionLogs.level, + status: workflowExecutionLogs.status, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + cost: workflowExecutionLogs.cost, + files: workflowExecutionLogs.files, + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowColor: workflow.color, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + deploymentVersion: workflowDeploymentVersion.version, + deploymentVersionName: workflowDeploymentVersion.name, + pausedStatus: pausedExecutions.status, + pausedTotalPauseCount: pausedExecutions.totalPauseCount, + pausedResumedCount: pausedExecutions.resumedCount, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .leftJoin( + workflowDeploymentVersion, + eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) + ) + .leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflowExecutionLogs.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(and(workflowMatch, eq(workflowExecutionLogs.workspaceId, workspaceId))) + .limit(1) + + const log = rows[0] + + if (log) { + const workflowSummary = log.workflowId + ? { + id: log.workflowId, + name: log.workflowName, + description: log.workflowDescription, + color: log.workflowColor, + folderId: log.workflowFolderId, + userId: log.workflowUserId, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt?.toISOString() ?? null, + updatedAt: log.workflowUpdatedAt?.toISOString() ?? null, + } + : null + + const totalPauseCount = Number(log.pausedTotalPauseCount ?? 0) + const resumedCount = Number(log.pausedResumedCount ?? 0) + const hasPendingPause = + (totalPauseCount > 0 && resumedCount < totalPauseCount) || + (log.pausedStatus !== null && log.pausedStatus !== 'fully_resumed') + + return { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + deploymentVersionId: log.deploymentVersionId, + deploymentVersion: log.deploymentVersion ?? null, + deploymentVersionName: log.deploymentVersionName ?? null, + level: log.level, + status: log.status, + duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, + trigger: log.trigger, + createdAt: log.startedAt.toISOString(), + workflow: workflowSummary, + jobTitle: null, + cost: log.cost ?? null, + pauseSummary: { + status: log.pausedStatus ?? null, + total: totalPauseCount, + resumed: resumedCount, + }, + hasPendingPause, + executionData: { + totalDuration: log.totalDurationMs, + ...((log.executionData as Record | null) ?? {}), + enhanced: true as const, + }, + files: log.files ?? null, + } + } + + const jobMatch: SQL = + lookupColumn === 'id' + ? eq(jobExecutionLogs.id, lookupValue) + : eq(jobExecutionLogs.executionId, lookupValue) + + const jobRows = await db + .select({ + id: jobExecutionLogs.id, + executionId: jobExecutionLogs.executionId, + level: jobExecutionLogs.level, + status: jobExecutionLogs.status, + trigger: jobExecutionLogs.trigger, + startedAt: jobExecutionLogs.startedAt, + endedAt: jobExecutionLogs.endedAt, + totalDurationMs: jobExecutionLogs.totalDurationMs, + executionData: jobExecutionLogs.executionData, + cost: jobExecutionLogs.cost, + createdAt: jobExecutionLogs.createdAt, + }) + .from(jobExecutionLogs) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, jobExecutionLogs.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(and(jobMatch, eq(jobExecutionLogs.workspaceId, workspaceId))) + .limit(1) + + const jobLog = jobRows[0] + if (!jobLog) return null + + const execData = (jobLog.executionData as Record | null) ?? {} + return { + id: jobLog.id, + workflowId: null, + executionId: jobLog.executionId, + deploymentVersionId: null, + deploymentVersion: null, + deploymentVersionName: null, + level: jobLog.level, + status: jobLog.status, + duration: jobLog.totalDurationMs ? `${jobLog.totalDurationMs}ms` : null, + trigger: jobLog.trigger, + createdAt: jobLog.startedAt.toISOString(), + workflow: null, + jobTitle: ((execData.trigger as Record | undefined)?.source as string) ?? null, + cost: jobLog.cost ?? null, + pauseSummary: { status: null, total: 0, resumed: 0 }, + hasPendingPause: false, + executionData: { + totalDuration: jobLog.totalDurationMs, + ...execData, + enhanced: true as const, + }, + files: null, + } +} From 2f249cad4fe616ec24fec24b9d35f816fd922340 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 4 May 2026 12:24:47 -0700 Subject: [PATCH 07/13] fix(logs): exclude job logs when level filter is workflow-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When level=running or level=pending (workflow-only states involving endedAt/pausedExecutions semantics), jobLevelConditions stayed empty so no level constraint reached jobConditions — every job log in the workspace leaked into the result. Skip the job side entirely when the level filter has no job-applicable values (error/info). --- apps/sim/app/api/logs/route.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index c43ec9745dd..73dcd600a24 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -175,7 +175,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const triggersList = params.triggers?.split(',').filter(Boolean) || [] const triggersExcludeJobs = triggersList.length > 0 && !triggersList.includes('all') && !triggersList.includes('mothership') - const includeJobLogs = !hasWorkflowSpecificFilters && !triggersExcludeJobs + const levelList = + params.level && params.level !== 'all' ? params.level.split(',').filter(Boolean) : [] + const levelExcludesJobs = + levelList.length > 0 && !levelList.some((l) => l === 'error' || l === 'info') + const includeJobLogs = !hasWorkflowSpecificFilters && !triggersExcludeJobs && !levelExcludesJobs const workflowQuery = db .select({ From a95a80f6dc31698f711aed4f0ba21efe9792f215 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 4 May 2026 12:31:58 -0700 Subject: [PATCH 08/13] =?UTF-8?q?chore(logs):=20drop=20dead=20utils=20?= =?UTF-8?q?=E2=80=94=20mapToExecutionLog=20and=20friends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove ExecutionLog/RawLogResponse/ExecutionCost/LogWithExecutionData/ TraceSpan/BlockExecution interfaces and the mapToExecutionLog, mapToExecutionLogAlt, extractOutput functions — all unreferenced after the contract split. -212 lines. --- .../app/workspace/[workspaceId]/logs/utils.ts | 212 ------------------ 1 file changed, 212 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index 55415e65692..533154bdf6e 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -180,218 +180,6 @@ export function parseDuration(log: LogWithDuration): number | null { return Number.isFinite(durationCandidate) ? durationCandidate : null } -interface TraceSpan { - output?: Record - status?: string - error?: unknown -} - -interface BlockExecution { - outputData?: unknown - errorMessage?: string -} - -interface LogWithExecutionData { - executionData?: { - finalOutput?: unknown - traceSpans?: TraceSpan[] - blockExecutions?: BlockExecution[] - output?: unknown - } - output?: string - message?: string -} - -/** - * Extract output from various sources in execution data. - * Checks multiple locations in priority order: - * 1. executionData.finalOutput - * 2. output (as string) - * 3. executionData.traceSpans (iterates through spans) - * 4. executionData.blockExecutions (last block) - * 5. message (fallback) - * @param log - Log object containing execution data - * @returns Extracted output value or null - */ -export function extractOutput(log: LogWithExecutionData): unknown { - let output: unknown = null - - // Check finalOutput first - if (log.executionData?.finalOutput !== undefined) { - output = log.executionData.finalOutput - } - - // Check direct output field - if (typeof log.output === 'string') { - output = log.output - } else if (log.executionData?.traceSpans && Array.isArray(log.executionData.traceSpans)) { - // Search through trace spans - const spans = log.executionData.traceSpans - for (let i = spans.length - 1; i >= 0; i--) { - const s = spans[i] - if (s?.output && Object.keys(s.output).length > 0) { - output = s.output - break - } - const outputWithError = s?.output as Record | undefined - if (s?.status === 'error' && (outputWithError?.error || s?.error)) { - output = outputWithError?.error || s.error - break - } - } - // Fallback to executionData.output - if (!output && log.executionData?.output) { - output = log.executionData.output - } - } - - // Check block executions - if (!output) { - const blockExecutions = log.executionData?.blockExecutions - if (Array.isArray(blockExecutions) && blockExecutions.length > 0) { - const lastBlock = blockExecutions[blockExecutions.length - 1] - output = lastBlock?.outputData || lastBlock?.errorMessage || null - } - } - - // Final fallback to message - if (!output) { - output = log.message || null - } - - return output -} - -/** Execution log cost breakdown */ -interface ExecutionCost { - input: number - output: number - total: number -} - -/** Mapped execution log format for UI consumption */ -export interface ExecutionLog { - id: string - executionId: string - startedAt: string - level: string - status: string - trigger: string - triggerUserId: string | null - triggerInputs?: unknown - outputs?: unknown - errorMessage: string | null - duration: number | null - cost: ExecutionCost | null - workflowName?: string - workflowColor?: string - hasPendingPause?: boolean -} - -/** Raw API log response structure */ -interface RawLogResponse extends LogWithDuration, LogWithExecutionData { - id: string - executionId: string - startedAt?: string - endedAt?: string - createdAt?: string - level?: string - status?: string - trigger?: string - triggerUserId?: string | null - error?: string - cost?: { - input?: number - output?: number - total?: number - } - workflowName?: string - workflowColor?: string - workflow?: { - name?: string - color?: string - } - hasPendingPause?: boolean -} - -/** - * Convert raw API log response to ExecutionLog format. - * @param log - Raw log response from API - * @returns Formatted execution log - */ -export function mapToExecutionLog(log: RawLogResponse): ExecutionLog { - const started = log.startedAt - ? new Date(log.startedAt) - : log.endedAt - ? new Date(log.endedAt) - : null - - const startedAt = - started && !Number.isNaN(started.getTime()) ? started.toISOString() : new Date().toISOString() - - const duration = parseDuration(log) - const output = extractOutput(log) - - return { - id: log.id, - executionId: log.executionId, - startedAt, - level: log.level || 'info', - status: log.status || 'completed', - trigger: log.trigger || 'manual', - triggerUserId: log.triggerUserId || null, - triggerInputs: undefined, - outputs: output || undefined, - errorMessage: log.error || null, - duration, - cost: log.cost - ? { - input: log.cost.input || 0, - output: log.cost.output || 0, - total: log.cost.total || 0, - } - : null, - workflowName: log.workflowName || log.workflow?.name, - workflowColor: log.workflowColor || log.workflow?.color, - hasPendingPause: log.hasPendingPause === true, - } -} - -/** - * Alternative version that uses createdAt as fallback for startedAt. - * Used in some API responses. - * @param log - Raw log response from API - * @returns Formatted execution log - */ -export function mapToExecutionLogAlt(log: RawLogResponse): ExecutionLog { - const duration = parseDuration(log) - const output = extractOutput(log) - - return { - id: log.id, - executionId: log.executionId, - startedAt: log.createdAt || log.startedAt || new Date().toISOString(), - level: log.level || 'info', - status: log.status || 'completed', - trigger: log.trigger || 'manual', - triggerUserId: log.triggerUserId || null, - triggerInputs: undefined, - outputs: output || undefined, - errorMessage: log.error || null, - duration, - cost: log.cost - ? { - input: log.cost.input || 0, - output: log.cost.output || 0, - total: log.cost.total || 0, - } - : null, - workflowName: log.workflow?.name, - workflowColor: log.workflow?.color, - hasPendingPause: log.hasPendingPause === true, - } -} - /** * Format latency value for display in dashboard UI * @param ms - Latency in milliseconds (number) From 5a91f6cd0bf0e91ca5ead18a6cf2aa2091caee25 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 4 May 2026 12:34:13 -0700 Subject: [PATCH 09/13] chore(logs): drop unused LOG_COLUMN_ORDER and LogColumnKey --- apps/sim/app/workspace/[workspaceId]/logs/utils.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index 533154bdf6e..8fa8f4624a6 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -16,17 +16,6 @@ export const LOG_COLUMNS = { duration: { width: 'w-[20%]', minWidth: 'min-w-[100px]', label: 'Duration' }, } as const -export type LogColumnKey = keyof typeof LOG_COLUMNS - -export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [ - 'workflow', - 'date', - 'status', - 'cost', - 'trigger', - 'duration', -] as const - export const DELETED_WORKFLOW_LABEL = 'Deleted Workflow' export const DELETED_WORKFLOW_COLOR = 'var(--text-tertiary)' From c92ea70118b53a8713a9c2625eee3be187c111ae Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 4 May 2026 12:40:29 -0700 Subject: [PATCH 10/13] fix(logs): hydrate filters from URL synchronously on mount The previous useEffect-based initializeFromURL caused useLogsList and useDashboardStats to fire once with default store filters, then refetch after the effect updated filters from the URL. Move the initial hydrate into a useState lazy initializer so the first render already reads URL-derived filters; the popstate handler keeps the existing effect for back/forward navigation. --- apps/sim/app/workspace/[workspaceId]/logs/logs.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 4e819ba3e0b..f3f770835c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -221,6 +221,14 @@ export default function Logs() { const params = useParams() const workspaceId = params.workspaceId as string + // Hydrate filters from the URL synchronously on first render so + // useLogsList / useDashboardStats fire once with the correct filters + // instead of refetching after a post-mount effect. + useState(() => { + useFilterStore.getState().initializeFromURL() + return null + }) + const { setWorkspaceId, initializeFromURL, @@ -666,11 +674,6 @@ export default function Logs() { debouncedSearchQuery, ]) - useEffect(() => { - initializeFromURL() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - useEffect(() => { const handlePopState = () => { initializeFromURL() From e98e80ac64fc27ee0f651d353d6c07e8d973c5e5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 4 May 2026 12:50:13 -0700 Subject: [PATCH 11/13] chore(logs): trim verbose comments added during PR Co-Authored-By: Claude Opus 4.7 --- .../components/log-details/log-details.tsx | 34 ++----------------- .../app/workspace/[workspaceId]/logs/logs.tsx | 3 -- apps/sim/hooks/queries/logs.ts | 5 --- 3 files changed, 2 insertions(+), 40 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index d8fc881ec69..7063f9d0f88 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -48,9 +48,6 @@ import { formatCost } from '@/providers/utils' import { useLogDetailsUIStore } from '@/stores/logs/store' import { MAX_LOG_DETAILS_WIDTH_RATIO, MIN_LOG_DETAILS_WIDTH } from '@/stores/logs/utils' -/** - * Workflow Output section with code viewer, copy, search, and context menu functionality - */ export const WorkflowOutputSection = memo( function WorkflowOutputSection({ output }: { output: Record }) { const contentRef = useRef(null) @@ -258,18 +255,10 @@ export const WorkflowOutputSection = memo( export type LogDetailsTab = 'overview' | 'trace' interface LogDetailsContentProps { - /** The log to display */ log: WorkflowLogRow - /** Fires when the active tab changes, so embedders can gate their own keyboard handlers */ onActiveTabChange?: (tab: LogDetailsTab) => void } -/** - * Tabbed body for a single log: overview details and trace spans, plus the - * execution snapshot modal. Used as the body of the `LogDetails` sidebar and - * embedded directly inside the Mothership resource panel — keep the two in - * sync by editing this component, not by re-implementing it elsewhere. - */ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentProps) { const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false) const [activeTab, setActiveTab] = useState('overview') @@ -310,7 +299,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP !permissionConfig.hideTraceSpans const showTraceTab = !permissionConfig.hideTraceSpans && isLikelyExecution - // double-cast-allowed: contract trace span schema is intentionally permissive (optional duration/startTime/endTime to tolerate legacy persisted JSON); the canonical TraceSpan used by TraceView/ExecutionSnapshot requires them, and runtime data from the executor always supplies them. + // double-cast-allowed: contract schema makes duration/startTime optional for legacy persisted JSON; runtime data always supplies them. const traceSpans = log.executionData?.traceSpans as unknown as TraceSpan[] | undefined const resolvedTab: LogDetailsTab = activeTab === 'trace' && !showTraceTab ? 'overview' : activeTab @@ -634,33 +623,18 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP } interface LogDetailsProps { - /** The log to display details for */ log: WorkflowLogRow | null - /** Whether the sidebar is open */ isOpen: boolean - /** Callback when closing the sidebar */ onClose: () => void - /** Callback to navigate to next log */ onNavigateNext?: () => void - /** Callback to navigate to previous log */ onNavigatePrev?: () => void - /** Whether there is a next log available */ hasNext?: boolean - /** Whether there is a previous log available */ hasPrev?: boolean - /** Callback to retry a failed execution */ onRetryExecution?: () => void - /** Whether a retry is currently in progress */ isRetryPending?: boolean - /** Fires when the active tab changes, so the parent can gate its own keyboard handlers */ onActiveTabChange?: (tab: LogDetailsTab) => void } -/** - * Sidebar panel displaying detailed information about a selected log. - * Wraps `LogDetailsContent` with sidebar chrome — resize handle, header, and - * keyboard navigation between logs. - */ export const LogDetails = memo(function LogDetails({ log, isOpen, @@ -687,9 +661,6 @@ export const LogDetails = memo(function LogDetails({ const { handleMouseDown } = useLogDetailsResize() const maxVw = `${MAX_LOG_DETAILS_WIDTH_RATIO * 100}vw` - // CSS-side clamp matching `clampPanelWidth` in stores/logs/utils.ts: the - // floor is itself capped at the max-vw ratio so a narrow viewport doesn't - // let the min outpace the cap and cover the table behind the panel. const effectiveWidth = `clamp(min(${MIN_LOG_DETAILS_WIDTH}px, ${maxVw}), ${panelWidth}px, ${maxVw})` useEffect(() => { @@ -700,8 +671,7 @@ export const LogDetails = memo(function LogDetails({ if (!isOpen) return - // When the Trace tab is active, arrow keys belong to TraceView's own - // span-navigation handler. Log-to-log navigation should not hijack them. + // Trace tab owns arrow keys for span navigation. if (activeTabRef.current === 'trace') return if (e.key === 'ArrowUp' && hasPrev && onNavigatePrev) { diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index f3f770835c9..47bdf7c3ff2 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -221,9 +221,6 @@ export default function Logs() { const params = useParams() const workspaceId = params.workspaceId as string - // Hydrate filters from the URL synchronously on first render so - // useLogsList / useDashboardStats fire once with the correct filters - // instead of refetching after a post-mount effect. useState(() => { useFilterStore.getState().initializeFromURL() return null diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index 0d39e0ccdc6..00b1aac4985 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -200,11 +200,6 @@ export function useLogDetail( }) } -/** - * Looks up a workflow log by its `executionId`. Writes the resulting detail - * through to the canonical `detail(id)` cache so subsequent `useLogDetail` - * reads hit instantly. - */ export function useLogByExecutionId( workspaceId: string | undefined, executionId: string | null | undefined From 849ea642de596b4af0d6fbb5e2e66365d14b14c0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 4 May 2026 13:43:42 -0700 Subject: [PATCH 12/13] fix(logs): guard navigation arrows when selected log is off-page Deep-linked logs resolved via useLogByExecutionId may not be in the current page list, leaving selectedLogIndex at -1. The hasNext prop was evaluating -1 < logs.length - 1 (true for any non-empty list), which enabled the next arrow and jumped to the first item on click. Co-Authored-By: Claude Opus 4.7 --- apps/sim/app/workspace/[workspaceId]/logs/logs.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 47bdf7c3ff2..5c3b3b0af66 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -452,7 +452,7 @@ export default function Logs() { const handleNavigateNext = useCallback(() => { const idx = selectedLogIndexRef.current const currentLogs = logsRef.current - if (idx < currentLogs.length - 1) { + if (idx >= 0 && idx < currentLogs.length - 1) { shouldScrollIntoViewRef.current = true dispatch({ type: 'SELECT_LOG', logId: currentLogs[idx + 1].id }) } @@ -801,7 +801,7 @@ export default function Logs() { onClose={handleCloseSidebar} onNavigateNext={handleNavigateNext} onNavigatePrev={handleNavigatePrev} - hasNext={selectedLogIndex < logs.length - 1} + hasNext={selectedLogIndex >= 0 && selectedLogIndex < logs.length - 1} hasPrev={selectedLogIndex > 0} onRetryExecution={handleRetrySidebarExecution} isRetryPending={retryExecution.isPending} From fb0ae86e91da287214b348d5f5e3a94e0cab84a8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 4 May 2026 15:28:06 -0700 Subject: [PATCH 13/13] fix(logs): sync active-tab callback before paint to keep keyboard guards aligned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run the resolvedTab → onActiveTabChange propagation in useLayoutEffect so the parent's activeTabRef updates synchronously before the next paint. This closes the brief window where window keydown handlers in the logs page would still see activeTabRef.current === 'trace' and short-circuit arrow-key navigation immediately after switching to a log without a Trace tab. Co-Authored-By: Claude Opus 4.7 --- .../[workspaceId]/logs/components/log-details/log-details.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 7063f9d0f88..addb5e8932b 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -1,6 +1,6 @@ 'use client' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { formatDuration } from '@sim/utils/formatting' import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Eye, Search, X } from 'lucide-react' import { createPortal } from 'react-dom' @@ -304,7 +304,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP const resolvedTab: LogDetailsTab = activeTab === 'trace' && !showTraceTab ? 'overview' : activeTab - useEffect(() => { + useLayoutEffect(() => { onActiveTabChange?.(resolvedTab) }, [resolvedTab, onActiveTabChange])