diff --git a/apps/sim/app/api/logs/[id]/route.ts b/apps/sim/app/api/logs/[id]/route.ts index 75f7378db2..5c0acd33e0 100644 --- a/apps/sim/app/api/logs/[id]/route.ts +++ b/apps/sim/app/api/logs/[id]/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getLogDetailContract } from '@/lib/api/contracts/logs' import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { fetchLogDetail } from '@/lib/logs/fetch-log-detail' @@ -10,9 +10,12 @@ const logger = createLogger('LogDetailsByIdAPI') export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json( + { error: authResult.error || 'Authentication required' }, + { status: 401 } + ) } const parsed = await parseRequest(getLogDetailContract, request, context) @@ -22,7 +25,7 @@ export const GET = withRouteHandler( const { workspaceId } = parsed.data.query const data = await fetchLogDetail({ - userId: session.user.id, + userId: authResult.userId, workspaceId, lookupColumn: 'id', lookupValue: id, diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index 73dcd600a2..cb3690441d 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -29,7 +29,7 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { listLogsContract, type WorkflowLogSummary } from '@/lib/api/contracts/logs' import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildFilterConditions } from '@/lib/logs/filters' @@ -58,11 +58,14 @@ function decodeCursor(cursor: string): CursorData | null { } export const GET = withRouteHandler(async (request: NextRequest) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json( + { error: authResult.error || 'Authentication required' }, + { status: 401 } + ) } - const userId = session.user.id + const userId = authResult.userId const parsed = await parseRequest(listLogsContract, request, {}) if (!parsed.success) return parsed.response diff --git a/apps/sim/blocks/blocks/logs.ts b/apps/sim/blocks/blocks/logs.ts new file mode 100644 index 0000000000..d7665089f9 --- /dev/null +++ b/apps/sim/blocks/blocks/logs.ts @@ -0,0 +1,253 @@ +import { Library } from '@/components/emcn/icons' +import type { BlockConfig } from '@/blocks/types' + +export const LogsBlock: BlockConfig = { + type: 'logs', + name: 'Logs', + description: 'Query workflow execution logs', + longDescription: + 'Search workflow execution logs in the current workspace, fetch a single log by id, or load full execution details with the per-block state snapshot.', + bgColor: '#EAB308', + bestPractices: ` + - The block always operates on the current workspace; you cannot query other workspaces. + - 'Query Logs' returns summary rows. To get a full log entry (executionData, files), use 'Get Log by ID' on a row's id. + - Use 'Get Execution Details' (with an executionId) to inspect per-block state for a single run. + - Pagination is cursor-based: pass the previous response's nextCursor as Cursor to fetch the next page. + `, + icon: Library, + category: 'blocks', + docsLink: 'https://docs.sim.ai/api-reference/logs/getExecutionDetails', + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Query Logs', id: 'query' }, + { label: 'Get Log by ID', id: 'get_log' }, + { label: 'Get Execution Details', id: 'get_execution' }, + ], + placeholder: 'Select operation', + value: () => 'query', + }, + { + id: 'workflowIds', + title: 'Workflow IDs', + type: 'short-input', + placeholder: 'Comma-separated workflow IDs', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'executionId', + title: 'Execution ID', + type: 'short-input', + placeholder: 'Filter by a single execution ID', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'level', + title: 'Level', + type: 'dropdown', + options: [ + { label: 'All', id: 'all' }, + { label: 'Info', id: 'info' }, + { label: 'Error', id: 'error' }, + { label: 'Running', id: 'running' }, + { label: 'Pending', id: 'pending' }, + ], + value: () => 'all', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'triggers', + title: 'Triggers', + type: 'short-input', + placeholder: 'api,webhook,schedule,manual,chat,mothership', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '100 (max 200)', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'startDate', + title: 'Start Date', + type: 'short-input', + placeholder: 'ISO 8601 timestamp', + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp from the user description. Return ONLY the timestamp string.', + generationType: 'timestamp', + }, + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'endDate', + title: 'End Date', + type: 'short-input', + placeholder: 'ISO 8601 timestamp', + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp from the user description. Return ONLY the timestamp string.', + generationType: 'timestamp', + }, + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'search', + title: 'Search', + type: 'short-input', + placeholder: 'Free-text search', + mode: 'advanced', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'sortBy', + title: 'Sort By', + type: 'dropdown', + options: [ + { label: 'Date', id: 'date' }, + { label: 'Duration', id: 'duration' }, + { label: 'Cost', id: 'cost' }, + { label: 'Status', id: 'status' }, + ], + value: () => 'date', + mode: 'advanced', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'sortOrder', + title: 'Sort Order', + type: 'dropdown', + options: [ + { label: 'Descending', id: 'desc' }, + { label: 'Ascending', id: 'asc' }, + ], + value: () => 'desc', + mode: 'advanced', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'cursor', + title: 'Cursor', + type: 'short-input', + placeholder: 'nextCursor from a previous response', + mode: 'advanced', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'logId', + title: 'Log ID', + type: 'short-input', + placeholder: 'Log entry ID', + condition: { field: 'operation', value: 'get_log' }, + required: true, + }, + { + id: 'executionIdLookup', + title: 'Execution ID', + type: 'short-input', + placeholder: 'Execution ID', + condition: { field: 'operation', value: 'get_execution' }, + required: true, + }, + ], + tools: { + access: ['logs_query', 'logs_get', 'logs_get_execution'], + config: { + tool: (params: Record) => { + const operation = params.operation || 'query' + if (operation === 'get_log') return 'logs_get' + if (operation === 'get_execution') return 'logs_get_execution' + return 'logs_query' + }, + params: (params: Record) => { + const operation = params.operation || 'query' + + if (operation === 'get_log') { + if (!params.logId) { + throw new Error('Logs Block Error: Log ID is required for get_log operation') + } + return { id: params.logId } + } + + if (operation === 'get_execution') { + if (!params.executionIdLookup) { + throw new Error( + 'Logs Block Error: Execution ID is required for get_execution operation' + ) + } + return { executionId: params.executionIdLookup } + } + + const rawLimit = + params.limit !== undefined && params.limit !== null && params.limit !== '' + ? Number(params.limit) + : undefined + const limit = Number.isFinite(rawLimit) ? rawLimit : undefined + + return { + workflowIds: params.workflowIds || undefined, + executionId: params.executionId || undefined, + level: params.level && params.level !== 'all' ? params.level : undefined, + triggers: params.triggers || undefined, + limit, + startDate: params.startDate || undefined, + endDate: params.endDate || undefined, + search: params.search || undefined, + cursor: params.cursor || undefined, + sortBy: params.sortBy || undefined, + sortOrder: params.sortOrder || undefined, + } + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + workflowIds: { type: 'string', description: 'Comma-separated workflow IDs' }, + executionId: { type: 'string', description: 'Execution ID filter (query operation)' }, + level: { type: 'string', description: 'Log level filter' }, + triggers: { type: 'string', description: 'Comma-separated triggers' }, + limit: { type: 'number', description: 'Max logs to return (default 100, max 200)' }, + startDate: { type: 'string', description: 'ISO 8601 lower bound' }, + endDate: { type: 'string', description: 'ISO 8601 upper bound' }, + search: { type: 'string', description: 'Free-text search term' }, + sortBy: { type: 'string', description: "'date' | 'duration' | 'cost' | 'status'" }, + sortOrder: { type: 'string', description: "'desc' | 'asc'" }, + cursor: { type: 'string', description: 'Pagination cursor' }, + logId: { type: 'string', description: 'Log entry ID (get_log operation)' }, + executionIdLookup: { + type: 'string', + description: 'Execution ID (get_execution operation)', + }, + }, + outputs: { + logs: { type: 'json', description: 'Array of log summary entries (query operation)' }, + nextCursor: { + type: 'string', + description: 'Cursor for next page; null when no more results (query operation)', + }, + log: { type: 'json', description: 'Full log entry (get_log operation)' }, + executionId: { type: 'string', description: 'Execution ID (get_execution operation)' }, + workflowId: { type: 'string', description: 'Workflow ID (get_execution operation)' }, + workflowState: { + type: 'json', + description: 'Per-block state snapshot (get_execution operation)', + }, + childWorkflowSnapshots: { + type: 'json', + description: 'Snapshots for child workflows (get_execution operation)', + }, + executionMetadata: { + type: 'json', + description: 'Trigger, timestamps, totalDurationMs, cost (get_execution operation)', + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index e7ca943af3..aacf6d4943 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -113,6 +113,7 @@ import { LemlistBlock } from '@/blocks/blocks/lemlist' import { LinearBlock, LinearV2Block } from '@/blocks/blocks/linear' import { LinkedInBlock } from '@/blocks/blocks/linkedin' import { LinkupBlock } from '@/blocks/blocks/linkup' +import { LogsBlock } from '@/blocks/blocks/logs' import { LoopsBlock } from '@/blocks/blocks/loops' import { LumaBlock } from '@/blocks/blocks/luma' import { MailchimpBlock } from '@/blocks/blocks/mailchimp' @@ -361,6 +362,7 @@ export const registry: Record = { linear_v2: LinearV2Block, linkedin: LinkedInBlock, linkup: LinkupBlock, + logs: LogsBlock, loops: LoopsBlock, luma: LumaBlock, mailchimp: MailchimpBlock, diff --git a/apps/sim/tools/logs/get_execution.ts b/apps/sim/tools/logs/get_execution.ts new file mode 100644 index 0000000000..a62eef0525 --- /dev/null +++ b/apps/sim/tools/logs/get_execution.ts @@ -0,0 +1,53 @@ +import type { LogsGetExecutionParams, LogsGetExecutionResponse } from '@/tools/logs/types' +import type { ToolConfig } from '@/tools/types' + +export const logsGetExecutionTool: ToolConfig = { + id: 'logs_get_execution', + name: 'Get Execution Details', + description: + 'Fetch full execution details for a workflow run, including the per-block state snapshot.', + version: '1.0.0', + + params: { + executionId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Execution ID returned by a workflow run', + }, + }, + + request: { + url: (params) => `/api/logs/execution/${encodeURIComponent(params.executionId)}`, + method: 'GET', + headers: () => ({ + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response): Promise => { + const data = await response.json() + if (!response.ok) { + throw new Error(data?.error || `Request failed with status ${response.status}`) + } + return { + success: true, + output: data, + } + }, + + outputs: { + executionId: { type: 'string', description: 'Execution ID' }, + workflowId: { type: 'string', description: 'Workflow ID this execution belongs to' }, + workflowState: { type: 'json', description: 'Per-block state snapshot for the execution' }, + childWorkflowSnapshots: { + type: 'json', + description: 'Snapshots for any child workflows invoked during the run', + optional: true, + }, + executionMetadata: { + type: 'json', + description: 'Trigger, timestamps, totalDurationMs, and cost for the run', + }, + }, +} diff --git a/apps/sim/tools/logs/get_log.ts b/apps/sim/tools/logs/get_log.ts new file mode 100644 index 0000000000..92e41e79b8 --- /dev/null +++ b/apps/sim/tools/logs/get_log.ts @@ -0,0 +1,50 @@ +import type { LogsGetParams, LogsGetResponse } from '@/tools/logs/types' +import type { ToolConfig } from '@/tools/types' + +export const logsGetTool: ToolConfig = { + id: 'logs_get', + name: 'Get Log by ID', + description: 'Fetch a single workflow execution log entry by its log ID.', + version: '1.0.0', + + params: { + id: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Log entry ID', + }, + }, + + request: { + url: (params) => { + const workspaceId = params._context?.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required in execution context') + } + const qs = new URLSearchParams({ workspaceId }) + return `/api/logs/${encodeURIComponent(params.id)}?${qs.toString()}` + }, + method: 'GET', + headers: () => ({ + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response): Promise => { + const result = await response.json() + if (!response.ok) { + throw new Error(result?.error || `Request failed with status ${response.status}`) + } + return { + success: true, + output: { + log: result.data, + }, + } + }, + + outputs: { + log: { type: 'json', description: 'Workflow execution log entry' }, + }, +} diff --git a/apps/sim/tools/logs/index.ts b/apps/sim/tools/logs/index.ts new file mode 100644 index 0000000000..109d223c8b --- /dev/null +++ b/apps/sim/tools/logs/index.ts @@ -0,0 +1,3 @@ +export { logsGetExecutionTool } from '@/tools/logs/get_execution' +export { logsGetTool } from '@/tools/logs/get_log' +export { logsQueryTool } from '@/tools/logs/query' diff --git a/apps/sim/tools/logs/query.ts b/apps/sim/tools/logs/query.ts new file mode 100644 index 0000000000..8ea660ee29 --- /dev/null +++ b/apps/sim/tools/logs/query.ts @@ -0,0 +1,132 @@ +import type { LogsQueryParams, LogsQueryResponse } from '@/tools/logs/types' +import type { ToolConfig } from '@/tools/types' + +export const logsQueryTool: ToolConfig = { + id: 'logs_query', + name: 'Query Logs', + description: 'Query workflow execution logs in the current workspace with filters.', + version: '1.0.0', + + params: { + workflowIds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated workflow IDs to filter by', + }, + executionId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter logs to a single execution ID', + }, + level: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + "Log level filter: 'all', 'info', 'error', 'running', 'pending'. Comma-separated for multiple.", + }, + triggers: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated triggers (api, webhook, schedule, manual, chat, mothership)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Max logs to return (default 100, max 200)', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Opaque pagination cursor returned by a previous query', + }, + sortBy: { + type: 'string', + required: false, + visibility: 'user-only', + description: "Sort field: 'date' (default), 'duration', 'cost', 'status'", + }, + sortOrder: { + type: 'string', + required: false, + visibility: 'user-only', + description: "Sort order: 'desc' (default) or 'asc'", + }, + startDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ISO 8601 timestamp; only logs at or after this time', + }, + endDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ISO 8601 timestamp; only logs at or before this time', + }, + search: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Free-text search across log fields', + }, + }, + + request: { + url: (params) => { + const workspaceId = params._context?.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required in execution context') + } + const qs = new URLSearchParams({ workspaceId }) + if (params.workflowIds) qs.set('workflowIds', params.workflowIds) + if (params.executionId) qs.set('executionId', params.executionId) + if (params.level && params.level !== 'all') qs.set('level', params.level) + if (params.triggers) qs.set('triggers', params.triggers) + if (params.startDate) qs.set('startDate', params.startDate) + if (params.endDate) qs.set('endDate', params.endDate) + if (params.search) qs.set('search', params.search) + if (params.cursor) qs.set('cursor', params.cursor) + if (params.sortBy) qs.set('sortBy', params.sortBy) + if (params.sortOrder) qs.set('sortOrder', params.sortOrder) + if (params.limit !== undefined && params.limit !== null) { + qs.set('limit', String(params.limit)) + } + return `/api/logs?${qs.toString()}` + }, + method: 'GET', + headers: () => ({ + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response): Promise => { + const result = await response.json() + if (!response.ok) { + throw new Error(result?.error || `Request failed with status ${response.status}`) + } + return { + success: true, + output: { + logs: result.data || [], + nextCursor: result.nextCursor ?? null, + }, + } + }, + + outputs: { + logs: { + type: 'array', + description: 'Array of workflow execution log entries', + }, + nextCursor: { + type: 'string', + description: 'Pagination cursor for the next page; null when no more results', + }, + }, +} diff --git a/apps/sim/tools/logs/types.ts b/apps/sim/tools/logs/types.ts new file mode 100644 index 0000000000..3053059b1f --- /dev/null +++ b/apps/sim/tools/logs/types.ts @@ -0,0 +1,48 @@ +import type { + ExecutionSnapshotData, + WorkflowLogDetail, + WorkflowLogSummary, +} from '@/lib/api/contracts/logs' +import type { ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' + +export interface LogsQueryParams { + workflowIds?: string + executionId?: string + level?: string + triggers?: string + limit?: number + cursor?: string + sortBy?: 'date' | 'duration' | 'cost' | 'status' + sortOrder?: 'asc' | 'desc' + startDate?: string + endDate?: string + search?: string + _context?: WorkflowToolExecutionContext +} + +export interface LogsGetParams { + id: string + _context?: WorkflowToolExecutionContext +} + +export interface LogsGetExecutionParams { + executionId: string + _context?: WorkflowToolExecutionContext +} + +export interface LogsQueryResponse extends ToolResponse { + output: { + logs: WorkflowLogSummary[] + nextCursor: string | null + } +} + +export interface LogsGetResponse extends ToolResponse { + output: { + log: WorkflowLogDetail + } +} + +export interface LogsGetExecutionResponse extends ToolResponse { + output: ExecutionSnapshotData +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index ad7dd38486..9130ac52de 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1568,6 +1568,7 @@ import { import { linkedInGetProfileTool, linkedInSharePostTool } from '@/tools/linkedin' import { linkupSearchTool } from '@/tools/linkup' import { llmChatTool } from '@/tools/llm' +import { logsGetExecutionTool, logsGetTool, logsQueryTool } from '@/tools/logs' import { loopsCreateContactPropertyTool, loopsCreateContactTool, @@ -3204,6 +3205,9 @@ export const tools: Record = { ketch_set_consent: ketchSetConsentTool, ketch_set_subscriptions: ketchSetSubscriptionsTool, linkup_search: linkupSearchTool, + logs_query: logsQueryTool, + logs_get: logsGetTool, + logs_get_execution: logsGetExecutionTool, loops_create_contact: loopsCreateContactTool, loops_create_contact_property: loopsCreateContactPropertyTool, loops_update_contact: loopsUpdateContactTool,