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
455 lines
16 KiB
TypeScript
455 lines
16 KiB
TypeScript
import { dbReplica } from '@sim/db'
|
|
import {
|
|
jobExecutionLogs,
|
|
pausedExecutions,
|
|
workflow,
|
|
workflowDeploymentVersion,
|
|
workflowExecutionLogs,
|
|
} from '@sim/db/schema'
|
|
import {
|
|
and,
|
|
asc,
|
|
desc,
|
|
eq,
|
|
gt,
|
|
gte,
|
|
inArray,
|
|
isNotNull,
|
|
isNull,
|
|
lt,
|
|
lte,
|
|
ne,
|
|
or,
|
|
type SQL,
|
|
sql,
|
|
} from 'drizzle-orm'
|
|
import type { z } from 'zod'
|
|
import type {
|
|
ListLogsResponse,
|
|
listLogsQuerySchema,
|
|
WorkflowLogSummary,
|
|
} from '@/lib/api/contracts/logs'
|
|
import { jobCostTotal } from '@/lib/logs/fetch-log-detail'
|
|
import { buildFilterConditions } from '@/lib/logs/filters'
|
|
import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion'
|
|
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
|
|
|
export type ListLogsParams = z.output<typeof listLogsQuerySchema>
|
|
|
|
type SortBy = 'date' | 'duration' | 'cost' | 'status'
|
|
type SortOrder = 'asc' | 'desc'
|
|
|
|
interface CursorData {
|
|
v: string | number | null
|
|
id: string
|
|
}
|
|
|
|
function encodeCursor(data: CursorData): string {
|
|
return Buffer.from(JSON.stringify(data)).toString('base64')
|
|
}
|
|
|
|
export function decodeCursor(cursor: string): CursorData | null {
|
|
try {
|
|
const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString())
|
|
if (typeof parsed?.id !== 'string') return null
|
|
return parsed as CursorData
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shared logs list query used by the `/api/logs` route and the copilot `query_logs`
|
|
* tool. Builds the workflow + job execution-log query (cursor pagination, sort,
|
|
* level running/pending logic, job-log merge) from the shared filter params. The
|
|
* caller is responsible for authenticating `userId`; this function enforces
|
|
* workspace permission via the `permissions` join.
|
|
*/
|
|
export async function listLogs(params: ListLogsParams, userId: string): Promise<ListLogsResponse> {
|
|
const access = await checkWorkspaceAccess(params.workspaceId, userId)
|
|
if (!access.hasAccess) {
|
|
return { data: [], nextCursor: null }
|
|
}
|
|
|
|
const sortBy = params.sortBy as SortBy
|
|
const sortOrder = params.sortOrder as SortOrder
|
|
const cursor = params.cursor ? decodeCursor(params.cursor) : null
|
|
|
|
// Expand selected folders to include descendants (matches the route behavior),
|
|
// without mutating the caller's params object.
|
|
const folderIds = params.folderIds
|
|
? await expandFolderIdsWithDescendants(params.workspaceId, params.folderIds)
|
|
: params.folderIds
|
|
const p: ListLogsParams = { ...params, folderIds }
|
|
|
|
const workflowSortExpr: SQL<unknown> = (() => {
|
|
switch (sortBy) {
|
|
case 'duration':
|
|
return sql`${workflowExecutionLogs.totalDurationMs}`
|
|
case 'cost':
|
|
// Indexed projection of the usage_log ledger (dollars); no live aggregation.
|
|
return sql`${workflowExecutionLogs.costTotal}`
|
|
case 'status':
|
|
return sql`${workflowExecutionLogs.status}`
|
|
default:
|
|
return sql`${workflowExecutionLogs.startedAt}`
|
|
}
|
|
})()
|
|
|
|
const jobSortExpr: SQL<unknown> = (() => {
|
|
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 fetchSize = p.limit + 1
|
|
|
|
// Build workflow log conditions
|
|
const workflowConditions: SQL[] = [eq(workflowExecutionLogs.workspaceId, p.workspaceId)]
|
|
|
|
if (p.level && p.level !== 'all') {
|
|
const levels = p.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 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 (c) levelConditions.push(c)
|
|
}
|
|
}
|
|
|
|
if (levelConditions.length > 0) {
|
|
workflowConditions.push(
|
|
levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)!
|
|
)
|
|
}
|
|
}
|
|
|
|
const commonFilters = buildFilterConditions(p, { 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 = !!(
|
|
p.workflowIds ||
|
|
p.folderIds ||
|
|
p.workflowName ||
|
|
p.folderName
|
|
)
|
|
const triggersList = p.triggers?.split(',').filter(Boolean) || []
|
|
const triggersExcludeJobs =
|
|
triggersList.length > 0 && !triggersList.includes('all') && !triggersList.includes('mothership')
|
|
const levelList = p.level && p.level !== 'all' ? p.level.split(',').filter(Boolean) : []
|
|
const levelExcludesJobs =
|
|
levelList.length > 0 && !levelList.some((l) => l === 'error' || l === 'info')
|
|
const includeJobLogs = !hasWorkflowSpecificFilters && !triggersExcludeJobs && !levelExcludesJobs
|
|
|
|
const workflowQuery = dbReplica
|
|
.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,
|
|
costTotal: workflowExecutionLogs.costTotal,
|
|
createdAt: workflowExecutionLogs.createdAt,
|
|
workflowName: workflow.name,
|
|
workflowDescription: workflow.description,
|
|
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<unknown>`${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))
|
|
.where(and(...workflowConditions))
|
|
.orderBy(orderByClause(workflowSortExpr), dir(workflowExecutionLogs.id))
|
|
.limit(fetchSize)
|
|
|
|
const jobConditions: SQL[] = [eq(jobExecutionLogs.workspaceId, p.workspaceId)]
|
|
|
|
if (includeJobLogs) {
|
|
if (p.level && p.level !== 'all') {
|
|
const levels = p.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(
|
|
jobLevelConditions.length === 1 ? jobLevelConditions[0] : or(...jobLevelConditions)!
|
|
)
|
|
}
|
|
}
|
|
|
|
if (triggersList.length > 0 && !triggersList.includes('all')) {
|
|
jobConditions.push(inArray(jobExecutionLogs.trigger, triggersList))
|
|
}
|
|
|
|
if (p.startDate) {
|
|
jobConditions.push(gte(jobExecutionLogs.startedAt, new Date(p.startDate)))
|
|
}
|
|
if (p.endDate) {
|
|
jobConditions.push(lte(jobExecutionLogs.startedAt, new Date(p.endDate)))
|
|
}
|
|
|
|
if (p.search) {
|
|
jobConditions.push(sql`${jobExecutionLogs.executionId} ILIKE ${`%${p.search}%`}`)
|
|
}
|
|
if (p.executionId) {
|
|
jobConditions.push(eq(jobExecutionLogs.executionId, p.executionId))
|
|
}
|
|
|
|
if (p.costOperator && p.costValue !== undefined) {
|
|
const costField = sql`(${jobExecutionLogs.cost}->>'total')::numeric`
|
|
const ops = {
|
|
'=': sql`=`,
|
|
'>': sql`>`,
|
|
'<': sql`<`,
|
|
'>=': sql`>=`,
|
|
'<=': sql`<=`,
|
|
'!=': sql`!=`,
|
|
} as const
|
|
jobConditions.push(sql`${costField} ${ops[p.costOperator]} ${p.costValue}`)
|
|
}
|
|
|
|
if (p.durationOperator && p.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[p.durationOperator]?.(
|
|
jobExecutionLogs.totalDurationMs,
|
|
p.durationValue
|
|
)
|
|
if (durationCond) jobConditions.push(durationCond)
|
|
}
|
|
|
|
const jobCursorCond = buildCursorCondition(jobSortExpr, jobExecutionLogs.id)
|
|
if (jobCursorCond) jobConditions.push(jobCursorCond)
|
|
}
|
|
|
|
const jobQuery = includeJobLogs
|
|
? dbReplica
|
|
.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<string | null>`${jobExecutionLogs.executionData}->'trigger'->>'source'`,
|
|
sortValue: sql<unknown>`${jobSortExpr}`.as('sort_value'),
|
|
})
|
|
.from(jobExecutionLogs)
|
|
.where(and(...jobConditions))
|
|
.orderBy(orderByClause(jobSortExpr), dir(jobExecutionLogs.id))
|
|
.limit(fetchSize)
|
|
: Promise.resolve([])
|
|
|
|
const [workflowRows, jobRows] = await Promise.all([workflowQuery, jobQuery])
|
|
|
|
type RowWithSort = {
|
|
id: string
|
|
sortValue: unknown
|
|
summary: WorkflowLogSummary
|
|
}
|
|
|
|
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,
|
|
folderId: log.workflowFolderId,
|
|
userId: log.workflowUserId,
|
|
workspaceId: log.workflowWorkspaceId,
|
|
createdAt: log.workflowCreatedAt?.toISOString() ?? null,
|
|
updatedAt: log.workflowUpdatedAt?.toISOString() ?? null,
|
|
}
|
|
: null,
|
|
jobTitle: null,
|
|
// List cost is the cost_total projection (faithful ledger sum). Null until
|
|
// completion (running) or until the one-time legacy backfill populates it.
|
|
cost: log.costTotal != null ? { total: Number(log.costTotal) } : null,
|
|
pauseSummary: {
|
|
status: log.pausedStatus ?? null,
|
|
total: totalPauseCount,
|
|
resumed: resumedCount,
|
|
},
|
|
hasPendingPause,
|
|
}
|
|
return { id: log.id, sortValue: log.sortValue, summary }
|
|
})
|
|
|
|
const jobMapped: RowWithSort[] = (jobRows as Awaited<typeof jobQuery>).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: jobCostTotal(log.cost),
|
|
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 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 aNull = a.sortValue === null || a.sortValue === undefined
|
|
const bNull = b.sortValue === null || b.sortValue === undefined
|
|
// Mirror SQL's NULLS LAST for both ASC and DESC so the cursor stays consistent.
|
|
if (aNull && !bNull) return 1
|
|
if (!aNull && bNull) return -1
|
|
if (!aNull && !bNull) {
|
|
const cmp = compareSortValues(a.sortValue, b.sortValue)
|
|
if (cmp !== 0) return sortOrder === 'asc' ? cmp : -cmp
|
|
}
|
|
const idCmp = a.id.localeCompare(b.id)
|
|
return sortOrder === 'asc' ? idCmp : -idCmp
|
|
})
|
|
|
|
const page = merged.slice(0, p.limit)
|
|
const hasMore = merged.length > p.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 })
|
|
}
|
|
|
|
return {
|
|
data: page.map((row) => row.summary),
|
|
nextCursor,
|
|
}
|
|
}
|