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
1141 lines
36 KiB
TypeScript
1141 lines
36 KiB
TypeScript
import { db } from '@sim/db'
|
|
import { workflowExecutionLogs } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { describeError, toError } from '@sim/utils/errors'
|
|
import { and, eq, sql } from 'drizzle-orm'
|
|
import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
|
|
import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure'
|
|
import { executionLogger } from '@/lib/logs/execution/logger'
|
|
import {
|
|
calculateCostSummary,
|
|
createEnvironmentObject,
|
|
createTriggerObject,
|
|
loadDeployedWorkflowStateForLogging,
|
|
loadWorkflowStateForExecution,
|
|
} from '@/lib/logs/execution/logging-factory'
|
|
import {
|
|
clearProgressMarkers,
|
|
getProgressMarkers,
|
|
setLastCompletedBlock,
|
|
setLastStartedBlock,
|
|
} from '@/lib/logs/execution/progress-markers'
|
|
import type {
|
|
ExecutionEnvironment,
|
|
ExecutionFinalizationPath,
|
|
ExecutionLastCompletedBlock,
|
|
ExecutionLastStartedBlock,
|
|
ExecutionTrigger,
|
|
TraceSpan,
|
|
WorkflowState,
|
|
} from '@/lib/logs/types'
|
|
import type { SerializableExecutionState } from '@/executor/execution/types'
|
|
|
|
type TriggerData = Record<string, unknown> & {
|
|
correlation?: NonNullable<ExecutionTrigger['data']>['correlation']
|
|
}
|
|
|
|
function buildStartedMarkerPersistenceQuery(params: {
|
|
executionId: string
|
|
workflowId: string
|
|
marker: ExecutionLastStartedBlock
|
|
}) {
|
|
const markerJson = JSON.stringify(params.marker)
|
|
|
|
return sql`UPDATE workflow_execution_logs
|
|
SET execution_data = jsonb_set(
|
|
COALESCE(execution_data, '{}'::jsonb),
|
|
'{lastStartedBlock}',
|
|
${markerJson}::jsonb,
|
|
true
|
|
)
|
|
WHERE execution_id = ${params.executionId}
|
|
AND workflow_id = ${params.workflowId}
|
|
AND COALESCE(
|
|
jsonb_extract_path_text(COALESCE(execution_data, '{}'::jsonb), 'lastStartedBlock', 'startedAt'),
|
|
''
|
|
) <= ${params.marker.startedAt}`
|
|
}
|
|
|
|
function buildCompletedMarkerPersistenceQuery(params: {
|
|
executionId: string
|
|
workflowId: string
|
|
marker: ExecutionLastCompletedBlock
|
|
}) {
|
|
const markerJson = JSON.stringify(params.marker)
|
|
|
|
return sql`UPDATE workflow_execution_logs
|
|
SET execution_data = jsonb_set(
|
|
COALESCE(execution_data, '{}'::jsonb),
|
|
'{lastCompletedBlock}',
|
|
${markerJson}::jsonb,
|
|
true
|
|
)
|
|
WHERE execution_id = ${params.executionId}
|
|
AND workflow_id = ${params.workflowId}
|
|
AND COALESCE(
|
|
jsonb_extract_path_text(COALESCE(execution_data, '{}'::jsonb), 'lastCompletedBlock', 'endedAt'),
|
|
''
|
|
) <= ${params.marker.endedAt}`
|
|
}
|
|
|
|
const logger = createLogger('LoggingSession')
|
|
|
|
type CompletionAttempt = 'complete' | 'error' | 'cancelled' | 'paused'
|
|
|
|
export interface SessionStartParams {
|
|
userId?: string
|
|
workspaceId: string
|
|
variables?: Record<string, string>
|
|
triggerData?: TriggerData
|
|
skipLogCreation?: boolean // For resume executions - reuse existing log entry
|
|
deploymentVersionId?: string // ID of the deployment version used (null for manual/editor executions)
|
|
workflowState?: WorkflowState
|
|
}
|
|
|
|
export interface SessionCompleteParams {
|
|
endedAt?: string
|
|
totalDurationMs?: number
|
|
finalOutput?: any
|
|
traceSpans?: TraceSpan[]
|
|
workflowInput?: any
|
|
executionState?: SerializableExecutionState
|
|
}
|
|
|
|
export interface SessionErrorCompleteParams {
|
|
endedAt?: string
|
|
totalDurationMs?: number
|
|
error?: {
|
|
message?: string
|
|
stackTrace?: string
|
|
}
|
|
traceSpans?: TraceSpan[]
|
|
skipCost?: boolean
|
|
}
|
|
|
|
export interface SessionCancelledParams {
|
|
endedAt?: string
|
|
totalDurationMs?: number
|
|
traceSpans?: TraceSpan[]
|
|
}
|
|
|
|
export interface SessionPausedParams {
|
|
endedAt?: string
|
|
totalDurationMs?: number
|
|
traceSpans?: TraceSpan[]
|
|
workflowInput?: any
|
|
}
|
|
|
|
export class LoggingSession {
|
|
private workflowId: string
|
|
private executionId: string
|
|
private triggerType: ExecutionTrigger['type']
|
|
private requestId?: string
|
|
private trigger?: ExecutionTrigger
|
|
private environment?: ExecutionEnvironment
|
|
private workflowState?: WorkflowState
|
|
private correlation?: NonNullable<ExecutionTrigger['data']>['correlation']
|
|
private isResume = false
|
|
private completed = false
|
|
/** Synchronous flag to prevent concurrent completion attempts (race condition guard) */
|
|
private completing = false
|
|
/** Tracks the in-flight completion promise so callers can await it */
|
|
private completionPromise: Promise<void> | null = null
|
|
private completionAttempt: CompletionAttempt | null = null
|
|
private completionAttemptFailed = false
|
|
private pendingProgressWrites = new Set<Promise<void>>()
|
|
private postExecutionPromise: Promise<void> | null = null
|
|
|
|
constructor(
|
|
workflowId: string,
|
|
executionId: string,
|
|
triggerType: ExecutionTrigger['type'],
|
|
requestId?: string
|
|
) {
|
|
this.workflowId = workflowId
|
|
this.executionId = executionId
|
|
this.triggerType = triggerType
|
|
this.requestId = requestId
|
|
}
|
|
|
|
async onBlockStart(
|
|
blockId: string,
|
|
blockName: string,
|
|
blockType: string,
|
|
startedAt: string
|
|
): Promise<void> {
|
|
await this.trackProgressWrite(
|
|
this.persistLastStartedBlock({
|
|
blockId,
|
|
blockName,
|
|
blockType,
|
|
startedAt,
|
|
})
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Persist the last-started-block marker. Redis is the primary path; falls back
|
|
* to the durable jsonb_set UPDATE when Redis is unavailable or the write fails,
|
|
* so a marker is never dropped.
|
|
*/
|
|
private async persistLastStartedBlock(marker: ExecutionLastStartedBlock): Promise<void> {
|
|
if (await setLastStartedBlock(this.executionId, marker)) {
|
|
return
|
|
}
|
|
try {
|
|
await db.execute(
|
|
buildStartedMarkerPersistenceQuery({
|
|
executionId: this.executionId,
|
|
workflowId: this.workflowId,
|
|
marker,
|
|
})
|
|
)
|
|
} catch (error) {
|
|
logger.error(`Failed to persist last started block for execution ${this.executionId}:`, {
|
|
error: toError(error).message,
|
|
cause: describeError(error),
|
|
retryable: isRetryableInfrastructureError(error),
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persist the last-completed-block marker. Redis is the primary path; falls
|
|
* back to the durable jsonb_set UPDATE when Redis is unavailable or the write
|
|
* fails, so a marker is never dropped.
|
|
*/
|
|
private async persistLastCompletedBlock(marker: ExecutionLastCompletedBlock): Promise<void> {
|
|
if (await setLastCompletedBlock(this.executionId, marker)) {
|
|
return
|
|
}
|
|
try {
|
|
await db.execute(
|
|
buildCompletedMarkerPersistenceQuery({
|
|
executionId: this.executionId,
|
|
workflowId: this.workflowId,
|
|
marker,
|
|
})
|
|
)
|
|
} catch (error) {
|
|
logger.error(`Failed to persist last completed block for execution ${this.executionId}:`, {
|
|
error: toError(error).message,
|
|
cause: describeError(error),
|
|
retryable: isRetryableInfrastructureError(error),
|
|
})
|
|
}
|
|
}
|
|
|
|
private async trackProgressWrite(writePromise: Promise<void>): Promise<void> {
|
|
this.pendingProgressWrites.add(writePromise)
|
|
|
|
try {
|
|
await writePromise
|
|
} finally {
|
|
this.pendingProgressWrites.delete(writePromise)
|
|
}
|
|
}
|
|
|
|
private async drainPendingProgressWrites(): Promise<void> {
|
|
while (this.pendingProgressWrites.size > 0) {
|
|
await Promise.allSettled(Array.from(this.pendingProgressWrites))
|
|
}
|
|
}
|
|
|
|
private async completeExecutionWithFinalization(params: {
|
|
endedAt: string
|
|
totalDurationMs: number
|
|
costSummary: {
|
|
totalCost: number
|
|
totalInputCost: number
|
|
totalOutputCost: number
|
|
totalTokens: number
|
|
totalPromptTokens: number
|
|
totalCompletionTokens: number
|
|
baseExecutionCharge: number
|
|
models: Record<
|
|
string,
|
|
{
|
|
input: number
|
|
output: number
|
|
total: number
|
|
tokens: { input: number; output: number; total: number }
|
|
}
|
|
>
|
|
// Non-model billable charges (standalone tool/integration costs). Carried
|
|
// through so the partition can't be silently dropped at this boundary.
|
|
charges?: Record<string, { total: number }>
|
|
}
|
|
finalOutput: Record<string, unknown>
|
|
traceSpans: TraceSpan[]
|
|
workflowInput?: unknown
|
|
executionState?: SerializableExecutionState
|
|
finalizationPath: ExecutionFinalizationPath
|
|
completionFailure?: string
|
|
level?: 'info' | 'error'
|
|
status?: 'completed' | 'failed' | 'cancelled' | 'pending'
|
|
}): Promise<void> {
|
|
await executionLogger.completeWorkflowExecution({
|
|
executionId: this.executionId,
|
|
endedAt: params.endedAt,
|
|
totalDurationMs: params.totalDurationMs,
|
|
costSummary: params.costSummary,
|
|
finalOutput: params.finalOutput,
|
|
traceSpans: params.traceSpans,
|
|
workflowInput: params.workflowInput,
|
|
executionState: params.executionState,
|
|
finalizationPath: params.finalizationPath,
|
|
completionFailure: params.completionFailure,
|
|
isResume: this.isResume,
|
|
level: params.level,
|
|
status: params.status,
|
|
})
|
|
|
|
// Release the admission reservation from preprocessing. Skipped on pause: a
|
|
// paused execution keeps its slot until it terminates (or the TTL expires).
|
|
if (params.finalizationPath !== 'paused') {
|
|
try {
|
|
await releaseExecutionSlot(this.executionId)
|
|
} catch (error) {
|
|
logger.warn(`Failed to release admission reservation for ${this.executionId}:`, {
|
|
error: toError(error).message,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
async onBlockComplete(
|
|
blockId: string,
|
|
blockName: string,
|
|
blockType: string,
|
|
output: any
|
|
): Promise<void> {
|
|
// Cost is recorded into the usage_log ledger and reconciled at completion
|
|
// boundaries (see recordExecutionUsage); onBlockComplete only persists the
|
|
// last-completed-block progress marker.
|
|
await this.trackProgressWrite(
|
|
this.persistLastCompletedBlock({
|
|
blockId,
|
|
blockName,
|
|
blockType,
|
|
endedAt: output?.endedAt || new Date().toISOString(),
|
|
success: !output?.output?.error,
|
|
})
|
|
)
|
|
}
|
|
|
|
async start(params: SessionStartParams): Promise<void> {
|
|
const {
|
|
userId,
|
|
workspaceId,
|
|
variables,
|
|
triggerData,
|
|
skipLogCreation,
|
|
deploymentVersionId,
|
|
workflowState,
|
|
} = params
|
|
|
|
try {
|
|
this.trigger = createTriggerObject(this.triggerType, triggerData)
|
|
this.correlation = triggerData?.correlation
|
|
this.environment = createEnvironmentObject(
|
|
this.workflowId,
|
|
this.executionId,
|
|
userId,
|
|
workspaceId,
|
|
variables
|
|
)
|
|
this.workflowState =
|
|
workflowState ??
|
|
(deploymentVersionId
|
|
? await loadDeployedWorkflowStateForLogging(this.workflowId)
|
|
: await loadWorkflowStateForExecution(this.workflowId))
|
|
|
|
if (!skipLogCreation) {
|
|
await executionLogger.startWorkflowExecution({
|
|
workflowId: this.workflowId,
|
|
workspaceId,
|
|
executionId: this.executionId,
|
|
trigger: this.trigger,
|
|
environment: this.environment,
|
|
workflowState: this.workflowState,
|
|
deploymentVersionId,
|
|
})
|
|
} else {
|
|
// Resume: no cost reload needed. Billing reconciles from the usage_log
|
|
// ledger (pre-pause rows already exist) plus the live cost summary.
|
|
this.isResume = true
|
|
}
|
|
} catch (error) {
|
|
if (this.requestId) {
|
|
logger.error(`[${this.requestId}] Failed to start logging:`, error)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async complete(params: SessionCompleteParams = {}): Promise<void> {
|
|
if (this.completed || this.completing) {
|
|
return
|
|
}
|
|
this.completing = true
|
|
|
|
const { endedAt, totalDurationMs, finalOutput, traceSpans, workflowInput, executionState } =
|
|
params
|
|
|
|
try {
|
|
const costSummary = calculateCostSummary(traceSpans || [])
|
|
const endTime = endedAt || new Date().toISOString()
|
|
const duration = totalDurationMs || 0
|
|
|
|
await this.completeExecutionWithFinalization({
|
|
endedAt: endTime,
|
|
totalDurationMs: duration,
|
|
costSummary,
|
|
finalOutput: finalOutput || {},
|
|
traceSpans: traceSpans || [],
|
|
workflowInput,
|
|
executionState,
|
|
finalizationPath: 'completed',
|
|
})
|
|
|
|
this.completed = true
|
|
|
|
if (traceSpans && traceSpans.length > 0) {
|
|
try {
|
|
const { PlatformEvents, createOTelSpansForWorkflowExecution } = await import(
|
|
'@/lib/core/telemetry'
|
|
)
|
|
|
|
const hasErrors = traceSpans.some((span: any) => {
|
|
const checkForErrors = (s: any): boolean => {
|
|
if (s.status === 'error' && !s.errorHandled) return true
|
|
if (s.children && Array.isArray(s.children)) {
|
|
return s.children.some(checkForErrors)
|
|
}
|
|
return false
|
|
}
|
|
return checkForErrors(span)
|
|
})
|
|
|
|
PlatformEvents.workflowExecuted({
|
|
workflowId: this.workflowId,
|
|
durationMs: duration,
|
|
status: hasErrors ? 'error' : 'success',
|
|
trigger: this.triggerType,
|
|
blocksExecuted: traceSpans.length,
|
|
hasErrors,
|
|
totalCost: costSummary.totalCost || 0,
|
|
})
|
|
|
|
const startTime = new Date(new Date(endTime).getTime() - duration).toISOString()
|
|
createOTelSpansForWorkflowExecution({
|
|
workflowId: this.workflowId,
|
|
workflowName: this.workflowState?.metadata?.name,
|
|
executionId: this.executionId,
|
|
traceSpans,
|
|
trigger: this.triggerType,
|
|
startTime,
|
|
endTime,
|
|
totalDurationMs: duration,
|
|
status: hasErrors ? 'error' : 'success',
|
|
})
|
|
} catch (_e) {
|
|
// Silently fail
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.completing = false
|
|
logger.error(`Failed to complete logging for execution ${this.executionId}:`, {
|
|
requestId: this.requestId,
|
|
workflowId: this.workflowId,
|
|
executionId: this.executionId,
|
|
error: toError(error).message,
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
cause: describeError(error),
|
|
retryable: isRetryableInfrastructureError(error),
|
|
})
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async completeWithError(params: SessionErrorCompleteParams = {}): Promise<void> {
|
|
if (this.completed || this.completing) {
|
|
return
|
|
}
|
|
this.completing = true
|
|
|
|
try {
|
|
const currentLog = await db
|
|
.select({ status: workflowExecutionLogs.status })
|
|
.from(workflowExecutionLogs)
|
|
.where(
|
|
and(
|
|
eq(workflowExecutionLogs.workflowId, this.workflowId),
|
|
eq(workflowExecutionLogs.executionId, this.executionId)
|
|
)
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0])
|
|
|
|
if (currentLog?.status === 'cancelled') {
|
|
this.completed = true
|
|
return
|
|
}
|
|
|
|
const { endedAt, totalDurationMs, error, traceSpans, skipCost } = params
|
|
|
|
const endTime = endedAt ? new Date(endedAt) : new Date()
|
|
const durationMs = typeof totalDurationMs === 'number' ? totalDurationMs : 0
|
|
const startTime = new Date(endTime.getTime() - Math.max(1, durationMs))
|
|
|
|
const hasProvidedSpans = Array.isArray(traceSpans) && traceSpans.length > 0
|
|
|
|
// calculateCostSummary([]) / (undefined) already returns the base-charge
|
|
// summary, so the no-spans branch needs no separate literal.
|
|
const costSummary = skipCost
|
|
? {
|
|
totalCost: 0,
|
|
totalInputCost: 0,
|
|
totalOutputCost: 0,
|
|
totalTokens: 0,
|
|
totalPromptTokens: 0,
|
|
totalCompletionTokens: 0,
|
|
baseExecutionCharge: 0,
|
|
models: {},
|
|
charges: {},
|
|
}
|
|
: calculateCostSummary(traceSpans)
|
|
|
|
const message = error?.message || 'Run failed before starting blocks'
|
|
|
|
const errorSpan: TraceSpan = {
|
|
id: 'workflow-error-root',
|
|
name: 'Workflow Error',
|
|
type: 'workflow',
|
|
duration: Math.max(1, durationMs),
|
|
startTime: startTime.toISOString(),
|
|
endTime: endTime.toISOString(),
|
|
status: 'error',
|
|
...(hasProvidedSpans ? {} : { children: [] }),
|
|
output: { error: message },
|
|
}
|
|
|
|
const spans = hasProvidedSpans ? traceSpans : [errorSpan]
|
|
|
|
await this.completeExecutionWithFinalization({
|
|
endedAt: endTime.toISOString(),
|
|
totalDurationMs: Math.max(1, durationMs),
|
|
costSummary,
|
|
finalOutput: { error: message },
|
|
traceSpans: spans,
|
|
level: 'error',
|
|
status: 'failed',
|
|
finalizationPath: 'force_failed',
|
|
completionFailure: message,
|
|
})
|
|
|
|
this.completed = true
|
|
|
|
try {
|
|
const { PlatformEvents, createOTelSpansForWorkflowExecution } = await import(
|
|
'@/lib/core/telemetry'
|
|
)
|
|
PlatformEvents.workflowExecuted({
|
|
workflowId: this.workflowId,
|
|
durationMs: Math.max(1, durationMs),
|
|
status: 'error',
|
|
trigger: this.triggerType,
|
|
blocksExecuted: spans.length,
|
|
hasErrors: true,
|
|
errorMessage: message,
|
|
})
|
|
|
|
createOTelSpansForWorkflowExecution({
|
|
workflowId: this.workflowId,
|
|
workflowName: this.workflowState?.metadata?.name,
|
|
executionId: this.executionId,
|
|
traceSpans: spans,
|
|
trigger: this.triggerType,
|
|
startTime: startTime.toISOString(),
|
|
endTime: endTime.toISOString(),
|
|
totalDurationMs: Math.max(1, durationMs),
|
|
status: 'error',
|
|
error: message,
|
|
})
|
|
} catch (_e) {
|
|
// Silently fail
|
|
}
|
|
|
|
if (this.requestId) {
|
|
logger.debug(
|
|
`[${this.requestId}] Completed error logging for execution ${this.executionId}`
|
|
)
|
|
}
|
|
} catch (enhancedError) {
|
|
this.completing = false
|
|
logger.error(`Failed to complete error logging for execution ${this.executionId}:`, {
|
|
requestId: this.requestId,
|
|
workflowId: this.workflowId,
|
|
executionId: this.executionId,
|
|
error: toError(enhancedError).message,
|
|
stack: enhancedError instanceof Error ? enhancedError.stack : undefined,
|
|
})
|
|
throw enhancedError
|
|
}
|
|
}
|
|
|
|
async completeWithCancellation(params: SessionCancelledParams = {}): Promise<void> {
|
|
if (this.completed || this.completing) {
|
|
return
|
|
}
|
|
this.completing = true
|
|
|
|
try {
|
|
const { endedAt, totalDurationMs, traceSpans } = params
|
|
|
|
const endTime = endedAt ? new Date(endedAt) : new Date()
|
|
const durationMs = typeof totalDurationMs === 'number' ? totalDurationMs : 0
|
|
|
|
const currentLog = await db
|
|
.select({ status: workflowExecutionLogs.status })
|
|
.from(workflowExecutionLogs)
|
|
.where(
|
|
and(
|
|
eq(workflowExecutionLogs.workflowId, this.workflowId),
|
|
eq(workflowExecutionLogs.executionId, this.executionId)
|
|
)
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0])
|
|
|
|
if (currentLog?.status === 'cancelled') {
|
|
this.completed = true
|
|
return
|
|
}
|
|
|
|
// calculateCostSummary handles empty/undefined spans by returning the
|
|
// base-charge summary, so no separate no-spans literal is needed.
|
|
const costSummary = calculateCostSummary(traceSpans)
|
|
|
|
await this.completeExecutionWithFinalization({
|
|
endedAt: endTime.toISOString(),
|
|
totalDurationMs: Math.max(1, durationMs),
|
|
costSummary,
|
|
finalOutput: { cancelled: true },
|
|
traceSpans: traceSpans || [],
|
|
finalizationPath: 'cancelled',
|
|
status: 'cancelled',
|
|
})
|
|
|
|
this.completed = true
|
|
|
|
try {
|
|
const { PlatformEvents, createOTelSpansForWorkflowExecution } = await import(
|
|
'@/lib/core/telemetry'
|
|
)
|
|
PlatformEvents.workflowExecuted({
|
|
workflowId: this.workflowId,
|
|
durationMs: Math.max(1, durationMs),
|
|
status: 'cancelled',
|
|
trigger: this.triggerType,
|
|
blocksExecuted: traceSpans?.length || 0,
|
|
hasErrors: false,
|
|
})
|
|
|
|
if (traceSpans && traceSpans.length > 0) {
|
|
const startTime = new Date(endTime.getTime() - Math.max(1, durationMs))
|
|
createOTelSpansForWorkflowExecution({
|
|
workflowId: this.workflowId,
|
|
workflowName: this.workflowState?.metadata?.name,
|
|
executionId: this.executionId,
|
|
traceSpans,
|
|
trigger: this.triggerType,
|
|
startTime: startTime.toISOString(),
|
|
endTime: endTime.toISOString(),
|
|
totalDurationMs: Math.max(1, durationMs),
|
|
status: 'success', // Cancelled executions are not errors
|
|
})
|
|
}
|
|
} catch (_e) {
|
|
// Silently fail
|
|
}
|
|
|
|
if (this.requestId) {
|
|
logger.debug(
|
|
`[${this.requestId}] Completed cancelled logging for execution ${this.executionId}`
|
|
)
|
|
}
|
|
} catch (cancelError) {
|
|
this.completing = false
|
|
logger.error(`Failed to complete cancelled logging for execution ${this.executionId}:`, {
|
|
requestId: this.requestId,
|
|
workflowId: this.workflowId,
|
|
executionId: this.executionId,
|
|
error: toError(cancelError).message,
|
|
stack: cancelError instanceof Error ? cancelError.stack : undefined,
|
|
})
|
|
throw cancelError
|
|
}
|
|
}
|
|
|
|
async completeWithPause(params: SessionPausedParams = {}): Promise<void> {
|
|
if (this.completed || this.completing) {
|
|
return
|
|
}
|
|
this.completing = true
|
|
|
|
try {
|
|
const { endedAt, totalDurationMs, traceSpans, workflowInput } = params
|
|
|
|
const endTime = endedAt ? new Date(endedAt) : new Date()
|
|
const durationMs = typeof totalDurationMs === 'number' ? totalDurationMs : 0
|
|
|
|
const currentLog = await db
|
|
.select({ status: workflowExecutionLogs.status })
|
|
.from(workflowExecutionLogs)
|
|
.where(
|
|
and(
|
|
eq(workflowExecutionLogs.workflowId, this.workflowId),
|
|
eq(workflowExecutionLogs.executionId, this.executionId)
|
|
)
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0])
|
|
|
|
if (currentLog?.status === 'cancelled') {
|
|
this.completed = true
|
|
return
|
|
}
|
|
|
|
// calculateCostSummary handles empty/undefined spans by returning the
|
|
// base-charge summary, so no separate no-spans literal is needed.
|
|
const costSummary = calculateCostSummary(traceSpans)
|
|
|
|
await this.completeExecutionWithFinalization({
|
|
endedAt: endTime.toISOString(),
|
|
totalDurationMs: Math.max(1, durationMs),
|
|
costSummary,
|
|
finalOutput: { paused: true },
|
|
traceSpans: traceSpans || [],
|
|
workflowInput,
|
|
finalizationPath: 'paused',
|
|
status: 'pending',
|
|
})
|
|
|
|
this.completed = true
|
|
|
|
try {
|
|
const { PlatformEvents, createOTelSpansForWorkflowExecution } = await import(
|
|
'@/lib/core/telemetry'
|
|
)
|
|
PlatformEvents.workflowExecuted({
|
|
workflowId: this.workflowId,
|
|
durationMs: Math.max(1, durationMs),
|
|
status: 'paused',
|
|
trigger: this.triggerType,
|
|
blocksExecuted: traceSpans?.length || 0,
|
|
hasErrors: false,
|
|
totalCost: costSummary.totalCost || 0,
|
|
})
|
|
|
|
if (traceSpans && traceSpans.length > 0) {
|
|
const startTime = new Date(endTime.getTime() - Math.max(1, durationMs))
|
|
createOTelSpansForWorkflowExecution({
|
|
workflowId: this.workflowId,
|
|
workflowName: this.workflowState?.metadata?.name,
|
|
executionId: this.executionId,
|
|
traceSpans,
|
|
trigger: this.triggerType,
|
|
startTime: startTime.toISOString(),
|
|
endTime: endTime.toISOString(),
|
|
totalDurationMs: Math.max(1, durationMs),
|
|
status: 'success', // Paused executions are not errors
|
|
})
|
|
}
|
|
} catch (_e) {}
|
|
|
|
if (this.requestId) {
|
|
logger.debug(
|
|
`[${this.requestId}] Completed paused logging for execution ${this.executionId}`
|
|
)
|
|
}
|
|
} catch (pauseError) {
|
|
this.completing = false
|
|
logger.error(`Failed to complete paused logging for execution ${this.executionId}:`, {
|
|
requestId: this.requestId,
|
|
workflowId: this.workflowId,
|
|
executionId: this.executionId,
|
|
error: toError(pauseError).message,
|
|
stack: pauseError instanceof Error ? pauseError.stack : undefined,
|
|
})
|
|
throw pauseError
|
|
}
|
|
}
|
|
|
|
async safeStart(params: SessionStartParams): Promise<boolean> {
|
|
try {
|
|
await this.start(params)
|
|
return true
|
|
} catch (error) {
|
|
if (this.requestId) {
|
|
logger.warn(
|
|
`[${this.requestId}] Logging start failed - falling back to minimal session:`,
|
|
error
|
|
)
|
|
}
|
|
|
|
// Fallback: create a minimal logging session without full workflow state
|
|
try {
|
|
const { userId, workspaceId, variables, triggerData, deploymentVersionId, workflowState } =
|
|
params
|
|
this.trigger = createTriggerObject(this.triggerType, triggerData)
|
|
this.correlation = triggerData?.correlation
|
|
this.environment = createEnvironmentObject(
|
|
this.workflowId,
|
|
this.executionId,
|
|
userId,
|
|
workspaceId,
|
|
variables
|
|
)
|
|
const fallbackWorkflowState: WorkflowState = workflowState ?? {
|
|
blocks: {},
|
|
edges: [],
|
|
loops: {},
|
|
parallels: {},
|
|
}
|
|
this.workflowState = fallbackWorkflowState
|
|
|
|
await executionLogger.startWorkflowExecution({
|
|
workflowId: this.workflowId,
|
|
workspaceId,
|
|
executionId: this.executionId,
|
|
trigger: this.trigger,
|
|
environment: this.environment,
|
|
workflowState: this.workflowState,
|
|
deploymentVersionId,
|
|
})
|
|
|
|
if (this.requestId) {
|
|
logger.debug(
|
|
`[${this.requestId}] Started minimal logging for execution ${this.executionId}`
|
|
)
|
|
}
|
|
return true
|
|
} catch (fallbackError) {
|
|
if (this.requestId) {
|
|
logger.error(`[${this.requestId}] Minimal logging start also failed:`, fallbackError)
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for any in-flight fire-and-forget completion to finish.
|
|
* Called internally by markAsFailed to ensure completion has settled
|
|
* before overwriting execution status.
|
|
*/
|
|
async waitForCompletion(): Promise<void> {
|
|
if (this.completionPromise) {
|
|
try {
|
|
await this.completionPromise
|
|
} catch {
|
|
/* already handled by safe* wrapper */
|
|
}
|
|
}
|
|
}
|
|
|
|
setPostExecutionPromise(promise: Promise<void>): void {
|
|
this.postExecutionPromise = promise
|
|
}
|
|
|
|
async waitForPostExecution(): Promise<void> {
|
|
if (this.postExecutionPromise) {
|
|
try {
|
|
await this.postExecutionPromise
|
|
} catch {
|
|
/* already handled inside the IIFE */
|
|
}
|
|
}
|
|
}
|
|
|
|
hasCompleted(): boolean {
|
|
return this.completed
|
|
}
|
|
|
|
private shouldStartNewCompletionAttempt(attempt: CompletionAttempt): boolean {
|
|
return this.completionAttemptFailed && this.completionAttempt !== 'error' && attempt === 'error'
|
|
}
|
|
|
|
private runCompletionAttempt(
|
|
attempt: CompletionAttempt,
|
|
run: () => Promise<void>
|
|
): Promise<void> {
|
|
if (this.completionPromise && !this.shouldStartNewCompletionAttempt(attempt)) {
|
|
return this.completionPromise
|
|
}
|
|
|
|
this.completionAttempt = attempt
|
|
this.completionAttemptFailed = false
|
|
this.completionPromise = run().catch((error) => {
|
|
this.completionAttemptFailed = true
|
|
throw error
|
|
})
|
|
return this.completionPromise
|
|
}
|
|
|
|
async safeComplete(params: SessionCompleteParams = {}): Promise<void> {
|
|
return this.runCompletionAttempt('complete', () => this._safeCompleteImpl(params))
|
|
}
|
|
|
|
private async _safeCompleteImpl(params: SessionCompleteParams = {}): Promise<void> {
|
|
try {
|
|
await this.drainPendingProgressWrites()
|
|
await this.complete(params)
|
|
} catch (error) {
|
|
const errorMsg = toError(error).message
|
|
logger.warn(
|
|
`[${this.requestId || 'unknown'}] Complete failed for execution ${this.executionId}, attempting fallback`,
|
|
{ error: errorMsg }
|
|
)
|
|
await this.completeWithCostOnlyLog({
|
|
traceSpans: params.traceSpans,
|
|
endedAt: params.endedAt,
|
|
totalDurationMs: params.totalDurationMs,
|
|
errorMessage: `Failed to store trace spans: ${errorMsg}`,
|
|
isError: false,
|
|
finalizationPath: 'fallback_completed',
|
|
finalOutput: params.finalOutput || {},
|
|
})
|
|
}
|
|
}
|
|
|
|
async safeCompleteWithError(params?: SessionErrorCompleteParams): Promise<void> {
|
|
return this.runCompletionAttempt('error', () => this._safeCompleteWithErrorImpl(params))
|
|
}
|
|
|
|
private async _safeCompleteWithErrorImpl(params?: SessionErrorCompleteParams): Promise<void> {
|
|
try {
|
|
await this.drainPendingProgressWrites()
|
|
await this.completeWithError(params)
|
|
} catch (error) {
|
|
const errorMsg = toError(error).message
|
|
logger.warn(
|
|
`[${this.requestId || 'unknown'}] CompleteWithError failed for execution ${this.executionId}, attempting fallback`,
|
|
{ error: errorMsg }
|
|
)
|
|
await this.completeWithCostOnlyLog({
|
|
traceSpans: params?.traceSpans,
|
|
endedAt: params?.endedAt,
|
|
totalDurationMs: params?.totalDurationMs,
|
|
errorMessage:
|
|
params?.error?.message || `Execution failed to store trace spans: ${errorMsg}`,
|
|
isError: true,
|
|
finalizationPath: 'force_failed',
|
|
finalOutput: {
|
|
error: params?.error?.message || `Execution failed to store trace spans: ${errorMsg}`,
|
|
},
|
|
status: 'failed',
|
|
})
|
|
}
|
|
}
|
|
|
|
async safeCompleteWithCancellation(params?: SessionCancelledParams): Promise<void> {
|
|
return this.runCompletionAttempt('cancelled', () =>
|
|
this._safeCompleteWithCancellationImpl(params)
|
|
)
|
|
}
|
|
|
|
private async _safeCompleteWithCancellationImpl(params?: SessionCancelledParams): Promise<void> {
|
|
try {
|
|
await this.drainPendingProgressWrites()
|
|
await this.completeWithCancellation(params)
|
|
} catch (error) {
|
|
const errorMsg = toError(error).message
|
|
logger.warn(
|
|
`[${this.requestId || 'unknown'}] CompleteWithCancellation failed for execution ${this.executionId}, attempting fallback`,
|
|
{ error: errorMsg }
|
|
)
|
|
await this.completeWithCostOnlyLog({
|
|
traceSpans: params?.traceSpans,
|
|
endedAt: params?.endedAt,
|
|
totalDurationMs: params?.totalDurationMs,
|
|
errorMessage: 'Run was cancelled',
|
|
isError: false,
|
|
finalizationPath: 'cancelled',
|
|
finalOutput: { cancelled: true },
|
|
status: 'cancelled',
|
|
})
|
|
}
|
|
}
|
|
|
|
async safeCompleteWithPause(params?: SessionPausedParams): Promise<void> {
|
|
return this.runCompletionAttempt('paused', () => this._safeCompleteWithPauseImpl(params))
|
|
}
|
|
|
|
private async _safeCompleteWithPauseImpl(params?: SessionPausedParams): Promise<void> {
|
|
try {
|
|
await this.drainPendingProgressWrites()
|
|
await this.completeWithPause(params)
|
|
} catch (error) {
|
|
const errorMsg = toError(error).message
|
|
logger.warn(
|
|
`[${this.requestId || 'unknown'}] CompleteWithPause failed for execution ${this.executionId}, attempting fallback`,
|
|
{ error: errorMsg }
|
|
)
|
|
await this.completeWithCostOnlyLog({
|
|
traceSpans: params?.traceSpans,
|
|
endedAt: params?.endedAt,
|
|
totalDurationMs: params?.totalDurationMs,
|
|
errorMessage: 'Run paused but failed to store full trace spans',
|
|
isError: false,
|
|
finalizationPath: 'paused',
|
|
finalOutput: { paused: true },
|
|
status: 'pending',
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Force-fail the execution. Waits for any in-flight completion and drains
|
|
* pending per-block marker writes first, so a force-fail racing
|
|
* onBlockStart/onBlockComplete still captures the latest breadcrumb in the fold.
|
|
*/
|
|
async markAsFailed(errorMessage?: string): Promise<void> {
|
|
await this.waitForCompletion()
|
|
await this.drainPendingProgressWrites()
|
|
await LoggingSession.markExecutionAsFailed(
|
|
this.executionId,
|
|
errorMessage,
|
|
this.requestId,
|
|
this.workflowId
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Force-fail terminal boundary that bypasses completeWorkflowExecution. Folds
|
|
* any live Redis progress markers into execution_data before clearing the key,
|
|
* so a run whose markers only ever lived in Redis still keeps its
|
|
* last-started/last-completed breadcrumb. Both the fold and clear are no-ops
|
|
* when the standard completion path already persisted and cleared them.
|
|
*/
|
|
static async markExecutionAsFailed(
|
|
executionId: string,
|
|
errorMessage: string | undefined,
|
|
requestId: string | undefined,
|
|
workflowId: string
|
|
): Promise<void> {
|
|
try {
|
|
const message = errorMessage || 'Run failed'
|
|
|
|
const markers = await getProgressMarkers(executionId)
|
|
|
|
let executionData = sql`jsonb_set(
|
|
jsonb_set(
|
|
jsonb_set(
|
|
COALESCE(execution_data, '{}'::jsonb),
|
|
ARRAY['error'],
|
|
to_jsonb(${message}::text)
|
|
),
|
|
ARRAY['finalOutput'],
|
|
jsonb_build_object('error', ${message}::text)
|
|
),
|
|
ARRAY['finalizationPath'],
|
|
to_jsonb('force_failed'::text)
|
|
)`
|
|
if (markers?.lastStartedBlock) {
|
|
const startedAt = markers.lastStartedBlock.startedAt
|
|
const startedJson = JSON.stringify(markers.lastStartedBlock)
|
|
executionData = sql`CASE WHEN COALESCE(jsonb_extract_path_text(execution_data, 'lastStartedBlock', 'startedAt'), '') <= ${startedAt}
|
|
THEN jsonb_set(${executionData}, ARRAY['lastStartedBlock'], ${startedJson}::jsonb)
|
|
ELSE ${executionData} END`
|
|
}
|
|
if (markers?.lastCompletedBlock) {
|
|
const endedAt = markers.lastCompletedBlock.endedAt
|
|
const completedJson = JSON.stringify(markers.lastCompletedBlock)
|
|
executionData = sql`CASE WHEN COALESCE(jsonb_extract_path_text(execution_data, 'lastCompletedBlock', 'endedAt'), '') <= ${endedAt}
|
|
THEN jsonb_set(${executionData}, ARRAY['lastCompletedBlock'], ${completedJson}::jsonb)
|
|
ELSE ${executionData} END`
|
|
}
|
|
|
|
await db
|
|
.update(workflowExecutionLogs)
|
|
.set({ level: 'error', status: 'failed', executionData })
|
|
.where(
|
|
and(
|
|
eq(workflowExecutionLogs.executionId, executionId),
|
|
eq(workflowExecutionLogs.workflowId, workflowId)
|
|
)
|
|
)
|
|
|
|
if (markers !== null) void clearProgressMarkers(executionId)
|
|
|
|
logger.info(`[${requestId || 'unknown'}] Marked execution ${executionId} as failed`)
|
|
} catch (error) {
|
|
logger.error(`Failed to mark execution ${executionId} as failed:`, {
|
|
error: toError(error).message,
|
|
})
|
|
}
|
|
}
|
|
|
|
private async completeWithCostOnlyLog(params: {
|
|
traceSpans?: TraceSpan[]
|
|
endedAt?: string
|
|
totalDurationMs?: number
|
|
errorMessage: string
|
|
isError: boolean
|
|
finalizationPath: ExecutionFinalizationPath
|
|
finalOutput?: Record<string, unknown>
|
|
status?: 'completed' | 'failed' | 'cancelled' | 'pending'
|
|
}): Promise<void> {
|
|
if (this.completed || this.completing) {
|
|
return
|
|
}
|
|
this.completing = true
|
|
|
|
logger.warn(
|
|
`[${this.requestId || 'unknown'}] Logging completion failed for execution ${this.executionId} - attempting cost-only fallback`
|
|
)
|
|
|
|
try {
|
|
// Billing is reconciled from the usage_log ledger in recordExecutionUsage;
|
|
// here we only need a cost summary to compute the run total. Derive it
|
|
// from the in-memory trace spans when available (this fallback fires when
|
|
// persisting spans failed, not when computing them did), else just the
|
|
// base execution charge.
|
|
const costSummary = calculateCostSummary(params.traceSpans)
|
|
|
|
const finalOutput = params.finalOutput || { _fallback: true, error: params.errorMessage }
|
|
|
|
await this.completeExecutionWithFinalization({
|
|
endedAt: params.endedAt || new Date().toISOString(),
|
|
totalDurationMs: params.totalDurationMs || 0,
|
|
costSummary,
|
|
finalOutput,
|
|
traceSpans: [],
|
|
finalizationPath: params.finalizationPath,
|
|
completionFailure: params.errorMessage,
|
|
level: params.isError ? 'error' : 'info',
|
|
status: params.status,
|
|
})
|
|
|
|
this.completed = true
|
|
|
|
logger.info(
|
|
`[${this.requestId || 'unknown'}] Cost-only fallback succeeded for execution ${this.executionId}`
|
|
)
|
|
} catch (fallbackError) {
|
|
this.completing = false
|
|
this.completionAttemptFailed = true
|
|
logger.error(
|
|
`[${this.requestId || 'unknown'}] Cost-only fallback also failed for execution ${this.executionId}:`,
|
|
{
|
|
error: toError(fallbackError).message,
|
|
cause: describeError(fallbackError),
|
|
retryable: isRetryableInfrastructureError(fallbackError),
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|