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