Files
simstudioai--sim/apps/sim/lib/logs/fetch-log-detail.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

277 lines
9.8 KiB
TypeScript

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<CostLedger | null> {
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<string, LedgerItem>()
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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,
}
}