import { db } from '@sim/db' import { jobExecutionLogs, pausedExecutions, usageLog, workflow, workflowDeploymentVersion, workflowExecutionLogs, } from '@sim/db/schema' import { and, eq, type SQL } from 'drizzle-orm' import type { CostLedger } from '@/lib/api/contracts/logs' import { type ExecutionProgressMarkers, getProgressMarkers, pickLatestCompletedMarker, pickLatestStartedMarker, } from '@/lib/logs/execution/progress-markers' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' type LookupColumn = 'id' | 'executionId' async function buildCostLedger(executionId: string): Promise { const rows = await db .select({ category: usageLog.category, description: usageLog.description, cost: usageLog.cost, metadata: usageLog.metadata, }) .from(usageLog) .where(and(eq(usageLog.executionId, executionId), eq(usageLog.source, 'workflow'))) if (rows.length === 0) return null type LedgerItem = CostLedger['items'][number] const byKey = new Map() for (const row of rows) { const metadata = (row.metadata ?? {}) as { inputTokens?: number; outputTokens?: number } const category = row.category as LedgerItem['category'] const key = `${category}::${row.description}` const existing = byKey.get(key) if (existing) { existing.cost += Number(row.cost) if (typeof metadata.inputTokens === 'number') { existing.inputTokens = Math.max(existing.inputTokens ?? 0, metadata.inputTokens) } if (typeof metadata.outputTokens === 'number') { existing.outputTokens = Math.max(existing.outputTokens ?? 0, metadata.outputTokens) } } else { byKey.set(key, { category, description: row.description, cost: Number(row.cost), ...(typeof metadata.inputTokens === 'number' ? { inputTokens: metadata.inputTokens } : {}), ...(typeof metadata.outputTokens === 'number' ? { outputTokens: metadata.outputTokens } : {}), }) } } const items = [...byKey.values()] const total = items.reduce((sum, item) => sum + item.cost, 0) return { total, items } } export function jobCostTotal(raw: unknown): { total: number } | null { const total = (raw as { total?: unknown } | null | undefined)?.total const n = total == null ? Number.NaN : Number(total) return Number.isFinite(n) ? { total: n } : null } 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. * * For in-flight (running/pending) executions, live progress markers are merged * from Redis, since they are only folded into the row at a terminal/pause * boundary. */ export async function fetchLogDetail({ userId, workspaceId, lookupColumn, lookupValue, }: FetchLogDetailArgs) { const access = await checkWorkspaceAccess(workspaceId, userId) if (!access.hasAccess) return null 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, costTotal: workflowExecutionLogs.costTotal, files: workflowExecutionLogs.files, createdAt: workflowExecutionLogs.createdAt, workflowName: workflow.name, workflowDescription: workflow.description, 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)) .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, 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') // Cost is sourced exclusively from the usage_log ledger (itemized breakdown) // and its cost_total projection (run total). The cost jsonb is never read. const costLedger = await buildCostLedger(log.executionId) const totalDollars = costLedger?.total ?? (log.costTotal != null ? Number(log.costTotal) : null) // Trace spans / heavy execution data may live in object storage; resolve the // pointer here (no-op for inline / pre-externalization rows). const executionData = await materializeExecutionData( log.executionData as Record | null, { workspaceId, workflowId: log.workflowId, executionId: log.executionId } ) const liveMarkers = log.status === 'running' || log.status === 'pending' ? ((await getProgressMarkers(log.executionId)) ?? {}) : {} const rowMarkers = (executionData ?? {}) as ExecutionProgressMarkers const mergedStartedBlock = pickLatestStartedMarker( liveMarkers.lastStartedBlock, rowMarkers.lastStartedBlock ) const mergedCompletedBlock = pickLatestCompletedMarker( liveMarkers.lastCompletedBlock, rowMarkers.lastCompletedBlock ) 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: totalDollars != null ? { total: totalDollars } : null, costLedger, pauseSummary: { status: log.pausedStatus ?? null, total: totalPauseCount, resumed: resumedCount, }, hasPendingPause, executionData: { totalDuration: log.totalDurationMs, ...executionData, ...(mergedStartedBlock ? { lastStartedBlock: mergedStartedBlock } : {}), ...(mergedCompletedBlock ? { lastCompletedBlock: mergedCompletedBlock } : {}), 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) .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: jobCostTotal(jobLog.cost), pauseSummary: { status: null, total: 0, resumed: 0 }, hasPendingPause: false, executionData: { totalDuration: jobLog.totalDurationMs, ...execData, enhanced: true as const, }, files: null, } }