mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
fix(logging): hitl + trigger dev crash protection (#2664)
* hitl gaps * deal with trigger worker crashes * cleanup import strcuture
This commit is contained in:
committed by
GitHub
parent
79be435918
commit
dc3de95c39
90
apps/sim/app/api/cron/cleanup-stale-executions/route.ts
Normal file
90
apps/sim/app/api/cron/cleanup-stale-executions/route.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { db } from '@sim/db'
|
||||||
|
import { workflowExecutionLogs } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { and, eq, lt, sql } from 'drizzle-orm'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||||
|
|
||||||
|
const logger = createLogger('CleanupStaleExecutions')
|
||||||
|
|
||||||
|
const STALE_THRESHOLD_MINUTES = 30
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authError = verifyCronAuth(request, 'Stale execution cleanup')
|
||||||
|
if (authError) {
|
||||||
|
return authError
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Starting stale execution cleanup job')
|
||||||
|
|
||||||
|
const staleThreshold = new Date(Date.now() - STALE_THRESHOLD_MINUTES * 60 * 1000)
|
||||||
|
|
||||||
|
const staleExecutions = await db
|
||||||
|
.select({
|
||||||
|
id: workflowExecutionLogs.id,
|
||||||
|
executionId: workflowExecutionLogs.executionId,
|
||||||
|
workflowId: workflowExecutionLogs.workflowId,
|
||||||
|
startedAt: workflowExecutionLogs.startedAt,
|
||||||
|
})
|
||||||
|
.from(workflowExecutionLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workflowExecutionLogs.status, 'running'),
|
||||||
|
lt(workflowExecutionLogs.startedAt, staleThreshold)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(100)
|
||||||
|
|
||||||
|
logger.info(`Found ${staleExecutions.length} stale executions to clean up`)
|
||||||
|
|
||||||
|
let cleaned = 0
|
||||||
|
let failed = 0
|
||||||
|
|
||||||
|
for (const execution of staleExecutions) {
|
||||||
|
try {
|
||||||
|
const staleDurationMs = Date.now() - new Date(execution.startedAt).getTime()
|
||||||
|
const staleDurationMinutes = Math.round(staleDurationMs / 60000)
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(workflowExecutionLogs)
|
||||||
|
.set({
|
||||||
|
status: 'failed',
|
||||||
|
endedAt: new Date(),
|
||||||
|
totalDurationMs: staleDurationMs,
|
||||||
|
executionData: sql`jsonb_set(
|
||||||
|
COALESCE(execution_data, '{}'::jsonb),
|
||||||
|
ARRAY['error'],
|
||||||
|
to_jsonb(${`Execution terminated: worker timeout or crash after ${staleDurationMinutes} minutes`}::text)
|
||||||
|
)`,
|
||||||
|
})
|
||||||
|
.where(eq(workflowExecutionLogs.id, execution.id))
|
||||||
|
|
||||||
|
logger.info(`Cleaned up stale execution ${execution.executionId}`, {
|
||||||
|
workflowId: execution.workflowId,
|
||||||
|
staleDurationMinutes,
|
||||||
|
})
|
||||||
|
|
||||||
|
cleaned++
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to clean up execution ${execution.executionId}:`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Stale execution cleanup completed. Cleaned: ${cleaned}, Failed: ${failed}`)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
found: staleExecutions.length,
|
||||||
|
cleaned,
|
||||||
|
failed,
|
||||||
|
thresholdMinutes: STALE_THRESHOLD_MINUTES,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in stale execution cleanup job:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,11 +23,11 @@ import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
|
|||||||
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
|
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
|
||||||
import type { WorkflowExecutionPayload } from '@/background/workflow-execution'
|
import type { WorkflowExecutionPayload } from '@/background/workflow-execution'
|
||||||
import { normalizeName } from '@/executor/constants'
|
import { normalizeName } from '@/executor/constants'
|
||||||
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
|
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||||
|
import type { ExecutionMetadata, IterationContext } from '@/executor/execution/types'
|
||||||
import type { StreamingExecution } from '@/executor/types'
|
import type { StreamingExecution } from '@/executor/types'
|
||||||
import { Serializer } from '@/serializer'
|
import { Serializer } from '@/serializer'
|
||||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||||
import type { SubflowType } from '@/stores/workflows/workflow/types'
|
|
||||||
|
|
||||||
const logger = createLogger('WorkflowExecuteAPI')
|
const logger = createLogger('WorkflowExecuteAPI')
|
||||||
|
|
||||||
@@ -541,11 +541,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
blockId: string,
|
blockId: string,
|
||||||
blockName: string,
|
blockName: string,
|
||||||
blockType: string,
|
blockType: string,
|
||||||
iterationContext?: {
|
iterationContext?: IterationContext
|
||||||
iterationCurrent: number
|
|
||||||
iterationTotal: number
|
|
||||||
iterationType: SubflowType
|
|
||||||
}
|
|
||||||
) => {
|
) => {
|
||||||
logger.info(`[${requestId}] 🔷 onBlockStart called:`, { blockId, blockName, blockType })
|
logger.info(`[${requestId}] 🔷 onBlockStart called:`, { blockId, blockName, blockType })
|
||||||
sendEvent({
|
sendEvent({
|
||||||
@@ -571,11 +567,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
blockName: string,
|
blockName: string,
|
||||||
blockType: string,
|
blockType: string,
|
||||||
callbackData: any,
|
callbackData: any,
|
||||||
iterationContext?: {
|
iterationContext?: IterationContext
|
||||||
iterationCurrent: number
|
|
||||||
iterationTotal: number
|
|
||||||
iterationType: SubflowType
|
|
||||||
}
|
|
||||||
) => {
|
) => {
|
||||||
const hasError = callbackData.output?.error
|
const hasError = callbackData.output?.error
|
||||||
|
|
||||||
@@ -713,14 +705,25 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
||||||
executionId,
|
executionId,
|
||||||
})
|
})
|
||||||
|
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
|
||||||
} else {
|
} else {
|
||||||
await PauseResumeManager.persistPauseResult({
|
try {
|
||||||
workflowId,
|
await PauseResumeManager.persistPauseResult({
|
||||||
executionId,
|
workflowId,
|
||||||
pausePoints: result.pausePoints || [],
|
executionId,
|
||||||
snapshotSeed: result.snapshotSeed,
|
pausePoints: result.pausePoints || [],
|
||||||
executorUserId: result.metadata?.userId,
|
snapshotSeed: result.snapshotSeed,
|
||||||
})
|
executorUserId: result.metadata?.userId,
|
||||||
|
})
|
||||||
|
} catch (pauseError) {
|
||||||
|
logger.error(`[${requestId}] Failed to persist pause result`, {
|
||||||
|
executionId,
|
||||||
|
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
|
||||||
|
})
|
||||||
|
await loggingSession.markAsFailed(
|
||||||
|
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await PauseResumeManager.processQueuedResumes(executionId)
|
await PauseResumeManager.processQueuedResumes(executionId)
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
getSubBlockValue,
|
getSubBlockValue,
|
||||||
} from '@/lib/workflows/schedules/utils'
|
} from '@/lib/workflows/schedules/utils'
|
||||||
import { REFERENCE } from '@/executor/constants'
|
import { REFERENCE } from '@/executor/constants'
|
||||||
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
|
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||||
|
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||||
import type { ExecutionResult } from '@/executor/types'
|
import type { ExecutionResult } from '@/executor/types'
|
||||||
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
|
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
|
||||||
import { mergeSubblockState } from '@/stores/workflows/server-utils'
|
import { mergeSubblockState } from '@/stores/workflows/server-utils'
|
||||||
@@ -285,14 +286,25 @@ async function runWorkflowExecution({
|
|||||||
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
||||||
executionId,
|
executionId,
|
||||||
})
|
})
|
||||||
|
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
|
||||||
} else {
|
} else {
|
||||||
await PauseResumeManager.persistPauseResult({
|
try {
|
||||||
workflowId: payload.workflowId,
|
await PauseResumeManager.persistPauseResult({
|
||||||
executionId,
|
workflowId: payload.workflowId,
|
||||||
pausePoints: executionResult.pausePoints || [],
|
executionId,
|
||||||
snapshotSeed: executionResult.snapshotSeed,
|
pausePoints: executionResult.pausePoints || [],
|
||||||
executorUserId: executionResult.metadata?.userId,
|
snapshotSeed: executionResult.snapshotSeed,
|
||||||
})
|
executorUserId: executionResult.metadata?.userId,
|
||||||
|
})
|
||||||
|
} catch (pauseError) {
|
||||||
|
logger.error(`[${requestId}] Failed to persist pause result`, {
|
||||||
|
executionId,
|
||||||
|
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
|
||||||
|
})
|
||||||
|
await loggingSession.markAsFailed(
|
||||||
|
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await PauseResumeManager.processQueuedResumes(executionId)
|
await PauseResumeManager.processQueuedResumes(executionId)
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import {
|
|||||||
loadWorkflowFromNormalizedTables,
|
loadWorkflowFromNormalizedTables,
|
||||||
} from '@/lib/workflows/persistence/utils'
|
} from '@/lib/workflows/persistence/utils'
|
||||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||||
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
|
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||||
|
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||||
import type { ExecutionResult } from '@/executor/types'
|
import type { ExecutionResult } from '@/executor/types'
|
||||||
import { Serializer } from '@/serializer'
|
import { Serializer } from '@/serializer'
|
||||||
import { mergeSubblockState } from '@/stores/workflows/server-utils'
|
import { mergeSubblockState } from '@/stores/workflows/server-utils'
|
||||||
@@ -268,14 +269,25 @@ async function executeWebhookJobInternal(
|
|||||||
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
||||||
executionId,
|
executionId,
|
||||||
})
|
})
|
||||||
|
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
|
||||||
} else {
|
} else {
|
||||||
await PauseResumeManager.persistPauseResult({
|
try {
|
||||||
workflowId: payload.workflowId,
|
await PauseResumeManager.persistPauseResult({
|
||||||
executionId,
|
workflowId: payload.workflowId,
|
||||||
pausePoints: executionResult.pausePoints || [],
|
executionId,
|
||||||
snapshotSeed: executionResult.snapshotSeed,
|
pausePoints: executionResult.pausePoints || [],
|
||||||
executorUserId: executionResult.metadata?.userId,
|
snapshotSeed: executionResult.snapshotSeed,
|
||||||
})
|
executorUserId: executionResult.metadata?.userId,
|
||||||
|
})
|
||||||
|
} catch (pauseError) {
|
||||||
|
logger.error(`[${requestId}] Failed to persist pause result`, {
|
||||||
|
executionId,
|
||||||
|
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
|
||||||
|
})
|
||||||
|
await loggingSession.markAsFailed(
|
||||||
|
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await PauseResumeManager.processQueuedResumes(executionId)
|
await PauseResumeManager.processQueuedResumes(executionId)
|
||||||
@@ -509,14 +521,25 @@ async function executeWebhookJobInternal(
|
|||||||
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
||||||
executionId,
|
executionId,
|
||||||
})
|
})
|
||||||
|
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
|
||||||
} else {
|
} else {
|
||||||
await PauseResumeManager.persistPauseResult({
|
try {
|
||||||
workflowId: payload.workflowId,
|
await PauseResumeManager.persistPauseResult({
|
||||||
executionId,
|
workflowId: payload.workflowId,
|
||||||
pausePoints: executionResult.pausePoints || [],
|
executionId,
|
||||||
snapshotSeed: executionResult.snapshotSeed,
|
pausePoints: executionResult.pausePoints || [],
|
||||||
executorUserId: executionResult.metadata?.userId,
|
snapshotSeed: executionResult.snapshotSeed,
|
||||||
})
|
executorUserId: executionResult.metadata?.userId,
|
||||||
|
})
|
||||||
|
} catch (pauseError) {
|
||||||
|
logger.error(`[${requestId}] Failed to persist pause result`, {
|
||||||
|
executionId,
|
||||||
|
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
|
||||||
|
})
|
||||||
|
await loggingSession.markAsFailed(
|
||||||
|
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await PauseResumeManager.processQueuedResumes(executionId)
|
await PauseResumeManager.processQueuedResumes(executionId)
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
|||||||
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
||||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||||
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
|
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||||
|
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||||
import type { ExecutionResult } from '@/executor/types'
|
import type { ExecutionResult } from '@/executor/types'
|
||||||
|
|
||||||
const logger = createLogger('TriggerWorkflowExecution')
|
const logger = createLogger('TriggerWorkflowExecution')
|
||||||
@@ -112,14 +113,25 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
|
|||||||
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
||||||
executionId,
|
executionId,
|
||||||
})
|
})
|
||||||
|
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
|
||||||
} else {
|
} else {
|
||||||
await PauseResumeManager.persistPauseResult({
|
try {
|
||||||
workflowId,
|
await PauseResumeManager.persistPauseResult({
|
||||||
executionId,
|
workflowId,
|
||||||
pausePoints: result.pausePoints || [],
|
executionId,
|
||||||
snapshotSeed: result.snapshotSeed,
|
pausePoints: result.pausePoints || [],
|
||||||
executorUserId: result.metadata?.userId,
|
snapshotSeed: result.snapshotSeed,
|
||||||
})
|
executorUserId: result.metadata?.userId,
|
||||||
|
})
|
||||||
|
} catch (pauseError) {
|
||||||
|
logger.error(`[${requestId}] Failed to persist pause result`, {
|
||||||
|
executionId,
|
||||||
|
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
|
||||||
|
})
|
||||||
|
await loggingSession.markAsFailed(
|
||||||
|
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await PauseResumeManager.processQueuedResumes(executionId)
|
await PauseResumeManager.processQueuedResumes(executionId)
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import type { DAG } from '@/executor/dag/builder'
|
import type { DAG } from '@/executor/dag/builder'
|
||||||
import {
|
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||||
type ExecutionMetadata,
|
import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types'
|
||||||
ExecutionSnapshot,
|
|
||||||
type SerializableExecutionState,
|
|
||||||
} from '@/executor/execution/snapshot'
|
|
||||||
import type { ExecutionContext, SerializedSnapshot } from '@/executor/types'
|
import type { ExecutionContext, SerializedSnapshot } from '@/executor/types'
|
||||||
|
|
||||||
function mapFromEntries<T>(map?: Map<string, T>): Record<string, T> | undefined {
|
function mapFromEntries<T>(map?: Map<string, T>): Record<string, T> | undefined {
|
||||||
|
|||||||
@@ -1,59 +1,4 @@
|
|||||||
import type { Edge } from 'reactflow'
|
import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types'
|
||||||
import type { BlockLog, BlockState } from '@/executor/types'
|
|
||||||
|
|
||||||
export interface ExecutionMetadata {
|
|
||||||
requestId: string
|
|
||||||
executionId: string
|
|
||||||
workflowId: string
|
|
||||||
workspaceId: string
|
|
||||||
userId: string
|
|
||||||
sessionUserId?: string
|
|
||||||
workflowUserId?: string
|
|
||||||
triggerType: string
|
|
||||||
triggerBlockId?: string
|
|
||||||
useDraftState: boolean
|
|
||||||
startTime: string
|
|
||||||
isClientSession?: boolean
|
|
||||||
pendingBlocks?: string[]
|
|
||||||
resumeFromSnapshot?: boolean
|
|
||||||
workflowStateOverride?: {
|
|
||||||
blocks: Record<string, any>
|
|
||||||
edges: Edge[]
|
|
||||||
loops?: Record<string, any>
|
|
||||||
parallels?: Record<string, any>
|
|
||||||
deploymentVersionId?: string // ID of deployment version if this is deployed state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExecutionCallbacks {
|
|
||||||
onStream?: (streamingExec: any) => Promise<void>
|
|
||||||
onBlockStart?: (blockId: string, blockName: string, blockType: string) => Promise<void>
|
|
||||||
onBlockComplete?: (
|
|
||||||
blockId: string,
|
|
||||||
blockName: string,
|
|
||||||
blockType: string,
|
|
||||||
output: any
|
|
||||||
) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SerializableExecutionState {
|
|
||||||
blockStates: Record<string, BlockState>
|
|
||||||
executedBlocks: string[]
|
|
||||||
blockLogs: BlockLog[]
|
|
||||||
decisions: {
|
|
||||||
router: Record<string, string>
|
|
||||||
condition: Record<string, string>
|
|
||||||
}
|
|
||||||
completedLoops: string[]
|
|
||||||
loopExecutions?: Record<string, any>
|
|
||||||
parallelExecutions?: Record<string, any>
|
|
||||||
parallelBlockMapping?: Record<string, any>
|
|
||||||
activeExecutionPath: string[]
|
|
||||||
pendingQueue?: string[]
|
|
||||||
remainingEdges?: Edge[]
|
|
||||||
dagIncomingEdges?: Record<string, string[]>
|
|
||||||
completedPauseContexts?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ExecutionSnapshot {
|
export class ExecutionSnapshot {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -1,7 +1,68 @@
|
|||||||
import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/snapshot'
|
import type { Edge } from 'reactflow'
|
||||||
import type { BlockState, NormalizedBlockOutput } from '@/executor/types'
|
import type { BlockLog, BlockState, NormalizedBlockOutput } from '@/executor/types'
|
||||||
import type { SubflowType } from '@/stores/workflows/workflow/types'
|
import type { SubflowType } from '@/stores/workflows/workflow/types'
|
||||||
|
|
||||||
|
export interface ExecutionMetadata {
|
||||||
|
requestId: string
|
||||||
|
executionId: string
|
||||||
|
workflowId: string
|
||||||
|
workspaceId: string
|
||||||
|
userId: string
|
||||||
|
sessionUserId?: string
|
||||||
|
workflowUserId?: string
|
||||||
|
triggerType: string
|
||||||
|
triggerBlockId?: string
|
||||||
|
useDraftState: boolean
|
||||||
|
startTime: string
|
||||||
|
isClientSession?: boolean
|
||||||
|
pendingBlocks?: string[]
|
||||||
|
resumeFromSnapshot?: boolean
|
||||||
|
workflowStateOverride?: {
|
||||||
|
blocks: Record<string, any>
|
||||||
|
edges: Edge[]
|
||||||
|
loops?: Record<string, any>
|
||||||
|
parallels?: Record<string, any>
|
||||||
|
deploymentVersionId?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializableExecutionState {
|
||||||
|
blockStates: Record<string, BlockState>
|
||||||
|
executedBlocks: string[]
|
||||||
|
blockLogs: BlockLog[]
|
||||||
|
decisions: {
|
||||||
|
router: Record<string, string>
|
||||||
|
condition: Record<string, string>
|
||||||
|
}
|
||||||
|
completedLoops: string[]
|
||||||
|
loopExecutions?: Record<string, any>
|
||||||
|
parallelExecutions?: Record<string, any>
|
||||||
|
parallelBlockMapping?: Record<string, any>
|
||||||
|
activeExecutionPath: string[]
|
||||||
|
pendingQueue?: string[]
|
||||||
|
remainingEdges?: Edge[]
|
||||||
|
dagIncomingEdges?: Record<string, string[]>
|
||||||
|
completedPauseContexts?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IterationContext {
|
||||||
|
iterationCurrent: number
|
||||||
|
iterationTotal: number
|
||||||
|
iterationType: SubflowType
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionCallbacks {
|
||||||
|
onStream?: (streamingExec: any) => Promise<void>
|
||||||
|
onBlockStart?: (blockId: string, blockName: string, blockType: string) => Promise<void>
|
||||||
|
onBlockComplete?: (
|
||||||
|
blockId: string,
|
||||||
|
blockName: string,
|
||||||
|
blockType: string,
|
||||||
|
output: any,
|
||||||
|
iterationContext?: IterationContext
|
||||||
|
) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContextExtensions {
|
export interface ContextExtensions {
|
||||||
workspaceId?: string
|
workspaceId?: string
|
||||||
executionId?: string
|
executionId?: string
|
||||||
@@ -32,22 +93,14 @@ export interface ContextExtensions {
|
|||||||
blockId: string,
|
blockId: string,
|
||||||
blockName: string,
|
blockName: string,
|
||||||
blockType: string,
|
blockType: string,
|
||||||
iterationContext?: {
|
iterationContext?: IterationContext
|
||||||
iterationCurrent: number
|
|
||||||
iterationTotal: number
|
|
||||||
iterationType: SubflowType
|
|
||||||
}
|
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
onBlockComplete?: (
|
onBlockComplete?: (
|
||||||
blockId: string,
|
blockId: string,
|
||||||
blockName: string,
|
blockName: string,
|
||||||
blockType: string,
|
blockType: string,
|
||||||
output: { input?: any; output: NormalizedBlockOutput; executionTime: number },
|
output: { input?: any; output: NormalizedBlockOutput; executionTime: number },
|
||||||
iterationContext?: {
|
iterationContext?: IterationContext
|
||||||
iterationCurrent: number
|
|
||||||
iterationTotal: number
|
|
||||||
iterationType: SubflowType
|
|
||||||
}
|
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -313,95 +313,4 @@ describe('ExecutionLogger', () => {
|
|||||||
expect(files[0].name).toBe('nested.json')
|
expect(files[0].name).toBe('nested.json')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('cost model merging', () => {
|
|
||||||
test('should merge cost models correctly', () => {
|
|
||||||
const loggerInstance = new ExecutionLogger()
|
|
||||||
const mergeCostModelsMethod = (loggerInstance as any).mergeCostModels.bind(loggerInstance)
|
|
||||||
|
|
||||||
const existing = {
|
|
||||||
'gpt-4': {
|
|
||||||
input: 0.01,
|
|
||||||
output: 0.02,
|
|
||||||
total: 0.03,
|
|
||||||
tokens: { input: 100, output: 200, total: 300 },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const additional = {
|
|
||||||
'gpt-4': {
|
|
||||||
input: 0.005,
|
|
||||||
output: 0.01,
|
|
||||||
total: 0.015,
|
|
||||||
tokens: { input: 50, output: 100, total: 150 },
|
|
||||||
},
|
|
||||||
'gpt-3.5-turbo': {
|
|
||||||
input: 0.001,
|
|
||||||
output: 0.002,
|
|
||||||
total: 0.003,
|
|
||||||
tokens: { input: 10, output: 20, total: 30 },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeCostModelsMethod(existing, additional)
|
|
||||||
|
|
||||||
expect(merged['gpt-4'].input).toBe(0.015)
|
|
||||||
expect(merged['gpt-4'].output).toBe(0.03)
|
|
||||||
expect(merged['gpt-4'].total).toBe(0.045)
|
|
||||||
expect(merged['gpt-4'].tokens.input).toBe(150)
|
|
||||||
expect(merged['gpt-4'].tokens.output).toBe(300)
|
|
||||||
expect(merged['gpt-4'].tokens.total).toBe(450)
|
|
||||||
|
|
||||||
expect(merged['gpt-3.5-turbo']).toBeDefined()
|
|
||||||
expect(merged['gpt-3.5-turbo'].total).toBe(0.003)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should handle prompt/completion token aliases', () => {
|
|
||||||
const loggerInstance = new ExecutionLogger()
|
|
||||||
const mergeCostModelsMethod = (loggerInstance as any).mergeCostModels.bind(loggerInstance)
|
|
||||||
|
|
||||||
const existing = {
|
|
||||||
'gpt-4': {
|
|
||||||
input: 0.01,
|
|
||||||
output: 0.02,
|
|
||||||
total: 0.03,
|
|
||||||
tokens: { prompt: 100, completion: 200, total: 300 },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const additional = {
|
|
||||||
'gpt-4': {
|
|
||||||
input: 0.005,
|
|
||||||
output: 0.01,
|
|
||||||
total: 0.015,
|
|
||||||
tokens: { input: 50, output: 100, total: 150 },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeCostModelsMethod(existing, additional)
|
|
||||||
|
|
||||||
expect(merged['gpt-4'].tokens.input).toBe(150)
|
|
||||||
expect(merged['gpt-4'].tokens.output).toBe(300)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should handle empty existing models', () => {
|
|
||||||
const loggerInstance = new ExecutionLogger()
|
|
||||||
const mergeCostModelsMethod = (loggerInstance as any).mergeCostModels.bind(loggerInstance)
|
|
||||||
|
|
||||||
const existing = {}
|
|
||||||
const additional = {
|
|
||||||
'claude-3': {
|
|
||||||
input: 0.02,
|
|
||||||
output: 0.04,
|
|
||||||
total: 0.06,
|
|
||||||
tokens: { input: 200, output: 400, total: 600 },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeCostModelsMethod(existing, additional)
|
|
||||||
|
|
||||||
expect(merged['claude-3']).toBeDefined()
|
|
||||||
expect(merged['claude-3'].total).toBe(0.06)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { eq, sql } from 'drizzle-orm'
|
import { eq, sql } from 'drizzle-orm'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||||
import {
|
import {
|
||||||
checkUsageStatus,
|
checkUsageStatus,
|
||||||
@@ -47,34 +48,6 @@ export interface ToolCall {
|
|||||||
const logger = createLogger('ExecutionLogger')
|
const logger = createLogger('ExecutionLogger')
|
||||||
|
|
||||||
export class ExecutionLogger implements IExecutionLoggerService {
|
export class ExecutionLogger implements IExecutionLoggerService {
|
||||||
private mergeCostModels(
|
|
||||||
existing: Record<string, any>,
|
|
||||||
additional: Record<string, any>
|
|
||||||
): Record<string, any> {
|
|
||||||
const merged = { ...existing }
|
|
||||||
for (const [model, costs] of Object.entries(additional)) {
|
|
||||||
if (merged[model]) {
|
|
||||||
merged[model] = {
|
|
||||||
input: (merged[model].input || 0) + (costs.input || 0),
|
|
||||||
output: (merged[model].output || 0) + (costs.output || 0),
|
|
||||||
total: (merged[model].total || 0) + (costs.total || 0),
|
|
||||||
tokens: {
|
|
||||||
input:
|
|
||||||
(merged[model].tokens?.input || merged[model].tokens?.prompt || 0) +
|
|
||||||
(costs.tokens?.input || costs.tokens?.prompt || 0),
|
|
||||||
output:
|
|
||||||
(merged[model].tokens?.output || merged[model].tokens?.completion || 0) +
|
|
||||||
(costs.tokens?.output || costs.tokens?.completion || 0),
|
|
||||||
total: (merged[model].tokens?.total || 0) + (costs.tokens?.total || 0),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
merged[model] = costs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
async startWorkflowExecution(params: {
|
async startWorkflowExecution(params: {
|
||||||
workflowId: string
|
workflowId: string
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
@@ -158,6 +131,13 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
|||||||
environment,
|
environment,
|
||||||
trigger,
|
trigger,
|
||||||
},
|
},
|
||||||
|
cost: {
|
||||||
|
total: BASE_EXECUTION_CHARGE,
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
tokens: { input: 0, output: 0, total: 0 },
|
||||||
|
models: {},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
@@ -209,7 +189,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
|||||||
workflowInput?: any
|
workflowInput?: any
|
||||||
isResume?: boolean
|
isResume?: boolean
|
||||||
level?: 'info' | 'error'
|
level?: 'info' | 'error'
|
||||||
status?: 'completed' | 'failed' | 'cancelled'
|
status?: 'completed' | 'failed' | 'cancelled' | 'pending'
|
||||||
}): Promise<WorkflowExecutionLog> {
|
}): Promise<WorkflowExecutionLog> {
|
||||||
const {
|
const {
|
||||||
executionId,
|
executionId,
|
||||||
@@ -268,43 +248,19 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
|||||||
const redactedTraceSpans = redactApiKeys(filteredTraceSpans)
|
const redactedTraceSpans = redactApiKeys(filteredTraceSpans)
|
||||||
const redactedFinalOutput = redactApiKeys(filteredFinalOutput)
|
const redactedFinalOutput = redactApiKeys(filteredFinalOutput)
|
||||||
|
|
||||||
// Merge costs if resuming
|
const executionCost = {
|
||||||
const existingCost = isResume && existingLog?.cost ? existingLog.cost : null
|
total: costSummary.totalCost,
|
||||||
const mergedCost = existingCost
|
input: costSummary.totalInputCost,
|
||||||
? {
|
output: costSummary.totalOutputCost,
|
||||||
// For resume, add only the model costs, NOT the base execution charge again
|
tokens: {
|
||||||
total: (existingCost.total || 0) + costSummary.modelCost,
|
input: costSummary.totalPromptTokens,
|
||||||
input: (existingCost.input || 0) + costSummary.totalInputCost,
|
output: costSummary.totalCompletionTokens,
|
||||||
output: (existingCost.output || 0) + costSummary.totalOutputCost,
|
total: costSummary.totalTokens,
|
||||||
tokens: {
|
},
|
||||||
input:
|
models: costSummary.models,
|
||||||
(existingCost.tokens?.input || existingCost.tokens?.prompt || 0) +
|
}
|
||||||
costSummary.totalPromptTokens,
|
|
||||||
output:
|
|
||||||
(existingCost.tokens?.output || existingCost.tokens?.completion || 0) +
|
|
||||||
costSummary.totalCompletionTokens,
|
|
||||||
total: (existingCost.tokens?.total || 0) + costSummary.totalTokens,
|
|
||||||
},
|
|
||||||
models: this.mergeCostModels(existingCost.models || {}, costSummary.models),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
total: costSummary.totalCost,
|
|
||||||
input: costSummary.totalInputCost,
|
|
||||||
output: costSummary.totalOutputCost,
|
|
||||||
tokens: {
|
|
||||||
input: costSummary.totalPromptTokens,
|
|
||||||
output: costSummary.totalCompletionTokens,
|
|
||||||
total: costSummary.totalTokens,
|
|
||||||
},
|
|
||||||
models: costSummary.models,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge files if resuming
|
const totalDuration =
|
||||||
const existingFiles = isResume && existingLog?.files ? existingLog.files : []
|
|
||||||
const mergedFiles = [...existingFiles, ...executionFiles]
|
|
||||||
|
|
||||||
// Calculate the actual total duration for resume executions
|
|
||||||
const actualTotalDuration =
|
|
||||||
isResume && existingLog?.startedAt
|
isResume && existingLog?.startedAt
|
||||||
? new Date(endedAt).getTime() - new Date(existingLog.startedAt).getTime()
|
? new Date(endedAt).getTime() - new Date(existingLog.startedAt).getTime()
|
||||||
: totalDurationMs
|
: totalDurationMs
|
||||||
@@ -315,19 +271,19 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
|||||||
level,
|
level,
|
||||||
status,
|
status,
|
||||||
endedAt: new Date(endedAt),
|
endedAt: new Date(endedAt),
|
||||||
totalDurationMs: actualTotalDuration,
|
totalDurationMs: totalDuration,
|
||||||
files: mergedFiles.length > 0 ? mergedFiles : null,
|
files: executionFiles.length > 0 ? executionFiles : null,
|
||||||
executionData: {
|
executionData: {
|
||||||
traceSpans: redactedTraceSpans,
|
traceSpans: redactedTraceSpans,
|
||||||
finalOutput: redactedFinalOutput,
|
finalOutput: redactedFinalOutput,
|
||||||
tokens: {
|
tokens: {
|
||||||
input: mergedCost.tokens.input,
|
input: executionCost.tokens.input,
|
||||||
output: mergedCost.tokens.output,
|
output: executionCost.tokens.output,
|
||||||
total: mergedCost.tokens.total,
|
total: executionCost.tokens.total,
|
||||||
},
|
},
|
||||||
models: mergedCost.models,
|
models: executionCost.models,
|
||||||
},
|
},
|
||||||
cost: mergedCost,
|
cost: executionCost,
|
||||||
})
|
})
|
||||||
.where(eq(workflowExecutionLogs.executionId, executionId))
|
.where(eq(workflowExecutionLogs.executionId, executionId))
|
||||||
.returning()
|
.returning()
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { db } from '@sim/db'
|
||||||
|
import { workflowExecutionLogs } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { eq, sql } from 'drizzle-orm'
|
||||||
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||||
import { executionLogger } from '@/lib/logs/execution/logger'
|
import { executionLogger } from '@/lib/logs/execution/logger'
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +53,29 @@ export interface SessionCancelledParams {
|
|||||||
traceSpans?: TraceSpan[]
|
traceSpans?: TraceSpan[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionPausedParams {
|
||||||
|
endedAt?: string
|
||||||
|
totalDurationMs?: number
|
||||||
|
traceSpans?: TraceSpan[]
|
||||||
|
workflowInput?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccumulatedCost {
|
||||||
|
total: number
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
tokens: { input: number; output: number; total: number }
|
||||||
|
models: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
total: number
|
||||||
|
tokens: { input: number; output: number; total: number }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
export class LoggingSession {
|
export class LoggingSession {
|
||||||
private workflowId: string
|
private workflowId: string
|
||||||
private executionId: string
|
private executionId: string
|
||||||
@@ -60,6 +86,14 @@ export class LoggingSession {
|
|||||||
private workflowState?: WorkflowState
|
private workflowState?: WorkflowState
|
||||||
private isResume = false
|
private isResume = false
|
||||||
private completed = false
|
private completed = false
|
||||||
|
private accumulatedCost: AccumulatedCost = {
|
||||||
|
total: BASE_EXECUTION_CHARGE,
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
tokens: { input: 0, output: 0, total: 0 },
|
||||||
|
models: {},
|
||||||
|
}
|
||||||
|
private costFlushed = false
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
workflowId: string,
|
workflowId: string,
|
||||||
@@ -73,6 +107,102 @@ export class LoggingSession {
|
|||||||
this.requestId = requestId
|
this.requestId = requestId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onBlockComplete(
|
||||||
|
blockId: string,
|
||||||
|
blockName: string,
|
||||||
|
blockType: string,
|
||||||
|
output: any
|
||||||
|
): Promise<void> {
|
||||||
|
if (!output?.cost || typeof output.cost.total !== 'number' || output.cost.total <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cost, tokens, model } = output
|
||||||
|
|
||||||
|
this.accumulatedCost.total += cost.total || 0
|
||||||
|
this.accumulatedCost.input += cost.input || 0
|
||||||
|
this.accumulatedCost.output += cost.output || 0
|
||||||
|
|
||||||
|
if (tokens) {
|
||||||
|
this.accumulatedCost.tokens.input += tokens.input || 0
|
||||||
|
this.accumulatedCost.tokens.output += tokens.output || 0
|
||||||
|
this.accumulatedCost.tokens.total += tokens.total || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model) {
|
||||||
|
if (!this.accumulatedCost.models[model]) {
|
||||||
|
this.accumulatedCost.models[model] = {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
total: 0,
|
||||||
|
tokens: { input: 0, output: 0, total: 0 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.accumulatedCost.models[model].input += cost.input || 0
|
||||||
|
this.accumulatedCost.models[model].output += cost.output || 0
|
||||||
|
this.accumulatedCost.models[model].total += cost.total || 0
|
||||||
|
if (tokens) {
|
||||||
|
this.accumulatedCost.models[model].tokens.input += tokens.input || 0
|
||||||
|
this.accumulatedCost.models[model].tokens.output += tokens.output || 0
|
||||||
|
this.accumulatedCost.models[model].tokens.total += tokens.total || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.flushAccumulatedCost()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flushAccumulatedCost(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(workflowExecutionLogs)
|
||||||
|
.set({
|
||||||
|
cost: {
|
||||||
|
total: this.accumulatedCost.total,
|
||||||
|
input: this.accumulatedCost.input,
|
||||||
|
output: this.accumulatedCost.output,
|
||||||
|
tokens: this.accumulatedCost.tokens,
|
||||||
|
models: this.accumulatedCost.models,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(workflowExecutionLogs.executionId, this.executionId))
|
||||||
|
|
||||||
|
this.costFlushed = true
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to flush accumulated cost for execution ${this.executionId}:`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadExistingCost(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ cost: workflowExecutionLogs.cost })
|
||||||
|
.from(workflowExecutionLogs)
|
||||||
|
.where(eq(workflowExecutionLogs.executionId, this.executionId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existing?.cost) {
|
||||||
|
const cost = existing.cost as any
|
||||||
|
this.accumulatedCost = {
|
||||||
|
total: cost.total || BASE_EXECUTION_CHARGE,
|
||||||
|
input: cost.input || 0,
|
||||||
|
output: cost.output || 0,
|
||||||
|
tokens: {
|
||||||
|
input: cost.tokens?.input || 0,
|
||||||
|
output: cost.tokens?.output || 0,
|
||||||
|
total: cost.tokens?.total || 0,
|
||||||
|
},
|
||||||
|
models: cost.models || {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to load existing cost for execution ${this.executionId}:`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async start(params: SessionStartParams): Promise<void> {
|
async start(params: SessionStartParams): Promise<void> {
|
||||||
const { userId, workspaceId, variables, triggerData, skipLogCreation, deploymentVersionId } =
|
const { userId, workspaceId, variables, triggerData, skipLogCreation, deploymentVersionId } =
|
||||||
params
|
params
|
||||||
@@ -92,7 +222,6 @@ export class LoggingSession {
|
|||||||
? await loadDeployedWorkflowStateForLogging(this.workflowId)
|
? await loadDeployedWorkflowStateForLogging(this.workflowId)
|
||||||
: await loadWorkflowStateForExecution(this.workflowId)
|
: await loadWorkflowStateForExecution(this.workflowId)
|
||||||
|
|
||||||
// Only create a new log entry if not resuming
|
|
||||||
if (!skipLogCreation) {
|
if (!skipLogCreation) {
|
||||||
await executionLogger.startWorkflowExecution({
|
await executionLogger.startWorkflowExecution({
|
||||||
workflowId: this.workflowId,
|
workflowId: this.workflowId,
|
||||||
@@ -108,7 +237,8 @@ export class LoggingSession {
|
|||||||
logger.debug(`[${this.requestId}] Started logging for execution ${this.executionId}`)
|
logger.debug(`[${this.requestId}] Started logging for execution ${this.executionId}`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.isResume = true // Mark as resume
|
this.isResume = true
|
||||||
|
await this.loadExistingCost()
|
||||||
if (this.requestId) {
|
if (this.requestId) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[${this.requestId}] Resuming logging for existing execution ${this.executionId}`
|
`[${this.requestId}] Resuming logging for existing execution ${this.executionId}`
|
||||||
@@ -364,6 +494,68 @@ export class LoggingSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async completeWithPause(params: SessionPausedParams = {}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { endedAt, totalDurationMs, traceSpans, workflowInput } = params
|
||||||
|
|
||||||
|
const endTime = endedAt ? new Date(endedAt) : new Date()
|
||||||
|
const durationMs = typeof totalDurationMs === 'number' ? totalDurationMs : 0
|
||||||
|
|
||||||
|
const costSummary = traceSpans?.length
|
||||||
|
? calculateCostSummary(traceSpans)
|
||||||
|
: {
|
||||||
|
totalCost: BASE_EXECUTION_CHARGE,
|
||||||
|
totalInputCost: 0,
|
||||||
|
totalOutputCost: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
totalPromptTokens: 0,
|
||||||
|
totalCompletionTokens: 0,
|
||||||
|
baseExecutionCharge: BASE_EXECUTION_CHARGE,
|
||||||
|
modelCost: 0,
|
||||||
|
models: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
await executionLogger.completeWorkflowExecution({
|
||||||
|
executionId: this.executionId,
|
||||||
|
endedAt: endTime.toISOString(),
|
||||||
|
totalDurationMs: Math.max(1, durationMs),
|
||||||
|
costSummary,
|
||||||
|
finalOutput: { paused: true },
|
||||||
|
traceSpans: traceSpans || [],
|
||||||
|
workflowInput,
|
||||||
|
status: 'pending',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { trackPlatformEvent } = await import('@/lib/core/telemetry')
|
||||||
|
trackPlatformEvent('platform.workflow.executed', {
|
||||||
|
'workflow.id': this.workflowId,
|
||||||
|
'execution.duration_ms': Math.max(1, durationMs),
|
||||||
|
'execution.status': 'paused',
|
||||||
|
'execution.trigger': this.triggerType,
|
||||||
|
'execution.blocks_executed': traceSpans?.length || 0,
|
||||||
|
'execution.has_errors': false,
|
||||||
|
'execution.total_cost': costSummary.totalCost || 0,
|
||||||
|
})
|
||||||
|
} catch (_e) {}
|
||||||
|
|
||||||
|
if (this.requestId) {
|
||||||
|
logger.debug(
|
||||||
|
`[${this.requestId}] Completed paused logging for execution ${this.executionId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (pauseError) {
|
||||||
|
logger.error(`Failed to complete paused logging for execution ${this.executionId}:`, {
|
||||||
|
requestId: this.requestId,
|
||||||
|
workflowId: this.workflowId,
|
||||||
|
executionId: this.executionId,
|
||||||
|
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
|
||||||
|
stack: pauseError instanceof Error ? pauseError.stack : undefined,
|
||||||
|
})
|
||||||
|
throw pauseError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async safeStart(params: SessionStartParams): Promise<boolean> {
|
async safeStart(params: SessionStartParams): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await this.start(params)
|
await this.start(params)
|
||||||
@@ -480,13 +672,64 @@ export class LoggingSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async safeCompleteWithPause(params?: SessionPausedParams): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.completeWithPause(params)
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||||
|
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: 'Execution paused but failed to store full trace spans',
|
||||||
|
isError: false,
|
||||||
|
status: 'pending',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsFailed(errorMessage?: string): Promise<void> {
|
||||||
|
await LoggingSession.markExecutionAsFailed(this.executionId, errorMessage, this.requestId)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async markExecutionAsFailed(
|
||||||
|
executionId: string,
|
||||||
|
errorMessage?: string,
|
||||||
|
requestId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const message = errorMessage || 'Execution failed'
|
||||||
|
await db
|
||||||
|
.update(workflowExecutionLogs)
|
||||||
|
.set({
|
||||||
|
status: 'failed',
|
||||||
|
executionData: sql`jsonb_set(
|
||||||
|
COALESCE(execution_data, '{}'::jsonb),
|
||||||
|
ARRAY['error'],
|
||||||
|
to_jsonb(${message}::text)
|
||||||
|
)`,
|
||||||
|
})
|
||||||
|
.where(eq(workflowExecutionLogs.executionId, executionId))
|
||||||
|
|
||||||
|
logger.info(`[${requestId || 'unknown'}] Marked execution ${executionId} as failed`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to mark execution ${executionId} as failed:`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async completeWithCostOnlyLog(params: {
|
private async completeWithCostOnlyLog(params: {
|
||||||
traceSpans?: TraceSpan[]
|
traceSpans?: TraceSpan[]
|
||||||
endedAt?: string
|
endedAt?: string
|
||||||
totalDurationMs?: number
|
totalDurationMs?: number
|
||||||
errorMessage: string
|
errorMessage: string
|
||||||
isError: boolean
|
isError: boolean
|
||||||
status?: 'completed' | 'failed' | 'cancelled'
|
status?: 'completed' | 'failed' | 'cancelled' | 'pending'
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (this.completed) {
|
if (this.completed) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -367,5 +367,9 @@ export interface ExecutionLoggerService {
|
|||||||
}
|
}
|
||||||
finalOutput: BlockOutputData
|
finalOutput: BlockOutputData
|
||||||
traceSpans?: TraceSpan[]
|
traceSpans?: TraceSpan[]
|
||||||
|
workflowInput?: any
|
||||||
|
isResume?: boolean
|
||||||
|
level?: 'info' | 'error'
|
||||||
|
status?: 'completed' | 'failed' | 'cancelled' | 'pending'
|
||||||
}): Promise<WorkflowExecutionLog>
|
}): Promise<WorkflowExecutionLog>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { v4 as uuidv4 } from 'uuid'
|
|||||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||||
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
||||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||||
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
|
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||||
|
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||||
|
|
||||||
const logger = createLogger('WorkflowExecution')
|
const logger = createLogger('WorkflowExecution')
|
||||||
|
|
||||||
@@ -83,14 +84,25 @@ export async function executeWorkflow(
|
|||||||
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
|
||||||
executionId,
|
executionId,
|
||||||
})
|
})
|
||||||
|
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
|
||||||
} else {
|
} else {
|
||||||
await PauseResumeManager.persistPauseResult({
|
try {
|
||||||
workflowId,
|
await PauseResumeManager.persistPauseResult({
|
||||||
executionId,
|
workflowId,
|
||||||
pausePoints: result.pausePoints || [],
|
executionId,
|
||||||
snapshotSeed: result.snapshotSeed,
|
pausePoints: result.pausePoints || [],
|
||||||
executorUserId: result.metadata?.userId,
|
snapshotSeed: result.snapshotSeed,
|
||||||
})
|
executorUserId: result.metadata?.userId,
|
||||||
|
})
|
||||||
|
} catch (pauseError) {
|
||||||
|
logger.error(`[${requestId}] Failed to persist pause result`, {
|
||||||
|
executionId,
|
||||||
|
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
|
||||||
|
})
|
||||||
|
await loggingSession.markAsFailed(
|
||||||
|
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await PauseResumeManager.processQueuedResumes(executionId)
|
await PauseResumeManager.processQueuedResumes(executionId)
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
|||||||
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
|
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
|
||||||
import { Executor } from '@/executor'
|
import { Executor } from '@/executor'
|
||||||
import { REFERENCE } from '@/executor/constants'
|
import { REFERENCE } from '@/executor/constants'
|
||||||
import type { ExecutionCallbacks, ExecutionSnapshot } from '@/executor/execution/snapshot'
|
import type { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||||
|
import type { ExecutionCallbacks, IterationContext } from '@/executor/execution/types'
|
||||||
import type { ExecutionResult } from '@/executor/types'
|
import type { ExecutionResult } from '@/executor/types'
|
||||||
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
|
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
|
||||||
import { Serializer } from '@/serializer'
|
import { Serializer } from '@/serializer'
|
||||||
@@ -316,6 +317,19 @@ export async function executeWorkflowCore(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wrappedOnBlockComplete = async (
|
||||||
|
blockId: string,
|
||||||
|
blockName: string,
|
||||||
|
blockType: string,
|
||||||
|
output: any,
|
||||||
|
iterationContext?: IterationContext
|
||||||
|
) => {
|
||||||
|
await loggingSession.onBlockComplete(blockId, blockName, blockType, output)
|
||||||
|
if (onBlockComplete) {
|
||||||
|
await onBlockComplete(blockId, blockName, blockType, output, iterationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const contextExtensions: any = {
|
const contextExtensions: any = {
|
||||||
stream: !!onStream,
|
stream: !!onStream,
|
||||||
selectedOutputs,
|
selectedOutputs,
|
||||||
@@ -324,7 +338,7 @@ export async function executeWorkflowCore(
|
|||||||
userId,
|
userId,
|
||||||
isDeployedContext: triggerType !== 'manual',
|
isDeployedContext: triggerType !== 'manual',
|
||||||
onBlockStart,
|
onBlockStart,
|
||||||
onBlockComplete,
|
onBlockComplete: wrappedOnBlockComplete,
|
||||||
onStream,
|
onStream,
|
||||||
resumeFromSnapshot,
|
resumeFromSnapshot,
|
||||||
resumePendingQueue,
|
resumePendingQueue,
|
||||||
@@ -386,6 +400,13 @@ export async function executeWorkflowCore(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.status === 'paused') {
|
if (result.status === 'paused') {
|
||||||
|
await loggingSession.safeCompleteWithPause({
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
totalDurationMs: totalDuration || 0,
|
||||||
|
traceSpans: traceSpans || [],
|
||||||
|
workflowInput: processedInput,
|
||||||
|
})
|
||||||
|
|
||||||
await clearExecutionCancellation(executionId)
|
await clearExecutionCancellation(executionId)
|
||||||
|
|
||||||
logger.info(`[${requestId}] Workflow execution paused`, {
|
logger.info(`[${requestId}] Workflow execution paused`, {
|
||||||
|
|||||||
@@ -155,11 +155,6 @@ export class PauseResumeManager {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await db
|
|
||||||
.update(workflowExecutionLogs)
|
|
||||||
.set({ status: 'pending' })
|
|
||||||
.where(eq(workflowExecutionLogs.executionId, executionId))
|
|
||||||
|
|
||||||
await PauseResumeManager.processQueuedResumes(executionId)
|
await PauseResumeManager.processQueuedResumes(executionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,18 +297,34 @@ export class PauseResumeManager {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (result.status === 'paused') {
|
if (result.status === 'paused') {
|
||||||
|
const effectiveExecutionId = result.metadata?.executionId ?? resumeExecutionId
|
||||||
if (!result.snapshotSeed) {
|
if (!result.snapshotSeed) {
|
||||||
logger.error('Missing snapshot seed for paused resume execution', {
|
logger.error('Missing snapshot seed for paused resume execution', {
|
||||||
resumeExecutionId,
|
resumeExecutionId,
|
||||||
})
|
})
|
||||||
|
await LoggingSession.markExecutionAsFailed(
|
||||||
|
effectiveExecutionId,
|
||||||
|
'Missing snapshot seed for paused execution'
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
await PauseResumeManager.persistPauseResult({
|
try {
|
||||||
workflowId: pausedExecution.workflowId,
|
await PauseResumeManager.persistPauseResult({
|
||||||
executionId: result.metadata?.executionId ?? resumeExecutionId,
|
workflowId: pausedExecution.workflowId,
|
||||||
pausePoints: result.pausePoints || [],
|
executionId: effectiveExecutionId,
|
||||||
snapshotSeed: result.snapshotSeed,
|
pausePoints: result.pausePoints || [],
|
||||||
executorUserId: result.metadata?.userId,
|
snapshotSeed: result.snapshotSeed,
|
||||||
})
|
executorUserId: result.metadata?.userId,
|
||||||
|
})
|
||||||
|
} catch (pauseError) {
|
||||||
|
logger.error('Failed to persist pause result for resumed execution', {
|
||||||
|
resumeExecutionId,
|
||||||
|
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
|
||||||
|
})
|
||||||
|
await LoggingSession.markExecutionAsFailed(
|
||||||
|
effectiveExecutionId,
|
||||||
|
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await PauseResumeManager.updateSnapshotAfterResume({
|
await PauseResumeManager.updateSnapshotAfterResume({
|
||||||
|
|||||||
Reference in New Issue
Block a user