From 12356252e0561728b663d2980a5a8c002bb6545d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 28 Mar 2026 14:55:55 -0700 Subject: [PATCH 1/9] feat(logs): add additional metadata for workflow execution logs --- .../app/api/workflows/[id]/execute/route.ts | 82 +++++++++---------- apps/sim/executor/execution/block-executor.ts | 58 ++++++++++--- apps/sim/executor/execution/engine.ts | 42 ++++++---- apps/sim/executor/execution/executor.ts | 28 +++++-- apps/sim/lib/logs/execution/logger.ts | 38 ++++----- packages/logger/src/index.test.ts | 59 +++++++++++++ packages/logger/src/index.ts | 33 +++++++- 7 files changed, 234 insertions(+), 106 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index df3fc41d434..dd5520c48a2 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -1,4 +1,4 @@ -import { createLogger } from '@sim/logger' +import { createLogger, type Logger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { validate as uuidValidate, v4 as uuidv4 } from 'uuid' import { z } from 'zod' @@ -187,6 +187,7 @@ type AsyncExecutionParams = { async function handleAsyncExecution(params: AsyncExecutionParams): Promise { const { requestId, workflowId, userId, workspaceId, input, triggerType, executionId, callChain } = params + const asyncLogger = logger.withMetadata({ requestId, workflowId, workspaceId, userId, executionId }) const correlation = { executionId, @@ -233,10 +234,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise { const requestId = generateRequestId() const { id: workflowId } = await params + let reqLogger = logger.withMetadata({ requestId, workflowId }) const incomingCallChain = parseCallChain(req.headers.get(SIM_VIA_HEADER)) const callChainError = validateCallChain(incomingCallChain) if (callChainError) { - logger.warn(`[${requestId}] Call chain rejected for workflow ${workflowId}: ${callChainError}`) + reqLogger.warn(`Call chain rejected: ${callChainError}`) return NextResponse.json({ error: callChainError }, { status: 409 }) } const callChain = buildNextCallChain(incomingCallChain, workflowId) @@ -414,12 +413,12 @@ async function handleExecutePost( body = JSON.parse(text) } } catch (error) { - logger.warn(`[${requestId}] Failed to parse request body, using defaults`) + reqLogger.warn('Failed to parse request body, using defaults') } const validation = ExecuteWorkflowSchema.safeParse(body) if (!validation.success) { - logger.warn(`[${requestId}] Invalid request body:`, validation.error.errors) + reqLogger.warn('Invalid request body:', validation.error.errors) return NextResponse.json( { error: 'Invalid request body', @@ -589,9 +588,10 @@ async function handleExecutePost( ) } - logger.info(`[${requestId}] Starting server-side execution`, { - workflowId, - userId, + const executionId = uuidv4() + reqLogger = reqLogger.withMetadata({ userId, executionId }) + + reqLogger.info('Starting server-side execution', { hasInput: !!input, triggerType, authType: auth.authType, @@ -600,8 +600,6 @@ async function handleExecutePost( enableSSE, isAsyncMode, }) - - const executionId = uuidv4() let loggingTriggerType: CoreTriggerType = 'manual' if (CORE_TRIGGER_TYPES.includes(triggerType as CoreTriggerType)) { loggingTriggerType = triggerType as CoreTriggerType @@ -657,10 +655,11 @@ async function handleExecutePost( const workflow = preprocessResult.workflowRecord! if (!workflow.workspaceId) { - logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`) + reqLogger.error('Workflow has no workspaceId') return NextResponse.json({ error: 'Workflow has no associated workspace' }, { status: 500 }) } const workspaceId = workflow.workspaceId + reqLogger = reqLogger.withMetadata({ workspaceId, userId: actorUserId }) if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workspaceId) { return NextResponse.json( @@ -669,11 +668,7 @@ async function handleExecutePost( ) } - logger.info(`[${requestId}] Preprocessing passed`, { - workflowId, - actorUserId, - workspaceId, - }) + reqLogger.info('Preprocessing passed') if (isAsyncMode) { return handleAsyncExecution({ @@ -744,7 +739,7 @@ async function handleExecutePost( ) } } catch (fileError) { - logger.error(`[${requestId}] Failed to process input file fields:`, fileError) + reqLogger.error('Failed to process input file fields:', fileError) await loggingSession.safeStart({ userId: actorUserId, @@ -772,7 +767,7 @@ async function handleExecutePost( sanitizedWorkflowStateOverride || cachedWorkflowData || undefined if (!enableSSE) { - logger.info(`[${requestId}] Using non-SSE execution (direct JSON response)`) + reqLogger.info('Using non-SSE execution (direct JSON response)') const metadata: ExecutionMetadata = { requestId, executionId, @@ -866,7 +861,7 @@ async function handleExecutePost( const errorMessage = error instanceof Error ? error.message : 'Unknown error' - logger.error(`[${requestId}] Queued non-SSE execution failed: ${errorMessage}`) + reqLogger.error(`Queued non-SSE execution failed: ${errorMessage}`) return NextResponse.json( { @@ -908,7 +903,7 @@ async function handleExecutePost( timeoutController.timeoutMs ) { const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) - logger.info(`[${requestId}] Non-SSE execution timed out`, { + reqLogger.info('Non-SSE execution timed out', { timeoutMs: timeoutController.timeoutMs, }) await loggingSession.markAsFailed(timeoutErrorMessage) @@ -962,7 +957,7 @@ async function handleExecutePost( } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' - logger.error(`[${requestId}] Non-SSE execution failed: ${errorMessage}`) + reqLogger.error(`Non-SSE execution failed: ${errorMessage}`) const executionResult = hasExecutionResult(error) ? error.executionResult : undefined @@ -985,7 +980,7 @@ async function handleExecutePost( timeoutController.cleanup() if (executionId) { void cleanupExecutionBase64Cache(executionId).catch((error) => { - logger.error(`[${requestId}] Failed to cleanup base64 cache`, { error }) + reqLogger.error('Failed to cleanup base64 cache', { error }) }) } } @@ -1039,9 +1034,9 @@ async function handleExecutePost( }) } - logger.info(`[${requestId}] Using SSE console log streaming (manual execution)`) + reqLogger.info('Using SSE console log streaming (manual execution)') } else { - logger.info(`[${requestId}] Using streaming API response`) + reqLogger.info('Using streaming API response') const resolvedSelectedOutputs = resolveOutputIds( selectedOutputs, @@ -1135,7 +1130,7 @@ async function handleExecutePost( iterationContext?: IterationContext, childWorkflowContext?: ChildWorkflowContext ) => { - logger.info(`[${requestId}] 🔷 onBlockStart called:`, { blockId, blockName, blockType }) + reqLogger.info('onBlockStart called', { blockId, blockName, blockType }) sendEvent({ type: 'block:started', timestamp: new Date().toISOString(), @@ -1184,7 +1179,7 @@ async function handleExecutePost( : {} if (hasError) { - logger.info(`[${requestId}] ✗ onBlockComplete (error) called:`, { + reqLogger.info('onBlockComplete (error) called', { blockId, blockName, blockType, @@ -1219,7 +1214,7 @@ async function handleExecutePost( }, }) } else { - logger.info(`[${requestId}] ✓ onBlockComplete called:`, { + reqLogger.info('onBlockComplete called', { blockId, blockName, blockType, @@ -1284,7 +1279,7 @@ async function handleExecutePost( data: { blockId }, }) } catch (error) { - logger.error(`[${requestId}] Error streaming block content:`, error) + reqLogger.error('Error streaming block content:', error) } finally { try { await reader.cancel().catch(() => {}) @@ -1360,9 +1355,7 @@ async function handleExecutePost( if (result.status === 'paused') { if (!result.snapshotSeed) { - logger.error(`[${requestId}] Missing snapshot seed for paused execution`, { - executionId, - }) + reqLogger.error('Missing snapshot seed for paused execution') await loggingSession.markAsFailed('Missing snapshot seed for paused execution') } else { try { @@ -1374,8 +1367,7 @@ async function handleExecutePost( executorUserId: result.metadata?.userId, }) } catch (pauseError) { - logger.error(`[${requestId}] Failed to persist pause result`, { - executionId, + reqLogger.error('Failed to persist pause result', { error: pauseError instanceof Error ? pauseError.message : String(pauseError), }) await loggingSession.markAsFailed( @@ -1390,7 +1382,7 @@ async function handleExecutePost( if (result.status === 'cancelled') { if (timeoutController.isTimedOut() && timeoutController.timeoutMs) { const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) - logger.info(`[${requestId}] Workflow execution timed out`, { + reqLogger.info('Workflow execution timed out', { timeoutMs: timeoutController.timeoutMs, }) @@ -1408,7 +1400,7 @@ async function handleExecutePost( }) finalMetaStatus = 'error' } else { - logger.info(`[${requestId}] Workflow execution was cancelled`) + reqLogger.info('Workflow execution was cancelled') sendEvent({ type: 'execution:cancelled', @@ -1452,7 +1444,7 @@ async function handleExecutePost( ? error.message : 'Unknown error' - logger.error(`[${requestId}] SSE execution failed: ${errorMessage}`, { isTimeout }) + reqLogger.error(`SSE execution failed: ${errorMessage}`, { isTimeout }) const executionResult = hasExecutionResult(error) ? error.executionResult : undefined @@ -1475,7 +1467,7 @@ async function handleExecutePost( try { await eventWriter.close() } catch (closeError) { - logger.warn(`[${requestId}] Failed to close event writer`, { + reqLogger.warn('Failed to close event writer', { error: closeError instanceof Error ? closeError.message : String(closeError), }) } @@ -1496,7 +1488,7 @@ async function handleExecutePost( }, cancel() { isStreamClosed = true - logger.info(`[${requestId}] Client disconnected from SSE stream`) + reqLogger.info('Client disconnected from SSE stream') }, }) @@ -1518,7 +1510,7 @@ async function handleExecutePost( ) } - logger.error(`[${requestId}] Failed to start workflow execution:`, error) + reqLogger.error('Failed to start workflow execution:', error) return NextResponse.json( { error: error.message || 'Failed to start workflow execution' }, { status: 500 } diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 5680ee4e3cf..6a155f6a07e 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -1,4 +1,4 @@ -import { createLogger } from '@sim/logger' +import { createLogger, type Logger } from '@sim/logger' import { redactApiKeys } from '@/lib/core/security/redaction' import { getBaseUrl } from '@/lib/core/utils/urls' import { @@ -56,11 +56,22 @@ export class BlockExecutor { private state: BlockStateWriter ) {} + private loggerFor(ctx: ExecutionContext): Logger { + return logger.withMetadata({ + workflowId: ctx.workflowId, + workspaceId: ctx.workspaceId, + executionId: ctx.executionId, + userId: ctx.userId, + requestId: ctx.metadata.requestId, + }) + } + async execute( ctx: ExecutionContext, node: DAGNode, block: SerializedBlock ): Promise { + const execLogger = this.loggerFor(ctx) const handler = this.findHandler(block) if (!handler) { throw buildBlockExecutionError({ @@ -75,9 +86,9 @@ export class BlockExecutor { let blockLog: BlockLog | undefined if (!isSentinel) { - blockLog = this.createBlockLog(ctx, node.id, block, node) + blockLog = this.createBlockLog(execLogger, ctx, node.id, block, node) ctx.blockLogs.push(blockLog) - await this.callOnBlockStart(ctx, node, block, blockLog.executionOrder) + await this.callOnBlockStart(execLogger, ctx, node, block, blockLog.executionOrder) } const startTime = performance.now() @@ -106,6 +117,7 @@ export class BlockExecutor { } catch (error) { cleanupSelfReference?.() return await this.handleBlockError( + execLogger, error, ctx, node, @@ -133,6 +145,7 @@ export class BlockExecutor { if (ctx.onStream) { await this.handleStreamingExecution( + execLogger, ctx, node, block, @@ -180,6 +193,7 @@ export class BlockExecutor { block, }) await this.callOnBlockComplete( + execLogger, ctx, node, block, @@ -196,6 +210,7 @@ export class BlockExecutor { return normalizedOutput } catch (error) { return await this.handleBlockError( + execLogger, error, ctx, node, @@ -227,6 +242,7 @@ export class BlockExecutor { } private async handleBlockError( + execLogger: Logger, error: unknown, ctx: ExecutionContext, node: DAGNode, @@ -273,7 +289,7 @@ export class BlockExecutor { } } - logger.error( + execLogger.error( phase === 'input_resolution' ? 'Failed to resolve block inputs' : 'Block execution failed', { blockId: node.id, @@ -288,6 +304,7 @@ export class BlockExecutor { : undefined const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block }) await this.callOnBlockComplete( + execLogger, ctx, node, block, @@ -306,7 +323,7 @@ export class BlockExecutor { if (blockLog) { blockLog.errorHandled = true } - logger.info('Block has error port - returning error output instead of throwing', { + execLogger.info('Block has error port - returning error output instead of throwing', { blockId: node.id, error: errorMessage, }) @@ -336,6 +353,7 @@ export class BlockExecutor { } private createBlockLog( + execLogger: Logger, ctx: ExecutionContext, blockId: string, block: SerializedBlock, @@ -358,7 +376,7 @@ export class BlockExecutor { blockName = `${blockName} (iteration ${loopScope.iteration})` iterationIndex = loopScope.iteration } else { - logger.warn('Loop scope not found for block', { blockId, loopId }) + execLogger.warn('Loop scope not found for block', { blockId, loopId }) } } } @@ -440,6 +458,7 @@ export class BlockExecutor { } private async callOnBlockStart( + execLogger: Logger, ctx: ExecutionContext, node: DAGNode, block: SerializedBlock, @@ -462,7 +481,7 @@ export class BlockExecutor { ctx.childWorkflowContext ) } catch (error) { - logger.warn('Block start callback failed', { + execLogger.warn('Block start callback failed', { blockId, blockType, error: error instanceof Error ? error.message : String(error), @@ -472,6 +491,7 @@ export class BlockExecutor { } private async callOnBlockComplete( + execLogger: Logger, ctx: ExecutionContext, node: DAGNode, block: SerializedBlock, @@ -508,7 +528,7 @@ export class BlockExecutor { ctx.childWorkflowContext ) } catch (error) { - logger.warn('Block completion callback failed', { + execLogger.warn('Block completion callback failed', { blockId, blockType, error: error instanceof Error ? error.message : String(error), @@ -588,6 +608,7 @@ export class BlockExecutor { } private async handleStreamingExecution( + execLogger: Logger, ctx: ExecutionContext, node: DAGNode, block: SerializedBlock, @@ -604,7 +625,15 @@ export class BlockExecutor { const stream = streamingExec.stream if (typeof stream.tee !== 'function') { - await this.forwardStream(ctx, blockId, streamingExec, stream, responseFormat, selectedOutputs) + await this.forwardStream( + execLogger, + ctx, + blockId, + streamingExec, + stream, + responseFormat, + selectedOutputs + ) return } @@ -623,6 +652,7 @@ export class BlockExecutor { } const executorConsumption = this.consumeExecutorStream( + execLogger, executorStream, streamingExec, blockId, @@ -633,7 +663,7 @@ export class BlockExecutor { try { await ctx.onStream?.(clientStreamingExec) } catch (error) { - logger.error('Error in onStream callback', { blockId, error }) + execLogger.error('Error in onStream callback', { blockId, error }) // Cancel the client stream to release the tee'd buffer await processedClientStream.cancel().catch(() => {}) } @@ -643,6 +673,7 @@ export class BlockExecutor { } private async forwardStream( + execLogger: Logger, ctx: ExecutionContext, blockId: string, streamingExec: { stream: ReadableStream; execution: any }, @@ -663,12 +694,13 @@ export class BlockExecutor { stream: processedStream, }) } catch (error) { - logger.error('Error in onStream callback', { blockId, error }) + execLogger.error('Error in onStream callback', { blockId, error }) await processedStream.cancel().catch(() => {}) } } private async consumeExecutorStream( + execLogger: Logger, stream: ReadableStream, streamingExec: { execution: any }, blockId: string, @@ -687,7 +719,7 @@ export class BlockExecutor { const tail = decoder.decode() if (tail) chunks.push(tail) } catch (error) { - logger.error('Error reading executor stream for block', { blockId, error }) + execLogger.error('Error reading executor stream for block', { blockId, error }) } finally { try { await reader.cancel().catch(() => {}) @@ -718,7 +750,7 @@ export class BlockExecutor { } return } catch (error) { - logger.warn('Failed to parse streamed content for response format', { blockId, error }) + execLogger.warn('Failed to parse streamed content for response format', { blockId, error }) } } diff --git a/apps/sim/executor/execution/engine.ts b/apps/sim/executor/execution/engine.ts index 2f479125282..a420c5df7dd 100644 --- a/apps/sim/executor/execution/engine.ts +++ b/apps/sim/executor/execution/engine.ts @@ -1,4 +1,4 @@ -import { createLogger } from '@sim/logger' +import { createLogger, type Logger } from '@sim/logger' import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation' import { BlockType } from '@/executor/constants' import type { DAG } from '@/executor/dag/builder' @@ -34,6 +34,7 @@ export class ExecutionEngine { private readonly CANCELLATION_CHECK_INTERVAL_MS = 500 private abortPromise: Promise | null = null private abortResolve: (() => void) | null = null + private execLogger: Logger constructor( private context: ExecutionContext, @@ -43,6 +44,13 @@ export class ExecutionEngine { ) { this.allowResumeTriggers = this.context.metadata.resumeFromSnapshot === true this.useRedisCancellation = isRedisCancellationEnabled() && !!this.context.executionId + this.execLogger = logger.withMetadata({ + workflowId: this.context.workflowId, + workspaceId: this.context.workspaceId, + executionId: this.context.executionId, + userId: this.context.userId, + requestId: this.context.metadata.requestId, + }) this.initializeAbortHandler() } @@ -88,7 +96,9 @@ export class ExecutionEngine { const cancelled = await isExecutionCancelled(this.context.executionId!) if (cancelled) { this.cancelledFlag = true - logger.info('Execution cancelled via Redis', { executionId: this.context.executionId }) + this.execLogger.info('Execution cancelled via Redis', { + executionId: this.context.executionId, + }) } return cancelled } @@ -169,7 +179,7 @@ export class ExecutionEngine { this.finalizeIncompleteLogs() const errorMessage = normalizeError(error) - logger.error('Execution failed', { error: errorMessage }) + this.execLogger.error('Execution failed', { error: errorMessage }) const executionResult: ExecutionResult = { success: false, @@ -270,7 +280,7 @@ export class ExecutionEngine { private initializeQueue(triggerBlockId?: string): void { if (this.context.runFromBlockContext) { const { startBlockId } = this.context.runFromBlockContext - logger.info('Initializing queue for run-from-block mode', { + this.execLogger.info('Initializing queue for run-from-block mode', { startBlockId, dirtySetSize: this.context.runFromBlockContext.dirtySet.size, }) @@ -282,7 +292,7 @@ export class ExecutionEngine { const remainingEdges = (this.context.metadata as any).remainingEdges if (remainingEdges && Array.isArray(remainingEdges) && remainingEdges.length > 0) { - logger.info('Removing edges from resumed pause blocks', { + this.execLogger.info('Removing edges from resumed pause blocks', { edgeCount: remainingEdges.length, edges: remainingEdges, }) @@ -294,13 +304,13 @@ export class ExecutionEngine { targetNode.incomingEdges.delete(edge.source) if (this.edgeManager.isNodeReady(targetNode)) { - logger.info('Node became ready after edge removal', { nodeId: targetNode.id }) + this.execLogger.info('Node became ready after edge removal', { nodeId: targetNode.id }) this.addToQueue(targetNode.id) } } } - logger.info('Edge removal complete, queued ready nodes', { + this.execLogger.info('Edge removal complete, queued ready nodes', { queueLength: this.readyQueue.length, queuedNodes: this.readyQueue, }) @@ -309,7 +319,7 @@ export class ExecutionEngine { } if (pendingBlocks && pendingBlocks.length > 0) { - logger.info('Initializing queue from pending blocks (resume mode)', { + this.execLogger.info('Initializing queue from pending blocks (resume mode)', { pendingBlocks, allowResumeTriggers: this.allowResumeTriggers, dagNodeCount: this.dag.nodes.size, @@ -319,7 +329,7 @@ export class ExecutionEngine { this.addToQueue(nodeId) } - logger.info('Pending blocks queued', { + this.execLogger.info('Pending blocks queued', { queueLength: this.readyQueue.length, queuedNodes: this.readyQueue, }) @@ -341,7 +351,7 @@ export class ExecutionEngine { if (startNode) { this.addToQueue(startNode.id) } else { - logger.warn('No start node found in DAG') + this.execLogger.warn('No start node found in DAG') } } @@ -373,7 +383,7 @@ export class ExecutionEngine { } } catch (error) { const errorMessage = normalizeError(error) - logger.error('Node execution failed', { nodeId, error: errorMessage }) + this.execLogger.error('Node execution failed', { nodeId, error: errorMessage }) throw error } } @@ -385,7 +395,7 @@ export class ExecutionEngine { ): Promise { const node = this.dag.nodes.get(nodeId) if (!node) { - logger.error('Node not found during completion', { nodeId }) + this.execLogger.error('Node not found during completion', { nodeId }) return } @@ -409,7 +419,7 @@ export class ExecutionEngine { // shouldContinue: true means more iterations, shouldExit: true means loop is done const shouldContinueLoop = output.shouldContinue === true if (!shouldContinueLoop) { - logger.info('Stopping execution after target block', { nodeId }) + this.execLogger.info('Stopping execution after target block', { nodeId }) this.stoppedEarlyFlag = true return } @@ -417,7 +427,7 @@ export class ExecutionEngine { const readyNodes = this.edgeManager.processOutgoingEdges(node, output, false) - logger.info('Processing outgoing edges', { + this.execLogger.info('Processing outgoing edges', { nodeId, outgoingEdgesCount: node.outgoingEdges.size, outgoingEdges: Array.from(node.outgoingEdges.entries()).map(([id, e]) => ({ @@ -435,7 +445,7 @@ export class ExecutionEngine { if (this.context.pendingDynamicNodes && this.context.pendingDynamicNodes.length > 0) { const dynamicNodes = this.context.pendingDynamicNodes this.context.pendingDynamicNodes = [] - logger.info('Adding dynamically expanded parallel nodes', { dynamicNodes }) + this.execLogger.info('Adding dynamically expanded parallel nodes', { dynamicNodes }) this.addMultipleToQueue(dynamicNodes) } } @@ -482,7 +492,7 @@ export class ExecutionEngine { } return parsedSnapshot.state } catch (error) { - logger.warn('Failed to serialize execution state', { + this.execLogger.warn('Failed to serialize execution state', { error: error instanceof Error ? error.message : String(error), }) return undefined diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index 67d3b4c24b8..8e3a8c8c8c9 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -1,4 +1,4 @@ -import { createLogger } from '@sim/logger' +import { createLogger, type Logger } from '@sim/logger' import { StartBlockPath } from '@/lib/workflows/triggers/triggers' import type { DAG } from '@/executor/dag/builder' import { DAGBuilder } from '@/executor/dag/builder' @@ -52,6 +52,7 @@ export class DAGExecutor { private workflowVariables: Record private contextExtensions: ContextExtensions private dagBuilder: DAGBuilder + private execLogger: Logger constructor(options: DAGExecutorOptions) { this.workflow = options.workflow @@ -60,6 +61,13 @@ export class DAGExecutor { this.workflowVariables = options.workflowVariables ?? {} this.contextExtensions = options.contextExtensions ?? {} this.dagBuilder = new DAGBuilder() + this.execLogger = logger.withMetadata({ + workflowId: this.contextExtensions.metadata?.workflowId, + workspaceId: this.contextExtensions.workspaceId, + executionId: this.contextExtensions.executionId, + userId: this.contextExtensions.userId, + requestId: this.contextExtensions.metadata?.requestId, + }) } async execute(workflowId: string, triggerBlockId?: string): Promise { @@ -79,7 +87,9 @@ export class DAGExecutor { _pendingBlocks: string[], context: ExecutionContext ): Promise { - logger.warn('Debug mode (continueExecution) is not yet implemented in the refactored executor') + this.execLogger.warn( + 'Debug mode (continueExecution) is not yet implemented in the refactored executor' + ) return { success: false, output: {}, @@ -163,7 +173,7 @@ export class DAGExecutor { parallelExecutions: filteredParallelExecutions, } - logger.info('Executing from block', { + this.execLogger.info('Executing from block', { workflowId, startBlockId, effectiveStartBlockId, @@ -247,7 +257,7 @@ export class DAGExecutor { if (overrides?.runFromBlockContext) { const { dirtySet } = overrides.runFromBlockContext executedBlocks = new Set([...executedBlocks].filter((id) => !dirtySet.has(id))) - logger.info('Cleared executed status for dirty blocks', { + this.execLogger.info('Cleared executed status for dirty blocks', { dirtySetSize: dirtySet.size, remainingExecutedBlocks: executedBlocks.size, }) @@ -332,7 +342,7 @@ export class DAGExecutor { if (this.contextExtensions.resumeFromSnapshot) { context.metadata.resumeFromSnapshot = true - logger.info('Resume from snapshot enabled', { + this.execLogger.info('Resume from snapshot enabled', { resumePendingQueue: this.contextExtensions.resumePendingQueue, remainingEdges: this.contextExtensions.remainingEdges, triggerBlockId, @@ -341,14 +351,14 @@ export class DAGExecutor { if (this.contextExtensions.remainingEdges) { ;(context.metadata as any).remainingEdges = this.contextExtensions.remainingEdges - logger.info('Set remaining edges for resume', { + this.execLogger.info('Set remaining edges for resume', { edgeCount: this.contextExtensions.remainingEdges.length, }) } if (this.contextExtensions.resumePendingQueue?.length) { context.metadata.pendingBlocks = [...this.contextExtensions.resumePendingQueue] - logger.info('Set pending blocks from resume queue', { + this.execLogger.info('Set pending blocks from resume queue', { pendingBlocks: context.metadata.pendingBlocks, skipStarterBlockInit: true, }) @@ -409,7 +419,7 @@ export class DAGExecutor { if (triggerBlockId) { const triggerBlock = this.workflow.blocks.find((b) => b.id === triggerBlockId) if (!triggerBlock) { - logger.error('Specified trigger block not found in workflow', { + this.execLogger.error('Specified trigger block not found in workflow', { triggerBlockId, }) throw new Error(`Trigger block not found: ${triggerBlockId}`) @@ -431,7 +441,7 @@ export class DAGExecutor { }) if (!startResolution?.block) { - logger.warn('No start block found in workflow') + this.execLogger.warn('No start block found in workflow') return } } diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index b21a8c9eaad..6e93026f33d 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -153,8 +153,9 @@ export class ExecutionLogger implements IExecutionLoggerService { workflowState, deploymentVersionId, } = params + const execLog = logger.withMetadata({ workflowId, workspaceId, executionId }) - logger.debug(`Starting workflow execution ${executionId} for workflow ${workflowId}`) + execLog.debug('Starting workflow execution') // Check if execution log already exists (idempotency check) const existingLog = await db @@ -164,9 +165,7 @@ export class ExecutionLogger implements IExecutionLoggerService { .limit(1) if (existingLog.length > 0) { - logger.debug( - `Execution log already exists for ${executionId}, skipping duplicate INSERT (idempotent)` - ) + execLog.debug('Execution log already exists, skipping duplicate INSERT (idempotent)') const snapshot = await snapshotService.getSnapshot(existingLog[0].stateSnapshotId) if (!snapshot) { throw new Error(`Snapshot ${existingLog[0].stateSnapshotId} not found for existing log`) @@ -228,7 +227,7 @@ export class ExecutionLogger implements IExecutionLoggerService { }) .returning() - logger.debug(`Created workflow log ${workflowLog.id} for execution ${executionId}`) + execLog.debug('Created workflow log', { logId: workflowLog.id }) return { workflowLog: { @@ -298,7 +297,8 @@ export class ExecutionLogger implements IExecutionLoggerService { status: statusOverride, } = params - logger.debug(`Completing workflow execution ${executionId}`, { isResume }) + const execLog = logger.withMetadata({ executionId }) + execLog.debug('Completing workflow execution', { isResume }) const [existingLog] = await db .select() @@ -507,10 +507,10 @@ export class ExecutionLogger implements IExecutionLoggerService { billingUserId ) } catch {} - logger.warn('Usage threshold notification check failed (non-fatal)', { error: e }) + execLog.warn('Usage threshold notification check failed (non-fatal)', { error: e }) } - logger.debug(`Completed workflow execution ${executionId}`) + execLog.debug('Completed workflow execution') const completedLog: WorkflowExecutionLog = { id: updatedLog.id, @@ -528,10 +528,7 @@ export class ExecutionLogger implements IExecutionLoggerService { } emitWorkflowExecutionCompleted(completedLog).catch((error) => { - logger.error('Failed to emit workflow execution completed event', { - error, - executionId, - }) + execLog.error('Failed to emit workflow execution completed event', { error }) }) return completedLog @@ -608,18 +605,20 @@ export class ExecutionLogger implements IExecutionLoggerService { executionId?: string, billingUserId?: string | null ): Promise { + const statsLog = logger.withMetadata({ workflowId: workflowId ?? undefined, executionId }) + if (!isBillingEnabled) { - logger.debug('Billing is disabled, skipping user stats cost update') + statsLog.debug('Billing is disabled, skipping user stats cost update') return } if (costSummary.totalCost <= 0) { - logger.debug('No cost to update in user stats') + statsLog.debug('No cost to update in user stats') return } if (!workflowId) { - logger.debug('Workflow was deleted, skipping user stats update') + statsLog.debug('Workflow was deleted, skipping user stats update') return } @@ -631,16 +630,14 @@ export class ExecutionLogger implements IExecutionLoggerService { .limit(1) if (!workflowRecord) { - logger.error(`Workflow ${workflowId} not found for user stats update`) + statsLog.error('Workflow not found for user stats update') return } const userId = billingUserId?.trim() || null if (!userId) { - logger.error('Missing billing actor in execution context; skipping stats update', { - workflowId, + statsLog.error('Missing billing actor in execution context; skipping stats update', { trigger, - executionId, }) return } @@ -702,8 +699,7 @@ export class ExecutionLogger implements IExecutionLoggerService { // Check if user has hit overage threshold and bill incrementally await checkAndBillOverageThreshold(userId) } catch (error) { - logger.error('Error updating user stats with cost information', { - workflowId, + statsLog.error('Error updating user stats with cost information', { error, costSummary, }) diff --git a/packages/logger/src/index.test.ts b/packages/logger/src/index.test.ts index 48652a34e95..db14f191603 100644 --- a/packages/logger/src/index.test.ts +++ b/packages/logger/src/index.test.ts @@ -154,4 +154,63 @@ describe('Logger', () => { }).not.toThrow() }) }) + + describe('withMetadata', () => { + const createEnabledLogger = () => + new Logger('Test', { enabled: true, colorize: false, logLevel: LogLevel.DEBUG }) + + test('should return a new Logger instance', () => { + const logger = createEnabledLogger() + const child = logger.withMetadata({ workflowId: 'wf_1' }) + expect(child).toBeInstanceOf(Logger) + expect(child).not.toBe(logger) + }) + + test('should include metadata in log output', () => { + const child = createEnabledLogger().withMetadata({ workflowId: 'wf_1' }) + child.info('hello') + expect(consoleLogSpy).toHaveBeenCalledTimes(1) + const prefix = consoleLogSpy.mock.calls[0][0] as string + expect(prefix).toContain('{workflowId=wf_1}') + }) + + test('should not affect original logger output', () => { + const logger = createEnabledLogger() + logger.withMetadata({ workflowId: 'wf_1' }) + logger.info('hello') + const prefix = consoleLogSpy.mock.calls[0][0] as string + expect(prefix).not.toContain('workflowId') + }) + + test('should merge metadata across chained calls', () => { + const child = createEnabledLogger().withMetadata({ a: '1' }).withMetadata({ b: '2' }) + child.info('hello') + const prefix = consoleLogSpy.mock.calls[0][0] as string + expect(prefix).toContain('{a=1 b=2}') + }) + + test('should override parent metadata for same key', () => { + const child = createEnabledLogger().withMetadata({ a: '1' }).withMetadata({ a: '2' }) + child.info('hello') + const prefix = consoleLogSpy.mock.calls[0][0] as string + expect(prefix).toContain('{a=2}') + expect(prefix).not.toContain('a=1') + }) + + test('should exclude undefined values from output', () => { + const child = createEnabledLogger().withMetadata({ a: '1', b: undefined }) + child.info('hello') + const prefix = consoleLogSpy.mock.calls[0][0] as string + expect(prefix).toContain('{a=1}') + expect(prefix).not.toContain('b=') + }) + + test('should produce no metadata segment when metadata is empty', () => { + const child = createEnabledLogger().withMetadata({}) + child.info('hello') + const prefix = consoleLogSpy.mock.calls[0][0] as string + expect(prefix).not.toContain('{') + expect(prefix).not.toContain('}') + }) + }) }) diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index ab848051222..d011eb02d07 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -33,6 +33,12 @@ export interface LoggerConfig { enabled?: boolean } +/** + * Metadata key-value pairs attached to a logger instance. + * Included automatically in every log line produced by that logger. + */ +export type LoggerMetadata = Record + const getNodeEnv = (): string => { if (typeof process !== 'undefined' && process.env) { return process.env.NODE_ENV || 'development' @@ -141,6 +147,7 @@ export class Logger { private module: string private config: ReturnType private isDev: boolean + private metadata: LoggerMetadata = {} /** * Create a new logger for a specific module @@ -172,6 +179,21 @@ export class Logger { } } + /** + * Creates a child logger with additional metadata merged in. + * The child inherits this logger's module name, config, and existing metadata. + * New metadata keys override existing ones with the same name. + */ + withMetadata(metadata: LoggerMetadata): Logger { + const child = new Logger(this.module, { + logLevel: this.config.minLevel, + colorize: this.config.colorize, + enabled: this.config.enabled, + }) + child.metadata = { ...this.metadata, ...metadata } + return child + } + /** * Determines if a log at the given level should be displayed */ @@ -209,6 +231,12 @@ export class Logger { const timestamp = new Date().toISOString() const formattedArgs = this.formatArgs(args) + const metadataEntries = Object.entries(this.metadata).filter(([_, v]) => v !== undefined) + const metadataStr = + metadataEntries.length > 0 + ? ` {${metadataEntries.map(([k, v]) => `${k}=${v}`).join(' ')}}` + : '' + if (this.config.colorize) { let levelColor: (text: string) => string const moduleColor = chalk.cyan @@ -229,7 +257,8 @@ export class Logger { break } - const coloredPrefix = `${timestampColor(`[${timestamp}]`)} ${levelColor(`[${level}]`)} ${moduleColor(`[${this.module}]`)}` + const coloredMeta = metadataStr ? ` ${chalk.magenta(metadataStr.trim())}` : '' + const coloredPrefix = `${timestampColor(`[${timestamp}]`)} ${levelColor(`[${level}]`)} ${moduleColor(`[${this.module}]`)}${coloredMeta}` if (level === LogLevel.ERROR) { console.error(coloredPrefix, message, ...formattedArgs) @@ -237,7 +266,7 @@ export class Logger { console.log(coloredPrefix, message, ...formattedArgs) } } else { - const prefix = `[${timestamp}] [${level}] [${this.module}]` + const prefix = `[${timestamp}] [${level}] [${this.module}]${metadataStr}` if (level === LogLevel.ERROR) { console.error(prefix, message, ...formattedArgs) From 584ac10fb88b2903679c95ae5a0d78be13f8b189 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 28 Mar 2026 14:56:55 -0700 Subject: [PATCH 2/9] Revert "Feat(logs) upgrade mothership chat messages to error (#3772)" This reverts commit 9d1b9763c56e5bd336d8aa37353aba549f7d34fd. --- apps/sim/app/api/copilot/chat/route.ts | 16 ++++++------- apps/sim/app/api/copilot/chat/stream/route.ts | 8 +++---- apps/sim/app/api/mothership/chat/route.ts | 2 +- apps/sim/app/api/v1/copilot/chat/route.ts | 2 +- apps/sim/lib/copilot/chat-payload.ts | 2 +- apps/sim/lib/copilot/chat-streaming.ts | 8 +++---- apps/sim/lib/copilot/orchestrator/index.ts | 24 +++++++++---------- .../orchestrator/sse/handlers/handlers.ts | 2 +- .../sse/handlers/tool-execution.ts | 21 +++++++--------- 9 files changed, 41 insertions(+), 44 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index e14b3d715eb..fe67059305c 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -194,7 +194,7 @@ export async function POST(req: NextRequest) { const userMessageIdToUse = userMessageId || crypto.randomUUID() try { - logger.error( + logger.info( appendCopilotLogContext('Received chat POST', { requestId: tracker.requestId, messageId: userMessageIdToUse, @@ -251,7 +251,7 @@ export async function POST(req: NextRequest) { actualChatId ) agentContexts = processed - logger.error( + logger.info( appendCopilotLogContext('Contexts processed for request', { requestId: tracker.requestId, messageId: userMessageIdToUse, @@ -358,7 +358,7 @@ export async function POST(req: NextRequest) { ) try { - logger.error( + logger.info( appendCopilotLogContext('About to call Sim Agent', { requestId: tracker.requestId, messageId: userMessageIdToUse, @@ -572,7 +572,7 @@ export async function POST(req: NextRequest) { provider: typeof requestPayload?.provider === 'string' ? requestPayload.provider : undefined, } - logger.error( + logger.info( appendCopilotLogContext('Non-streaming response from orchestrator', { requestId: tracker.requestId, messageId: userMessageIdToUse, @@ -617,7 +617,7 @@ export async function POST(req: NextRequest) { // Start title generation in parallel if this is first message (non-streaming) if (actualChatId && !currentChat.title && conversationHistory.length === 0) { - logger.error( + logger.info( appendCopilotLogContext('Starting title generation for non-streaming response', { requestId: tracker.requestId, messageId: userMessageIdToUse, @@ -633,7 +633,7 @@ export async function POST(req: NextRequest) { updatedAt: new Date(), }) .where(eq(copilotChats.id, actualChatId!)) - logger.error( + logger.info( appendCopilotLogContext(`Generated and saved title: ${title}`, { requestId: tracker.requestId, messageId: userMessageIdToUse, @@ -662,7 +662,7 @@ export async function POST(req: NextRequest) { .where(eq(copilotChats.id, actualChatId!)) } - logger.error( + logger.info( appendCopilotLogContext('Returning non-streaming response', { requestId: tracker.requestId, messageId: userMessageIdToUse, @@ -795,7 +795,7 @@ export async function GET(req: NextRequest) { ...(streamSnapshot ? { streamSnapshot } : {}), } - logger.error( + logger.info( appendCopilotLogContext(`Retrieved chat ${chatId}`, { messageId: chat.conversationId || undefined, }) diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index c442f72ed18..851fb642034 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -36,7 +36,7 @@ export async function GET(request: NextRequest) { const toParam = url.searchParams.get('to') const toEventId = toParam ? Number(toParam) : undefined - logger.error( + logger.info( appendCopilotLogContext('[Resume] Received resume request', { messageId: streamId || undefined, }), @@ -53,7 +53,7 @@ export async function GET(request: NextRequest) { } const meta = (await getStreamMeta(streamId)) as StreamMeta | null - logger.error(appendCopilotLogContext('[Resume] Stream lookup', { messageId: streamId }), { + logger.info(appendCopilotLogContext('[Resume] Stream lookup', { messageId: streamId }), { streamId, fromEventId, toEventId, @@ -72,7 +72,7 @@ export async function GET(request: NextRequest) { if (batchMode) { const events = await readStreamEvents(streamId, fromEventId) const filteredEvents = toEventId ? events.filter((e) => e.eventId <= toEventId) : events - logger.error(appendCopilotLogContext('[Resume] Batch response', { messageId: streamId }), { + logger.info(appendCopilotLogContext('[Resume] Batch response', { messageId: streamId }), { streamId, fromEventId, toEventId, @@ -124,7 +124,7 @@ export async function GET(request: NextRequest) { const flushEvents = async () => { const events = await readStreamEvents(streamId, lastEventId) if (events.length > 0) { - logger.error( + logger.info( appendCopilotLogContext('[Resume] Flushing events', { messageId: streamId }), { streamId, diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index 5e51f4aa4c9..c5fdddba6fb 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -113,7 +113,7 @@ export async function POST(req: NextRequest) { const userMessageId = providedMessageId || crypto.randomUUID() userMessageIdForLogs = userMessageId - logger.error( + logger.info( appendCopilotLogContext('Received mothership chat start request', { requestId: tracker.requestId, messageId: userMessageId, diff --git a/apps/sim/app/api/v1/copilot/chat/route.ts b/apps/sim/app/api/v1/copilot/chat/route.ts index dafb1baf0e4..7e0418ffc12 100644 --- a/apps/sim/app/api/v1/copilot/chat/route.ts +++ b/apps/sim/app/api/v1/copilot/chat/route.ts @@ -84,7 +84,7 @@ export async function POST(req: NextRequest) { const chatId = parsed.chatId || crypto.randomUUID() messageId = crypto.randomUUID() - logger.error( + logger.info( appendCopilotLogContext('Received headless copilot chat start request', { messageId }), { workflowId: resolved.workflowId, diff --git a/apps/sim/lib/copilot/chat-payload.ts b/apps/sim/lib/copilot/chat-payload.ts index 81731cf9dff..783bb1e1eee 100644 --- a/apps/sim/lib/copilot/chat-payload.ts +++ b/apps/sim/lib/copilot/chat-payload.ts @@ -201,7 +201,7 @@ export async function buildCopilotRequestPayload( }) } if (mcpTools.length > 0) { - logger.error( + logger.info( appendCopilotLogContext('Added MCP tools to copilot payload', { messageId: userMessageId, }), diff --git a/apps/sim/lib/copilot/chat-streaming.ts b/apps/sim/lib/copilot/chat-streaming.ts index 76ae305a9f8..bbb17f6c49d 100644 --- a/apps/sim/lib/copilot/chat-streaming.ts +++ b/apps/sim/lib/copilot/chat-streaming.ts @@ -467,7 +467,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS }) if (abortController.signal.aborted) { - logger.error( + logger.info( appendCopilotLogContext('Stream aborted by explicit stop', { requestId, messageId }) ) await eventWriter.close().catch(() => {}) @@ -483,7 +483,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS 'An unexpected error occurred while processing the response.' if (clientDisconnected) { - logger.error( + logger.info( appendCopilotLogContext('Stream failed after client disconnect', { requestId, messageId, @@ -539,7 +539,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS } } catch (error) { if (abortController.signal.aborted) { - logger.error( + logger.info( appendCopilotLogContext('Stream aborted by explicit stop', { requestId, messageId }) ) await eventWriter.close().catch(() => {}) @@ -548,7 +548,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS return } if (clientDisconnected) { - logger.error( + logger.info( appendCopilotLogContext('Stream errored after client disconnect', { requestId, messageId, diff --git a/apps/sim/lib/copilot/orchestrator/index.ts b/apps/sim/lib/copilot/orchestrator/index.ts index d07553ca645..9672330e866 100644 --- a/apps/sim/lib/copilot/orchestrator/index.ts +++ b/apps/sim/lib/copilot/orchestrator/index.ts @@ -136,7 +136,7 @@ export async function orchestrateCopilotStream( let claimedToolCallIds: string[] = [] let claimedByWorkerId: string | null = null - logger.error(withLogContext('Starting copilot orchestration'), { + logger.info(withLogContext('Starting copilot orchestration'), { goRoute, workflowId, workspaceId, @@ -155,7 +155,7 @@ export async function orchestrateCopilotStream( for (;;) { context.streamComplete = false - logger.error(withLogContext('Starting orchestration loop iteration'), { + logger.info(withLogContext('Starting orchestration loop iteration'), { route, hasPendingAsyncContinuation: Boolean(context.awaitingAsyncContinuation), claimedToolCallCount: claimedToolCallIds.length, @@ -168,7 +168,7 @@ export async function orchestrateCopilotStream( const d = (event.data ?? {}) as Record const response = (d.response ?? {}) as Record if (response.async_pause) { - logger.error(withLogContext('Detected async pause from copilot backend'), { + logger.info(withLogContext('Detected async pause from copilot backend'), { route, checkpointId: typeof (response.async_pause as Record)?.checkpointId === @@ -201,7 +201,7 @@ export async function orchestrateCopilotStream( loopOptions ) - logger.error(withLogContext('Completed orchestration loop iteration'), { + logger.info(withLogContext('Completed orchestration loop iteration'), { route, streamComplete: context.streamComplete, wasAborted: context.wasAborted, @@ -210,7 +210,7 @@ export async function orchestrateCopilotStream( }) if (claimedToolCallIds.length > 0) { - logger.error(withLogContext('Marking async tool calls as delivered'), { + logger.info(withLogContext('Marking async tool calls as delivered'), { toolCallIds: claimedToolCallIds, }) await Promise.all( @@ -223,7 +223,7 @@ export async function orchestrateCopilotStream( } if (options.abortSignal?.aborted || context.wasAborted) { - logger.error(withLogContext('Stopping orchestration because request was aborted'), { + logger.info(withLogContext('Stopping orchestration because request was aborted'), { pendingToolCallCount: Array.from(context.toolCalls.values()).filter( (toolCall) => toolCall.status === 'pending' || toolCall.status === 'executing' ).length, @@ -241,13 +241,13 @@ export async function orchestrateCopilotStream( const continuation = context.awaitingAsyncContinuation if (!continuation) { - logger.error(withLogContext('No async continuation pending; finishing orchestration')) + logger.info(withLogContext('No async continuation pending; finishing orchestration')) break } let resumeReady = false let resumeRetries = 0 - logger.error(withLogContext('Processing async continuation'), { + logger.info(withLogContext('Processing async continuation'), { checkpointId: continuation.checkpointId, runId: continuation.runId, pendingToolCallIds: continuation.pendingToolCallIds, @@ -443,7 +443,7 @@ export async function orchestrateCopilotStream( } if (resumeRetries < 3) { resumeRetries++ - logger.error(withLogContext('Retrying async resume after claim contention'), { + logger.info(withLogContext('Retrying async resume after claim contention'), { checkpointId: continuation.checkpointId, runId: continuation.runId, workerId: resumeWorkerId, @@ -474,7 +474,7 @@ export async function orchestrateCopilotStream( ] claimedByWorkerId = claimedToolCallIds.length > 0 ? resumeWorkerId : null - logger.error(withLogContext('Resuming async tool continuation'), { + logger.info(withLogContext('Resuming async tool continuation'), { checkpointId: continuation.checkpointId, runId: continuation.runId, workerId: resumeWorkerId, @@ -540,7 +540,7 @@ export async function orchestrateCopilotStream( checkpointId: continuation.checkpointId, results, } - logger.error(withLogContext('Prepared async continuation payload for resume endpoint'), { + logger.info(withLogContext('Prepared async continuation payload for resume endpoint'), { route, checkpointId: continuation.checkpointId, resultCount: results.length, @@ -569,7 +569,7 @@ export async function orchestrateCopilotStream( usage: context.usage, cost: context.cost, } - logger.error(withLogContext('Completing copilot orchestration'), { + logger.info(withLogContext('Completing copilot orchestration'), { success: result.success, chatId: result.chatId, hasRequestId: Boolean(result.requestId), diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts index 3732fed983e..dbb6bebe6cc 100644 --- a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts +++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts @@ -328,7 +328,7 @@ export const sseHandlers: Record = { const rid = typeof event.data === 'string' ? event.data : undefined if (rid) { context.requestId = rid - logger.error( + logger.info( appendCopilotLogContext('Mapped copilot message to Go trace ID', { messageId: context.messageId, }), diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts index 445075ef992..93742c961b3 100644 --- a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts +++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts @@ -187,7 +187,7 @@ async function maybeWriteOutputToFile( contentType ) - logger.error( + logger.info( appendCopilotLogContext('Tool output written to file', { messageId: context.messageId }), { toolName, @@ -401,7 +401,7 @@ async function maybeWriteOutputToTable( } }) - logger.error( + logger.info( appendCopilotLogContext('Tool output written to table', { messageId: context.messageId }), { toolName, @@ -528,7 +528,7 @@ async function maybeWriteReadCsvToTable( } }) - logger.error( + logger.info( appendCopilotLogContext('Read output written to table', { messageId: context.messageId }), { toolName, @@ -599,14 +599,11 @@ export async function executeToolAndReport( toolCall.status = 'executing' await markAsyncToolRunning(toolCall.id, 'sim-stream').catch(() => {}) - logger.error( - appendCopilotLogContext('Tool execution started', { messageId: context.messageId }), - { - toolCallId: toolCall.id, - toolName: toolCall.name, - params: toolCall.params, - } - ) + logger.info(appendCopilotLogContext('Tool execution started', { messageId: context.messageId }), { + toolCallId: toolCall.id, + toolName: toolCall.name, + params: toolCall.params, + }) try { let result = await executeToolServerSide(toolCall, execContext) @@ -693,7 +690,7 @@ export async function executeToolAndReport( : raw && typeof raw === 'object' ? JSON.stringify(raw).slice(0, 200) : undefined - logger.error( + logger.info( appendCopilotLogContext('Tool execution succeeded', { messageId: context.messageId }), { toolCallId: toolCall.id, From c64795b50db618caac18ea7211565c274ea0abd1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 28 Mar 2026 15:46:07 -0700 Subject: [PATCH 3/9] Fix lint, address greptile comments --- .../app/api/workflows/[id]/execute/route.ts | 10 ++- apps/sim/executor/execution/block-executor.ts | 67 +++++++------------ apps/sim/lib/auth/hybrid.ts | 2 +- apps/sim/lib/logs/execution/logger.ts | 8 ++- packages/logger/src/index.ts | 9 ++- 5 files changed, 44 insertions(+), 52 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index dd5520c48a2..86522989004 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -1,4 +1,4 @@ -import { createLogger, type Logger } from '@sim/logger' +import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { validate as uuidValidate, v4 as uuidv4 } from 'uuid' import { z } from 'zod' @@ -187,7 +187,13 @@ type AsyncExecutionParams = { async function handleAsyncExecution(params: AsyncExecutionParams): Promise { const { requestId, workflowId, userId, workspaceId, input, triggerType, executionId, callChain } = params - const asyncLogger = logger.withMetadata({ requestId, workflowId, workspaceId, userId, executionId }) + const asyncLogger = logger.withMetadata({ + requestId, + workflowId, + workspaceId, + userId, + executionId, + }) const correlation = { executionId, diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 6a155f6a07e..5044eab5639 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -49,20 +49,20 @@ import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' const logger = createLogger('BlockExecutor') export class BlockExecutor { + private execLogger: Logger + constructor( private blockHandlers: BlockHandler[], private resolver: VariableResolver, private contextExtensions: ContextExtensions, private state: BlockStateWriter - ) {} - - private loggerFor(ctx: ExecutionContext): Logger { - return logger.withMetadata({ - workflowId: ctx.workflowId, - workspaceId: ctx.workspaceId, - executionId: ctx.executionId, - userId: ctx.userId, - requestId: ctx.metadata.requestId, + ) { + this.execLogger = logger.withMetadata({ + workflowId: this.contextExtensions.metadata?.workflowId, + workspaceId: this.contextExtensions.workspaceId, + executionId: this.contextExtensions.executionId, + userId: this.contextExtensions.userId, + requestId: this.contextExtensions.metadata?.requestId, }) } @@ -71,7 +71,6 @@ export class BlockExecutor { node: DAGNode, block: SerializedBlock ): Promise { - const execLogger = this.loggerFor(ctx) const handler = this.findHandler(block) if (!handler) { throw buildBlockExecutionError({ @@ -86,9 +85,9 @@ export class BlockExecutor { let blockLog: BlockLog | undefined if (!isSentinel) { - blockLog = this.createBlockLog(execLogger, ctx, node.id, block, node) + blockLog = this.createBlockLog(ctx, node.id, block, node) ctx.blockLogs.push(blockLog) - await this.callOnBlockStart(execLogger, ctx, node, block, blockLog.executionOrder) + await this.callOnBlockStart(ctx, node, block, blockLog.executionOrder) } const startTime = performance.now() @@ -117,7 +116,6 @@ export class BlockExecutor { } catch (error) { cleanupSelfReference?.() return await this.handleBlockError( - execLogger, error, ctx, node, @@ -145,7 +143,6 @@ export class BlockExecutor { if (ctx.onStream) { await this.handleStreamingExecution( - execLogger, ctx, node, block, @@ -193,7 +190,6 @@ export class BlockExecutor { block, }) await this.callOnBlockComplete( - execLogger, ctx, node, block, @@ -210,7 +206,6 @@ export class BlockExecutor { return normalizedOutput } catch (error) { return await this.handleBlockError( - execLogger, error, ctx, node, @@ -242,7 +237,6 @@ export class BlockExecutor { } private async handleBlockError( - execLogger: Logger, error: unknown, ctx: ExecutionContext, node: DAGNode, @@ -289,7 +283,7 @@ export class BlockExecutor { } } - execLogger.error( + this.execLogger.error( phase === 'input_resolution' ? 'Failed to resolve block inputs' : 'Block execution failed', { blockId: node.id, @@ -304,7 +298,6 @@ export class BlockExecutor { : undefined const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block }) await this.callOnBlockComplete( - execLogger, ctx, node, block, @@ -323,7 +316,7 @@ export class BlockExecutor { if (blockLog) { blockLog.errorHandled = true } - execLogger.info('Block has error port - returning error output instead of throwing', { + this.execLogger.info('Block has error port - returning error output instead of throwing', { blockId: node.id, error: errorMessage, }) @@ -353,7 +346,6 @@ export class BlockExecutor { } private createBlockLog( - execLogger: Logger, ctx: ExecutionContext, blockId: string, block: SerializedBlock, @@ -376,7 +368,7 @@ export class BlockExecutor { blockName = `${blockName} (iteration ${loopScope.iteration})` iterationIndex = loopScope.iteration } else { - execLogger.warn('Loop scope not found for block', { blockId, loopId }) + this.execLogger.warn('Loop scope not found for block', { blockId, loopId }) } } } @@ -458,7 +450,6 @@ export class BlockExecutor { } private async callOnBlockStart( - execLogger: Logger, ctx: ExecutionContext, node: DAGNode, block: SerializedBlock, @@ -481,7 +472,7 @@ export class BlockExecutor { ctx.childWorkflowContext ) } catch (error) { - execLogger.warn('Block start callback failed', { + this.execLogger.warn('Block start callback failed', { blockId, blockType, error: error instanceof Error ? error.message : String(error), @@ -491,7 +482,6 @@ export class BlockExecutor { } private async callOnBlockComplete( - execLogger: Logger, ctx: ExecutionContext, node: DAGNode, block: SerializedBlock, @@ -528,7 +518,7 @@ export class BlockExecutor { ctx.childWorkflowContext ) } catch (error) { - execLogger.warn('Block completion callback failed', { + this.execLogger.warn('Block completion callback failed', { blockId, blockType, error: error instanceof Error ? error.message : String(error), @@ -608,7 +598,6 @@ export class BlockExecutor { } private async handleStreamingExecution( - execLogger: Logger, ctx: ExecutionContext, node: DAGNode, block: SerializedBlock, @@ -625,15 +614,7 @@ export class BlockExecutor { const stream = streamingExec.stream if (typeof stream.tee !== 'function') { - await this.forwardStream( - execLogger, - ctx, - blockId, - streamingExec, - stream, - responseFormat, - selectedOutputs - ) + await this.forwardStream(ctx, blockId, streamingExec, stream, responseFormat, selectedOutputs) return } @@ -652,7 +633,6 @@ export class BlockExecutor { } const executorConsumption = this.consumeExecutorStream( - execLogger, executorStream, streamingExec, blockId, @@ -663,7 +643,7 @@ export class BlockExecutor { try { await ctx.onStream?.(clientStreamingExec) } catch (error) { - execLogger.error('Error in onStream callback', { blockId, error }) + this.execLogger.error('Error in onStream callback', { blockId, error }) // Cancel the client stream to release the tee'd buffer await processedClientStream.cancel().catch(() => {}) } @@ -673,7 +653,6 @@ export class BlockExecutor { } private async forwardStream( - execLogger: Logger, ctx: ExecutionContext, blockId: string, streamingExec: { stream: ReadableStream; execution: any }, @@ -694,13 +673,12 @@ export class BlockExecutor { stream: processedStream, }) } catch (error) { - execLogger.error('Error in onStream callback', { blockId, error }) + this.execLogger.error('Error in onStream callback', { blockId, error }) await processedStream.cancel().catch(() => {}) } } private async consumeExecutorStream( - execLogger: Logger, stream: ReadableStream, streamingExec: { execution: any }, blockId: string, @@ -719,7 +697,7 @@ export class BlockExecutor { const tail = decoder.decode() if (tail) chunks.push(tail) } catch (error) { - execLogger.error('Error reading executor stream for block', { blockId, error }) + this.execLogger.error('Error reading executor stream for block', { blockId, error }) } finally { try { await reader.cancel().catch(() => {}) @@ -750,7 +728,10 @@ export class BlockExecutor { } return } catch (error) { - execLogger.warn('Failed to parse streamed content for response format', { blockId, error }) + this.execLogger.warn('Failed to parse streamed content for response format', { + blockId, + error, + }) } } diff --git a/apps/sim/lib/auth/hybrid.ts b/apps/sim/lib/auth/hybrid.ts index 3f2311e7927..c793fe5d900 100644 --- a/apps/sim/lib/auth/hybrid.ts +++ b/apps/sim/lib/auth/hybrid.ts @@ -25,7 +25,7 @@ const BEARER_PREFIX = 'Bearer ' export function hasExternalApiCredentials(headers: Headers): boolean { if (headers.has(API_KEY_HEADER)) return true const auth = headers.get('authorization') - return auth !== null && auth.startsWith(BEARER_PREFIX) + return auth?.startsWith(BEARER_PREFIX) } export interface AuthResult { diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 6e93026f33d..83f97e6cd58 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -297,7 +297,7 @@ export class ExecutionLogger implements IExecutionLoggerService { status: statusOverride, } = params - const execLog = logger.withMetadata({ executionId }) + let execLog = logger.withMetadata({ executionId }) execLog.debug('Completing workflow execution', { isResume }) const [existingLog] = await db @@ -305,6 +305,12 @@ export class ExecutionLogger implements IExecutionLoggerService { .from(workflowExecutionLogs) .where(eq(workflowExecutionLogs.executionId, executionId)) .limit(1) + if (existingLog) { + execLog = execLog.withMetadata({ + workflowId: existingLog.workflowId ?? undefined, + workspaceId: existingLog.workspaceId ?? undefined, + }) + } const billingUserId = this.extractBillingUserId(existingLog?.executionData) const existingExecutionData = existingLog?.executionData as | WorkflowExecutionLog['executionData'] diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index d011eb02d07..eb28fec3fcf 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -185,11 +185,10 @@ export class Logger { * New metadata keys override existing ones with the same name. */ withMetadata(metadata: LoggerMetadata): Logger { - const child = new Logger(this.module, { - logLevel: this.config.minLevel, - colorize: this.config.colorize, - enabled: this.config.enabled, - }) + const child = Object.create(Logger.prototype) as Logger + child.module = this.module + child.config = this.config + child.isDev = this.isDev child.metadata = { ...this.metadata, ...metadata } return child } From dc6d0e94d8b41e4d1ba5a51375c4493b2f3db3bd Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 28 Mar 2026 14:06:30 -0700 Subject: [PATCH 4/9] improvement(sidebar): expand sidebar by hovering and clicking the edge (#3830) * improvement(sidebar): expand sidebar by hovering and clicking the edge * improvement(sidebar): add keyboard shortcuts for new workflow/task, center search modal, fix edge ARIA * improvement(sidebar): use Tooltip.Shortcut for inline shortcut display * fix(sidebar): change new workflow shortcut from Mod+Shift+W to Mod+Shift+P to avoid browser close-window conflict * fix(hotkeys): fall back to event.code for international keyboard layout compatibility * fix(sidebar): guard add-workflow shortcut with canEdit and isCreatingWorkflow checks --- .../providers/global-commands-provider.tsx | 14 +- .../[workspaceId]/utils/commands-utils.ts | 12 + .../components/search-modal/search-modal.tsx | 2 +- .../w/components/sidebar/sidebar.tsx | 1021 +++++++++-------- 4 files changed, 558 insertions(+), 491 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx index f7bbcd00946..2d8747efb92 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx @@ -57,14 +57,26 @@ function parseShortcut(shortcut: string): ParsedShortcut { } } +/** + * Maps a KeyboardEvent.code value to the logical key name used in shortcut definitions. + * Needed for international keyboard layouts where e.key may produce unexpected characters + * (e.g. macOS Option+letter yields 'å' instead of 'a', dead keys yield 'Dead'). + */ +function codeToKey(code: string): string | undefined { + if (code.startsWith('Key')) return code.slice(3).toLowerCase() + if (code.startsWith('Digit')) return code.slice(5) + return undefined +} + function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean { const isMac = isMacPlatform() const expectedCtrl = parsed.ctrl || (parsed.mod ? !isMac : false) const expectedMeta = parsed.meta || (parsed.mod ? isMac : false) const eventKey = e.key.length === 1 ? e.key.toLowerCase() : e.key + const keyMatches = eventKey === parsed.key || codeToKey(e.code) === parsed.key return ( - eventKey === parsed.key && + keyMatches && !!e.ctrlKey === !!expectedCtrl && !!e.metaKey === !!expectedMeta && !!e.shiftKey === !!parsed.shift && diff --git a/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts b/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts index fd9e9678287..8bdbeac2577 100644 --- a/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts @@ -9,6 +9,8 @@ import type { GlobalCommand } from '@/app/workspace/[workspaceId]/providers/glob export type CommandId = | 'accept-diff-changes' | 'add-agent' + | 'add-workflow' + | 'add-task' // | 'goto-templates' | 'goto-logs' | 'open-search' @@ -52,6 +54,16 @@ export const COMMAND_DEFINITIONS: Record = { shortcut: 'Mod+Shift+A', allowInEditable: true, }, + 'add-workflow': { + id: 'add-workflow', + shortcut: 'Mod+Shift+P', + allowInEditable: false, + }, + 'add-task': { + id: 'add-task', + shortcut: 'Mod+Shift+K', + allowInEditable: false, + }, // 'goto-templates': { // id: 'goto-templates', // shortcut: 'Mod+Y', diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 592d3562d6a..23c34bfdea4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -343,7 +343,7 @@ export function SearchModal({ '-translate-x-1/2 fixed top-[15%] z-50 w-[500px] rounded-xl border-[4px] border-black/[0.06] bg-[var(--bg)] shadow-[0_24px_80px_-16px_rgba(0,0,0,0.15)] dark:border-white/[0.06] dark:shadow-[0_24px_80px_-16px_rgba(0,0,0,0.4)]', open ? 'visible opacity-100' : 'invisible opacity-0' )} - style={{ left: '50%' }} + style={{ left: 'calc(var(--sidebar-width) / 2 + 50%)' }} >
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index a48982e5631..deac68e7c99 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -37,6 +37,7 @@ import { } from '@/components/emcn/icons' import { useSession } from '@/lib/auth/auth-client' import { cn } from '@/lib/core/utils/cn' +import { isMacPlatform } from '@/lib/core/utils/platform' import { START_NAV_TOUR_EVENT, START_WORKFLOW_TOUR_EVENT, @@ -322,6 +323,8 @@ export const Sidebar = memo(function Sidebar() { isCollapsedRef.current = isCollapsed }, [isCollapsed]) + const isMac = useMemo(() => isMacPlatform(), []) + // Delay collapsed tooltips until the width transition finishes. const [showCollapsedTooltips, setShowCollapsedTooltips] = useState(isCollapsed) @@ -1065,6 +1068,16 @@ export const Sidebar = memo(function Sidebar() { // Stable callback for DeleteModal close const handleCloseTaskDeleteModal = useCallback(() => setIsTaskDeleteModalOpen(false), []) + const handleEdgeKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (isCollapsed && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + toggleCollapsed() + } + }, + [isCollapsed, toggleCollapsed] + ) + // Stable handler for help modal open from dropdown const handleOpenHelpFromMenu = useCallback(() => setIsHelpModalOpen(true), []) @@ -1153,548 +1166,578 @@ export const Sidebar = memo(function Sidebar() { openSearchModal() }, }, + { + id: 'add-workflow', + handler: () => { + if (!canEdit || isCreatingWorkflow) return + handleCreateWorkflow() + }, + }, + { + id: 'add-task', + handler: () => { + handleNewTask() + }, + }, ]) ) return ( <> - +
{/* Universal Search Modal */} Date: Sat, 28 Mar 2026 14:15:33 -0700 Subject: [PATCH 5/9] feat(ui): handle image paste (#3826) * feat(ui): handle image paste * Fix lint * Fix type error --------- Co-authored-by: Theodore Li --- .../home/components/user-input/user-input.tsx | 23 +++++++++++++++++++ .../user-input/hooks/use-file-attachments.ts | 1 + apps/sim/lib/auth/hybrid.ts | 2 +- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 809c157945b..3fb58aceb18 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -522,6 +522,28 @@ export function UserInput({ [isInitialView] ) + const handlePaste = useCallback((e: React.ClipboardEvent) => { + const items = e.clipboardData?.items + if (!items) return + + const imageFiles: File[] = [] + for (const item of Array.from(items)) { + if (item.kind === 'file' && item.type.startsWith('image/')) { + const file = item.getAsFile() + if (file) imageFiles.push(file) + } + } + + if (imageFiles.length === 0) return + + e.preventDefault() + const dt = new DataTransfer() + for (const file of imageFiles) { + dt.items.add(file) + } + filesRef.current.processFiles(dt.files) + }, []) + const handleScroll = useCallback((e: React.UIEvent) => { if (overlayRef.current) { overlayRef.current.scrollTop = e.currentTarget.scrollTop @@ -661,6 +683,7 @@ export function UserInput({ onChange={handleInputChange} onKeyDown={handleKeyDown} onInput={handleInput} + onPaste={handlePaste} onCut={mentionTokensWithContext.handleCut} onSelect={handleSelectAdjust} onMouseUp={handleSelectAdjust} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts index 24d21ea5173..499b62d1a51 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts @@ -320,5 +320,6 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { handleDragOver, handleDrop, clearAttachedFiles, + processFiles, } } diff --git a/apps/sim/lib/auth/hybrid.ts b/apps/sim/lib/auth/hybrid.ts index c793fe5d900..67fc19a36af 100644 --- a/apps/sim/lib/auth/hybrid.ts +++ b/apps/sim/lib/auth/hybrid.ts @@ -25,7 +25,7 @@ const BEARER_PREFIX = 'Bearer ' export function hasExternalApiCredentials(headers: Headers): boolean { if (headers.has(API_KEY_HEADER)) return true const auth = headers.get('authorization') - return auth?.startsWith(BEARER_PREFIX) + return auth?.startsWith(BEARER_PREFIX) ?? false } export interface AuthResult { From f181d1f049d9c0a3a67a3abf5d9c6b086208b126 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 28 Mar 2026 14:26:29 -0700 Subject: [PATCH 6/9] feat(files): interactive markdown checkbox toggling in preview (#3829) * feat(files): interactive markdown checkbox toggling in preview * fix(files): handle ordered-list checkboxes and fix index drift * lint * fix(files): remove counter offset that prevented checkbox toggling * fix(files): apply task-list styling to ordered lists too * fix(files): render single pass when interactive to avoid index drift * fix(files): move useMemo above conditional return to fix Rules of Hooks * fix(files): pass content directly to preview when not streaming to avoid stale frame --- .../components/file-viewer/file-viewer.tsx | 21 +++- .../components/file-viewer/preview-panel.tsx | 116 +++++++++++++++--- 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 10be5a87703..45f4bc223ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -290,6 +290,16 @@ function TextEditor({ } }, [isResizing]) + const handleCheckboxToggle = useCallback( + (checkboxIndex: number, checked: boolean) => { + const toggled = toggleMarkdownCheckbox(contentRef.current, checkboxIndex, checked) + if (toggled !== contentRef.current) { + handleContentChange(toggled) + } + }, + [handleContentChange] + ) + const isStreaming = streamingContent !== undefined const revealedContent = useStreamingText(content, isStreaming) @@ -392,10 +402,11 @@ function TextEditor({ className={cn('min-w-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')} > @@ -703,6 +714,14 @@ function PptxPreview({ ) } +function toggleMarkdownCheckbox(markdown: string, targetIndex: number, checked: boolean): string { + let currentIndex = 0 + return markdown.replace(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm, (match, prefix: string) => { + if (currentIndex++ !== targetIndex) return match + return `${prefix}[${checked ? 'x' : ' '}]` + }) +} + const UnsupportedPreview = memo(function UnsupportedPreview({ file, }: { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index b3c9666d768..06678e6acd3 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -1,9 +1,10 @@ 'use client' -import { memo, useMemo } from 'react' +import { memo, useMemo, useRef } from 'react' import ReactMarkdown from 'react-markdown' import remarkBreaks from 'remark-breaks' import remarkGfm from 'remark-gfm' +import { Checkbox } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { getFileExtension } from '@/lib/uploads/utils/file-utils' import { useAutoScroll } from '@/hooks/use-auto-scroll' @@ -40,6 +41,7 @@ interface PreviewPanelProps { mimeType: string | null filename: string isStreaming?: boolean + onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void } export const PreviewPanel = memo(function PreviewPanel({ @@ -47,11 +49,18 @@ export const PreviewPanel = memo(function PreviewPanel({ mimeType, filename, isStreaming, + onCheckboxToggle, }: PreviewPanelProps) { const previewType = resolvePreviewType(mimeType, filename) if (previewType === 'markdown') - return + return ( + + ) if (previewType === 'html') return if (previewType === 'csv') return if (previewType === 'svg') return @@ -61,7 +70,7 @@ export const PreviewPanel = memo(function PreviewPanel({ const REMARK_PLUGINS = [remarkGfm, remarkBreaks] -const PREVIEW_MARKDOWN_COMPONENTS = { +const STATIC_MARKDOWN_COMPONENTS = { p: ({ children }: any) => (

{children} @@ -87,17 +96,6 @@ const PREVIEW_MARKDOWN_COMPONENTS = { {children} ), - ul: ({ children }: any) => ( -

    - {children} -
- ), - ol: ({ children }: any) => ( -
    - {children} -
- ), - li: ({ children }: any) =>
  • {children}
  • , code: ({ inline, className, children, ...props }: any) => { const isInline = inline || !className?.includes('language-') @@ -165,26 +163,110 @@ const PREVIEW_MARKDOWN_COMPONENTS = { td: ({ children }: any) => {children}, } +function buildMarkdownComponents( + checkboxCounterRef: React.MutableRefObject, + onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void +) { + const isInteractive = Boolean(onCheckboxToggle) + + return { + ...STATIC_MARKDOWN_COMPONENTS, + ul: ({ className, children }: any) => { + const isTaskList = typeof className === 'string' && className.includes('contains-task-list') + return ( +
      + {children} +
    + ) + }, + ol: ({ className, children }: any) => { + const isTaskList = typeof className === 'string' && className.includes('contains-task-list') + return ( +
      + {children} +
    + ) + }, + li: ({ className, children }: any) => { + const isTaskItem = typeof className === 'string' && className.includes('task-list-item') + if (isTaskItem) { + return
  • {children}
  • + } + return
  • {children}
  • + }, + input: ({ type, checked, ...props }: any) => { + if (type !== 'checkbox') return + + const index = checkboxCounterRef.current++ + + return ( + onCheckboxToggle!(index, Boolean(newChecked)) + : undefined + } + disabled={!isInteractive} + size='sm' + className='mt-1 shrink-0' + /> + ) + }, + } +} + const MarkdownPreview = memo(function MarkdownPreview({ content, isStreaming = false, + onCheckboxToggle, }: { content: string isStreaming?: boolean + onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void }) { const { ref: scrollRef } = useAutoScroll(isStreaming) const { committed, incoming, generation } = useStreamingReveal(content, isStreaming) + const checkboxCounterRef = useRef(0) + + const components = useMemo( + () => buildMarkdownComponents(checkboxCounterRef, onCheckboxToggle), + [onCheckboxToggle] + ) + + checkboxCounterRef.current = 0 + const committedMarkdown = useMemo( () => committed ? ( - + {committed} ) : null, - [committed] + [committed, components] ) + if (onCheckboxToggle) { + return ( +
    + + {content} + +
    + ) + } + return (
    {committedMarkdown} @@ -193,7 +275,7 @@ const MarkdownPreview = memo(function MarkdownPreview({ key={generation} className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')} > - + {incoming}
    From de8f53ee827f218551a953d811185190ad75ac3b Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 28 Mar 2026 14:48:41 -0700 Subject: [PATCH 7/9] improvement(home): position @ mention popup at caret and fix icon consistency (#3831) * improvement(home): position @ mention popup at caret and fix icon consistency * fix(home): pin mirror div to document origin and guard button anchor * chore(auth): restore hybrid.ts to staging --- .../resource-registry/resource-registry.tsx | 17 +- .../user-input/components/constants.ts | 2 +- .../components/plus-menu-dropdown.tsx | 313 +++++++++--------- .../home/components/user-input/user-input.tsx | 47 ++- 4 files changed, 224 insertions(+), 155 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index bf5c3029d3d..4b545dc298b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -52,7 +52,7 @@ function WorkflowDropdownItem({ item }: DropdownItemRenderProps) { return ( <>
    - + + {item.name} + + ) +} + +function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon: ElementType }) { + return ( + <> + {item.name} ) @@ -104,7 +113,7 @@ export const RESOURCE_REGISTRY: Record ( ), - renderDropdownItem: (props) => , + renderDropdownItem: (props) => , }, file: { type: 'file', @@ -123,7 +132,7 @@ export const RESOURCE_REGISTRY: Record ( ), - renderDropdownItem: (props) => , + renderDropdownItem: (props) => , }, } as const diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index dc6fa0aab58..93c3d79fa39 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -34,7 +34,7 @@ export type WindowWithSpeech = Window & { } export interface PlusMenuHandle { - open: () => void + open: (anchor?: { left: number; top: number }) => void } export const TEXTAREA_BASE_CLASSES = cn( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx index 1b7606b7822..54a0142fc81 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useMemo, useRef, useState } from 'react' import { Paperclip } from 'lucide-react' import { DropdownMenu, @@ -13,7 +13,6 @@ import { DropdownMenuTrigger, } from '@/components/emcn' import { Plus, Sim } from '@/components/emcn/icons' -import { cn } from '@/lib/core/utils/cn' import type { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' @@ -37,24 +36,24 @@ export const PlusMenuDropdown = React.memo( ) { const [open, setOpen] = useState(false) const [search, setSearch] = useState('') - const [activeIndex, setActiveIndex] = useState(0) - const activeIndexRef = useRef(activeIndex) + const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null) + const buttonRef = useRef(null) + const searchRef = useRef(null) + const contentRef = useRef(null) - useEffect(() => { - activeIndexRef.current = activeIndex - }, [activeIndex]) + const doOpen = useCallback((anchor?: { left: number; top: number }) => { + if (anchor) { + setAnchorPos(anchor) + } else { + const rect = buttonRef.current?.getBoundingClientRect() + if (!rect) return + setAnchorPos({ left: rect.left, top: rect.top }) + } + setOpen(true) + setSearch('') + }, []) - React.useImperativeHandle( - ref, - () => ({ - open: () => { - setOpen(true) - setSearch('') - setActiveIndex(0) - }, - }), - [] - ) + React.useImperativeHandle(ref, () => ({ open: doOpen }), [doOpen]) const filteredItems = useMemo(() => { const q = search.toLowerCase().trim() @@ -69,7 +68,6 @@ export const PlusMenuDropdown = React.memo( onResourceSelect(resource) setOpen(false) setSearch('') - setActiveIndex(0) }, [onResourceSelect] ) @@ -79,32 +77,37 @@ export const PlusMenuDropdown = React.memo( const handleSearchKeyDown = useCallback( (e: React.KeyboardEvent) => { - const items = filteredItemsRef.current - if (!items) return if (e.key === 'ArrowDown') { e.preventDefault() - setActiveIndex((prev) => Math.min(prev + 1, items.length - 1)) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - setActiveIndex((prev) => Math.max(prev - 1, 0)) + const firstItem = contentRef.current?.querySelector('[role="menuitem"]') + firstItem?.focus() } else if (e.key === 'Enter') { e.preventDefault() - const idx = activeIndexRef.current - if (items.length > 0 && items[idx]) { - const { type, item } = items[idx] - handleSelect({ type, id: item.id, title: item.name }) - } + const first = filteredItemsRef.current?.[0] + if (first) handleSelect({ type: first.type, id: first.item.id, title: first.item.name }) } }, [handleSelect] ) + const handleContentKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp') { + const items = Array.from( + contentRef.current?.querySelectorAll('[role="menuitem"]') ?? [] + ) + if (items[0] && items[0] === document.activeElement) { + e.preventDefault() + searchRef.current?.focus() + } + } + }, []) + const handleOpenChange = useCallback( (isOpen: boolean) => { setOpen(isOpen) if (!isOpen) { setSearch('') - setActiveIndex(0) + setAnchorPos(null) onClose() } }, @@ -126,126 +129,138 @@ export const PlusMenuDropdown = React.memo( ) return ( - - - - - - { - setSearch(e.target.value) - setActiveIndex(0) - }} - onKeyDown={handleSearchKeyDown} - /> -
    - {filteredItems ? ( - filteredItems.length > 0 ? ( - filteredItems.map(({ type, item }, index) => { - const config = getResourceConfig(type) - return ( - setActiveIndex(index)} - onClick={() => { - handleSelect({ - type, - id: item.id, - title: item.name, - }) - }} - > - {config.renderDropdownItem({ item })} - - {config.label} - - - ) - }) + setSearch(e.target.value)} + onKeyDown={handleSearchKeyDown} + /> +
    + {filteredItems ? ( + filteredItems.length > 0 ? ( + filteredItems.map(({ type, item }, index) => { + const config = getResourceConfig(type) + return ( + { + handleSelect({ + type, + id: item.id, + title: item.name, + }) + }} + > + {config.renderDropdownItem({ item })} + + {config.label} + + + ) + }) + ) : ( +
    + No results +
    + ) ) : ( -
    - No results -
    - ) - ) : ( - <> - { - setOpen(false) - onFileSelect() - }} - > - - Attachments - - - - - Workspace - - - {availableResources.map(({ type, items }) => { - if (items.length === 0) return null - const config = getResourceConfig(type) - const Icon = config.icon - return ( - - - {type === 'workflow' ? ( -
    - ) : ( - - )} - {config.label} - - - {items.map((item) => ( - { - handleSelect({ - type, - id: item.id, - title: item.name, - }) - }} - > - {config.renderDropdownItem({ item })} - - ))} - - - ) - })} - - - - )} -
    - - + <> + { + setOpen(false) + onFileSelect() + }} + > + + Attachments + + + + + Workspace + + + {availableResources.map(({ type, items }) => { + if (items.length === 0) return null + const config = getResourceConfig(type) + const Icon = config.icon + return ( + + + {type === 'workflow' ? ( +
    + ) : ( + + )} + {config.label} + + + {items.map((item) => ( + { + handleSelect({ + type, + id: item.id, + title: item.name, + }) + }} + > + {config.renderDropdownItem({ item })} + + ))} + + + ) + })} + + + + )} +
    + + + + ) }) ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 3fb58aceb18..b830123d1a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -50,6 +50,50 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' +function getCaretAnchor( + textarea: HTMLTextAreaElement, + caretPos: number +): { left: number; top: number } { + const textareaRect = textarea.getBoundingClientRect() + const style = window.getComputedStyle(textarea) + + const mirror = document.createElement('div') + mirror.style.position = 'absolute' + mirror.style.top = '0' + mirror.style.left = '0' + mirror.style.visibility = 'hidden' + mirror.style.whiteSpace = 'pre-wrap' + mirror.style.overflowWrap = 'break-word' + mirror.style.font = style.font + mirror.style.padding = style.padding + mirror.style.border = style.border + mirror.style.width = style.width + mirror.style.lineHeight = style.lineHeight + mirror.style.boxSizing = style.boxSizing + mirror.style.letterSpacing = style.letterSpacing + mirror.style.textTransform = style.textTransform + mirror.style.textIndent = style.textIndent + mirror.style.textAlign = style.textAlign + mirror.textContent = textarea.value.substring(0, caretPos) + + const marker = document.createElement('span') + marker.style.display = 'inline-block' + marker.style.width = '0px' + marker.style.padding = '0' + marker.style.border = '0' + mirror.appendChild(marker) + + document.body.appendChild(mirror) + const markerRect = marker.getBoundingClientRect() + const mirrorRect = mirror.getBoundingClientRect() + document.body.removeChild(mirror) + + return { + left: textareaRect.left + (markerRect.left - mirrorRect.left) - textarea.scrollLeft, + top: textareaRect.top + (markerRect.top - mirrorRect.top) - textarea.scrollTop, + } +} + interface UserInputProps { defaultValue?: string editValue?: string @@ -486,7 +530,8 @@ export function UserInput({ const adjusted = `${before}${after}` setValue(adjusted) atInsertPosRef.current = caret - 1 - plusMenuRef.current?.open() + const anchor = getCaretAnchor(e.target, caret - 1) + plusMenuRef.current?.open(anchor) restartRecognition(adjusted) return } From eaaf644f1f07c802483c6aa514a74668bab59d79 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 28 Mar 2026 15:24:07 -0700 Subject: [PATCH 8/9] improvement(ui): sidebar (#3832) --- .../collapsed-sidebar-menu.tsx | 4 +- .../settings-sidebar/settings-sidebar.tsx | 2 +- .../workspace-header/workspace-header.tsx | 2 +- .../w/components/sidebar/sidebar.tsx | 66 +++++++++---------- 4 files changed, 35 insertions(+), 39 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx index 2aa727b68af..79aeab6f1ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx @@ -128,7 +128,7 @@ function TaskStatusIcon({ function WorkflowColorSwatch({ color }: { color: string }) { return (
    {icon} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 9792e3ed574..a21344fe3f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -210,7 +210,7 @@ export function SettingsSidebar({ {/* Settings sections */}
    diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index ce5b8c67b57..fe33dc9ab00 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -343,7 +343,7 @@ export function WorkspaceHeader({ type='button' aria-label='Switch workspace' className={cn( - 'group flex h-[32px] min-w-0 items-center rounded-lg border border-[var(--border)] bg-[var(--surface-2)] pl-1.5 transition-colors hover-hover:bg-[var(--surface-5)]', + 'group flex h-[32px] min-w-0 items-center rounded-lg border border-[var(--border)] bg-[var(--surface-2)] pl-[5px] transition-colors hover-hover:bg-[var(--surface-5)]', isCollapsed ? 'w-[32px]' : 'w-full cursor-pointer gap-2 pr-2' )} title={activeWorkspace?.name || 'Loading...'} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index deac68e7c99..df8f68cff4d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -102,8 +102,9 @@ const logger = createLogger('Sidebar') function SidebarItemSkeleton() { return ( -
    - +
    + +
    ) } @@ -1030,7 +1031,7 @@ export const Sidebar = memo(function Sidebar() { const workflowsCollapsedIcon = useMemo( () => (
    ), @@ -1056,6 +1057,14 @@ export const Sidebar = memo(function Sidebar() { // Stable no-op for collapsed workflow context menu delete (never changes) const noop = useCallback(() => {}, []) + const handleExpandSidebar = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + toggleCollapsed() + }, + [toggleCollapsed] + ) + // Stable callback for the "New task" button in expanded mode const handleNewTask = useCallback( () => navigateToPage(`/workspace/${workspaceId}/home`), @@ -1198,34 +1207,14 @@ export const Sidebar = memo(function Sidebar() {
    {/* Top bar: Logo + Collapse toggle */}
    -
    - - {brand.logoUrl ? ( - {brand.name} - ) : ( - - )} - - +
    - + - {isCollapsed && ( + {showCollapsedTooltips && (

    Expand sidebar

    @@ -1272,7 +1268,7 @@ export const Sidebar = memo(function Sidebar() {
    {/* Workspace Header */} -
    +
    Date: Sat, 28 Mar 2026 17:36:53 -0700 Subject: [PATCH 9/9] Fix logger tests --- .../app/api/workflows/[id]/execute/route.async.test.ts | 10 ++++++---- packages/testing/src/mocks/logger.mock.ts | 2 ++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index 355ae6ddf06..a800994c004 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -82,14 +82,16 @@ vi.mock('@/background/workflow-execution', () => ({ executeWorkflowJob: vi.fn(), })) -vi.mock('@sim/logger', () => ({ - createLogger: vi.fn().mockReturnValue({ +vi.mock('@sim/logger', () => { + const createMockLogger = (): Record => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), - }), -})) + withMetadata: vi.fn(() => createMockLogger()), + }) + return { createLogger: vi.fn(() => createMockLogger()) } +}) vi.mock('uuid', () => ({ validate: vi.fn().mockReturnValue(true), diff --git a/packages/testing/src/mocks/logger.mock.ts b/packages/testing/src/mocks/logger.mock.ts index 50c25122b3a..a71eedb1ecc 100644 --- a/packages/testing/src/mocks/logger.mock.ts +++ b/packages/testing/src/mocks/logger.mock.ts @@ -21,6 +21,7 @@ export function createMockLogger() { trace: vi.fn(), fatal: vi.fn(), child: vi.fn(() => createMockLogger()), + withMetadata: vi.fn(() => createMockLogger()), } } @@ -60,4 +61,5 @@ export function clearLoggerMocks(logger: ReturnType) { logger.debug.mockClear() logger.trace.mockClear() logger.fatal.mockClear() + logger.withMetadata.mockClear() }