From 40e30a11e91dfa12184c2b93e54e6d6a6ba13850 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 23 Dec 2025 10:23:27 -0800 Subject: [PATCH 01/18] improvement(logs): update logs export route to respect filters (#2550) --- apps/sim/app/api/logs/export/route.ts | 71 +---- apps/sim/app/api/logs/route.ts | 136 +-------- .../app/workspace/[workspaceId]/logs/logs.tsx | 6 + apps/sim/hooks/queries/logs.ts | 38 +-- apps/sim/lib/logs/filters.ts | 258 ++++++++++++++++++ 5 files changed, 283 insertions(+), 226 deletions(-) create mode 100644 apps/sim/lib/logs/filters.ts diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts index 31a2e103f1..5b98331132 100644 --- a/apps/sim/app/api/logs/export/route.ts +++ b/apps/sim/app/api/logs/export/route.ts @@ -1,28 +1,15 @@ import { db } from '@sim/db' import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema' -import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' +import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' +import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' const logger = createLogger('LogsExportAPI') export const revalidate = 0 -const ExportParamsSchema = z.object({ - level: z.string().optional(), - workflowIds: z.string().optional(), - folderIds: z.string().optional(), - triggers: z.string().optional(), - startDate: z.string().optional(), - endDate: z.string().optional(), - search: z.string().optional(), - workflowName: z.string().optional(), - folderName: z.string().optional(), - workspaceId: z.string(), -}) - function escapeCsv(value: any): string { if (value === null || value === undefined) return '' const str = String(value) @@ -41,7 +28,7 @@ export async function GET(request: NextRequest) { const userId = session.user.id const { searchParams } = new URL(request.url) - const params = ExportParamsSchema.parse(Object.fromEntries(searchParams.entries())) + const params = LogFilterParamsSchema.parse(Object.fromEntries(searchParams.entries())) const selectColumns = { id: workflowExecutionLogs.id, @@ -57,53 +44,11 @@ export async function GET(request: NextRequest) { workflowName: workflow.name, } - let conditions: SQL | undefined = eq(workflowExecutionLogs.workspaceId, params.workspaceId) - - if (params.level && params.level !== 'all') { - const levels = params.level.split(',').filter(Boolean) - if (levels.length === 1) { - conditions = and(conditions, eq(workflowExecutionLogs.level, levels[0])) - } else if (levels.length > 1) { - conditions = and(conditions, inArray(workflowExecutionLogs.level, levels)) - } - } - - if (params.workflowIds) { - const workflowIds = params.workflowIds.split(',').filter(Boolean) - if (workflowIds.length > 0) conditions = and(conditions, inArray(workflow.id, workflowIds)) - } - - if (params.folderIds) { - const folderIds = params.folderIds.split(',').filter(Boolean) - if (folderIds.length > 0) conditions = and(conditions, inArray(workflow.folderId, folderIds)) - } - - if (params.triggers) { - const triggers = params.triggers.split(',').filter(Boolean) - if (triggers.length > 0 && !triggers.includes('all')) { - conditions = and(conditions, inArray(workflowExecutionLogs.trigger, triggers)) - } - } - - if (params.startDate) { - conditions = and(conditions, gte(workflowExecutionLogs.startedAt, new Date(params.startDate))) - } - if (params.endDate) { - conditions = and(conditions, lte(workflowExecutionLogs.startedAt, new Date(params.endDate))) - } - - if (params.search) { - const term = `%${params.search}%` - conditions = and(conditions, sql`${workflowExecutionLogs.executionId} ILIKE ${term}`) - } - if (params.workflowName) { - const nameTerm = `%${params.workflowName}%` - conditions = and(conditions, sql`${workflow.name} ILIKE ${nameTerm}`) - } - if (params.folderName) { - const folderTerm = `%${params.folderName}%` - conditions = and(conditions, sql`${workflow.name} ILIKE ${folderTerm}`) - } + const workspaceCondition = eq(workflowExecutionLogs.workspaceId, params.workspaceId) + const filterConditions = buildFilterConditions(params) + const conditions = filterConditions + ? and(workspaceCondition, filterConditions) + : workspaceCondition const header = [ 'startedAt', diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index 0c00d1738e..ab9f571013 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -6,51 +6,22 @@ import { workflowDeploymentVersion, workflowExecutionLogs, } from '@sim/db/schema' -import { - and, - desc, - eq, - gt, - gte, - inArray, - isNotNull, - isNull, - lt, - lte, - ne, - or, - type SQL, - sql, -} from 'drizzle-orm' +import { and, desc, eq, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' +import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' const logger = createLogger('LogsAPI') export const revalidate = 0 -const QueryParamsSchema = z.object({ +const QueryParamsSchema = LogFilterParamsSchema.extend({ details: z.enum(['basic', 'full']).optional().default('basic'), limit: z.coerce.number().optional().default(100), offset: z.coerce.number().optional().default(0), - level: z.string().optional(), - workflowIds: z.string().optional(), - folderIds: z.string().optional(), - triggers: z.string().optional(), - startDate: z.string().optional(), - endDate: z.string().optional(), - search: z.string().optional(), - workflowName: z.string().optional(), - folderName: z.string().optional(), - executionId: z.string().optional(), - costOperator: z.enum(['=', '>', '<', '>=', '<=', '!=']).optional(), - costValue: z.coerce.number().optional(), - durationOperator: z.enum(['=', '>', '<', '>=', '<=', '!=']).optional(), - durationValue: z.coerce.number().optional(), - workspaceId: z.string(), }) export async function GET(request: NextRequest) { @@ -197,102 +168,11 @@ export async function GET(request: NextRequest) { } } - if (params.workflowIds) { - const workflowIds = params.workflowIds.split(',').filter(Boolean) - if (workflowIds.length > 0) { - conditions = and(conditions, inArray(workflow.id, workflowIds)) - } - } - - if (params.folderIds) { - const folderIds = params.folderIds.split(',').filter(Boolean) - if (folderIds.length > 0) { - conditions = and(conditions, inArray(workflow.folderId, folderIds)) - } - } - - if (params.triggers) { - const triggers = params.triggers.split(',').filter(Boolean) - if (triggers.length > 0 && !triggers.includes('all')) { - conditions = and(conditions, inArray(workflowExecutionLogs.trigger, triggers)) - } - } - - if (params.startDate) { - conditions = and( - conditions, - gte(workflowExecutionLogs.startedAt, new Date(params.startDate)) - ) - } - if (params.endDate) { - conditions = and(conditions, lte(workflowExecutionLogs.startedAt, new Date(params.endDate))) - } - - if (params.search) { - const searchTerm = `%${params.search}%` - conditions = and(conditions, sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`) - } - - if (params.workflowName) { - const nameTerm = `%${params.workflowName}%` - conditions = and(conditions, sql`${workflow.name} ILIKE ${nameTerm}`) - } - - if (params.folderName) { - const folderTerm = `%${params.folderName}%` - conditions = and(conditions, sql`${workflow.name} ILIKE ${folderTerm}`) - } - - if (params.executionId) { - conditions = and(conditions, eq(workflowExecutionLogs.executionId, params.executionId)) - } - - if (params.costOperator && params.costValue !== undefined) { - const costField = sql`(${workflowExecutionLogs.cost}->>'total')::numeric` - switch (params.costOperator) { - case '=': - conditions = and(conditions, sql`${costField} = ${params.costValue}`) - break - case '>': - conditions = and(conditions, sql`${costField} > ${params.costValue}`) - break - case '<': - conditions = and(conditions, sql`${costField} < ${params.costValue}`) - break - case '>=': - conditions = and(conditions, sql`${costField} >= ${params.costValue}`) - break - case '<=': - conditions = and(conditions, sql`${costField} <= ${params.costValue}`) - break - case '!=': - conditions = and(conditions, sql`${costField} != ${params.costValue}`) - break - } - } - - if (params.durationOperator && params.durationValue !== undefined) { - const durationField = workflowExecutionLogs.totalDurationMs - switch (params.durationOperator) { - case '=': - conditions = and(conditions, eq(durationField, params.durationValue)) - break - case '>': - conditions = and(conditions, gt(durationField, params.durationValue)) - break - case '<': - conditions = and(conditions, lt(durationField, params.durationValue)) - break - case '>=': - conditions = and(conditions, gte(durationField, params.durationValue)) - break - case '<=': - conditions = and(conditions, lte(durationField, params.durationValue)) - break - case '!=': - conditions = and(conditions, ne(durationField, params.durationValue)) - break - } + // Apply common filters (workflowIds, folderIds, triggers, dates, search, cost, duration) + // Level filtering is handled above with advanced running/pending state logic + const commonFilters = buildFilterConditions(params, { useSimpleLevelFilter: false }) + if (commonFilters) { + conditions = and(conditions, commonFilters) } const logs = await baseQuery diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index c717867da9..f5f15a0ea9 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AlertCircle, Loader2 } from 'lucide-react' import { useParams } from 'next/navigation' import { cn } from '@/lib/core/utils/cn' +import { getStartDateFromTimeRange } from '@/lib/logs/filters' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import { useFolders } from '@/hooks/queries/folders' import { useDashboardLogs, useLogDetail, useLogsList } from '@/hooks/queries/logs' @@ -262,6 +263,11 @@ export default function Logs() { if (workflowIds.length > 0) params.set('workflowIds', workflowIds.join(',')) if (folderIds.length > 0) params.set('folderIds', folderIds.join(',')) + const startDate = getStartDateFromTimeRange(timeRange) + if (startDate) { + params.set('startDate', startDate.toISOString()) + } + const parsed = parseQuery(debouncedSearchQuery) const extra = queryToApiParams(parsed) Object.entries(extra).forEach(([k, v]) => params.set(k, v)) diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index 45abdbfe40..862b2fb8b1 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -1,6 +1,7 @@ import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { getStartDateFromTimeRange } from '@/lib/logs/filters' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' -import type { LogsResponse, WorkflowLog } from '@/stores/logs/filters/types' +import type { LogsResponse, TimeRange, WorkflowLog } from '@/stores/logs/filters/types' export const logKeys = { all: ['logs'] as const, @@ -14,7 +15,7 @@ export const logKeys = { } interface LogFilters { - timeRange: string + timeRange: TimeRange level: string workflowIds: string[] folderIds: string[] @@ -23,39 +24,6 @@ interface LogFilters { limit: number } -/** - * Calculates start date from a time range string. - * Returns null for 'All time' to indicate no date filtering. - */ -function getStartDateFromTimeRange(timeRange: string): Date | null { - if (timeRange === 'All time') return null - - const now = new Date() - - switch (timeRange) { - case 'Past 30 minutes': - return new Date(now.getTime() - 30 * 60 * 1000) - case 'Past hour': - return new Date(now.getTime() - 60 * 60 * 1000) - case 'Past 6 hours': - return new Date(now.getTime() - 6 * 60 * 60 * 1000) - case 'Past 12 hours': - return new Date(now.getTime() - 12 * 60 * 60 * 1000) - case 'Past 24 hours': - return new Date(now.getTime() - 24 * 60 * 60 * 1000) - case 'Past 3 days': - return new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000) - case 'Past 7 days': - return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) - case 'Past 14 days': - return new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000) - case 'Past 30 days': - return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) - default: - return new Date(0) - } -} - /** * Applies common filter parameters to a URLSearchParams object. * Shared between paginated and non-paginated log fetches. diff --git a/apps/sim/lib/logs/filters.ts b/apps/sim/lib/logs/filters.ts new file mode 100644 index 0000000000..988195ef6c --- /dev/null +++ b/apps/sim/lib/logs/filters.ts @@ -0,0 +1,258 @@ +import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { and, eq, gt, gte, inArray, lt, lte, ne, type SQL, sql } from 'drizzle-orm' +import { z } from 'zod' +import type { TimeRange } from '@/stores/logs/filters/types' + +/** + * Shared schema for log filter parameters. + * Used by both the logs list API and export API. + */ +export const LogFilterParamsSchema = z.object({ + workspaceId: z.string(), + level: z.string().optional(), + workflowIds: z.string().optional(), + folderIds: z.string().optional(), + triggers: z.string().optional(), + startDate: z.string().optional(), + endDate: z.string().optional(), + search: z.string().optional(), + workflowName: z.string().optional(), + folderName: z.string().optional(), + executionId: z.string().optional(), + costOperator: z.enum(['=', '>', '<', '>=', '<=', '!=']).optional(), + costValue: z.coerce.number().optional(), + durationOperator: z.enum(['=', '>', '<', '>=', '<=', '!=']).optional(), + durationValue: z.coerce.number().optional(), +}) + +export type LogFilterParams = z.infer + +/** + * Calculates start date from a time range string. + * Returns null for 'All time' to indicate no date filtering. + * @param timeRange - The time range option selected by the user + * @returns Date object for the start of the range, or null for 'All time' + */ +export function getStartDateFromTimeRange(timeRange: TimeRange): Date | null { + if (timeRange === 'All time') return null + + const now = new Date() + + switch (timeRange) { + case 'Past 30 minutes': + return new Date(now.getTime() - 30 * 60 * 1000) + case 'Past hour': + return new Date(now.getTime() - 60 * 60 * 1000) + case 'Past 6 hours': + return new Date(now.getTime() - 6 * 60 * 60 * 1000) + case 'Past 12 hours': + return new Date(now.getTime() - 12 * 60 * 60 * 1000) + case 'Past 24 hours': + return new Date(now.getTime() - 24 * 60 * 60 * 1000) + case 'Past 3 days': + return new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000) + case 'Past 7 days': + return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + case 'Past 14 days': + return new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000) + case 'Past 30 days': + return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) + default: + return new Date(0) + } +} + +type ComparisonOperator = '=' | '>' | '<' | '>=' | '<=' | '!=' + +function buildWorkflowIdsCondition(workflowIds: string): SQL | undefined { + const ids = workflowIds.split(',').filter(Boolean) + if (ids.length > 0) { + return inArray(workflow.id, ids) + } + return undefined +} + +function buildFolderIdsCondition(folderIds: string): SQL | undefined { + const ids = folderIds.split(',').filter(Boolean) + if (ids.length > 0) { + return inArray(workflow.folderId, ids) + } + return undefined +} + +function buildTriggersCondition(triggers: string): SQL | undefined { + const triggerList = triggers.split(',').filter(Boolean) + if (triggerList.length > 0 && !triggerList.includes('all')) { + return inArray(workflowExecutionLogs.trigger, triggerList) + } + return undefined +} + +function buildDateConditions( + startDate?: string, + endDate?: string +): { startCondition?: SQL; endCondition?: SQL } { + const result: { startCondition?: SQL; endCondition?: SQL } = {} + + if (startDate) { + result.startCondition = gte(workflowExecutionLogs.startedAt, new Date(startDate)) + } + if (endDate) { + result.endCondition = lte(workflowExecutionLogs.startedAt, new Date(endDate)) + } + + return result +} + +function buildSearchConditions(params: { + search?: string + workflowName?: string + folderName?: string + executionId?: string +}): SQL[] { + const conditions: SQL[] = [] + + if (params.search) { + const searchTerm = `%${params.search}%` + conditions.push(sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`) + } + + if (params.workflowName) { + const nameTerm = `%${params.workflowName}%` + conditions.push(sql`${workflow.name} ILIKE ${nameTerm}`) + } + + if (params.folderName) { + const folderTerm = `%${params.folderName}%` + conditions.push(sql`${workflow.name} ILIKE ${folderTerm}`) + } + + if (params.executionId) { + conditions.push(eq(workflowExecutionLogs.executionId, params.executionId)) + } + + return conditions +} + +function buildCostCondition(operator: ComparisonOperator, value: number): SQL { + const costField = sql`(${workflowExecutionLogs.cost}->>'total')::numeric` + + switch (operator) { + case '=': + return sql`${costField} = ${value}` + case '>': + return sql`${costField} > ${value}` + case '<': + return sql`${costField} < ${value}` + case '>=': + return sql`${costField} >= ${value}` + case '<=': + return sql`${costField} <= ${value}` + case '!=': + return sql`${costField} != ${value}` + } +} + +function buildDurationCondition(operator: ComparisonOperator, value: number): SQL | undefined { + const durationField = workflowExecutionLogs.totalDurationMs + + switch (operator) { + case '=': + return eq(durationField, value) + case '>': + return gt(durationField, value) + case '<': + return lt(durationField, value) + case '>=': + return gte(durationField, value) + case '<=': + return lte(durationField, value) + case '!=': + return ne(durationField, value) + } +} + +/** + * Builds SQL conditions for simple level filtering (used by export API). + * Does not handle complex running/pending states. + */ +export function buildSimpleLevelCondition(level: string): SQL | undefined { + if (!level || level === 'all') return undefined + + const levels = level.split(',').filter(Boolean) + if (levels.length === 1) { + return eq(workflowExecutionLogs.level, levels[0]) + } + if (levels.length > 1) { + return inArray(workflowExecutionLogs.level, levels) + } + return undefined +} + +export interface BuildFilterConditionsOptions { + /** + * Whether to use simple level filtering (just matches level string). + * Set to false to skip level filtering (caller will handle it separately). + */ + useSimpleLevelFilter?: boolean +} + +/** + * Builds combined SQL conditions from log filter parameters. + * Returns a single SQL condition that can be used in a WHERE clause. + * @param params - The filter parameters from the request + * @param options - Configuration options for filter building + * @returns Combined SQL condition or undefined if no filters + */ +export function buildFilterConditions( + params: LogFilterParams, + options: BuildFilterConditionsOptions = {} +): SQL | undefined { + const { useSimpleLevelFilter = true } = options + const conditions: SQL[] = [] + + if (useSimpleLevelFilter && params.level) { + const levelCondition = buildSimpleLevelCondition(params.level) + if (levelCondition) conditions.push(levelCondition) + } + + if (params.workflowIds) { + const condition = buildWorkflowIdsCondition(params.workflowIds) + if (condition) conditions.push(condition) + } + + if (params.folderIds) { + const condition = buildFolderIdsCondition(params.folderIds) + if (condition) conditions.push(condition) + } + + if (params.triggers) { + const condition = buildTriggersCondition(params.triggers) + if (condition) conditions.push(condition) + } + + const { startCondition, endCondition } = buildDateConditions(params.startDate, params.endDate) + if (startCondition) conditions.push(startCondition) + if (endCondition) conditions.push(endCondition) + + const searchConditions = buildSearchConditions({ + search: params.search, + workflowName: params.workflowName, + folderName: params.folderName, + executionId: params.executionId, + }) + conditions.push(...searchConditions) + + if (params.costOperator && params.costValue !== undefined) { + conditions.push(buildCostCondition(params.costOperator, params.costValue)) + } + + if (params.durationOperator && params.durationValue !== undefined) { + const condition = buildDurationCondition(params.durationOperator, params.durationValue) + if (condition) conditions.push(condition) + } + + if (conditions.length === 0) return undefined + if (conditions.length === 1) return conditions[0] + return and(...conditions) +} From 6c1e4ff7d69543327e236bdd4fd314bc721d5a2e Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 23 Dec 2025 11:26:49 -0800 Subject: [PATCH 02/18] improvement(oauth): remove unused scope hints (#2551) * improvement(oauth): remove unused scope hints * improvement(oauth): remove scopeHints and extraneous oauth provider data * cleanup --- .../api/auth/oauth/connections/route.test.ts | 2 +- .../app/api/auth/oauth/connections/route.ts | 4 +- .../api/auth/oauth/credentials/route.test.ts | 2 +- .../app/api/auth/oauth/credentials/route.ts | 4 +- apps/sim/app/api/auth/oauth/utils.test.ts | 3 +- apps/sim/app/api/auth/oauth/utils.ts | 2 +- .../components/oauth-required-modal.tsx | 10 +- .../slack-selector/slack-selector-input.tsx | 2 +- .../components/tool-credential-selector.tsx | 15 +- .../components/tool-input/tool-input.tsx | 2 +- .../components/integrations/integrations.tsx | 2 +- apps/sim/hooks/queries/oauth-connections.ts | 9 +- apps/sim/hooks/use-oauth-scope-status.ts | 2 +- .../client/other/oauth-request-access.ts | 2 +- .../tools/server/user/get-credentials.ts | 2 +- apps/sim/lib/oauth/index.ts | 4 +- apps/sim/lib/oauth/oauth.test.ts | 405 +++++---- apps/sim/lib/oauth/oauth.ts | 635 +++----------- apps/sim/lib/oauth/types.ts | 136 +++ apps/sim/lib/oauth/utils.test.ts | 804 ++++++++++++++++++ apps/sim/lib/oauth/utils.ts | 157 ++++ .../credentials/credential-resolver.ts | 2 +- apps/sim/tools/types.ts | 2 +- 23 files changed, 1451 insertions(+), 757 deletions(-) create mode 100644 apps/sim/lib/oauth/types.ts create mode 100644 apps/sim/lib/oauth/utils.test.ts create mode 100644 apps/sim/lib/oauth/utils.ts diff --git a/apps/sim/app/api/auth/oauth/connections/route.test.ts b/apps/sim/app/api/auth/oauth/connections/route.test.ts index 9ab1fd8c73..f3aceda583 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.test.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.test.ts @@ -70,7 +70,7 @@ describe('OAuth Connections API Route', () => { }) ) - vi.doMock('@/lib/oauth/oauth', () => ({ + vi.doMock('@/lib/oauth/utils', () => ({ parseProvider: mockParseProvider, evaluateScopeCoverage: mockEvaluateScopeCoverage, })) diff --git a/apps/sim/app/api/auth/oauth/connections/route.ts b/apps/sim/app/api/auth/oauth/connections/route.ts index 8ec7c85993..783f3d2ce2 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.ts @@ -5,8 +5,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' -import type { OAuthProvider } from '@/lib/oauth/oauth' -import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth/oauth' +import type { OAuthProvider } from '@/lib/oauth' +import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth' const logger = createLogger('OAuthConnectionsAPI') diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index 0b17ea290e..1e0a2889a0 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -42,7 +42,7 @@ describe('OAuth Credentials API Route', () => { getSession: mockGetSession, })) - vi.doMock('@/lib/oauth/oauth', () => ({ + vi.doMock('@/lib/oauth/utils', () => ({ parseProvider: mockParseProvider, evaluateScopeCoverage: mockEvaluateScopeCoverage, })) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index 6f5f40de81..04f5e9c5ba 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -7,7 +7,7 @@ import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' -import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth/oauth' +import { evaluateScopeCoverage, type OAuthProvider, parseProvider } from '@/lib/oauth' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -132,7 +132,7 @@ export async function GET(request: NextRequest) { } // Parse the provider to get base provider and feature type (if provider is present) - const { baseProvider } = parseProvider(providerParam || 'google-default') + const { baseProvider } = parseProvider((providerParam || 'google') as OAuthProvider) let accountsData diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 2c61b903f1..f53402f5b5 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -26,6 +26,7 @@ vi.mock('@sim/db', () => ({ vi.mock('@/lib/oauth/oauth', () => ({ refreshOAuthToken: vi.fn(), + OAUTH_PROVIDERS: {}, })) vi.mock('@/lib/logs/console/logger', () => ({ @@ -38,7 +39,7 @@ vi.mock('@/lib/logs/console/logger', () => ({ })) import { db } from '@sim/db' -import { refreshOAuthToken } from '@/lib/oauth/oauth' +import { refreshOAuthToken } from '@/lib/oauth' import { getCredential, getUserId, diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index b23cf06da3..85b63961d1 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -3,7 +3,7 @@ import { account, workflow } from '@sim/db/schema' import { and, desc, eq } from 'drizzle-orm' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' -import { refreshOAuthToken } from '@/lib/oauth/oauth' +import { refreshOAuthToken } from '@/lib/oauth' const logger = createLogger('OAuthUtilsAPI') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 2744a2b23b..2fc0f8c0e7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -7,7 +7,6 @@ import { client } from '@/lib/auth/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { getProviderIdFromServiceId, - getServiceIdFromScopes, OAUTH_PROVIDERS, type OAuthProvider, parseProvider, @@ -21,7 +20,7 @@ export interface OAuthRequiredModalProps { provider: OAuthProvider toolName: string requiredScopes?: string[] - serviceId?: string + serviceId: string newScopes?: string[] } @@ -301,7 +300,6 @@ export function OAuthRequiredModal({ serviceId, newScopes = [], }: OAuthRequiredModalProps) { - const effectiveServiceId = serviceId || getServiceIdFromScopes(provider, requiredScopes) const { baseProvider } = parseProvider(provider) const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] @@ -309,8 +307,8 @@ export function OAuthRequiredModal({ let ProviderIcon = baseProviderConfig?.icon || (() => null) if (baseProviderConfig) { - for (const service of Object.values(baseProviderConfig.services)) { - if (service.id === effectiveServiceId || service.providerId === provider) { + for (const [key, service] of Object.entries(baseProviderConfig.services)) { + if (key === serviceId || service.providerId === provider) { providerName = service.name ProviderIcon = service.icon break @@ -343,7 +341,7 @@ export function OAuthRequiredModal({ const handleConnectDirectly = async () => { try { - const providerId = getProviderIdFromServiceId(effectiveServiceId) + const providerId = getProviderIdFromServiceId(serviceId) onClose() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx index 9267ff174d..9a7e4ebfa2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react' import { useParams } from 'next/navigation' import { Tooltip } from '@/components/emcn' -import { getProviderIdFromServiceId } from '@/lib/oauth/oauth' +import { getProviderIdFromServiceId } from '@/lib/oauth' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx index e92adee1f0..542eb2c57f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx @@ -4,7 +4,6 @@ import { Button, Combobox } from '@/components/emcn/components' import { getCanonicalScopesForProvider, getProviderIdFromServiceId, - getServiceIdFromScopes, OAUTH_PROVIDERS, type OAuthProvider, type OAuthService, @@ -45,7 +44,7 @@ interface ToolCredentialSelectorProps { provider: OAuthProvider requiredScopes?: string[] label?: string - serviceId?: OAuthService + serviceId: OAuthService disabled?: boolean } @@ -65,15 +64,7 @@ export function ToolCredentialSelector({ const selectedId = value || '' - const effectiveServiceId = useMemo( - () => serviceId || getServiceIdFromScopes(provider, requiredScopes), - [provider, requiredScopes, serviceId] - ) - - const effectiveProviderId = useMemo( - () => getProviderIdFromServiceId(effectiveServiceId), - [effectiveServiceId] - ) + const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) const { data: credentials = [], @@ -240,7 +231,7 @@ export function ToolCredentialSelector({ toolName={getProviderName(provider)} requiredScopes={getCanonicalScopesForProvider(effectiveProviderId)} newScopes={missingRequiredScopes} - serviceId={effectiveServiceId} + serviceId={serviceId} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 7d209c115a..2911fed8f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -24,7 +24,7 @@ import { getProviderIdFromServiceId, type OAuthProvider, type OAuthService, -} from '@/lib/oauth/oauth' +} from '@/lib/oauth' import { CheckboxList, Code, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx index 614d182836..7f0aa9753d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx @@ -15,7 +15,7 @@ import { import { Input, Skeleton } from '@/components/ui' import { cn } from '@/lib/core/utils/cn' import { createLogger } from '@/lib/logs/console/logger' -import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' +import { OAUTH_PROVIDERS } from '@/lib/oauth' import { type ServiceInfo, useConnectOAuthService, diff --git a/apps/sim/hooks/queries/oauth-connections.ts b/apps/sim/hooks/queries/oauth-connections.ts index f4e5eef3f9..fbda55963d 100644 --- a/apps/sim/hooks/queries/oauth-connections.ts +++ b/apps/sim/hooks/queries/oauth-connections.ts @@ -1,7 +1,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { client } from '@/lib/auth/auth-client' import { createLogger } from '@/lib/logs/console/logger' -import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth/oauth' +import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth' const logger = createLogger('OAuthConnectionsQuery') @@ -14,9 +14,11 @@ export const oauthConnectionsKeys = { } /** - * Service info type + * Service info type - extends OAuthServiceConfig with connection status and the service key */ export interface ServiceInfo extends OAuthServiceConfig { + /** The service key from OAUTH_PROVIDERS (e.g., 'gmail', 'google-drive') */ + id: string isConnected: boolean lastConnected?: string accounts?: { id: string; name: string }[] @@ -29,9 +31,10 @@ function defineServices(): ServiceInfo[] { const servicesList: ServiceInfo[] = [] Object.entries(OAUTH_PROVIDERS).forEach(([_providerKey, provider]) => { - Object.values(provider.services).forEach((service) => { + Object.entries(provider.services).forEach(([serviceKey, service]) => { servicesList.push({ ...service, + id: serviceKey, isConnected: false, scopes: service.scopes || [], }) diff --git a/apps/sim/hooks/use-oauth-scope-status.ts b/apps/sim/hooks/use-oauth-scope-status.ts index 5ea4e65dce..d2576e098e 100644 --- a/apps/sim/hooks/use-oauth-scope-status.ts +++ b/apps/sim/hooks/use-oauth-scope-status.ts @@ -1,6 +1,6 @@ 'use client' -import type { Credential } from '@/lib/oauth/oauth' +import type { Credential } from '@/lib/oauth' export interface OAuthScopeStatus { requiresReauthorization: boolean diff --git a/apps/sim/lib/copilot/tools/client/other/oauth-request-access.ts b/apps/sim/lib/copilot/tools/client/other/oauth-request-access.ts index bcd0cafd7a..b3aaddced8 100644 --- a/apps/sim/lib/copilot/tools/client/other/oauth-request-access.ts +++ b/apps/sim/lib/copilot/tools/client/other/oauth-request-access.ts @@ -5,7 +5,7 @@ import { ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' import { createLogger } from '@/lib/logs/console/logger' -import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth/oauth' +import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth' const logger = createLogger('OAuthRequestAccessClientTool') diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts index c9bd25c5ba..473326ff8d 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts @@ -7,7 +7,7 @@ import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { generateRequestId } from '@/lib/core/utils/request' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { createLogger } from '@/lib/logs/console/logger' -import { getAllOAuthServices } from '@/lib/oauth/oauth' +import { getAllOAuthServices } from '@/lib/oauth' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' interface GetCredentialsParams { diff --git a/apps/sim/lib/oauth/index.ts b/apps/sim/lib/oauth/index.ts index 036e7ec1be..a8f9ce5859 100644 --- a/apps/sim/lib/oauth/index.ts +++ b/apps/sim/lib/oauth/index.ts @@ -1 +1,3 @@ -export * from '@/lib/oauth/oauth' +export * from './oauth' +export * from './types' +export * from './utils' diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts index 877baa5afc..cc64ea45fd 100644 --- a/apps/sim/lib/oauth/oauth.test.ts +++ b/apps/sim/lib/oauth/oauth.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/config/env', () => ({ env: { @@ -14,12 +14,8 @@ vi.mock('@/lib/core/config/env', () => ({ JIRA_CLIENT_SECRET: 'jira_client_secret', AIRTABLE_CLIENT_ID: 'airtable_client_id', AIRTABLE_CLIENT_SECRET: 'airtable_client_secret', - SUPABASE_CLIENT_ID: 'supabase_client_id', - SUPABASE_CLIENT_SECRET: 'supabase_client_secret', NOTION_CLIENT_ID: 'notion_client_id', NOTION_CLIENT_SECRET: 'notion_client_secret', - // DISCORD_CLIENT_ID: 'discord_client_id', - // DISCORD_CLIENT_SECRET: 'discord_client_secret', MICROSOFT_CLIENT_ID: 'microsoft_client_id', MICROSOFT_CLIENT_SECRET: 'microsoft_client_secret', LINEAR_CLIENT_ID: 'linear_client_id', @@ -28,6 +24,30 @@ vi.mock('@/lib/core/config/env', () => ({ SLACK_CLIENT_SECRET: 'slack_client_secret', REDDIT_CLIENT_ID: 'reddit_client_id', REDDIT_CLIENT_SECRET: 'reddit_client_secret', + DROPBOX_CLIENT_ID: 'dropbox_client_id', + DROPBOX_CLIENT_SECRET: 'dropbox_client_secret', + WEALTHBOX_CLIENT_ID: 'wealthbox_client_id', + WEALTHBOX_CLIENT_SECRET: 'wealthbox_client_secret', + WEBFLOW_CLIENT_ID: 'webflow_client_id', + WEBFLOW_CLIENT_SECRET: 'webflow_client_secret', + ASANA_CLIENT_ID: 'asana_client_id', + ASANA_CLIENT_SECRET: 'asana_client_secret', + PIPEDRIVE_CLIENT_ID: 'pipedrive_client_id', + PIPEDRIVE_CLIENT_SECRET: 'pipedrive_client_secret', + HUBSPOT_CLIENT_ID: 'hubspot_client_id', + HUBSPOT_CLIENT_SECRET: 'hubspot_client_secret', + LINKEDIN_CLIENT_ID: 'linkedin_client_id', + LINKEDIN_CLIENT_SECRET: 'linkedin_client_secret', + SALESFORCE_CLIENT_ID: 'salesforce_client_id', + SALESFORCE_CLIENT_SECRET: 'salesforce_client_secret', + SHOPIFY_CLIENT_ID: 'shopify_client_id', + SHOPIFY_CLIENT_SECRET: 'shopify_client_secret', + ZOOM_CLIENT_ID: 'zoom_client_id', + ZOOM_CLIENT_SECRET: 'zoom_client_secret', + WORDPRESS_CLIENT_ID: 'wordpress_client_id', + WORDPRESS_CLIENT_SECRET: 'wordpress_client_secret', + SPOTIFY_CLIENT_ID: 'spotify_client_id', + SPOTIFY_CLIENT_SECRET: 'spotify_client_secret', }, })) @@ -40,28 +60,28 @@ vi.mock('@/lib/logs/console/logger', () => ({ }), })) -const mockFetch = vi.fn() -global.fetch = mockFetch +import { refreshOAuthToken } from '@/lib/oauth' -import { refreshOAuthToken } from '@/lib/oauth/oauth' - -describe('OAuth Token Refresh', () => { - beforeEach(() => { - vi.clearAllMocks() - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - access_token: 'new_access_token', - expires_in: 3600, - refresh_token: 'new_refresh_token', - }), - }) +function createMockFetch() { + return vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: 'new_access_token', + expires_in: 3600, + refresh_token: 'new_refresh_token', + }), }) +} - afterEach(() => { - vi.clearAllMocks() +function withMockFetch(mockFetch: ReturnType, fn: () => Promise): Promise { + const originalFetch = global.fetch + global.fetch = mockFetch + return fn().finally(() => { + global.fetch = originalFetch }) +} +describe('OAuth Token Refresh', () => { describe('Basic Auth Providers', () => { const basicAuthProviders = [ { @@ -76,64 +96,73 @@ describe('OAuth Token Refresh', () => { endpoint: 'https://auth.atlassian.com/oauth/token', }, { name: 'Jira', providerId: 'jira', endpoint: 'https://auth.atlassian.com/oauth/token' }, - // Discord is currently disabled - // { - // name: 'Discord', - // providerId: 'discord', - // endpoint: 'https://discord.com/api/v10/oauth2/token', - // }, { name: 'Linear', providerId: 'linear', endpoint: 'https://api.linear.app/oauth/token' }, { name: 'Reddit', providerId: 'reddit', endpoint: 'https://www.reddit.com/api/v1/access_token', }, + { + name: 'Asana', + providerId: 'asana', + endpoint: 'https://app.asana.com/-/oauth_token', + }, + { + name: 'Zoom', + providerId: 'zoom', + endpoint: 'https://zoom.us/oauth/token', + }, + { + name: 'Spotify', + providerId: 'spotify', + endpoint: 'https://accounts.spotify.com/api/token', + }, ] basicAuthProviders.forEach(({ name, providerId, endpoint }) => { - it(`should send ${name} request with Basic Auth header and no credentials in body`, async () => { - const refreshToken = 'test_refresh_token' - - await refreshOAuthToken(providerId, refreshToken) - - expect(mockFetch).toHaveBeenCalledWith( - endpoint, - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: expect.stringMatching(/^Basic /), - }), - body: expect.any(String), - }) - ) - - const [, requestOptions] = (mockFetch as Mock).mock.calls[0] - - // Verify Basic Auth header - const authHeader = requestOptions.headers.Authorization - expect(authHeader).toMatch(/^Basic /) - - // Decode and verify credentials - const base64Credentials = authHeader.replace('Basic ', '') - const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8') - const [clientId, clientSecret] = credentials.split(':') - - expect(clientId).toBe(`${providerId}_client_id`) - expect(clientSecret).toBe(`${providerId}_client_secret`) - - // Verify body contains only required parameters - const bodyParams = new URLSearchParams(requestOptions.body) - const bodyKeys = Array.from(bodyParams.keys()) - - expect(bodyKeys).toEqual(['grant_type', 'refresh_token']) - expect(bodyParams.get('grant_type')).toBe('refresh_token') - expect(bodyParams.get('refresh_token')).toBe(refreshToken) - - // Verify client credentials are NOT in the body - expect(bodyParams.get('client_id')).toBeNull() - expect(bodyParams.get('client_secret')).toBeNull() - }) + it.concurrent( + `should send ${name} request with Basic Auth header and no credentials in body`, + async () => { + const mockFetch = createMockFetch() + const refreshToken = 'test_refresh_token' + + await withMockFetch(mockFetch, () => refreshOAuthToken(providerId, refreshToken)) + + expect(mockFetch).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: expect.stringMatching(/^Basic /), + }), + body: expect.any(String), + }) + ) + + const [, requestOptions] = mockFetch.mock.calls[0] + + const authHeader = requestOptions.headers.Authorization + expect(authHeader).toMatch(/^Basic /) + + const base64Credentials = authHeader.replace('Basic ', '') + const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8') + const [clientId, clientSecret] = credentials.split(':') + + expect(clientId).toBe(`${providerId}_client_id`) + expect(clientSecret).toBe(`${providerId}_client_secret`) + + const bodyParams = new URLSearchParams(requestOptions.body) + const bodyKeys = Array.from(bodyParams.keys()) + + expect(bodyKeys).toEqual(['grant_type', 'refresh_token']) + expect(bodyParams.get('grant_type')).toBe('refresh_token') + expect(bodyParams.get('refresh_token')).toBe(refreshToken) + + expect(bodyParams.get('client_id')).toBeNull() + expect(bodyParams.get('client_secret')).toBeNull() + } + ) }) }) @@ -155,72 +184,114 @@ describe('OAuth Token Refresh', () => { providerId: 'outlook', endpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', }, - // Supabase is currently disabled - // { - // name: 'Supabase', - // providerId: 'supabase', - // endpoint: 'https://api.supabase.com/v1/oauth/token', - // }, { name: 'Notion', providerId: 'notion', endpoint: 'https://api.notion.com/v1/oauth/token' }, { name: 'Slack', providerId: 'slack', endpoint: 'https://slack.com/api/oauth.v2.access' }, + { + name: 'Dropbox', + providerId: 'dropbox', + endpoint: 'https://api.dropboxapi.com/oauth2/token', + }, + { + name: 'Wealthbox', + providerId: 'wealthbox', + endpoint: 'https://app.crmworkspace.com/oauth/token', + }, + { + name: 'Webflow', + providerId: 'webflow', + endpoint: 'https://api.webflow.com/oauth/access_token', + }, + { + name: 'Pipedrive', + providerId: 'pipedrive', + endpoint: 'https://oauth.pipedrive.com/oauth/token', + }, + { + name: 'HubSpot', + providerId: 'hubspot', + endpoint: 'https://api.hubapi.com/oauth/v1/token', + }, + { + name: 'LinkedIn', + providerId: 'linkedin', + endpoint: 'https://www.linkedin.com/oauth/v2/accessToken', + }, + { + name: 'Salesforce', + providerId: 'salesforce', + endpoint: 'https://login.salesforce.com/services/oauth2/token', + }, + { + name: 'Shopify', + providerId: 'shopify', + endpoint: 'https://accounts.shopify.com/oauth/token', + }, + { + name: 'WordPress', + providerId: 'wordpress', + endpoint: 'https://public-api.wordpress.com/oauth2/token', + }, ] bodyCredentialProviders.forEach(({ name, providerId, endpoint }) => { - it(`should send ${name} request with credentials in body and no Basic Auth`, async () => { - const refreshToken = 'test_refresh_token' - - await refreshOAuthToken(providerId, refreshToken) - - expect(mockFetch).toHaveBeenCalledWith( - endpoint, - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/x-www-form-urlencoded', - }), - body: expect.any(String), - }) - ) - - const [, requestOptions] = (mockFetch as Mock).mock.calls[0] - - // Verify no Basic Auth header - expect(requestOptions.headers.Authorization).toBeUndefined() - - // Verify body contains all required parameters - const bodyParams = new URLSearchParams(requestOptions.body) - const bodyKeys = Array.from(bodyParams.keys()).sort() - - expect(bodyKeys).toEqual(['client_id', 'client_secret', 'grant_type', 'refresh_token']) - expect(bodyParams.get('grant_type')).toBe('refresh_token') - expect(bodyParams.get('refresh_token')).toBe(refreshToken) - - // Verify client credentials are in the body - const expectedClientId = - providerId === 'outlook' ? 'microsoft_client_id' : `${providerId}_client_id` - const expectedClientSecret = - providerId === 'outlook' ? 'microsoft_client_secret' : `${providerId}_client_secret` - - expect(bodyParams.get('client_id')).toBe(expectedClientId) - expect(bodyParams.get('client_secret')).toBe(expectedClientSecret) - }) + it.concurrent( + `should send ${name} request with credentials in body and no Basic Auth`, + async () => { + const mockFetch = createMockFetch() + const refreshToken = 'test_refresh_token' + + await withMockFetch(mockFetch, () => refreshOAuthToken(providerId, refreshToken)) + + expect(mockFetch).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/x-www-form-urlencoded', + }), + body: expect.any(String), + }) + ) + + const [, requestOptions] = mockFetch.mock.calls[0] + + expect(requestOptions.headers.Authorization).toBeUndefined() + + const bodyParams = new URLSearchParams(requestOptions.body) + const bodyKeys = Array.from(bodyParams.keys()).sort() + + expect(bodyKeys).toEqual(['client_id', 'client_secret', 'grant_type', 'refresh_token']) + expect(bodyParams.get('grant_type')).toBe('refresh_token') + expect(bodyParams.get('refresh_token')).toBe(refreshToken) + + const expectedClientId = + providerId === 'outlook' ? 'microsoft_client_id' : `${providerId}_client_id` + const expectedClientSecret = + providerId === 'outlook' ? 'microsoft_client_secret' : `${providerId}_client_secret` + + expect(bodyParams.get('client_id')).toBe(expectedClientId) + expect(bodyParams.get('client_secret')).toBe(expectedClientSecret) + } + ) }) - it('should include Accept header for GitHub requests', async () => { + it.concurrent('should include Accept header for GitHub requests', async () => { + const mockFetch = createMockFetch() const refreshToken = 'test_refresh_token' - await refreshOAuthToken('github', refreshToken) + await withMockFetch(mockFetch, () => refreshOAuthToken('github', refreshToken)) - const [, requestOptions] = (mockFetch as Mock).mock.calls[0] + const [, requestOptions] = mockFetch.mock.calls[0] expect(requestOptions.headers.Accept).toBe('application/json') }) - it('should include User-Agent header for Reddit requests', async () => { + it.concurrent('should include User-Agent header for Reddit requests', async () => { + const mockFetch = createMockFetch() const refreshToken = 'test_refresh_token' - await refreshOAuthToken('reddit', refreshToken) + await withMockFetch(mockFetch, () => refreshOAuthToken('reddit', refreshToken)) - const [, requestOptions] = (mockFetch as Mock).mock.calls[0] + const [, requestOptions] = mockFetch.mock.calls[0] expect(requestOptions.headers['User-Agent']).toBe( 'sim-studio/1.0 (https://github.com/simstudioai/sim)' ) @@ -228,18 +299,19 @@ describe('OAuth Token Refresh', () => { }) describe('Error Handling', () => { - it('should return null for unsupported provider', async () => { + it.concurrent('should return null for unsupported provider', async () => { + const mockFetch = createMockFetch() const refreshToken = 'test_refresh_token' - const result = await refreshOAuthToken('unsupported', refreshToken) + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('unsupported', refreshToken) + ) expect(result).toBeNull() }) - it('should return null for API error responses', async () => { - const refreshToken = 'test_refresh_token' - - mockFetch.mockResolvedValueOnce({ + it.concurrent('should return null for API error responses', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 400, text: async () => @@ -248,29 +320,29 @@ describe('OAuth Token Refresh', () => { error_description: 'Invalid refresh token', }), }) + const refreshToken = 'test_refresh_token' - const result = await refreshOAuthToken('google', refreshToken) + const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken)) expect(result).toBeNull() }) - it('should return null for network errors', async () => { + it.concurrent('should return null for network errors', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('Network error')) const refreshToken = 'test_refresh_token' - mockFetch.mockRejectedValueOnce(new Error('Network error')) - - const result = await refreshOAuthToken('google', refreshToken) + const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken)) expect(result).toBeNull() }) }) describe('Token Response Handling', () => { - it('should handle providers that return new refresh tokens', async () => { + it.concurrent('should handle providers that return new refresh tokens', async () => { const refreshToken = 'old_refresh_token' const newRefreshToken = 'new_refresh_token' - mockFetch.mockResolvedValueOnce({ + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ access_token: 'new_access_token', @@ -279,7 +351,9 @@ describe('OAuth Token Refresh', () => { }), }) - const result = await refreshOAuthToken('airtable', refreshToken) + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('airtable', refreshToken) + ) expect(result).toEqual({ accessToken: 'new_access_token', @@ -288,19 +362,18 @@ describe('OAuth Token Refresh', () => { }) }) - it('should use original refresh token when new one is not provided', async () => { + it.concurrent('should use original refresh token when new one is not provided', async () => { const refreshToken = 'original_refresh_token' - mockFetch.mockResolvedValueOnce({ + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ access_token: 'new_access_token', expires_in: 3600, - // No refresh_token in response }), }) - const result = await refreshOAuthToken('google', refreshToken) + const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken)) expect(result).toEqual({ accessToken: 'new_access_token', @@ -309,34 +382,32 @@ describe('OAuth Token Refresh', () => { }) }) - it('should return null when access token is missing', async () => { + it.concurrent('should return null when access token is missing', async () => { const refreshToken = 'test_refresh_token' - mockFetch.mockResolvedValueOnce({ + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ expires_in: 3600, - // No access_token in response }), }) - const result = await refreshOAuthToken('google', refreshToken) + const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken)) expect(result).toBeNull() }) - it('should use default expiration when not provided', async () => { + it.concurrent('should use default expiration when not provided', async () => { const refreshToken = 'test_refresh_token' - mockFetch.mockResolvedValueOnce({ + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ access_token: 'new_access_token', - // No expires_in in response }), }) - const result = await refreshOAuthToken('google', refreshToken) + const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken)) expect(result).toEqual({ accessToken: 'new_access_token', @@ -345,44 +416,4 @@ describe('OAuth Token Refresh', () => { }) }) }) - - describe('Airtable Tests', () => { - it('should not have duplicate client ID issue', async () => { - const refreshToken = 'test_refresh_token' - - await refreshOAuthToken('airtable', refreshToken) - - const [, requestOptions] = (mockFetch as Mock).mock.calls[0] - - // Verify Authorization header is present and correct - expect(requestOptions.headers.Authorization).toMatch(/^Basic /) - - // Parse body and verify client credentials are NOT present - const bodyParams = new URLSearchParams(requestOptions.body) - expect(bodyParams.get('client_id')).toBeNull() - expect(bodyParams.get('client_secret')).toBeNull() - - // Verify only expected parameters are present - const bodyKeys = Array.from(bodyParams.keys()) - expect(bodyKeys).toEqual(['grant_type', 'refresh_token']) - }) - - it('should handle Airtable refresh token rotation', async () => { - const refreshToken = 'old_refresh_token' - const newRefreshToken = 'rotated_refresh_token' - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - access_token: 'new_access_token', - expires_in: 3600, - refresh_token: newRefreshToken, - }), - }) - - const result = await refreshOAuthToken('airtable', refreshToken) - - expect(result?.refreshToken).toBe(newRefreshToken) - }) - }) }) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index f9937f3bed..5c1e99b931 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -1,4 +1,3 @@ -import type { ReactNode } from 'react' import { AirtableIcon, AsanaIcon, @@ -41,238 +40,131 @@ import { } from '@/components/icons' import { env } from '@/lib/core/config/env' import { createLogger } from '@/lib/logs/console/logger' +import type { OAuthProviderConfig } from './types' const logger = createLogger('OAuth') -export type OAuthProvider = - | 'google' - | 'github' - | 'x' - | 'confluence' - | 'airtable' - | 'notion' - | 'jira' - | 'dropbox' - | 'microsoft' - | 'linear' - | 'slack' - | 'reddit' - | 'trello' - | 'wealthbox' - | 'webflow' - | 'asana' - | 'pipedrive' - | 'hubspot' - | 'salesforce' - | 'linkedin' - | 'shopify' - | 'zoom' - | 'wordpress' - | 'spotify' - | string - -export type OAuthService = - | 'google' - | 'google-email' - | 'google-drive' - | 'google-docs' - | 'google-sheets' - | 'google-calendar' - | 'google-vault' - | 'google-forms' - | 'google-groups' - | 'vertex-ai' - | 'github' - | 'x' - | 'confluence' - | 'airtable' - | 'notion' - | 'jira' - | 'dropbox' - | 'microsoft-excel' - | 'microsoft-teams' - | 'microsoft-planner' - | 'sharepoint' - | 'outlook' - | 'linear' - | 'slack' - | 'reddit' - | 'wealthbox' - | 'onedrive' - | 'webflow' - | 'trello' - | 'asana' - | 'pipedrive' - | 'hubspot' - | 'salesforce' - | 'linkedin' - | 'shopify' - | 'zoom' - | 'wordpress' - | 'spotify' - -export interface OAuthProviderConfig { - id: OAuthProvider - name: string - icon: (props: { className?: string }) => ReactNode - services: Record - defaultService: string -} - -export interface OAuthServiceConfig { - id: string - name: string - description: string - providerId: string - icon: (props: { className?: string }) => ReactNode - baseProviderIcon: (props: { className?: string }) => ReactNode - scopes: string[] - scopeHints?: string[] -} - export const OAUTH_PROVIDERS: Record = { google: { - id: 'google', name: 'Google', - icon: (props) => GoogleIcon(props), + icon: GoogleIcon, services: { gmail: { - id: 'gmail', name: 'Gmail', description: 'Automate email workflows and enhance communication efficiency.', providerId: 'google-email', - icon: (props) => GmailIcon(props), - baseProviderIcon: (props) => GoogleIcon(props), + icon: GmailIcon, + baseProviderIcon: GoogleIcon, scopes: [ 'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/gmail.labels', ], - scopeHints: ['gmail', 'mail'], }, 'google-drive': { - id: 'google-drive', name: 'Google Drive', description: 'Streamline file organization and document workflows.', providerId: 'google-drive', - icon: (props) => GoogleDriveIcon(props), - baseProviderIcon: (props) => GoogleIcon(props), + icon: GoogleDriveIcon, + baseProviderIcon: GoogleIcon, scopes: [ 'https://www.googleapis.com/auth/drive.file', 'https://www.googleapis.com/auth/drive', ], - scopeHints: ['drive'], }, 'google-docs': { - id: 'google-docs', name: 'Google Docs', description: 'Create, read, and edit Google Documents programmatically.', providerId: 'google-docs', - icon: (props) => GoogleDocsIcon(props), - baseProviderIcon: (props) => GoogleIcon(props), + icon: GoogleDocsIcon, + baseProviderIcon: GoogleIcon, scopes: [ 'https://www.googleapis.com/auth/drive.file', 'https://www.googleapis.com/auth/drive', ], - scopeHints: ['docs'], }, 'google-sheets': { - id: 'google-sheets', name: 'Google Sheets', description: 'Manage and analyze data with Google Sheets integration.', providerId: 'google-sheets', - icon: (props) => GoogleSheetsIcon(props), - baseProviderIcon: (props) => GoogleIcon(props), + icon: GoogleSheetsIcon, + baseProviderIcon: GoogleIcon, scopes: [ 'https://www.googleapis.com/auth/drive.file', 'https://www.googleapis.com/auth/drive', ], - scopeHints: ['sheets'], }, 'google-forms': { - id: 'google-forms', name: 'Google Forms', description: 'Retrieve Google Form responses.', providerId: 'google-forms', - icon: (props) => GoogleFormsIcon(props), - baseProviderIcon: (props) => GoogleIcon(props), + icon: GoogleFormsIcon, + baseProviderIcon: GoogleIcon, scopes: [ 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/forms.responses.readonly', ], - scopeHints: ['forms'], }, 'google-calendar': { - id: 'google-calendar', name: 'Google Calendar', description: 'Schedule and manage events with Google Calendar.', providerId: 'google-calendar', - icon: (props) => GoogleCalendarIcon(props), - baseProviderIcon: (props) => GoogleIcon(props), + icon: GoogleCalendarIcon, + baseProviderIcon: GoogleIcon, scopes: ['https://www.googleapis.com/auth/calendar'], - scopeHints: ['calendar'], }, 'google-vault': { - id: 'google-vault', name: 'Google Vault', description: 'Search, export, and manage matters/holds via Google Vault.', providerId: 'google-vault', - icon: (props) => GoogleIcon(props), - baseProviderIcon: (props) => GoogleIcon(props), + icon: GoogleIcon, + baseProviderIcon: GoogleIcon, scopes: [ 'https://www.googleapis.com/auth/ediscovery', 'https://www.googleapis.com/auth/devstorage.read_only', ], - scopeHints: ['ediscovery', 'devstorage'], }, 'google-groups': { - id: 'google-groups', name: 'Google Groups', description: 'Manage Google Workspace Groups and their members.', providerId: 'google-groups', - icon: (props) => GoogleGroupsIcon(props), - baseProviderIcon: (props) => GoogleIcon(props), + icon: GoogleGroupsIcon, + baseProviderIcon: GoogleIcon, scopes: [ 'https://www.googleapis.com/auth/admin.directory.group', 'https://www.googleapis.com/auth/admin.directory.group.member', ], - scopeHints: ['admin.directory.group'], }, 'vertex-ai': { - id: 'vertex-ai', name: 'Vertex AI', description: 'Access Google Cloud Vertex AI for Gemini models with OAuth.', providerId: 'vertex-ai', - icon: (props) => VertexIcon(props), - baseProviderIcon: (props) => VertexIcon(props), + icon: VertexIcon, + baseProviderIcon: VertexIcon, scopes: ['https://www.googleapis.com/auth/cloud-platform'], - scopeHints: ['cloud-platform', 'vertex', 'aiplatform'], }, }, defaultService: 'gmail', }, microsoft: { - id: 'microsoft', name: 'Microsoft', - icon: (props) => MicrosoftIcon(props), + icon: MicrosoftIcon, services: { 'microsoft-excel': { - id: 'microsoft-excel', name: 'Microsoft Excel', description: 'Connect to Microsoft Excel and manage spreadsheets.', providerId: 'microsoft-excel', - icon: (props) => MicrosoftExcelIcon(props), - baseProviderIcon: (props) => MicrosoftIcon(props), + icon: MicrosoftExcelIcon, + baseProviderIcon: MicrosoftIcon, scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'], }, 'microsoft-planner': { - id: 'microsoft-planner', name: 'Microsoft Planner', description: 'Connect to Microsoft Planner and manage tasks.', providerId: 'microsoft-planner', - icon: (props) => MicrosoftPlannerIcon(props), - baseProviderIcon: (props) => MicrosoftIcon(props), + icon: MicrosoftPlannerIcon, + baseProviderIcon: MicrosoftIcon, scopes: [ 'openid', 'profile', @@ -284,12 +176,11 @@ export const OAUTH_PROVIDERS: Record = { ], }, 'microsoft-teams': { - id: 'microsoft-teams', name: 'Microsoft Teams', description: 'Connect to Microsoft Teams and manage messages.', providerId: 'microsoft-teams', - icon: (props) => MicrosoftTeamsIcon(props), - baseProviderIcon: (props) => MicrosoftIcon(props), + icon: MicrosoftTeamsIcon, + baseProviderIcon: MicrosoftIcon, scopes: [ 'openid', 'profile', @@ -314,12 +205,11 @@ export const OAUTH_PROVIDERS: Record = { ], }, outlook: { - id: 'outlook', name: 'Outlook', description: 'Connect to Outlook and manage emails.', providerId: 'outlook', - icon: (props) => OutlookIcon(props), - baseProviderIcon: (props) => MicrosoftIcon(props), + icon: OutlookIcon, + baseProviderIcon: MicrosoftIcon, scopes: [ 'openid', 'profile', @@ -332,21 +222,19 @@ export const OAUTH_PROVIDERS: Record = { ], }, onedrive: { - id: 'onedrive', name: 'OneDrive', description: 'Connect to OneDrive and manage files.', providerId: 'onedrive', - icon: (props) => MicrosoftOneDriveIcon(props), - baseProviderIcon: (props) => MicrosoftIcon(props), + icon: MicrosoftOneDriveIcon, + baseProviderIcon: MicrosoftIcon, scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'], }, sharepoint: { - id: 'sharepoint', name: 'SharePoint', description: 'Connect to SharePoint and manage sites.', providerId: 'sharepoint', - icon: (props) => MicrosoftSharepointIcon(props), - baseProviderIcon: (props) => MicrosoftIcon(props), + icon: MicrosoftSharepointIcon, + baseProviderIcon: MicrosoftIcon, scopes: [ 'openid', 'profile', @@ -358,54 +246,48 @@ export const OAUTH_PROVIDERS: Record = { ], }, }, - defaultService: 'microsoft', + defaultService: 'outlook', }, github: { - id: 'github', name: 'GitHub', - icon: (props) => GithubIcon(props), + icon: GithubIcon, services: { github: { - id: 'github', name: 'GitHub', description: 'Manage repositories, issues, and pull requests.', providerId: 'github-repo', - icon: (props) => GithubIcon(props), - baseProviderIcon: (props) => GithubIcon(props), + icon: GithubIcon, + baseProviderIcon: GithubIcon, scopes: ['repo', 'user:email', 'read:user', 'workflow'], }, }, defaultService: 'github', }, x: { - id: 'x', name: 'X', - icon: (props) => xIcon(props), + icon: xIcon, services: { x: { - id: 'x', name: 'X', description: 'Read and post tweets on X (formerly Twitter).', providerId: 'x', - icon: (props) => xIcon(props), - baseProviderIcon: (props) => xIcon(props), + icon: xIcon, + baseProviderIcon: xIcon, scopes: ['tweet.read', 'tweet.write', 'users.read', 'offline.access'], }, }, defaultService: 'x', }, confluence: { - id: 'confluence', name: 'Confluence', - icon: (props) => ConfluenceIcon(props), + icon: ConfluenceIcon, services: { confluence: { - id: 'confluence', name: 'Confluence', description: 'Access Confluence content and documentation.', providerId: 'confluence', - icon: (props) => ConfluenceIcon(props), - baseProviderIcon: (props) => ConfluenceIcon(props), + icon: ConfluenceIcon, + baseProviderIcon: ConfluenceIcon, scopes: [ 'read:confluence-content.all', 'read:confluence-space.summary', @@ -435,17 +317,15 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'confluence', }, jira: { - id: 'jira', name: 'Jira', - icon: (props) => JiraIcon(props), + icon: JiraIcon, services: { jira: { - id: 'jira', name: 'Jira', description: 'Access Jira projects and issues.', providerId: 'jira', - icon: (props) => JiraIcon(props), - baseProviderIcon: (props) => JiraIcon(props), + icon: JiraIcon, + baseProviderIcon: JiraIcon, scopes: [ 'read:jira-user', 'read:jira-work', @@ -491,68 +371,60 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'jira', }, airtable: { - id: 'airtable', name: 'Airtable', - icon: (props) => AirtableIcon(props), + icon: AirtableIcon, services: { airtable: { - id: 'airtable', name: 'Airtable', description: 'Manage Airtable bases, tables, and records.', providerId: 'airtable', - icon: (props) => AirtableIcon(props), - baseProviderIcon: (props) => AirtableIcon(props), + icon: AirtableIcon, + baseProviderIcon: AirtableIcon, scopes: ['data.records:read', 'data.records:write', 'user.email:read', 'webhook:manage'], }, }, defaultService: 'airtable', }, notion: { - id: 'notion', name: 'Notion', - icon: (props) => NotionIcon(props), + icon: NotionIcon, services: { notion: { - id: 'notion', name: 'Notion', description: 'Connect to your Notion workspace to manage pages and databases.', providerId: 'notion', - icon: (props) => NotionIcon(props), - baseProviderIcon: (props) => NotionIcon(props), + icon: NotionIcon, + baseProviderIcon: NotionIcon, scopes: [], }, }, defaultService: 'notion', }, linear: { - id: 'linear', name: 'Linear', - icon: (props) => LinearIcon(props), + icon: LinearIcon, services: { linear: { - id: 'linear', name: 'Linear', description: 'Manage issues and projects in Linear.', providerId: 'linear', - icon: (props) => LinearIcon(props), - baseProviderIcon: (props) => LinearIcon(props), + icon: LinearIcon, + baseProviderIcon: LinearIcon, scopes: ['read', 'write'], }, }, defaultService: 'linear', }, dropbox: { - id: 'dropbox', name: 'Dropbox', - icon: (props) => DropboxIcon(props), + icon: DropboxIcon, services: { dropbox: { - id: 'dropbox', name: 'Dropbox', description: 'Upload, download, share, and manage files in Dropbox.', providerId: 'dropbox', - icon: (props) => DropboxIcon(props), - baseProviderIcon: (props) => DropboxIcon(props), + icon: DropboxIcon, + baseProviderIcon: DropboxIcon, scopes: [ 'account_info.read', 'files.metadata.read', @@ -567,17 +439,15 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'dropbox', }, shopify: { - id: 'shopify', name: 'Shopify', - icon: (props) => ShopifyIcon(props), + icon: ShopifyIcon, services: { shopify: { - id: 'shopify', name: 'Shopify', description: 'Manage products, orders, and customers in your Shopify store.', providerId: 'shopify', - icon: (props) => ShopifyIcon(props), - baseProviderIcon: (props) => ShopifyIcon(props), + icon: ShopifyIcon, + baseProviderIcon: ShopifyIcon, scopes: [ 'write_products', 'write_orders', @@ -591,17 +461,15 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'shopify', }, slack: { - id: 'slack', name: 'Slack', - icon: (props) => SlackIcon(props), + icon: SlackIcon, services: { slack: { - id: 'slack', name: 'Slack', description: 'Send messages using a Slack bot.', providerId: 'slack', - icon: (props) => SlackIcon(props), - baseProviderIcon: (props) => SlackIcon(props), + icon: SlackIcon, + baseProviderIcon: SlackIcon, scopes: [ 'channels:read', 'channels:history', @@ -623,17 +491,15 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'slack', }, reddit: { - id: 'reddit', name: 'Reddit', - icon: (props) => RedditIcon(props), + icon: RedditIcon, services: { reddit: { - id: 'reddit', name: 'Reddit', description: 'Access Reddit data and content from subreddits.', providerId: 'reddit', - icon: (props) => RedditIcon(props), - baseProviderIcon: (props) => RedditIcon(props), + icon: RedditIcon, + baseProviderIcon: RedditIcon, scopes: [ 'identity', 'read', @@ -657,85 +523,75 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'reddit', }, wealthbox: { - id: 'wealthbox', name: 'Wealthbox', - icon: (props) => WealthboxIcon(props), + icon: WealthboxIcon, services: { wealthbox: { - id: 'wealthbox', name: 'Wealthbox', description: 'Manage contacts, notes, and tasks in your Wealthbox CRM.', providerId: 'wealthbox', - icon: (props) => WealthboxIcon(props), - baseProviderIcon: (props) => WealthboxIcon(props), + icon: WealthboxIcon, + baseProviderIcon: WealthboxIcon, scopes: ['login', 'data'], }, }, defaultService: 'wealthbox', }, webflow: { - id: 'webflow', name: 'Webflow', - icon: (props) => WebflowIcon(props), + icon: WebflowIcon, services: { webflow: { - id: 'webflow', name: 'Webflow', description: 'Manage Webflow CMS collections, sites, and content.', providerId: 'webflow', - icon: (props) => WebflowIcon(props), - baseProviderIcon: (props) => WebflowIcon(props), + icon: WebflowIcon, + baseProviderIcon: WebflowIcon, scopes: ['cms:read', 'cms:write', 'sites:read', 'sites:write'], }, }, defaultService: 'webflow', }, trello: { - id: 'trello', name: 'Trello', - icon: (props) => TrelloIcon(props), + icon: TrelloIcon, services: { trello: { - id: 'trello', name: 'Trello', description: 'Manage Trello boards, cards, and workflows.', providerId: 'trello', - icon: (props) => TrelloIcon(props), - baseProviderIcon: (props) => TrelloIcon(props), + icon: TrelloIcon, + baseProviderIcon: TrelloIcon, scopes: ['read', 'write'], }, }, defaultService: 'trello', }, asana: { - id: 'asana', name: 'Asana', - icon: (props) => AsanaIcon(props), + icon: AsanaIcon, services: { asana: { - id: 'asana', name: 'Asana', description: 'Manage Asana projects, tasks, and workflows.', providerId: 'asana', - icon: (props) => AsanaIcon(props), - baseProviderIcon: (props) => AsanaIcon(props), + icon: AsanaIcon, + baseProviderIcon: AsanaIcon, scopes: ['default'], }, }, defaultService: 'asana', }, pipedrive: { - id: 'pipedrive', name: 'Pipedrive', - icon: (props) => PipedriveIcon(props), + icon: PipedriveIcon, services: { pipedrive: { - id: 'pipedrive', name: 'Pipedrive', description: 'Manage deals, contacts, and sales pipeline in Pipedrive CRM.', providerId: 'pipedrive', - icon: (props) => PipedriveIcon(props), - baseProviderIcon: (props) => PipedriveIcon(props), + icon: PipedriveIcon, + baseProviderIcon: PipedriveIcon, scopes: [ 'base', 'deals:full', @@ -750,17 +606,15 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'pipedrive', }, hubspot: { - id: 'hubspot', name: 'HubSpot', - icon: (props) => HubspotIcon(props), + icon: HubspotIcon, services: { hubspot: { - id: 'hubspot', name: 'HubSpot', description: 'Access and manage your HubSpot CRM data.', providerId: 'hubspot', - icon: (props) => HubspotIcon(props), - baseProviderIcon: (props) => HubspotIcon(props), + icon: HubspotIcon, + baseProviderIcon: HubspotIcon, scopes: [ 'crm.objects.contacts.read', 'crm.objects.contacts.write', @@ -791,51 +645,45 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'hubspot', }, linkedin: { - id: 'linkedin', name: 'LinkedIn', - icon: (props) => LinkedInIcon(props), + icon: LinkedInIcon, services: { linkedin: { - id: 'linkedin', name: 'LinkedIn', description: 'Share posts and access profile data on LinkedIn.', providerId: 'linkedin', - icon: (props) => LinkedInIcon(props), - baseProviderIcon: (props) => LinkedInIcon(props), + icon: LinkedInIcon, + baseProviderIcon: LinkedInIcon, scopes: ['profile', 'openid', 'email', 'w_member_social'], }, }, defaultService: 'linkedin', }, salesforce: { - id: 'salesforce', name: 'Salesforce', - icon: (props) => SalesforceIcon(props), + icon: SalesforceIcon, services: { salesforce: { - id: 'salesforce', name: 'Salesforce', description: 'Access and manage your Salesforce CRM data.', providerId: 'salesforce', - icon: (props) => SalesforceIcon(props), - baseProviderIcon: (props) => SalesforceIcon(props), + icon: SalesforceIcon, + baseProviderIcon: SalesforceIcon, scopes: ['api', 'refresh_token', 'openid', 'offline_access'], }, }, defaultService: 'salesforce', }, zoom: { - id: 'zoom', name: 'Zoom', - icon: (props) => ZoomIcon(props), + icon: ZoomIcon, services: { zoom: { - id: 'zoom', name: 'Zoom', description: 'Create and manage Zoom meetings, users, and recordings.', providerId: 'zoom', - icon: (props) => ZoomIcon(props), - baseProviderIcon: (props) => ZoomIcon(props), + icon: ZoomIcon, + baseProviderIcon: ZoomIcon, scopes: [ 'user:read:user', 'meeting:write:meeting', @@ -854,34 +702,30 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'zoom', }, wordpress: { - id: 'wordpress', name: 'WordPress', - icon: (props) => WordpressIcon(props), + icon: WordpressIcon, services: { wordpress: { - id: 'wordpress', name: 'WordPress', description: 'Manage posts, pages, media, comments, and more on WordPress sites.', providerId: 'wordpress', - icon: (props) => WordpressIcon(props), - baseProviderIcon: (props) => WordpressIcon(props), + icon: WordpressIcon, + baseProviderIcon: WordpressIcon, scopes: ['global'], }, }, defaultService: 'wordpress', }, spotify: { - id: 'spotify', name: 'Spotify', - icon: (props) => SpotifyIcon(props), + icon: SpotifyIcon, services: { spotify: { - id: 'spotify', name: 'Spotify', description: 'Search music, manage playlists, control playback, and access your library.', providerId: 'spotify', - icon: (props) => SpotifyIcon(props), - baseProviderIcon: (props) => SpotifyIcon(props), + icon: SpotifyIcon, + baseProviderIcon: SpotifyIcon, scopes: [ 'user-read-private', 'user-read-email', @@ -907,234 +751,6 @@ export const OAUTH_PROVIDERS: Record = { }, } -/** - * Service metadata without React components - safe for server-side use - */ -export interface OAuthServiceMetadata { - providerId: string - name: string - description: string - baseProvider: string -} - -/** - * Returns a flat list of all available OAuth services with metadata. - * This is safe to use on the server as it doesn't include React components. - */ -export function getAllOAuthServices(): OAuthServiceMetadata[] { - const services: OAuthServiceMetadata[] = [] - - for (const [baseProviderId, provider] of Object.entries(OAUTH_PROVIDERS)) { - for (const service of Object.values(provider.services)) { - services.push({ - providerId: service.providerId, - name: service.name, - description: service.description, - baseProvider: baseProviderId, - }) - } - } - - return services -} - -export function getServiceByProviderAndId( - provider: OAuthProvider, - serviceId?: string -): OAuthServiceConfig { - const providerConfig = OAUTH_PROVIDERS[provider] - if (!providerConfig) { - throw new Error(`Provider ${provider} not found`) - } - - if (!serviceId) { - return providerConfig.services[providerConfig.defaultService] - } - - return ( - providerConfig.services[serviceId] || providerConfig.services[providerConfig.defaultService] - ) -} - -export function getServiceIdFromScopes(provider: OAuthProvider, scopes: string[]): string { - const { baseProvider, featureType } = parseProvider(provider) - const providerConfig = OAUTH_PROVIDERS[baseProvider] || OAUTH_PROVIDERS[provider] - if (!providerConfig) { - return provider - } - - if (featureType !== 'default' && providerConfig.services[featureType]) { - return featureType - } - - const normalizedScopes = (scopes || []).map((s) => s.toLowerCase()) - for (const service of Object.values(providerConfig.services)) { - const hints = (service.scopeHints || []).map((h) => h.toLowerCase()) - if (hints.length === 0) continue - if (normalizedScopes.some((scope) => hints.some((hint) => scope.includes(hint)))) { - return service.id - } - } - - return providerConfig.defaultService -} - -export function getProviderIdFromServiceId(serviceId: string): string { - for (const provider of Object.values(OAUTH_PROVIDERS)) { - for (const [id, service] of Object.entries(provider.services)) { - if (id === serviceId) { - return service.providerId - } - } - } - - // Default fallback - return serviceId -} - -export function getServiceConfigByProviderId(providerId: string): OAuthServiceConfig | null { - for (const provider of Object.values(OAUTH_PROVIDERS)) { - for (const service of Object.values(provider.services)) { - if (service.providerId === providerId || service.id === providerId) { - return service - } - } - } - - return null -} - -export function getCanonicalScopesForProvider(providerId: string): string[] { - const service = getServiceConfigByProviderId(providerId) - return service?.scopes ? [...service.scopes] : [] -} - -export function normalizeScopes(scopes: string[]): string[] { - const seen = new Set() - for (const scope of scopes) { - const trimmed = scope.trim() - if (trimmed && !seen.has(trimmed)) { - seen.add(trimmed) - } - } - return Array.from(seen) -} - -export interface ScopeEvaluation { - canonicalScopes: string[] - grantedScopes: string[] - missingScopes: string[] - extraScopes: string[] - requiresReauthorization: boolean -} - -export function evaluateScopeCoverage( - providerId: string, - grantedScopes: string[] -): ScopeEvaluation { - const canonicalScopes = getCanonicalScopesForProvider(providerId) - const normalizedGranted = normalizeScopes(grantedScopes) - - const canonicalSet = new Set(canonicalScopes) - const grantedSet = new Set(normalizedGranted) - - const missingScopes = canonicalScopes.filter((scope) => !grantedSet.has(scope)) - const extraScopes = normalizedGranted.filter((scope) => !canonicalSet.has(scope)) - - return { - canonicalScopes, - grantedScopes: normalizedGranted, - missingScopes, - extraScopes, - requiresReauthorization: missingScopes.length > 0, - } -} - -export interface Credential { - id: string - name: string - provider: OAuthProvider - serviceId?: string - lastUsed?: string - isDefault?: boolean - scopes?: string[] - canonicalScopes?: string[] - missingScopes?: string[] - extraScopes?: string[] - requiresReauthorization?: boolean -} - -export interface ProviderConfig { - baseProvider: string - featureType: string -} - -/** - * Parse a provider string into its base provider and feature type - * This is a server-safe utility that can be used in both client and server code - */ -export function parseProvider(provider: OAuthProvider): ProviderConfig { - // Handle special cases first - if (provider === 'outlook') { - return { - baseProvider: 'microsoft', - featureType: 'outlook', - } - } - if (provider === 'onedrive') { - return { - baseProvider: 'microsoft', - featureType: 'onedrive', - } - } - if (provider === 'sharepoint') { - return { - baseProvider: 'microsoft', - featureType: 'sharepoint', - } - } - if (provider === 'microsoft-teams' || provider === 'microsoftteams') { - return { - baseProvider: 'microsoft', - featureType: 'microsoft-teams', - } - } - if (provider === 'microsoft-excel') { - return { - baseProvider: 'microsoft', - featureType: 'microsoft-excel', - } - } - if (provider === 'microsoft-planner') { - return { - baseProvider: 'microsoft', - featureType: 'microsoft-planner', - } - } - if (provider === 'vertex-ai') { - return { - baseProvider: 'google', - featureType: 'vertex-ai', - } - } - - // Handle compound providers (e.g., 'google-email' -> { baseProvider: 'google', featureType: 'email' }) - const [base, feature] = provider.split('-') - - if (feature) { - return { - baseProvider: base, - featureType: feature, - } - } - - // For simple providers, use 'default' as feature type - return { - baseProvider: provider, - featureType: 'default', - } -} - interface ProviderAuthConfig { tokenEndpoint: string clientId: string @@ -1226,18 +842,6 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { supportsRefreshTokenRotation: true, } } - // case 'supabase': { - // const { clientId, clientSecret } = getCredentials( - // env.SUPABASE_CLIENT_ID, - // env.SUPABASE_CLIENT_SECRET - // ) - // return { - // tokenEndpoint: 'https://api.supabase.com/v1/oauth/token', - // clientId, - // clientSecret, - // useBasicAuth: false, - // } - // } case 'notion': { const { clientId, clientSecret } = getCredentials( env.NOTION_CLIENT_ID, @@ -1250,42 +854,9 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { useBasicAuth: false, } } - case 'microsoft': { - const { clientId, clientSecret } = getCredentials( - env.MICROSOFT_CLIENT_ID, - env.MICROSOFT_CLIENT_SECRET - ) - return { - tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - clientId, - clientSecret, - useBasicAuth: false, - } - } - case 'outlook': { - const { clientId, clientSecret } = getCredentials( - env.MICROSOFT_CLIENT_ID, - env.MICROSOFT_CLIENT_SECRET - ) - return { - tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - clientId, - clientSecret, - useBasicAuth: false, - } - } - case 'onedrive': { - const { clientId, clientSecret } = getCredentials( - env.MICROSOFT_CLIENT_ID, - env.MICROSOFT_CLIENT_SECRET - ) - return { - tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - clientId, - clientSecret, - useBasicAuth: false, - } - } + case 'microsoft': + case 'outlook': + case 'onedrive': case 'sharepoint': { const { clientId, clientSecret } = getCredentials( env.MICROSOFT_CLIENT_ID, diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts new file mode 100644 index 0000000000..0555688813 --- /dev/null +++ b/apps/sim/lib/oauth/types.ts @@ -0,0 +1,136 @@ +import type { ReactNode } from 'react' + +export type OAuthProvider = + | 'google' + | 'google-email' + | 'google-drive' + | 'google-docs' + | 'google-sheets' + | 'google-calendar' + | 'google-vault' + | 'google-forms' + | 'google-groups' + | 'vertex-ai' + | 'github' + | 'github-repo' + | 'x' + | 'confluence' + | 'airtable' + | 'notion' + | 'jira' + | 'dropbox' + | 'microsoft' + | 'microsoft-excel' + | 'microsoft-planner' + | 'microsoft-teams' + | 'outlook' + | 'onedrive' + | 'sharepoint' + | 'linear' + | 'slack' + | 'reddit' + | 'trello' + | 'wealthbox' + | 'webflow' + | 'asana' + | 'pipedrive' + | 'hubspot' + | 'salesforce' + | 'linkedin' + | 'shopify' + | 'zoom' + | 'wordpress' + | 'spotify' + +export type OAuthService = + | 'google' + | 'google-email' + | 'google-drive' + | 'google-docs' + | 'google-sheets' + | 'google-calendar' + | 'google-vault' + | 'google-forms' + | 'google-groups' + | 'vertex-ai' + | 'github' + | 'x' + | 'confluence' + | 'airtable' + | 'notion' + | 'jira' + | 'dropbox' + | 'microsoft-excel' + | 'microsoft-teams' + | 'microsoft-planner' + | 'sharepoint' + | 'outlook' + | 'linear' + | 'slack' + | 'reddit' + | 'wealthbox' + | 'onedrive' + | 'webflow' + | 'trello' + | 'asana' + | 'pipedrive' + | 'hubspot' + | 'salesforce' + | 'linkedin' + | 'shopify' + | 'zoom' + | 'wordpress' + | 'spotify' + +export interface OAuthProviderConfig { + name: string + icon: (props: { className?: string }) => ReactNode + services: Record + defaultService: string +} + +export interface OAuthServiceConfig { + name: string + description: string + providerId: string + icon: (props: { className?: string }) => ReactNode + baseProviderIcon: (props: { className?: string }) => ReactNode + scopes: string[] +} + +/** + * Service metadata without React components - safe for server-side use + */ +export interface OAuthServiceMetadata { + providerId: string + name: string + description: string + baseProvider: string +} + +export interface ScopeEvaluation { + canonicalScopes: string[] + grantedScopes: string[] + missingScopes: string[] + extraScopes: string[] + requiresReauthorization: boolean +} + +export interface Credential { + id: string + name: string + provider: OAuthProvider + serviceId?: string + lastUsed?: string + isDefault?: boolean + scopes?: string[] + canonicalScopes?: string[] + missingScopes?: string[] + extraScopes?: string[] + requiresReauthorization?: boolean +} + +export interface ProviderConfig { + baseProvider: string + featureType: string +} diff --git a/apps/sim/lib/oauth/utils.test.ts b/apps/sim/lib/oauth/utils.test.ts new file mode 100644 index 0000000000..08fa08a229 --- /dev/null +++ b/apps/sim/lib/oauth/utils.test.ts @@ -0,0 +1,804 @@ +import { describe, expect, it } from 'vitest' +import type { OAuthProvider, OAuthServiceMetadata } from './types' +import { + evaluateScopeCoverage, + getAllOAuthServices, + getCanonicalScopesForProvider, + getProviderIdFromServiceId, + getServiceByProviderAndId, + getServiceConfigByProviderId, + normalizeScopes, + parseProvider, +} from './utils' + +describe('getAllOAuthServices', () => { + it.concurrent('should return an array of OAuth services', () => { + const services = getAllOAuthServices() + + expect(services).toBeInstanceOf(Array) + expect(services.length).toBeGreaterThan(0) + }) + + it.concurrent('should include all required metadata fields for each service', () => { + const services = getAllOAuthServices() + + services.forEach((service) => { + expect(service).toHaveProperty('providerId') + expect(service).toHaveProperty('name') + expect(service).toHaveProperty('description') + expect(service).toHaveProperty('baseProvider') + + expect(typeof service.providerId).toBe('string') + expect(typeof service.name).toBe('string') + expect(typeof service.description).toBe('string') + expect(typeof service.baseProvider).toBe('string') + }) + }) + + it.concurrent('should include Google services', () => { + const services = getAllOAuthServices() + + const gmailService = services.find((s) => s.providerId === 'google-email') + expect(gmailService).toBeDefined() + expect(gmailService?.name).toBe('Gmail') + expect(gmailService?.baseProvider).toBe('google') + + const driveService = services.find((s) => s.providerId === 'google-drive') + expect(driveService).toBeDefined() + expect(driveService?.name).toBe('Google Drive') + expect(driveService?.baseProvider).toBe('google') + }) + + it.concurrent('should include Microsoft services', () => { + const services = getAllOAuthServices() + + const outlookService = services.find((s) => s.providerId === 'outlook') + expect(outlookService).toBeDefined() + expect(outlookService?.name).toBe('Outlook') + expect(outlookService?.baseProvider).toBe('microsoft') + + const excelService = services.find((s) => s.providerId === 'microsoft-excel') + expect(excelService).toBeDefined() + expect(excelService?.name).toBe('Microsoft Excel') + expect(excelService?.baseProvider).toBe('microsoft') + }) + + it.concurrent('should include single-service providers', () => { + const services = getAllOAuthServices() + + const githubService = services.find((s) => s.providerId === 'github-repo') + expect(githubService).toBeDefined() + expect(githubService?.name).toBe('GitHub') + expect(githubService?.baseProvider).toBe('github') + + const slackService = services.find((s) => s.providerId === 'slack') + expect(slackService).toBeDefined() + expect(slackService?.name).toBe('Slack') + expect(slackService?.baseProvider).toBe('slack') + }) + + it.concurrent('should not include duplicate services', () => { + const services = getAllOAuthServices() + const providerIds = services.map((s) => s.providerId) + const uniqueProviderIds = new Set(providerIds) + + expect(providerIds.length).toBe(uniqueProviderIds.size) + }) + + it.concurrent('should return services that match the OAuthServiceMetadata interface', () => { + const services = getAllOAuthServices() + + services.forEach((service) => { + const metadata: OAuthServiceMetadata = service + expect(metadata.providerId).toBeDefined() + expect(metadata.name).toBeDefined() + expect(metadata.description).toBeDefined() + expect(metadata.baseProvider).toBeDefined() + }) + }) +}) + +describe('getServiceByProviderAndId', () => { + it.concurrent('should return default service when no serviceId is provided', () => { + const service = getServiceByProviderAndId('google') + + expect(service).toBeDefined() + expect(service.providerId).toBe('google-email') + expect(service.name).toBe('Gmail') + }) + + it.concurrent('should return specific service when serviceId is provided', () => { + const service = getServiceByProviderAndId('google', 'google-drive') + + expect(service).toBeDefined() + expect(service.providerId).toBe('google-drive') + expect(service.name).toBe('Google Drive') + }) + + it.concurrent('should return default service when invalid serviceId is provided', () => { + const service = getServiceByProviderAndId('google', 'invalid-service') + + expect(service).toBeDefined() + expect(service.providerId).toBe('google-email') + expect(service.name).toBe('Gmail') + }) + + it.concurrent('should throw error for invalid provider', () => { + expect(() => { + getServiceByProviderAndId('invalid-provider' as OAuthProvider) + }).toThrow('Provider invalid-provider not found') + }) + + it.concurrent('should work with Microsoft provider', () => { + const service = getServiceByProviderAndId('microsoft') + + expect(service).toBeDefined() + expect(service.providerId).toBe('outlook') + expect(service.name).toBe('Outlook') + }) + + it.concurrent('should work with Microsoft Excel serviceId', () => { + const service = getServiceByProviderAndId('microsoft', 'microsoft-excel') + + expect(service).toBeDefined() + expect(service.providerId).toBe('microsoft-excel') + expect(service.name).toBe('Microsoft Excel') + }) + + it.concurrent('should work with single-service providers', () => { + const service = getServiceByProviderAndId('github') + + expect(service).toBeDefined() + expect(service.providerId).toBe('github-repo') + expect(service.name).toBe('GitHub') + }) + + it.concurrent('should include scopes in returned service config', () => { + const service = getServiceByProviderAndId('google', 'gmail') + + expect(service.scopes).toBeDefined() + expect(Array.isArray(service.scopes)).toBe(true) + expect(service.scopes.length).toBeGreaterThan(0) + expect(service.scopes).toContain('https://www.googleapis.com/auth/gmail.send') + }) +}) + +describe('getProviderIdFromServiceId', () => { + it.concurrent('should return correct providerId for Gmail', () => { + const providerId = getProviderIdFromServiceId('gmail') + + expect(providerId).toBe('google-email') + }) + + it.concurrent('should return correct providerId for Google Drive', () => { + const providerId = getProviderIdFromServiceId('google-drive') + + expect(providerId).toBe('google-drive') + }) + + it.concurrent('should return correct providerId for Outlook', () => { + const providerId = getProviderIdFromServiceId('outlook') + + expect(providerId).toBe('outlook') + }) + + it.concurrent('should return correct providerId for GitHub', () => { + const providerId = getProviderIdFromServiceId('github') + + expect(providerId).toBe('github-repo') + }) + + it.concurrent('should return correct providerId for Microsoft Excel', () => { + const providerId = getProviderIdFromServiceId('microsoft-excel') + + expect(providerId).toBe('microsoft-excel') + }) + + it.concurrent('should return serviceId as fallback for unknown service', () => { + const providerId = getProviderIdFromServiceId('unknown-service') + + expect(providerId).toBe('unknown-service') + }) + + it.concurrent('should handle empty string', () => { + const providerId = getProviderIdFromServiceId('') + + expect(providerId).toBe('') + }) + + it.concurrent('should work for all Google services', () => { + const googleServices = [ + { serviceId: 'gmail', expectedProviderId: 'google-email' }, + { serviceId: 'google-drive', expectedProviderId: 'google-drive' }, + { serviceId: 'google-docs', expectedProviderId: 'google-docs' }, + { serviceId: 'google-sheets', expectedProviderId: 'google-sheets' }, + { serviceId: 'google-forms', expectedProviderId: 'google-forms' }, + { serviceId: 'google-calendar', expectedProviderId: 'google-calendar' }, + { serviceId: 'google-vault', expectedProviderId: 'google-vault' }, + { serviceId: 'google-groups', expectedProviderId: 'google-groups' }, + { serviceId: 'vertex-ai', expectedProviderId: 'vertex-ai' }, + ] + + googleServices.forEach(({ serviceId, expectedProviderId }) => { + expect(getProviderIdFromServiceId(serviceId)).toBe(expectedProviderId) + }) + }) +}) + +describe('getServiceConfigByProviderId', () => { + it.concurrent('should return service config for valid providerId', () => { + const service = getServiceConfigByProviderId('google-email') + + expect(service).toBeDefined() + expect(service?.providerId).toBe('google-email') + expect(service?.name).toBe('Gmail') + }) + + it.concurrent('should return service config for service key', () => { + const service = getServiceConfigByProviderId('gmail') + + expect(service).toBeDefined() + expect(service?.providerId).toBe('google-email') + expect(service?.name).toBe('Gmail') + }) + + it.concurrent('should return null for invalid providerId', () => { + const service = getServiceConfigByProviderId('invalid-provider') + + expect(service).toBeNull() + }) + + it.concurrent('should work for Microsoft services', () => { + const outlookService = getServiceConfigByProviderId('outlook') + + expect(outlookService).toBeDefined() + expect(outlookService?.providerId).toBe('outlook') + expect(outlookService?.name).toBe('Outlook') + + const excelService = getServiceConfigByProviderId('microsoft-excel') + + expect(excelService).toBeDefined() + expect(excelService?.providerId).toBe('microsoft-excel') + expect(excelService?.name).toBe('Microsoft Excel') + }) + + it.concurrent('should work for GitHub', () => { + const service = getServiceConfigByProviderId('github-repo') + + expect(service).toBeDefined() + expect(service?.providerId).toBe('github-repo') + expect(service?.name).toBe('GitHub') + }) + + it.concurrent('should work for Slack', () => { + const service = getServiceConfigByProviderId('slack') + + expect(service).toBeDefined() + expect(service?.providerId).toBe('slack') + expect(service?.name).toBe('Slack') + }) + + it.concurrent('should return service with scopes', () => { + const service = getServiceConfigByProviderId('google-drive') + + expect(service).toBeDefined() + expect(service?.scopes).toBeDefined() + expect(Array.isArray(service?.scopes)).toBe(true) + expect(service?.scopes.length).toBeGreaterThan(0) + }) + + it.concurrent('should handle empty string', () => { + const service = getServiceConfigByProviderId('') + + expect(service).toBeNull() + }) +}) + +describe('getCanonicalScopesForProvider', () => { + it.concurrent('should return scopes for valid providerId', () => { + const scopes = getCanonicalScopesForProvider('google-email') + + expect(Array.isArray(scopes)).toBe(true) + expect(scopes.length).toBeGreaterThan(0) + expect(scopes).toContain('https://www.googleapis.com/auth/gmail.send') + expect(scopes).toContain('https://www.googleapis.com/auth/gmail.modify') + }) + + it.concurrent('should return new array instance (not reference)', () => { + const scopes1 = getCanonicalScopesForProvider('google-email') + const scopes2 = getCanonicalScopesForProvider('google-email') + + expect(scopes1).not.toBe(scopes2) + expect(scopes1).toEqual(scopes2) + }) + + it.concurrent('should return empty array for invalid providerId', () => { + const scopes = getCanonicalScopesForProvider('invalid-provider') + + expect(Array.isArray(scopes)).toBe(true) + expect(scopes.length).toBe(0) + }) + + it.concurrent('should work for service key', () => { + const scopes = getCanonicalScopesForProvider('gmail') + + expect(Array.isArray(scopes)).toBe(true) + expect(scopes.length).toBeGreaterThan(0) + }) + + it.concurrent('should return scopes for Microsoft services', () => { + const outlookScopes = getCanonicalScopesForProvider('outlook') + + expect(outlookScopes.length).toBeGreaterThan(0) + expect(outlookScopes).toContain('Mail.ReadWrite') + + const excelScopes = getCanonicalScopesForProvider('microsoft-excel') + + expect(excelScopes.length).toBeGreaterThan(0) + expect(excelScopes).toContain('Files.Read') + }) + + it.concurrent('should return scopes for GitHub', () => { + const scopes = getCanonicalScopesForProvider('github-repo') + + expect(scopes.length).toBeGreaterThan(0) + expect(scopes).toContain('repo') + expect(scopes).toContain('user:email') + }) + + it.concurrent('should handle providers with empty scopes array', () => { + const scopes = getCanonicalScopesForProvider('notion') + + expect(Array.isArray(scopes)).toBe(true) + expect(scopes.length).toBe(0) + }) + + it.concurrent('should return empty array for empty string', () => { + const scopes = getCanonicalScopesForProvider('') + + expect(Array.isArray(scopes)).toBe(true) + expect(scopes.length).toBe(0) + }) +}) + +describe('normalizeScopes', () => { + it.concurrent('should remove duplicates from scope array', () => { + const scopes = ['scope1', 'scope2', 'scope1', 'scope3', 'scope2'] + const normalized = normalizeScopes(scopes) + + expect(normalized.length).toBe(3) + expect(normalized).toContain('scope1') + expect(normalized).toContain('scope2') + expect(normalized).toContain('scope3') + }) + + it.concurrent('should trim whitespace from scopes', () => { + const scopes = [' scope1 ', 'scope2', ' scope3 '] + const normalized = normalizeScopes(scopes) + + expect(normalized).toEqual(['scope1', 'scope2', 'scope3']) + }) + + it.concurrent('should remove empty strings', () => { + const scopes = ['scope1', '', 'scope2', ' ', 'scope3'] + const normalized = normalizeScopes(scopes) + + expect(normalized.length).toBe(3) + expect(normalized).toEqual(['scope1', 'scope2', 'scope3']) + }) + + it.concurrent('should handle empty array', () => { + const normalized = normalizeScopes([]) + + expect(Array.isArray(normalized)).toBe(true) + expect(normalized.length).toBe(0) + }) + + it.concurrent('should handle array with only empty strings', () => { + const normalized = normalizeScopes(['', ' ', ' ']) + + expect(Array.isArray(normalized)).toBe(true) + expect(normalized.length).toBe(0) + }) + + it.concurrent('should preserve order of first occurrence', () => { + const scopes = ['scope3', 'scope1', 'scope2', 'scope1', 'scope3'] + const normalized = normalizeScopes(scopes) + + expect(normalized).toEqual(['scope3', 'scope1', 'scope2']) + }) + + it.concurrent('should handle scopes with special characters', () => { + const scopes = [ + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.send', + ] + const normalized = normalizeScopes(scopes) + + expect(normalized.length).toBe(2) + expect(normalized).toContain('https://www.googleapis.com/auth/gmail.send') + expect(normalized).toContain('https://www.googleapis.com/auth/gmail.modify') + }) + + it.concurrent('should handle single scope', () => { + const normalized = normalizeScopes(['scope1']) + + expect(normalized).toEqual(['scope1']) + }) + + it.concurrent('should handle scopes with mixed whitespace', () => { + const scopes = ['scope1', '\tscope2\t', '\nscope3\n', ' scope1 '] + const normalized = normalizeScopes(scopes) + + expect(normalized.length).toBe(3) + expect(normalized).toContain('scope1') + expect(normalized).toContain('scope2') + expect(normalized).toContain('scope3') + }) +}) + +describe('evaluateScopeCoverage', () => { + it.concurrent('should identify missing scopes', () => { + const evaluation = evaluateScopeCoverage('google-email', [ + 'https://www.googleapis.com/auth/gmail.send', + ]) + + expect(evaluation.missingScopes.length).toBeGreaterThan(0) + expect(evaluation.missingScopes).toContain('https://www.googleapis.com/auth/gmail.modify') + expect(evaluation.requiresReauthorization).toBe(true) + }) + + it.concurrent('should identify extra scopes', () => { + const evaluation = evaluateScopeCoverage('google-email', [ + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.labels', + 'https://www.googleapis.com/auth/calendar', + ]) + + expect(evaluation.extraScopes.length).toBe(1) + expect(evaluation.extraScopes).toContain('https://www.googleapis.com/auth/calendar') + }) + + it.concurrent('should return no missing scopes when all are present', () => { + const evaluation = evaluateScopeCoverage('google-email', [ + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.labels', + ]) + + expect(evaluation.missingScopes.length).toBe(0) + expect(evaluation.requiresReauthorization).toBe(false) + }) + + it.concurrent('should normalize granted scopes before evaluation', () => { + const evaluation = evaluateScopeCoverage('google-email', [ + ' https://www.googleapis.com/auth/gmail.send ', + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.labels', + 'https://www.googleapis.com/auth/gmail.send', + ]) + + expect(evaluation.grantedScopes.length).toBe(3) + expect(evaluation.missingScopes.length).toBe(0) + expect(evaluation.requiresReauthorization).toBe(false) + }) + + it.concurrent('should handle empty granted scopes', () => { + const evaluation = evaluateScopeCoverage('google-email', []) + + expect(evaluation.grantedScopes.length).toBe(0) + expect(evaluation.missingScopes.length).toBeGreaterThan(0) + expect(evaluation.requiresReauthorization).toBe(true) + }) + + it.concurrent('should return correct structure', () => { + const evaluation = evaluateScopeCoverage('google-email', [ + 'https://www.googleapis.com/auth/gmail.send', + ]) + + expect(evaluation).toHaveProperty('canonicalScopes') + expect(evaluation).toHaveProperty('grantedScopes') + expect(evaluation).toHaveProperty('missingScopes') + expect(evaluation).toHaveProperty('extraScopes') + expect(evaluation).toHaveProperty('requiresReauthorization') + + expect(Array.isArray(evaluation.canonicalScopes)).toBe(true) + expect(Array.isArray(evaluation.grantedScopes)).toBe(true) + expect(Array.isArray(evaluation.missingScopes)).toBe(true) + expect(Array.isArray(evaluation.extraScopes)).toBe(true) + expect(typeof evaluation.requiresReauthorization).toBe('boolean') + }) + + it.concurrent('should handle provider with no scopes', () => { + const evaluation = evaluateScopeCoverage('notion', []) + + expect(evaluation.canonicalScopes.length).toBe(0) + expect(evaluation.missingScopes.length).toBe(0) + expect(evaluation.requiresReauthorization).toBe(false) + }) + + it.concurrent('should handle provider with no scopes but granted scopes present', () => { + const evaluation = evaluateScopeCoverage('notion', ['some.scope', 'another.scope']) + + expect(evaluation.canonicalScopes.length).toBe(0) + expect(evaluation.missingScopes.length).toBe(0) + expect(evaluation.extraScopes.length).toBe(2) + expect(evaluation.extraScopes).toContain('some.scope') + expect(evaluation.extraScopes).toContain('another.scope') + expect(evaluation.requiresReauthorization).toBe(false) + }) + + it.concurrent('should handle invalid provider', () => { + const evaluation = evaluateScopeCoverage('invalid-provider', ['scope1', 'scope2']) + + expect(evaluation.canonicalScopes.length).toBe(0) + expect(evaluation.grantedScopes.length).toBe(2) + expect(evaluation.missingScopes.length).toBe(0) + expect(evaluation.extraScopes.length).toBe(2) + expect(evaluation.requiresReauthorization).toBe(false) + }) + + it.concurrent('should work with Microsoft services', () => { + const evaluation = evaluateScopeCoverage('outlook', [ + 'openid', + 'profile', + 'email', + 'Mail.ReadWrite', + 'Mail.Send', + ]) + + expect(evaluation.canonicalScopes.length).toBeGreaterThan(0) + expect(evaluation.missingScopes.length).toBeGreaterThan(0) + expect(evaluation.requiresReauthorization).toBe(true) + }) + + it.concurrent('should handle exact match with no extra or missing scopes', () => { + const canonicalScopes = getCanonicalScopesForProvider('linear') + const evaluation = evaluateScopeCoverage('linear', [...canonicalScopes]) + + expect(evaluation.missingScopes.length).toBe(0) + expect(evaluation.extraScopes.length).toBe(0) + expect(evaluation.requiresReauthorization).toBe(false) + }) +}) + +describe('parseProvider', () => { + it.concurrent('should parse simple provider without hyphen', () => { + const config = parseProvider('slack' as OAuthProvider) + + expect(config.baseProvider).toBe('slack') + expect(config.featureType).toBe('slack') + }) + + it.concurrent('should parse compound provider', () => { + const config = parseProvider('google-email' as OAuthProvider) + + expect(config.baseProvider).toBe('google') + expect(config.featureType).toBe('gmail') + }) + + it.concurrent('should use mapping for known providerId', () => { + const config = parseProvider('google-drive' as OAuthProvider) + + expect(config.baseProvider).toBe('google') + expect(config.featureType).toBe('google-drive') + }) + + it.concurrent('should parse Microsoft services', () => { + const outlookConfig = parseProvider('outlook' as OAuthProvider) + expect(outlookConfig.baseProvider).toBe('microsoft') + expect(outlookConfig.featureType).toBe('outlook') + + const excelConfig = parseProvider('microsoft-excel' as OAuthProvider) + expect(excelConfig.baseProvider).toBe('microsoft') + expect(excelConfig.featureType).toBe('microsoft-excel') + + const teamsConfig = parseProvider('microsoft-teams' as OAuthProvider) + expect(teamsConfig.baseProvider).toBe('microsoft') + expect(teamsConfig.featureType).toBe('microsoft-teams') + }) + + it.concurrent('should parse GitHub provider', () => { + const config = parseProvider('github-repo' as OAuthProvider) + + expect(config.baseProvider).toBe('github') + expect(config.featureType).toBe('github') + }) + + it.concurrent('should parse Slack provider', () => { + const config = parseProvider('slack' as OAuthProvider) + + expect(config.baseProvider).toBe('slack') + expect(config.featureType).toBe('slack') + }) + + it.concurrent('should parse X provider', () => { + const config = parseProvider('x' as OAuthProvider) + + expect(config.baseProvider).toBe('x') + expect(config.featureType).toBe('x') + }) + + it.concurrent('should parse all Google services correctly', () => { + const googleServices: Array<{ provider: OAuthProvider; expectedFeature: string }> = [ + { provider: 'google-email', expectedFeature: 'gmail' }, + { provider: 'google-drive', expectedFeature: 'google-drive' }, + { provider: 'google-docs', expectedFeature: 'google-docs' }, + { provider: 'google-sheets', expectedFeature: 'google-sheets' }, + { provider: 'google-forms', expectedFeature: 'google-forms' }, + { provider: 'google-calendar', expectedFeature: 'google-calendar' }, + { provider: 'google-vault', expectedFeature: 'google-vault' }, + { provider: 'google-groups', expectedFeature: 'google-groups' }, + { provider: 'vertex-ai', expectedFeature: 'vertex-ai' }, + ] + + googleServices.forEach(({ provider, expectedFeature }) => { + const config = parseProvider(provider) + expect(config.baseProvider).toBe('google') + expect(config.featureType).toBe(expectedFeature) + }) + }) + + it.concurrent('should parse Confluence provider', () => { + const config = parseProvider('confluence' as OAuthProvider) + + expect(config.baseProvider).toBe('confluence') + expect(config.featureType).toBe('confluence') + }) + + it.concurrent('should parse Jira provider', () => { + const config = parseProvider('jira' as OAuthProvider) + + expect(config.baseProvider).toBe('jira') + expect(config.featureType).toBe('jira') + }) + + it.concurrent('should parse Airtable provider', () => { + const config = parseProvider('airtable' as OAuthProvider) + + expect(config.baseProvider).toBe('airtable') + expect(config.featureType).toBe('airtable') + }) + + it.concurrent('should parse Notion provider', () => { + const config = parseProvider('notion' as OAuthProvider) + + expect(config.baseProvider).toBe('notion') + expect(config.featureType).toBe('notion') + }) + + it.concurrent('should parse Linear provider', () => { + const config = parseProvider('linear' as OAuthProvider) + + expect(config.baseProvider).toBe('linear') + expect(config.featureType).toBe('linear') + }) + + it.concurrent('should parse Dropbox provider', () => { + const config = parseProvider('dropbox' as OAuthProvider) + + expect(config.baseProvider).toBe('dropbox') + expect(config.featureType).toBe('dropbox') + }) + + it.concurrent('should parse Shopify provider', () => { + const config = parseProvider('shopify' as OAuthProvider) + + expect(config.baseProvider).toBe('shopify') + expect(config.featureType).toBe('shopify') + }) + + it.concurrent('should parse Reddit provider', () => { + const config = parseProvider('reddit' as OAuthProvider) + + expect(config.baseProvider).toBe('reddit') + expect(config.featureType).toBe('reddit') + }) + + it.concurrent('should parse Wealthbox provider', () => { + const config = parseProvider('wealthbox' as OAuthProvider) + + expect(config.baseProvider).toBe('wealthbox') + expect(config.featureType).toBe('wealthbox') + }) + + it.concurrent('should parse Webflow provider', () => { + const config = parseProvider('webflow' as OAuthProvider) + + expect(config.baseProvider).toBe('webflow') + expect(config.featureType).toBe('webflow') + }) + + it.concurrent('should parse Trello provider', () => { + const config = parseProvider('trello' as OAuthProvider) + + expect(config.baseProvider).toBe('trello') + expect(config.featureType).toBe('trello') + }) + + it.concurrent('should parse Asana provider', () => { + const config = parseProvider('asana' as OAuthProvider) + + expect(config.baseProvider).toBe('asana') + expect(config.featureType).toBe('asana') + }) + + it.concurrent('should parse Pipedrive provider', () => { + const config = parseProvider('pipedrive' as OAuthProvider) + + expect(config.baseProvider).toBe('pipedrive') + expect(config.featureType).toBe('pipedrive') + }) + + it.concurrent('should parse HubSpot provider', () => { + const config = parseProvider('hubspot' as OAuthProvider) + + expect(config.baseProvider).toBe('hubspot') + expect(config.featureType).toBe('hubspot') + }) + + it.concurrent('should parse LinkedIn provider', () => { + const config = parseProvider('linkedin' as OAuthProvider) + + expect(config.baseProvider).toBe('linkedin') + expect(config.featureType).toBe('linkedin') + }) + + it.concurrent('should parse Salesforce provider', () => { + const config = parseProvider('salesforce' as OAuthProvider) + + expect(config.baseProvider).toBe('salesforce') + expect(config.featureType).toBe('salesforce') + }) + + it.concurrent('should parse Zoom provider', () => { + const config = parseProvider('zoom' as OAuthProvider) + + expect(config.baseProvider).toBe('zoom') + expect(config.featureType).toBe('zoom') + }) + + it.concurrent('should parse WordPress provider', () => { + const config = parseProvider('wordpress' as OAuthProvider) + + expect(config.baseProvider).toBe('wordpress') + expect(config.featureType).toBe('wordpress') + }) + + it.concurrent('should parse Spotify provider', () => { + const config = parseProvider('spotify' as OAuthProvider) + + expect(config.baseProvider).toBe('spotify') + expect(config.featureType).toBe('spotify') + }) + + it.concurrent('should fallback to default for unknown compound provider', () => { + const config = parseProvider('unknown-provider' as OAuthProvider) + + expect(config.baseProvider).toBe('unknown') + expect(config.featureType).toBe('provider') + }) + + it.concurrent('should use default featureType for simple unknown provider', () => { + const config = parseProvider('unknown' as OAuthProvider) + + expect(config.baseProvider).toBe('unknown') + expect(config.featureType).toBe('default') + }) + + it.concurrent('should parse OneDrive provider correctly', () => { + const config = parseProvider('onedrive' as OAuthProvider) + + expect(config.baseProvider).toBe('microsoft') + expect(config.featureType).toBe('onedrive') + }) + + it.concurrent('should parse SharePoint provider correctly', () => { + const config = parseProvider('sharepoint' as OAuthProvider) + + expect(config.baseProvider).toBe('microsoft') + expect(config.featureType).toBe('sharepoint') + }) +}) diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts new file mode 100644 index 0000000000..989b0c3ce3 --- /dev/null +++ b/apps/sim/lib/oauth/utils.ts @@ -0,0 +1,157 @@ +import { OAUTH_PROVIDERS } from './oauth' +import type { + OAuthProvider, + OAuthServiceConfig, + OAuthServiceMetadata, + ProviderConfig, + ScopeEvaluation, +} from './types' + +/** + * Returns a flat list of all available OAuth services with metadata. + * This is safe to use on the server as it doesn't include React components. + */ +export function getAllOAuthServices(): OAuthServiceMetadata[] { + const services: OAuthServiceMetadata[] = [] + + for (const [baseProviderId, provider] of Object.entries(OAUTH_PROVIDERS)) { + for (const service of Object.values(provider.services)) { + services.push({ + providerId: service.providerId, + name: service.name, + description: service.description, + baseProvider: baseProviderId, + }) + } + } + + return services +} + +export function getServiceByProviderAndId( + provider: OAuthProvider, + serviceId?: string +): OAuthServiceConfig { + const providerConfig = OAUTH_PROVIDERS[provider] + if (!providerConfig) { + throw new Error(`Provider ${provider} not found`) + } + + if (!serviceId) { + return providerConfig.services[providerConfig.defaultService] + } + + return ( + providerConfig.services[serviceId] || providerConfig.services[providerConfig.defaultService] + ) +} + +export function getProviderIdFromServiceId(serviceId: string): string { + for (const provider of Object.values(OAUTH_PROVIDERS)) { + for (const [id, service] of Object.entries(provider.services)) { + if (id === serviceId) { + return service.providerId + } + } + } + + // Default fallback + return serviceId +} + +export function getServiceConfigByProviderId(providerId: string): OAuthServiceConfig | null { + for (const provider of Object.values(OAUTH_PROVIDERS)) { + for (const [key, service] of Object.entries(provider.services)) { + if (service.providerId === providerId || key === providerId) { + return service + } + } + } + + return null +} + +export function getCanonicalScopesForProvider(providerId: string): string[] { + const service = getServiceConfigByProviderId(providerId) + return service?.scopes ? [...service.scopes] : [] +} + +export function normalizeScopes(scopes: string[]): string[] { + const seen = new Set() + for (const scope of scopes) { + const trimmed = scope.trim() + if (trimmed && !seen.has(trimmed)) { + seen.add(trimmed) + } + } + return Array.from(seen) +} + +export function evaluateScopeCoverage( + providerId: string, + grantedScopes: string[] +): ScopeEvaluation { + const canonicalScopes = getCanonicalScopesForProvider(providerId) + const normalizedGranted = normalizeScopes(grantedScopes) + + const canonicalSet = new Set(canonicalScopes) + const grantedSet = new Set(normalizedGranted) + + const missingScopes = canonicalScopes.filter((scope) => !grantedSet.has(scope)) + const extraScopes = normalizedGranted.filter((scope) => !canonicalSet.has(scope)) + + return { + canonicalScopes, + grantedScopes: normalizedGranted, + missingScopes, + extraScopes, + requiresReauthorization: missingScopes.length > 0, + } +} + +/** + * Build a mapping of providerId -> { baseProvider, serviceKey } from OAUTH_PROVIDERS + * This is computed once at module load time + */ +const PROVIDER_ID_TO_BASE_PROVIDER: Record = + {} + +for (const [baseProviderId, providerConfig] of Object.entries(OAUTH_PROVIDERS)) { + for (const [serviceKey, service] of Object.entries(providerConfig.services)) { + PROVIDER_ID_TO_BASE_PROVIDER[service.providerId] = { + baseProvider: baseProviderId, + serviceKey, + } + } +} + +/** + * Parse a provider string into its base provider and feature type. + * Uses the pre-computed mapping from OAUTH_PROVIDERS for accuracy. + */ +export function parseProvider(provider: OAuthProvider): ProviderConfig { + // First, check if this is a known providerId from our config + const mapping = PROVIDER_ID_TO_BASE_PROVIDER[provider] + if (mapping) { + return { + baseProvider: mapping.baseProvider, + featureType: mapping.serviceKey, + } + } + + // Handle compound providers (e.g., 'google-email' -> { baseProvider: 'google', featureType: 'email' }) + const [base, feature] = provider.split('-') + + if (feature) { + return { + baseProvider: base, + featureType: feature, + } + } + + // For simple providers, use 'default' as feature type + return { + baseProvider: provider, + featureType: 'default', + } +} diff --git a/apps/sim/lib/workflows/credentials/credential-resolver.ts b/apps/sim/lib/workflows/credentials/credential-resolver.ts index 1f7af60216..1658de0156 100644 --- a/apps/sim/lib/workflows/credentials/credential-resolver.ts +++ b/apps/sim/lib/workflows/credentials/credential-resolver.ts @@ -1,5 +1,5 @@ import { createLogger } from '@/lib/logs/console/logger' -import { getProviderIdFromServiceId } from '@/lib/oauth/oauth' +import { getProviderIdFromServiceId } from '@/lib/oauth' import { getBlock } from '@/blocks/index' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 2fb71e09bb..324f254e09 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -1,4 +1,4 @@ -import type { OAuthService } from '@/lib/oauth/oauth' +import type { OAuthService } from '@/lib/oauth' export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' From 641ac580173ac5f2d2d9c1c597cada1fc2d35ea2 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 23 Dec 2025 11:37:07 -0800 Subject: [PATCH 03/18] fix(frozen-canvas): need to fetch the deployment version correctly (#2552) --- .../sim/lib/logs/execution/logging-factory.ts | 23 ++++++++++++++++++- .../sim/lib/logs/execution/logging-session.ts | 9 ++++++-- .../lib/workflows/executor/execution-core.ts | 4 ++-- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/logs/execution/logging-factory.ts b/apps/sim/lib/logs/execution/logging-factory.ts index c09f4b2ec4..9124ade486 100644 --- a/apps/sim/lib/logs/execution/logging-factory.ts +++ b/apps/sim/lib/logs/execution/logging-factory.ts @@ -1,6 +1,9 @@ import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import type { ExecutionEnvironment, ExecutionTrigger, WorkflowState } from '@/lib/logs/types' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { + loadDeployedWorkflowState, + loadWorkflowFromNormalizedTables, +} from '@/lib/workflows/persistence/utils' export function createTriggerObject( type: ExecutionTrigger['type'], @@ -47,6 +50,24 @@ export async function loadWorkflowStateForExecution(workflowId: string): Promise } } +/** + * Load deployed workflow state for logging purposes. + * This fetches the active deployment state, ensuring logs capture + * the exact state that was executed (not the live editor state). + */ +export async function loadDeployedWorkflowStateForLogging( + workflowId: string +): Promise { + const deployedData = await loadDeployedWorkflowState(workflowId) + + return { + blocks: deployedData.blocks || {}, + edges: deployedData.edges || [], + loops: deployedData.loops || {}, + parallels: deployedData.parallels || {}, + } +} + export function calculateCostSummary(traceSpans: any[]): { totalCost: number totalInputCost: number diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index b6fded83f7..1005954e22 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -5,6 +5,7 @@ import { calculateCostSummary, createEnvironmentObject, createTriggerObject, + loadDeployedWorkflowStateForLogging, loadWorkflowStateForExecution, } from '@/lib/logs/execution/logging-factory' import type { @@ -78,7 +79,11 @@ export class LoggingSession { workspaceId, variables ) - this.workflowState = await loadWorkflowStateForExecution(this.workflowId) + // Use deployed state if deploymentVersionId is provided (non-manual execution) + // Otherwise fall back to loading from normalized tables (manual/draft execution) + this.workflowState = deploymentVersionId + ? await loadDeployedWorkflowStateForLogging(this.workflowId) + : await loadWorkflowStateForExecution(this.workflowId) // Only create a new log entry if not resuming if (!skipLogCreation) { @@ -295,7 +300,7 @@ export class LoggingSession { workspaceId, variables ) - // Minimal workflow state when normalized data is unavailable + // Minimal workflow state when normalized/deployed data is unavailable this.workflowState = { blocks: {}, edges: [], diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index d57d04f587..11dc2a1f4f 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -179,8 +179,8 @@ export async function executeWorkflowCore( userId, workspaceId: providedWorkspaceId, variables, - skipLogCreation, // Skip if resuming an existing execution - deploymentVersionId, // Only set for deployed executions + skipLogCreation, + deploymentVersionId, }) // Process block states with env var substitution using pre-decrypted values From 4e09c389e8da52dc2a68d8fd18ae56df56a66085 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 23 Dec 2025 13:04:47 -0800 Subject: [PATCH 04/18] improvement(usage): update usage limit in realtime, standardize token output object across providers (#2553) * improvement(usage-limit): update usage in real time, fix token output object * updated tokenBreakdown to tokens, standardized input/output/total token object type across providers * update remaining references * ack PR comment * remove singleton query client instance from hooks, leave only in zustand --- apps/sim/app/(landing)/components/nav/nav.tsx | 2 +- .../app/_shell/providers/query-provider.tsx | 54 ++++++++++++------- apps/sim/app/api/logs/route.ts | 11 ++-- apps/sim/app/api/providers/route.ts | 4 +- apps/sim/app/api/tools/search/route.ts | 4 +- .../[notificationId]/test/route.ts | 2 +- apps/sim/app/chat/[identifier]/chat.tsx | 2 +- .../frozen-canvas/frozen-canvas.tsx | 16 +++--- .../components/log-details/log-details.tsx | 4 +- .../credential-selector.tsx | 24 ++------- .../components/tool-credential-selector.tsx | 24 ++------- .../workflow-block/hooks/use-schedule-info.ts | 14 +---- .../w/[workflowId]/hooks/use-wand.ts | 21 ++++---- .../hooks/use-workflow-execution.ts | 17 +++--- .../[workspaceId]/w/[workflowId]/workflow.tsx | 21 -------- apps/sim/executor/constants.ts | 4 +- .../executor/execution/edge-manager.test.ts | 2 +- .../handlers/agent/agent-handler.test.ts | 48 ++++++++--------- .../executor/handlers/agent/agent-handler.ts | 6 +-- .../evaluator/evaluator-handler.test.ts | 4 +- .../handlers/evaluator/evaluator-handler.ts | 15 +++--- .../handlers/generic/generic-handler.test.ts | 20 +++---- .../handlers/router/router-handler.test.ts | 4 +- .../handlers/router/router-handler.ts | 12 ++--- apps/sim/executor/types.ts | 4 +- apps/sim/lib/billing/core/usage-log.ts | 6 +-- .../server/workflow/get-workflow-console.ts | 2 +- apps/sim/lib/logs/execution/logger.ts | 30 +++++++---- .../sim/lib/logs/execution/logging-factory.ts | 10 ++-- .../execution/trace-spans/trace-spans.test.ts | 8 +-- apps/sim/lib/logs/types.ts | 19 +++++-- apps/sim/lib/tokenization/calculators.ts | 4 +- apps/sim/lib/tokenization/types.ts | 8 +-- apps/sim/lib/tokenization/utils.ts | 2 +- apps/sim/providers/anthropic/index.ts | 40 +++++++------- apps/sim/providers/azure-openai/index.ts | 24 ++++----- apps/sim/providers/cerebras/index.ts | 28 +++++----- apps/sim/providers/deepseek/index.ts | 24 ++++----- apps/sim/providers/gemini/core.ts | 18 +++---- apps/sim/providers/gemini/types.ts | 2 +- apps/sim/providers/groq/index.ts | 24 ++++----- apps/sim/providers/index.ts | 2 +- apps/sim/providers/mistral/index.ts | 24 ++++----- apps/sim/providers/ollama/index.ts | 24 ++++----- apps/sim/providers/openai/index.ts | 24 ++++----- apps/sim/providers/openrouter/index.ts | 26 ++++----- apps/sim/providers/types.ts | 8 +-- apps/sim/providers/vllm/index.ts | 24 ++++----- apps/sim/providers/xai/index.ts | 24 ++++----- apps/sim/stores/logs/filters/types.ts | 4 ++ apps/sim/stores/panel/copilot/store.ts | 8 +++ 51 files changed, 368 insertions(+), 388 deletions(-) diff --git a/apps/sim/app/(landing)/components/nav/nav.tsx b/apps/sim/app/(landing)/components/nav/nav.tsx index 4fe23e3de0..b4ce6ddbe9 100644 --- a/apps/sim/app/(landing)/components/nav/nav.tsx +++ b/apps/sim/app/(landing)/components/nav/nav.tsx @@ -20,7 +20,7 @@ interface NavProps { } export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) { - const [githubStars, setGithubStars] = useState('18.6k') + const [githubStars, setGithubStars] = useState('24k') const [isHovered, setIsHovered] = useState(false) const [isLoginHovered, setIsLoginHovered] = useState(false) const router = useRouter() diff --git a/apps/sim/app/_shell/providers/query-provider.tsx b/apps/sim/app/_shell/providers/query-provider.tsx index 84231ce39a..ea286d5f75 100644 --- a/apps/sim/app/_shell/providers/query-provider.tsx +++ b/apps/sim/app/_shell/providers/query-provider.tsx @@ -1,26 +1,42 @@ 'use client' -import { type ReactNode, useState } from 'react' +import type { ReactNode } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -export function QueryProvider({ children }: { children: ReactNode }) { - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30 * 1000, - gcTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - retry: 1, - retryOnMount: false, - }, - mutations: { - retry: 1, - }, - }, - }) - ) +/** + * Singleton QueryClient instance for client-side use. + * Can be imported directly for cache operations outside React components. + */ +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + retry: 1, + retryOnMount: false, + }, + mutations: { + retry: 1, + }, + }, + }) +} + +let browserQueryClient: QueryClient | undefined +export function getQueryClient() { + if (typeof window === 'undefined') { + return makeQueryClient() + } + if (!browserQueryClient) { + browserQueryClient = makeQueryClient() + } + return browserQueryClient +} + +export function QueryProvider({ children }: { children: ReactNode }) { + const queryClient = getQueryClient() return {children} } diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index ab9f571013..90184d9cde 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -259,15 +259,16 @@ export async function GET(request: NextRequest) { input: 0, output: 0, total: 0, - tokens: { prompt: 0, completion: 0, total: 0 }, + tokens: { input: 0, output: 0, total: 0 }, }) } const modelCost = models.get(block.cost.model) modelCost.input += Number(block.cost.input) || 0 modelCost.output += Number(block.cost.output) || 0 modelCost.total += Number(block.cost.total) || 0 - modelCost.tokens.prompt += block.cost.tokens?.prompt || 0 - modelCost.tokens.completion += block.cost.tokens?.completion || 0 + modelCost.tokens.input += block.cost.tokens?.input || block.cost.tokens?.prompt || 0 + modelCost.tokens.output += + block.cost.tokens?.output || block.cost.tokens?.completion || 0 modelCost.tokens.total += block.cost.tokens?.total || 0 } } @@ -279,8 +280,8 @@ export async function GET(request: NextRequest) { output: totalOutputCost, tokens: { total: totalTokens, - prompt: totalPromptTokens, - completion: totalCompletionTokens, + input: totalPromptTokens, + output: totalCompletionTokens, }, models: Object.fromEntries(models), } diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index ada02eb093..9a52f0bd71 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -165,8 +165,8 @@ export async function POST(request: NextRequest) { : '', model: executionData.output?.model, tokens: executionData.output?.tokens || { - prompt: 0, - completion: 0, + input: 0, + output: 0, total: 0, }, // Sanitize any potential Unicode characters in tool calls diff --git a/apps/sim/app/api/tools/search/route.ts b/apps/sim/app/api/tools/search/route.ts index e396cdf9a0..2ae8af018a 100644 --- a/apps/sim/app/api/tools/search/route.ts +++ b/apps/sim/app/api/tools/search/route.ts @@ -87,8 +87,8 @@ export async function POST(request: NextRequest) { output: 0, total: SEARCH_TOOL_COST, tokens: { - prompt: 0, - completion: 0, + input: 0, + output: 0, total: 0, }, model: 'search-exa', diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts index a24d511968..3cc3c3733d 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts @@ -53,7 +53,7 @@ function buildTestPayload(subscription: typeof workspaceNotificationSubscription totalDurationMs: 5000, cost: { total: 0.00123, - tokens: { prompt: 100, completion: 50, total: 150 }, + tokens: { input: 100, output: 50, total: 150 }, }, }, links: { diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/chat/[identifier]/chat.tsx index cb70cbbb91..96be0631d7 100644 --- a/apps/sim/app/chat/[identifier]/chat.tsx +++ b/apps/sim/app/chat/[identifier]/chat.tsx @@ -117,7 +117,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { const [error, setError] = useState(null) const messagesEndRef = useRef(null) const messagesContainerRef = useRef(null) - const [starCount, setStarCount] = useState('19.4k') + const [starCount, setStarCount] = useState('24k') const [conversationId, setConversationId] = useState('') const [showScrollButton, setShowScrollButton] = useState(false) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/frozen-canvas/frozen-canvas.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/frozen-canvas/frozen-canvas.tsx index 8edf09fa77..83ff85c362 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/frozen-canvas/frozen-canvas.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/frozen-canvas/frozen-canvas.tsx @@ -131,8 +131,8 @@ function formatExecutionData(executionData: any) { : null, tokens: tokens ? { - prompt: tokens.prompt || 0, - completion: tokens.completion || 0, + input: tokens.input || tokens.prompt || 0, + output: tokens.output || tokens.completion || 0, total: tokens.total || 0, } : null, @@ -347,12 +347,12 @@ function PinnedLogs({
- Prompt: - {formatted.tokens.prompt} + Input: + {formatted.tokens.input}
- Completion: - {formatted.tokens.completion} + Output: + {formatted.tokens.output}
Total: @@ -498,8 +498,8 @@ export function FrozenCanvas({ total: null, }, tokens: span.tokens || { - prompt: null, - completion: null, + input: null, + output: null, total: null, }, modelUsed: span.model || null, diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index e87f8aa52b..c6ecea76d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -344,8 +344,8 @@ export const LogDetails = memo(function LogDetails({ Tokens: - {log.cost?.tokens?.prompt || 0} in / {log.cost?.tokens?.completion || 0}{' '} - out + {log.cost?.tokens?.input || log.cost?.tokens?.prompt || 0} in /{' '} + {log.cost?.tokens?.output || log.cost?.tokens?.completion || 0} out
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index a487fb7b5d..a0b3b8b6fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -116,7 +116,7 @@ export function CredentialSelector({ setStoreValue('') }, [invalidSelection, selectedId, effectiveProviderId, setStoreValue]) - useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, provider) + useCredentialRefreshTriggers(refetchCredentials) const handleOpenChange = useCallback( (isOpen: boolean) => { @@ -268,11 +268,7 @@ export function CredentialSelector({ ) } -function useCredentialRefreshTriggers( - refetchCredentials: () => Promise, - effectiveProviderId?: string, - provider?: OAuthProvider -) { +function useCredentialRefreshTriggers(refetchCredentials: () => Promise) { useEffect(() => { const refresh = () => { void refetchCredentials() @@ -290,26 +286,12 @@ function useCredentialRefreshTriggers( } } - const handleCredentialDisconnected = (event: Event) => { - const customEvent = event as CustomEvent<{ providerId?: string }> - const providerId = customEvent.detail?.providerId - - if ( - providerId && - (providerId === effectiveProviderId || (provider && providerId.startsWith(provider))) - ) { - refresh() - } - } - document.addEventListener('visibilitychange', handleVisibilityChange) window.addEventListener('pageshow', handlePageShow) - window.addEventListener('credential-disconnected', handleCredentialDisconnected) return () => { document.removeEventListener('visibilitychange', handleVisibilityChange) window.removeEventListener('pageshow', handlePageShow) - window.removeEventListener('credential-disconnected', handleCredentialDisconnected) } - }, [refetchCredentials, effectiveProviderId, provider]) + }, [refetchCredentials]) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx index 542eb2c57f..f7f755ac30 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx @@ -117,7 +117,7 @@ export function ToolCredentialSelector({ onChange('') }, [invalidSelection, onChange]) - useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, provider) + useCredentialRefreshTriggers(refetchCredentials) const handleOpenChange = useCallback( (isOpen: boolean) => { @@ -238,11 +238,7 @@ export function ToolCredentialSelector({ ) } -function useCredentialRefreshTriggers( - refetchCredentials: () => Promise, - effectiveProviderId?: string, - provider?: OAuthProvider -) { +function useCredentialRefreshTriggers(refetchCredentials: () => Promise) { useEffect(() => { const refresh = () => { void refetchCredentials() @@ -260,26 +256,12 @@ function useCredentialRefreshTriggers( } } - const handleCredentialDisconnected = (event: Event) => { - const customEvent = event as CustomEvent<{ providerId?: string }> - const providerId = customEvent.detail?.providerId - - if ( - providerId && - (providerId === effectiveProviderId || (provider && providerId.startsWith(provider))) - ) { - refresh() - } - } - document.addEventListener('visibilitychange', handleVisibilityChange) window.addEventListener('pageshow', handlePageShow) - window.addEventListener('credential-disconnected', handleCredentialDisconnected) return () => { document.removeEventListener('visibilitychange', handleVisibilityChange) window.removeEventListener('pageshow', handlePageShow) - window.removeEventListener('credential-disconnected', handleCredentialDisconnected) } - }, [refetchCredentials, effectiveProviderId, provider]) + }, [refetchCredentials]) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-schedule-info.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-schedule-info.ts index 459dbffd0d..b970fb3cd0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-schedule-info.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-schedule-info.ts @@ -143,22 +143,10 @@ export function useScheduleInfo( setIsLoading(false) } - const handleScheduleUpdate = (event: CustomEvent) => { - if (event.detail?.workflowId === workflowId && event.detail?.blockId === blockId) { - logger.debug('Schedule update event received, refetching schedule info') - if (blockType === 'schedule') { - fetchScheduleInfo(workflowId) - } - } - } - - window.addEventListener('schedule-updated', handleScheduleUpdate as EventListener) - return () => { setIsLoading(false) - window.removeEventListener('schedule-updated', handleScheduleUpdate as EventListener) } - }, [blockType, workflowId, blockId, fetchScheduleInfo]) + }, [blockType, workflowId, fetchScheduleInfo]) return { scheduleInfo, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts index a4bd70ac03..8e90f79fb8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts @@ -1,6 +1,8 @@ import { useCallback, useRef, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { createLogger } from '@/lib/logs/console/logger' import type { GenerationType } from '@/blocks/types' +import { subscriptionKeys } from '@/hooks/queries/subscription' const logger = createLogger('useWand') @@ -17,12 +19,10 @@ function buildContextInfo(currentValue?: string, generationType?: string): strin let contextInfo = `Current content (${contentLength} characters, ${lineCount} lines):\n${currentValue}` - // Add type-specific context analysis if (generationType) { switch (generationType) { case 'javascript-function-body': case 'typescript-function-body': { - // Analyze code structure const hasFunction = /function\s+\w+/.test(currentValue) const hasArrowFunction = /=>\s*{/.test(currentValue) const hasReturn = /return\s+/.test(currentValue) @@ -32,7 +32,6 @@ function buildContextInfo(currentValue?: string, generationType?: string): strin case 'json-schema': case 'json-object': - // Analyze JSON structure try { const parsed = JSON.parse(currentValue) const keys = Object.keys(parsed) @@ -77,13 +76,13 @@ export function useWand({ onStreamStart, onGenerationComplete, }: UseWandProps) { + const queryClient = useQueryClient() const [isLoading, setIsLoading] = useState(false) const [isPromptVisible, setIsPromptVisible] = useState(false) const [promptInputValue, setPromptInputValue] = useState('') const [error, setError] = useState(null) const [isStreaming, setIsStreaming] = useState(false) - // Conversation history state const [conversationHistory, setConversationHistory] = useState([]) const abortControllerRef = useRef(null) @@ -143,25 +142,20 @@ export function useWand({ abortControllerRef.current = new AbortController() - // Signal the start of streaming to clear previous content if (onStreamStart) { onStreamStart() } try { - // Build context-aware message const contextInfo = buildContextInfo(currentValue, wandConfig?.generationType) - // Build the system prompt with context information let systemPrompt = wandConfig?.prompt || '' if (systemPrompt.includes('{context}')) { systemPrompt = systemPrompt.replace('{context}', contextInfo) } - // User message is just the user's specific request const userMessage = prompt - // Keep track of the current prompt for history const currentPrompt = prompt const response = await fetch('/api/wand', { @@ -172,9 +166,9 @@ export function useWand({ }, body: JSON.stringify({ prompt: userMessage, - systemPrompt: systemPrompt, // Send the processed system prompt with context + systemPrompt: systemPrompt, stream: true, - history: wandConfig?.maintainHistory ? conversationHistory : [], // Include history if enabled + history: wandConfig?.maintainHistory ? conversationHistory : [], }), signal: abortControllerRef.current.signal, cache: 'no-store', @@ -256,6 +250,10 @@ export function useWand({ prompt, contentLength: accumulatedContent.length, }) + + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() }) + }, 1000) } catch (error: any) { if (error.name === 'AbortError') { logger.debug('Wand generation cancelled') @@ -276,6 +274,7 @@ export function useWand({ onStreamChunk, onStreamStart, onGenerationComplete, + queryClient, ] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 93cc39bf73..58cdfc4cc7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -88,9 +88,9 @@ function extractExecutionResult(error: unknown): ExecutionResult | null { } export function useWorkflowExecution() { + const queryClient = useQueryClient() const currentWorkflow = useCurrentWorkflow() const { activeWorkflowId, workflows } = useWorkflowRegistry() - const queryClient = useQueryClient() const { toggleConsole, addConsole } = useTerminalConsoleStore() const { getAllVariables } = useEnvironmentStore() const { getVariablesByWorkflowId, variables } = useVariablesStore() @@ -563,9 +563,10 @@ export function useWorkflowExecution() { logger.info(`Processed ${processedCount} blocks for streaming tokenization`) } - // Invalidate subscription query to update usage - queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() }) - queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() }) + // Invalidate subscription queries to update usage + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() }) + }, 1000) const { encodeSSE } = await import('@/lib/core/utils/sse') controller.enqueue(encodeSSE({ event: 'final', data: result })) @@ -630,9 +631,10 @@ export function useWorkflowExecution() { ;(result.metadata as any).source = 'chat' } - // Invalidate subscription query to update usage - queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() }) - queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() }) + // Invalidate subscription queries to update usage + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() }) + }, 1000) } return result } catch (error: any) { @@ -654,6 +656,7 @@ export function useWorkflowExecution() { setPendingBlocks, setActiveBlocks, workflows, + queryClient, ] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 7159ce3e47..f2fe6cef85 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -2236,27 +2236,6 @@ const WorkflowContent = React.memo(() => { return () => window.removeEventListener('keydown', handleKeyDown) }, [selectedEdgeInfo, removeEdge, getNodes, removeBlock, effectivePermissions.canEdit]) - /** Handles sub-block value updates from custom events. */ - useEffect(() => { - const handleSubBlockValueUpdate = (event: CustomEvent) => { - const { blockId, subBlockId, value } = event.detail - if (blockId && subBlockId) { - // Use collaborative function to go through queue system - // This ensures 5-second timeout and error detection work - collaborativeSetSubblockValue(blockId, subBlockId, value) - } - } - - window.addEventListener('update-subblock-value', handleSubBlockValueUpdate as EventListener) - - return () => { - window.removeEventListener( - 'update-subblock-value', - handleSubBlockValueUpdate as EventListener - ) - } - }, [collaborativeSetSubblockValue]) - return (
diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts index 364ee2c1f0..99d0b5b164 100644 --- a/apps/sim/executor/constants.ts +++ b/apps/sim/executor/constants.ts @@ -273,8 +273,8 @@ export function supportsHandles(blockType: string | undefined): boolean { export function getDefaultTokens() { return { - prompt: DEFAULTS.TOKENS.PROMPT, - completion: DEFAULTS.TOKENS.COMPLETION, + input: DEFAULTS.TOKENS.PROMPT, + output: DEFAULTS.TOKENS.COMPLETION, total: DEFAULTS.TOKENS.TOTAL, } } diff --git a/apps/sim/executor/execution/edge-manager.test.ts b/apps/sim/executor/execution/edge-manager.test.ts index 3470c2d67e..14c7c7cc5e 100644 --- a/apps/sim/executor/execution/edge-manager.test.ts +++ b/apps/sim/executor/execution/edge-manager.test.ts @@ -129,7 +129,7 @@ describe('EdgeManager', () => { const output = { result: { data: 'test' }, content: 'Hello world', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, } const readyNodes = edgeManager.processOutgoingEdges(sourceNode, output) diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 273be0936c..e6e5e95e92 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -31,7 +31,7 @@ vi.mock('@/providers/utils', () => ({ create: vi.fn().mockResolvedValue({ content: 'Mocked response content', model: 'mock-model', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, toolCalls: [], cost: 0.001, timing: { total: 100 }, @@ -53,7 +53,7 @@ vi.mock('@/providers', () => ({ executeProviderRequest: vi.fn().mockResolvedValue({ content: 'Mocked response content', model: 'mock-model', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, toolCalls: [], cost: 0.001, timing: { total: 100 }, @@ -134,7 +134,7 @@ describe('AgentBlockHandler', () => { Promise.resolve({ content: 'Mocked response content', model: 'mock-model', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, toolCalls: [], cost: 0.001, timing: { total: 100 }, @@ -211,7 +211,7 @@ describe('AgentBlockHandler', () => { const expectedOutput = { content: 'Mocked response content', model: 'mock-model', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, toolCalls: { list: [], count: 0 }, providerTiming: { total: 100 }, cost: 0.001, @@ -253,7 +253,7 @@ describe('AgentBlockHandler', () => { Promise.resolve({ content: 'Using tools to respond', model: 'mock-model', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, toolCalls: [ { name: 'auto_tool', @@ -591,7 +591,7 @@ describe('AgentBlockHandler', () => { const expectedOutput = { content: 'Mocked response content', model: 'mock-model', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, toolCalls: { list: [], count: 0 }, // Assuming no tool calls in this mock response providerTiming: { total: 100 }, cost: 0.001, @@ -672,7 +672,7 @@ describe('AgentBlockHandler', () => { Promise.resolve({ content: '{"result": "Success", "score": 0.95}', model: 'mock-model', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, timing: { total: 100 }, toolCalls: [], cost: undefined, @@ -693,7 +693,7 @@ describe('AgentBlockHandler', () => { expect(result).toEqual({ result: 'Success', score: 0.95, - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, toolCalls: { list: [], count: 0 }, providerTiming: { total: 100 }, cost: undefined, @@ -715,7 +715,7 @@ describe('AgentBlockHandler', () => { Promise.resolve({ content: 'Regular text response', model: 'mock-model', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, timing: { total: 100 }, }), }) @@ -733,7 +733,7 @@ describe('AgentBlockHandler', () => { expect(result).toEqual({ content: 'Regular text response', model: 'mock-model', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, toolCalls: { list: [], count: 0 }, providerTiming: { total: 100 }, cost: undefined, @@ -755,7 +755,7 @@ describe('AgentBlockHandler', () => { Promise.resolve({ content: 'Regular text response', model: 'mock-model', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, timing: { total: 100 }, toolCalls: [], cost: undefined, @@ -776,7 +776,7 @@ describe('AgentBlockHandler', () => { expect(result).toEqual({ content: 'Regular text response', model: 'mock-model', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, toolCalls: { list: [], count: 0 }, providerTiming: { total: 100 }, cost: undefined, @@ -798,7 +798,7 @@ describe('AgentBlockHandler', () => { Promise.resolve({ content: 'Regular text response', model: 'mock-model', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, timing: { total: 100 }, toolCalls: [], cost: undefined, @@ -819,7 +819,7 @@ describe('AgentBlockHandler', () => { expect(result).toEqual({ content: 'Regular text response', model: 'mock-model', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, toolCalls: { list: [], count: 0 }, providerTiming: { total: 100 }, cost: undefined, @@ -907,7 +907,7 @@ describe('AgentBlockHandler', () => { output: { content: '', model: 'mock-model', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, }, logs: [ { @@ -988,7 +988,7 @@ describe('AgentBlockHandler', () => { output: { content: 'Test streaming content', model: 'gpt-4o', - tokens: { prompt: 10, completion: 5, total: 15 }, + tokens: { input: 10, output: 5, total: 15 }, }, logs: [], metadata: { @@ -1414,7 +1414,7 @@ describe('AgentBlockHandler', () => { Promise.resolve({ content: 'I will use MCP tools to help you.', model: 'gpt-4o', - tokens: { prompt: 15, completion: 25, total: 40 }, + tokens: { input: 15, output: 25, total: 40 }, toolCalls: [ { name: 'mcp-server1-list_files', @@ -1525,7 +1525,7 @@ describe('AgentBlockHandler', () => { Promise.resolve({ content: 'Let me try to use this tool.', model: 'gpt-4o', - tokens: { prompt: 10, completion: 15, total: 25 }, + tokens: { input: 10, output: 15, total: 25 }, toolCalls: [ { name: 'mcp-server1-failing_tool', @@ -1630,7 +1630,7 @@ describe('AgentBlockHandler', () => { Promise.resolve({ content: 'Used MCP tools successfully', model: 'gpt-4o', - tokens: { prompt: 20, completion: 30, total: 50 }, + tokens: { input: 20, output: 30, total: 50 }, toolCalls: [], timing: { total: 200 }, }), @@ -1679,7 +1679,7 @@ describe('AgentBlockHandler', () => { Promise.resolve({ content: 'Using MCP tool', model: 'gpt-4o', - tokens: { prompt: 10, completion: 10, total: 20 }, + tokens: { input: 10, output: 10, total: 20 }, toolCalls: [{ name: 'mcp-test-tool', arguments: {} }], timing: { total: 50 }, }), @@ -1734,7 +1734,7 @@ describe('AgentBlockHandler', () => { Promise.resolve({ content: 'Used MCP tool successfully', model: 'gpt-4o', - tokens: { prompt: 10, completion: 10, total: 20 }, + tokens: { input: 10, output: 10, total: 20 }, toolCalls: [], timing: { total: 50 }, }), @@ -1811,7 +1811,7 @@ describe('AgentBlockHandler', () => { Promise.resolve({ content: 'Tool executed', model: 'gpt-4o', - tokens: { prompt: 10, completion: 10, total: 20 }, + tokens: { input: 10, output: 10, total: 20 }, toolCalls: [ { name: 'search_files', @@ -1901,7 +1901,7 @@ describe('AgentBlockHandler', () => { Promise.resolve({ content: 'Used tools', model: 'gpt-4o', - tokens: { prompt: 10, completion: 10, total: 20 }, + tokens: { input: 10, output: 10, total: 20 }, toolCalls: [], timing: { total: 50 }, }), @@ -2008,7 +2008,7 @@ describe('AgentBlockHandler', () => { Promise.resolve({ content: 'Used legacy tool', model: 'gpt-4o', - tokens: { prompt: 10, completion: 10, total: 20 }, + tokens: { input: 10, output: 10, total: 20 }, toolCalls: [], timing: { total: 50 }, }), diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index a358685efe..2f4a36332a 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -1317,15 +1317,15 @@ export class AgentBlockHandler implements BlockHandler { } private createResponseMetadata(result: { - tokens?: { prompt?: number; completion?: number; total?: number } + tokens?: { input?: number; output?: number; total?: number } toolCalls?: Array timing?: any cost?: any }) { return { tokens: result.tokens || { - prompt: DEFAULTS.TOKENS.PROMPT, - completion: DEFAULTS.TOKENS.COMPLETION, + input: DEFAULTS.TOKENS.PROMPT, + output: DEFAULTS.TOKENS.COMPLETION, total: DEFAULTS.TOKENS.TOTAL, }, toolCalls: { diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts index 0acdcc41c5..498412aaf3 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts @@ -60,7 +60,7 @@ describe('EvaluatorBlockHandler', () => { Promise.resolve({ content: JSON.stringify({ score1: 5, score2: 8 }), model: 'mock-model', - tokens: { prompt: 50, completion: 10, total: 60 }, + tokens: { input: 50, output: 10, total: 60 }, cost: 0.002, timing: { total: 200 }, }), @@ -121,7 +121,7 @@ describe('EvaluatorBlockHandler', () => { expect(result).toEqual({ content: 'This is the content to evaluate.', model: 'mock-model', - tokens: { prompt: 50, completion: 10, total: 60 }, + tokens: { input: 50, output: 10, total: 60 }, cost: { input: 0, output: 0, diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index 2e97298818..694cf885c5 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -124,19 +124,18 @@ export class EvaluatorBlockHandler implements BlockHandler { const metricScores = this.extractMetricScores(parsedContent, inputs.metrics) - const costCalculation = calculateCost( - result.model, - result.tokens?.prompt || DEFAULTS.TOKENS.PROMPT, - result.tokens?.completion || DEFAULTS.TOKENS.COMPLETION, - false - ) + const inputTokens = result.tokens?.input || result.tokens?.prompt || DEFAULTS.TOKENS.PROMPT + const outputTokens = + result.tokens?.output || result.tokens?.completion || DEFAULTS.TOKENS.COMPLETION + + const costCalculation = calculateCost(result.model, inputTokens, outputTokens, false) return { content: inputs.content, model: result.model, tokens: { - prompt: result.tokens?.prompt || DEFAULTS.TOKENS.PROMPT, - completion: result.tokens?.completion || DEFAULTS.TOKENS.COMPLETION, + input: inputTokens, + output: outputTokens, total: result.tokens?.total || DEFAULTS.TOKENS.TOTAL, }, cost: { diff --git a/apps/sim/executor/handlers/generic/generic-handler.test.ts b/apps/sim/executor/handlers/generic/generic-handler.test.ts index 042592b81f..dfbe364802 100644 --- a/apps/sim/executor/handlers/generic/generic-handler.test.ts +++ b/apps/sim/executor/handlers/generic/generic-handler.test.ts @@ -186,8 +186,8 @@ describe('GenericBlockHandler', () => { output: 0, total: 0.00001042, tokens: { - prompt: 521, - completion: 0, + input: 521, + output: 0, total: 521, }, model: 'text-embedding-3-small', @@ -215,8 +215,8 @@ describe('GenericBlockHandler', () => { total: 0.00001042, }, tokens: { - prompt: 521, - completion: 0, + input: 521, + output: 0, total: 521, }, model: 'text-embedding-3-small', @@ -253,8 +253,8 @@ describe('GenericBlockHandler', () => { output: 0, total: 0.00000521, tokens: { - prompt: 260, - completion: 0, + input: 260, + output: 0, total: 260, }, model: 'text-embedding-3-small', @@ -286,8 +286,8 @@ describe('GenericBlockHandler', () => { total: 0.00000521, }, tokens: { - prompt: 260, - completion: 0, + input: 260, + output: 0, total: 260, }, model: 'text-embedding-3-small', @@ -340,7 +340,7 @@ describe('GenericBlockHandler', () => { input: 0.001, output: 0.002, total: 0.003, - tokens: { prompt: 100, completion: 50, total: 150 }, + tokens: { input: 100, output: 50, total: 150 }, model: 'some-model', }, }, @@ -357,7 +357,7 @@ describe('GenericBlockHandler', () => { output: 0.002, total: 0.003, }, - tokens: { prompt: 100, completion: 50, total: 150 }, + tokens: { input: 100, output: 50, total: 150 }, model: 'some-model', }) } diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index eb3cc73337..b57367f73d 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -87,7 +87,7 @@ describe('RouterBlockHandler', () => { Promise.resolve({ content: 'target-block-1', model: 'mock-model', - tokens: { prompt: 100, completion: 5, total: 105 }, + tokens: { input: 100, output: 5, total: 105 }, cost: 0.003, timing: { total: 300 }, }), @@ -160,7 +160,7 @@ describe('RouterBlockHandler', () => { expect(result).toEqual({ prompt: 'Choose the best option.', model: 'mock-model', - tokens: { prompt: 100, completion: 5, total: 105 }, + tokens: { input: 100, output: 5, total: 105 }, cost: { input: 0, output: 0, diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 327d490f35..59c5e8291a 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -82,15 +82,15 @@ export class RouterBlockHandler implements BlockHandler { } const tokens = result.tokens || { - prompt: DEFAULTS.TOKENS.PROMPT, - completion: DEFAULTS.TOKENS.COMPLETION, + input: DEFAULTS.TOKENS.PROMPT, + output: DEFAULTS.TOKENS.COMPLETION, total: DEFAULTS.TOKENS.TOTAL, } const cost = calculateCost( result.model, - tokens.prompt || DEFAULTS.TOKENS.PROMPT, - tokens.completion || DEFAULTS.TOKENS.COMPLETION, + tokens.input || DEFAULTS.TOKENS.PROMPT, + tokens.output || DEFAULTS.TOKENS.COMPLETION, false ) @@ -98,8 +98,8 @@ export class RouterBlockHandler implements BlockHandler { prompt: inputs.prompt, model: result.model, tokens: { - prompt: tokens.prompt || DEFAULTS.TOKENS.PROMPT, - completion: tokens.completion || DEFAULTS.TOKENS.COMPLETION, + input: tokens.input || DEFAULTS.TOKENS.PROMPT, + output: tokens.output || DEFAULTS.TOKENS.COMPLETION, total: tokens.total || DEFAULTS.TOKENS.TOTAL, }, cost: { diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 4c52fb0d7e..f565fad55a 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -69,8 +69,8 @@ export interface NormalizedBlockOutput { content?: string model?: string tokens?: { - prompt?: number - completion?: number + input?: number + output?: number total?: number } toolCalls?: { diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index 478fc3bc8f..cfbf1a1057 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -154,7 +154,7 @@ export interface LogWorkflowUsageBatchParams { string, { total: number - tokens: { prompt: number; completion: number } + tokens: { input: number; output: number } } > } @@ -205,8 +205,8 @@ export async function logWorkflowUsageBatch(params: LogWorkflowUsageBatchParams) source: 'workflow', description: modelName, metadata: { - inputTokens: modelData.tokens.prompt, - outputTokens: modelData.tokens.completion, + inputTokens: modelData.tokens.input, + outputTokens: modelData.tokens.output, }, cost: modelData.total.toString(), workspaceId: params.workspaceId ?? null, diff --git a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-console.ts b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-console.ts index 4adf279edc..3aa6ba245c 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-console.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-console.ts @@ -27,7 +27,7 @@ interface BlockExecution { input: number output: number model?: string - tokens?: { total: number; prompt: number; completion: number } + tokens?: { total: number; input: number; output: number } } } diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 613ff9bf19..962eff8195 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -59,8 +59,12 @@ export class ExecutionLogger implements IExecutionLoggerService { output: (merged[model].output || 0) + (costs.output || 0), total: (merged[model].total || 0) + (costs.total || 0), tokens: { - prompt: (merged[model].tokens?.prompt || 0) + (costs.tokens?.prompt || 0), - completion: (merged[model].tokens?.completion || 0) + (costs.tokens?.completion || 0), + input: + (merged[model].tokens?.input || merged[model].tokens?.prompt || 0) + + (costs.tokens?.input || costs.tokens?.prompt || 0), + output: + (merged[model].tokens?.output || merged[model].tokens?.completion || 0) + + (costs.tokens?.output || costs.tokens?.completion || 0), total: (merged[model].tokens?.total || 0) + (costs.tokens?.total || 0), }, } @@ -195,7 +199,7 @@ export class ExecutionLogger implements IExecutionLoggerService { input: number output: number total: number - tokens: { prompt: number; completion: number; total: number } + tokens: { input: number; output: number; total: number } } > } @@ -269,8 +273,12 @@ export class ExecutionLogger implements IExecutionLoggerService { input: (existingCost.input || 0) + costSummary.totalInputCost, output: (existingCost.output || 0) + costSummary.totalOutputCost, tokens: { - prompt: (existingCost.tokens?.prompt || 0) + costSummary.totalPromptTokens, - completion: (existingCost.tokens?.completion || 0) + costSummary.totalCompletionTokens, + input: + (existingCost.tokens?.input || existingCost.tokens?.prompt || 0) + + costSummary.totalPromptTokens, + output: + (existingCost.tokens?.output || existingCost.tokens?.completion || 0) + + costSummary.totalCompletionTokens, total: (existingCost.tokens?.total || 0) + costSummary.totalTokens, }, models: this.mergeCostModels(existingCost.models || {}, costSummary.models), @@ -280,8 +288,8 @@ export class ExecutionLogger implements IExecutionLoggerService { input: costSummary.totalInputCost, output: costSummary.totalOutputCost, tokens: { - prompt: costSummary.totalPromptTokens, - completion: costSummary.totalCompletionTokens, + input: costSummary.totalPromptTokens, + output: costSummary.totalCompletionTokens, total: costSummary.totalTokens, }, models: costSummary.models, @@ -307,9 +315,9 @@ export class ExecutionLogger implements IExecutionLoggerService { executionData: { traceSpans: redactedTraceSpans, finalOutput: redactedFinalOutput, - tokenBreakdown: { - prompt: mergedCost.tokens.prompt, - completion: mergedCost.tokens.completion, + tokens: { + input: mergedCost.tokens.input, + output: mergedCost.tokens.output, total: mergedCost.tokens.total, }, models: mergedCost.models, @@ -508,7 +516,7 @@ export class ExecutionLogger implements IExecutionLoggerService { input: number output: number total: number - tokens: { prompt: number; completion: number; total: number } + tokens: { input: number; output: number; total: number } } > }, diff --git a/apps/sim/lib/logs/execution/logging-factory.ts b/apps/sim/lib/logs/execution/logging-factory.ts index 9124ade486..5d5e5f8eb3 100644 --- a/apps/sim/lib/logs/execution/logging-factory.ts +++ b/apps/sim/lib/logs/execution/logging-factory.ts @@ -83,7 +83,7 @@ export function calculateCostSummary(traceSpans: any[]): { input: number output: number total: number - tokens: { prompt: number; completion: number; total: number } + tokens: { input: number; output: number; total: number } } > } { @@ -131,7 +131,7 @@ export function calculateCostSummary(traceSpans: any[]): { input: number output: number total: number - tokens: { prompt: number; completion: number; total: number } + tokens: { input: number; output: number; total: number } } > = {} @@ -150,14 +150,14 @@ export function calculateCostSummary(traceSpans: any[]): { input: 0, output: 0, total: 0, - tokens: { prompt: 0, completion: 0, total: 0 }, + tokens: { input: 0, output: 0, total: 0 }, } } models[model].input += span.cost.input || 0 models[model].output += span.cost.output || 0 models[model].total += span.cost.total || 0 - models[model].tokens.prompt += span.tokens?.input ?? span.tokens?.prompt ?? 0 - models[model].tokens.completion += span.tokens?.output ?? span.tokens?.completion ?? 0 + models[model].tokens.input += span.tokens?.input ?? span.tokens?.prompt ?? 0 + models[model].tokens.output += span.tokens?.output ?? span.tokens?.completion ?? 0 models[model].tokens.total += span.tokens?.total || 0 } } diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts index 379fe4eacf..829f8cad63 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts @@ -23,7 +23,7 @@ describe('buildTraceSpans', () => { output: { content: 'Agent response', model: 'gpt-4o', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, providerTiming: { duration: 8000, startTime: '2024-01-01T10:00:00.000Z', @@ -138,7 +138,7 @@ describe('buildTraceSpans', () => { output: { content: 'Agent response', model: 'gpt-4o', - tokens: { prompt: 10, completion: 20, total: 30 }, + tokens: { input: 10, output: 20, total: 30 }, providerTiming: { duration: 4000, startTime: '2024-01-01T10:00:00.500Z', @@ -427,8 +427,8 @@ describe('buildTraceSpans', () => { output: { content: 'Based on my research using multiple sources...', model: 'gpt-4o', - tokens: { prompt: 50, completion: 200, total: 250 }, - cost: { total: 0.0025, prompt: 0.001, completion: 0.0015 }, + tokens: { input: 50, output: 200, total: 250 }, + cost: { total: 0.0025, input: 0.001, output: 0.0015 }, providerTiming: { duration: 15000, startTime: '2024-01-01T10:00:00.000Z', diff --git a/apps/sim/lib/logs/types.ts b/apps/sim/lib/logs/types.ts index d10f4dce5c..3fa807a637 100644 --- a/apps/sim/lib/logs/types.ts +++ b/apps/sim/lib/logs/types.ts @@ -15,8 +15,8 @@ export interface PricingInfo { } export interface TokenUsage { - prompt: number - completion: number + input: number + output: number total: number } @@ -102,6 +102,17 @@ export interface WorkflowExecutionLog { environment?: ExecutionEnvironment trigger?: ExecutionTrigger traceSpans?: TraceSpan[] + tokens?: { input?: number; output?: number; total?: number } + models?: Record< + string, + { + input?: number + output?: number + total?: number + tokens?: { input?: number; output?: number; total?: number } + } + > + finalOutput?: any errorDetails?: { blockId: string blockName: string @@ -114,14 +125,14 @@ export interface WorkflowExecutionLog { input?: number output?: number total?: number - tokens?: { prompt?: number; completion?: number; total?: number } + tokens?: { input?: number; output?: number; total?: number } models?: Record< string, { input?: number output?: number total?: number - tokens?: { prompt?: number; completion?: number; total?: number } + tokens?: { input?: number; output?: number; total?: number } } > } diff --git a/apps/sim/lib/tokenization/calculators.ts b/apps/sim/lib/tokenization/calculators.ts index 9b0d0a2d4e..e22aa1302d 100644 --- a/apps/sim/lib/tokenization/calculators.ts +++ b/apps/sim/lib/tokenization/calculators.ts @@ -57,8 +57,8 @@ export function calculateStreamingCost( // Create token usage object const tokens: TokenUsage = { - prompt: totalPromptTokens, - completion: completionTokens, + input: totalPromptTokens, + output: completionTokens, total: totalTokens, } diff --git a/apps/sim/lib/tokenization/types.ts b/apps/sim/lib/tokenization/types.ts index 10566f9e0a..221bb31646 100644 --- a/apps/sim/lib/tokenization/types.ts +++ b/apps/sim/lib/tokenization/types.ts @@ -14,10 +14,10 @@ export interface TokenEstimate { } export interface TokenUsage { - /** Number of prompt/input tokens */ - prompt: number - /** Number of completion/output tokens */ - completion: number + /** Number of input tokens */ + input: number + /** Number of output tokens */ + output: number /** Total number of tokens */ total: number } diff --git a/apps/sim/lib/tokenization/utils.ts b/apps/sim/lib/tokenization/utils.ts index 3f1866a8f7..c1a2600ef2 100644 --- a/apps/sim/lib/tokenization/utils.ts +++ b/apps/sim/lib/tokenization/utils.ts @@ -57,7 +57,7 @@ export function isTokenizableBlockType(blockType?: string): boolean { */ export function hasRealTokenData(tokens?: TokenUsage): boolean { if (!tokens) return false - return tokens.total > 0 || tokens.prompt > 0 || tokens.completion > 0 + return tokens.total > 0 || tokens.input > 0 || tokens.output > 0 } /** diff --git a/apps/sim/providers/anthropic/index.ts b/apps/sim/providers/anthropic/index.ts index 16c92ef846..0ad50fa90f 100644 --- a/apps/sim/providers/anthropic/index.ts +++ b/apps/sim/providers/anthropic/index.ts @@ -227,8 +227,8 @@ export const anthropicProvider: ProviderConfig = { stream: createReadableStreamFromAnthropicStream(streamResponse, (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: usage.input_tokens, - completion: usage.output_tokens, + input: usage.input_tokens, + output: usage.output_tokens, total: usage.input_tokens + usage.output_tokens, } @@ -260,7 +260,7 @@ export const anthropicProvider: ProviderConfig = { output: { content: '', model: request.model, - tokens: { prompt: 0, completion: 0, total: 0 }, + tokens: { input: 0, output: 0, total: 0 }, toolCalls: undefined, providerTiming: { startTime: providerStartTimeISO, @@ -320,8 +320,8 @@ export const anthropicProvider: ProviderConfig = { } const tokens = { - prompt: currentResponse.usage?.input_tokens || 0, - completion: currentResponse.usage?.output_tokens || 0, + input: currentResponse.usage?.input_tokens || 0, + output: currentResponse.usage?.output_tokens || 0, total: (currentResponse.usage?.input_tokens || 0) + (currentResponse.usage?.output_tokens || 0), @@ -547,8 +547,8 @@ export const anthropicProvider: ProviderConfig = { modelTime += thisModelTime if (currentResponse.usage) { - tokens.prompt += currentResponse.usage.input_tokens || 0 - tokens.completion += currentResponse.usage.output_tokens || 0 + tokens.input += currentResponse.usage.input_tokens || 0 + tokens.output += currentResponse.usage.output_tokens || 0 tokens.total += (currentResponse.usage.input_tokens || 0) + (currentResponse.usage.output_tokens || 0) @@ -561,7 +561,7 @@ export const anthropicProvider: ProviderConfig = { throw error } - const accumulatedCost = calculateCost(request.model, tokens.prompt, tokens.completion) + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) const streamingPayload = { ...payload, @@ -578,8 +578,8 @@ export const anthropicProvider: ProviderConfig = { (streamContent, usage) => { streamingResult.execution.output.content = streamContent streamingResult.execution.output.tokens = { - prompt: tokens.prompt + usage.input_tokens, - completion: tokens.completion + usage.output_tokens, + input: tokens.input + usage.input_tokens, + output: tokens.output + usage.output_tokens, total: tokens.total + usage.input_tokens + usage.output_tokens, } @@ -610,8 +610,8 @@ export const anthropicProvider: ProviderConfig = { content: '', model: request.model, tokens: { - prompt: tokens.prompt, - completion: tokens.completion, + input: tokens.input, + output: tokens.output, total: tokens.total, }, toolCalls: @@ -692,8 +692,8 @@ export const anthropicProvider: ProviderConfig = { } const tokens = { - prompt: currentResponse.usage?.input_tokens || 0, - completion: currentResponse.usage?.output_tokens || 0, + input: currentResponse.usage?.input_tokens || 0, + output: currentResponse.usage?.output_tokens || 0, total: (currentResponse.usage?.input_tokens || 0) + (currentResponse.usage?.output_tokens || 0), } @@ -923,8 +923,8 @@ export const anthropicProvider: ProviderConfig = { modelTime += thisModelTime if (currentResponse.usage) { - tokens.prompt += currentResponse.usage.input_tokens || 0 - tokens.completion += currentResponse.usage.output_tokens || 0 + tokens.input += currentResponse.usage.input_tokens || 0 + tokens.output += currentResponse.usage.output_tokens || 0 tokens.total += (currentResponse.usage.input_tokens || 0) + (currentResponse.usage.output_tokens || 0) @@ -965,8 +965,8 @@ export const anthropicProvider: ProviderConfig = { stream: createReadableStreamFromAnthropicStream(streamResponse, (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: tokens.prompt + usage.input_tokens, - completion: tokens.completion + usage.output_tokens, + input: tokens.input + usage.input_tokens, + output: tokens.output + usage.output_tokens, total: tokens.total + usage.input_tokens + usage.output_tokens, } @@ -992,8 +992,8 @@ export const anthropicProvider: ProviderConfig = { content: '', model: request.model, tokens: { - prompt: tokens.prompt, - completion: tokens.completion, + input: tokens.input, + output: tokens.output, total: tokens.total, }, toolCalls: diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index 2d63967c7e..3964971697 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -165,8 +165,8 @@ export const azureOpenAIProvider: ProviderConfig = { stream: createReadableStreamFromAzureOpenAIStream(streamResponse, (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: usage.prompt_tokens, - completion: usage.completion_tokens, + input: usage.prompt_tokens, + output: usage.completion_tokens, total: usage.total_tokens, } @@ -202,7 +202,7 @@ export const azureOpenAIProvider: ProviderConfig = { output: { content: '', model: request.model, - tokens: { prompt: 0, completion: 0, total: 0 }, + tokens: { input: 0, output: 0, total: 0 }, toolCalls: undefined, providerTiming: { startTime: providerStartTimeISO, @@ -242,8 +242,8 @@ export const azureOpenAIProvider: ProviderConfig = { let content = currentResponse.choices[0]?.message?.content || '' const tokens = { - prompt: currentResponse.usage?.prompt_tokens || 0, - completion: currentResponse.usage?.completion_tokens || 0, + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, total: currentResponse.usage?.total_tokens || 0, } const toolCalls = [] @@ -445,8 +445,8 @@ export const azureOpenAIProvider: ProviderConfig = { } if (currentResponse.usage) { - tokens.prompt += currentResponse.usage.prompt_tokens || 0 - tokens.completion += currentResponse.usage.completion_tokens || 0 + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 tokens.total += currentResponse.usage.total_tokens || 0 } @@ -456,7 +456,7 @@ export const azureOpenAIProvider: ProviderConfig = { if (request.stream) { logger.info('Using streaming for final response after tool processing') - const accumulatedCost = calculateCost(request.model, tokens.prompt, tokens.completion) + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) const streamingParams: ChatCompletionCreateParamsStreaming = { ...payload, @@ -471,8 +471,8 @@ export const azureOpenAIProvider: ProviderConfig = { stream: createReadableStreamFromAzureOpenAIStream(streamResponse, (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: tokens.prompt + usage.prompt_tokens, - completion: tokens.completion + usage.completion_tokens, + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, total: tokens.total + usage.total_tokens, } @@ -493,8 +493,8 @@ export const azureOpenAIProvider: ProviderConfig = { content: '', model: request.model, tokens: { - prompt: tokens.prompt, - completion: tokens.completion, + input: tokens.input, + output: tokens.output, total: tokens.total, }, toolCalls: diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index 131aad5451..7fb5c04f9c 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -125,8 +125,8 @@ export const cerebrasProvider: ProviderConfig = { stream: createReadableStreamFromCerebrasStream(streamResponse, (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: usage.prompt_tokens, - completion: usage.completion_tokens, + input: usage.prompt_tokens, + output: usage.completion_tokens, total: usage.total_tokens, } @@ -146,7 +146,7 @@ export const cerebrasProvider: ProviderConfig = { output: { content: '', model: request.model, - tokens: { prompt: 0, completion: 0, total: 0 }, + tokens: { input: 0, output: 0, total: 0 }, toolCalls: undefined, providerTiming: { startTime: providerStartTimeISO, @@ -183,8 +183,8 @@ export const cerebrasProvider: ProviderConfig = { let content = currentResponse.choices[0]?.message?.content || '' const tokens = { - prompt: currentResponse.usage?.prompt_tokens || 0, - completion: currentResponse.usage?.completion_tokens || 0, + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, total: currentResponse.usage?.total_tokens || 0, } const toolCalls = [] @@ -384,8 +384,8 @@ export const cerebrasProvider: ProviderConfig = { content = finalResponse.choices[0].message.content } if (finalResponse.usage) { - tokens.prompt += finalResponse.usage.prompt_tokens || 0 - tokens.completion += finalResponse.usage.completion_tokens || 0 + tokens.input += finalResponse.usage.prompt_tokens || 0 + tokens.output += finalResponse.usage.completion_tokens || 0 tokens.total += finalResponse.usage.total_tokens || 0 } @@ -416,8 +416,8 @@ export const cerebrasProvider: ProviderConfig = { modelTime += thisModelTime if (currentResponse.usage) { - tokens.prompt += currentResponse.usage.prompt_tokens || 0 - tokens.completion += currentResponse.usage.completion_tokens || 0 + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 tokens.total += currentResponse.usage.total_tokens || 0 } @@ -444,14 +444,14 @@ export const cerebrasProvider: ProviderConfig = { const streamResponse: any = await client.chat.completions.create(streamingPayload) - const accumulatedCost = calculateCost(request.model, tokens.prompt, tokens.completion) + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) const streamingResult = { stream: createReadableStreamFromCerebrasStream(streamResponse, (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: tokens.prompt + usage.prompt_tokens, - completion: tokens.completion + usage.completion_tokens, + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, total: tokens.total + usage.total_tokens, } @@ -472,8 +472,8 @@ export const cerebrasProvider: ProviderConfig = { content: '', model: request.model, tokens: { - prompt: tokens.prompt, - completion: tokens.completion, + input: tokens.input, + output: tokens.output, total: tokens.total, }, toolCalls: diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index c82809dd39..f645c9ea2d 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -124,8 +124,8 @@ export const deepseekProvider: ProviderConfig = { (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: usage.prompt_tokens, - completion: usage.completion_tokens, + input: usage.prompt_tokens, + output: usage.completion_tokens, total: usage.total_tokens, } @@ -146,7 +146,7 @@ export const deepseekProvider: ProviderConfig = { output: { content: '', model: request.model, - tokens: { prompt: 0, completion: 0, total: 0 }, + tokens: { input: 0, output: 0, total: 0 }, toolCalls: undefined, providerTiming: { startTime: providerStartTimeISO, @@ -193,8 +193,8 @@ export const deepseekProvider: ProviderConfig = { } const tokens = { - prompt: currentResponse.usage?.prompt_tokens || 0, - completion: currentResponse.usage?.completion_tokens || 0, + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, total: currentResponse.usage?.total_tokens || 0, } const toolCalls = [] @@ -413,8 +413,8 @@ export const deepseekProvider: ProviderConfig = { } if (currentResponse.usage) { - tokens.prompt += currentResponse.usage.prompt_tokens || 0 - tokens.completion += currentResponse.usage.completion_tokens || 0 + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 tokens.total += currentResponse.usage.total_tokens || 0 } @@ -440,7 +440,7 @@ export const deepseekProvider: ProviderConfig = { const streamResponse = await deepseek.chat.completions.create(streamingPayload) - const accumulatedCost = calculateCost(request.model, tokens.prompt, tokens.completion) + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) const streamingResult = { stream: createReadableStreamFromDeepseekStream( @@ -448,8 +448,8 @@ export const deepseekProvider: ProviderConfig = { (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: tokens.prompt + usage.prompt_tokens, - completion: tokens.completion + usage.completion_tokens, + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, total: tokens.total + usage.total_tokens, } @@ -471,8 +471,8 @@ export const deepseekProvider: ProviderConfig = { content: '', model: request.model, tokens: { - prompt: tokens.prompt, - completion: tokens.completion, + input: tokens.input, + output: tokens.output, total: tokens.total, }, toolCalls: diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index f7cff4bacd..08ee02cff2 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -53,8 +53,8 @@ function createInitialState( return { contents, tokens: { - prompt: initialUsage.promptTokenCount, - completion: initialUsage.candidatesTokenCount, + input: initialUsage.promptTokenCount, + output: initialUsage.candidatesTokenCount, total: initialUsage.totalTokenCount, }, cost: initialCost, @@ -192,8 +192,8 @@ function updateStateWithResponse( return { ...state, tokens: { - prompt: state.tokens.prompt + usage.promptTokenCount, - completion: state.tokens.completion + usage.candidatesTokenCount, + input: state.tokens.input + usage.promptTokenCount, + output: state.tokens.output + usage.candidatesTokenCount, total: state.tokens.total + usage.totalTokenCount, }, cost: { @@ -263,7 +263,7 @@ function createStreamingResult( output: { content: '', model: '', - tokens: state?.tokens ?? { prompt: 0, completion: 0, total: 0 }, + tokens: state?.tokens ?? { input: 0, output: 0, total: 0 }, toolCalls: state?.toolCalls.length ? { list: state.toolCalls, count: state.toolCalls.length } : undefined, @@ -447,8 +447,8 @@ export async function executeGeminiRequest( (content: string, usage: GeminiUsage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: usage.promptTokenCount, - completion: usage.candidatesTokenCount, + input: usage.promptTokenCount, + output: usage.candidatesTokenCount, total: usage.totalTokenCount, } @@ -592,8 +592,8 @@ export async function executeGeminiRequest( (streamContent: string, usage: GeminiUsage) => { streamingResult.execution.output.content = streamContent streamingResult.execution.output.tokens = { - prompt: accumulatedTokens.prompt + usage.promptTokenCount, - completion: accumulatedTokens.completion + usage.candidatesTokenCount, + input: accumulatedTokens.input + usage.promptTokenCount, + output: accumulatedTokens.output + usage.candidatesTokenCount, total: accumulatedTokens.total + usage.totalTokenCount, } diff --git a/apps/sim/providers/gemini/types.ts b/apps/sim/providers/gemini/types.ts index 02592d09b6..7216086cb6 100644 --- a/apps/sim/providers/gemini/types.ts +++ b/apps/sim/providers/gemini/types.ts @@ -23,7 +23,7 @@ export interface ParsedFunctionCall { */ export interface ExecutionState { contents: Content[] - tokens: { prompt: number; completion: number; total: number } + tokens: { input: number; output: number; total: number } cost: { input: number; output: number; total: number; pricing: ModelPricing } toolCalls: FunctionCallResponse[] toolResults: Record[] diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index 58ff64197d..b61cd5fd49 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -126,8 +126,8 @@ export const groqProvider: ProviderConfig = { stream: createReadableStreamFromGroqStream(streamResponse as any, (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: usage.prompt_tokens, - completion: usage.completion_tokens, + input: usage.prompt_tokens, + output: usage.completion_tokens, total: usage.total_tokens, } @@ -147,7 +147,7 @@ export const groqProvider: ProviderConfig = { output: { content: '', model: request.model, - tokens: { prompt: 0, completion: 0, total: 0 }, + tokens: { input: 0, output: 0, total: 0 }, toolCalls: undefined, providerTiming: { startTime: providerStartTimeISO, @@ -189,8 +189,8 @@ export const groqProvider: ProviderConfig = { let content = currentResponse.choices[0]?.message?.content || '' const tokens = { - prompt: currentResponse.usage?.prompt_tokens || 0, - completion: currentResponse.usage?.completion_tokens || 0, + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, total: currentResponse.usage?.total_tokens || 0, } const toolCalls = [] @@ -373,8 +373,8 @@ export const groqProvider: ProviderConfig = { } if (currentResponse.usage) { - tokens.prompt += currentResponse.usage.prompt_tokens || 0 - tokens.completion += currentResponse.usage.completion_tokens || 0 + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 tokens.total += currentResponse.usage.total_tokens || 0 } @@ -396,14 +396,14 @@ export const groqProvider: ProviderConfig = { const streamResponse = await groq.chat.completions.create(streamingPayload) - const accumulatedCost = calculateCost(request.model, tokens.prompt, tokens.completion) + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) const streamingResult = { stream: createReadableStreamFromGroqStream(streamResponse as any, (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: tokens.prompt + usage.prompt_tokens, - completion: tokens.completion + usage.completion_tokens, + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, total: tokens.total + usage.total_tokens, } @@ -424,8 +424,8 @@ export const groqProvider: ProviderConfig = { content: '', model: request.model, tokens: { - prompt: tokens.prompt, - completion: tokens.completion, + input: tokens.input, + output: tokens.output, total: tokens.total, }, toolCalls: diff --git a/apps/sim/providers/index.ts b/apps/sim/providers/index.ts index 6c4fa15c9c..6825af2851 100644 --- a/apps/sim/providers/index.ts +++ b/apps/sim/providers/index.ts @@ -85,7 +85,7 @@ export async function executeProviderRequest( } if (response.tokens) { - const { prompt: promptTokens = 0, completion: completionTokens = 0 } = response.tokens + const { input: promptTokens = 0, output: completionTokens = 0 } = response.tokens const useCachedInput = !!request.context && request.context.length > 0 if (shouldBillModelUsage(response.model)) { diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index 9cfda86f1f..1a5d2b5585 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -149,8 +149,8 @@ export const mistralProvider: ProviderConfig = { stream: createReadableStreamFromMistralStream(streamResponse, (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: usage.prompt_tokens, - completion: usage.completion_tokens, + input: usage.prompt_tokens, + output: usage.completion_tokens, total: usage.total_tokens, } @@ -186,7 +186,7 @@ export const mistralProvider: ProviderConfig = { output: { content: '', model: request.model, - tokens: { prompt: 0, completion: 0, total: 0 }, + tokens: { input: 0, output: 0, total: 0 }, toolCalls: undefined, providerTiming: { startTime: providerStartTimeISO, @@ -247,8 +247,8 @@ export const mistralProvider: ProviderConfig = { let content = currentResponse.choices[0]?.message?.content || '' const tokens = { - prompt: currentResponse.usage?.prompt_tokens || 0, - completion: currentResponse.usage?.completion_tokens || 0, + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, total: currentResponse.usage?.total_tokens || 0, } const toolCalls = [] @@ -434,8 +434,8 @@ export const mistralProvider: ProviderConfig = { } if (currentResponse.usage) { - tokens.prompt += currentResponse.usage.prompt_tokens || 0 - tokens.completion += currentResponse.usage.completion_tokens || 0 + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 tokens.total += currentResponse.usage.total_tokens || 0 } @@ -445,7 +445,7 @@ export const mistralProvider: ProviderConfig = { if (request.stream) { logger.info('Using streaming for final response after tool processing') - const accumulatedCost = calculateCost(request.model, tokens.prompt, tokens.completion) + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) const streamingParams: ChatCompletionCreateParamsStreaming = { ...payload, @@ -460,8 +460,8 @@ export const mistralProvider: ProviderConfig = { stream: createReadableStreamFromMistralStream(streamResponse, (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: tokens.prompt + usage.prompt_tokens, - completion: tokens.completion + usage.completion_tokens, + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, total: tokens.total + usage.total_tokens, } @@ -482,8 +482,8 @@ export const mistralProvider: ProviderConfig = { content: '', model: request.model, tokens: { - prompt: tokens.prompt, - completion: tokens.completion, + input: tokens.input, + output: tokens.output, total: tokens.total, }, toolCalls: diff --git a/apps/sim/providers/ollama/index.ts b/apps/sim/providers/ollama/index.ts index 1838443c18..467cd5b141 100644 --- a/apps/sim/providers/ollama/index.ts +++ b/apps/sim/providers/ollama/index.ts @@ -178,8 +178,8 @@ export const ollamaProvider: ProviderConfig = { } streamingResult.execution.output.tokens = { - prompt: usage.prompt_tokens, - completion: usage.completion_tokens, + input: usage.prompt_tokens, + output: usage.completion_tokens, total: usage.total_tokens, } @@ -215,7 +215,7 @@ export const ollamaProvider: ProviderConfig = { output: { content: '', model: request.model, - tokens: { prompt: 0, completion: 0, total: 0 }, + tokens: { input: 0, output: 0, total: 0 }, toolCalls: undefined, providerTiming: { startTime: providerStartTimeISO, @@ -258,8 +258,8 @@ export const ollamaProvider: ProviderConfig = { } const tokens = { - prompt: currentResponse.usage?.prompt_tokens || 0, - completion: currentResponse.usage?.completion_tokens || 0, + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, total: currentResponse.usage?.total_tokens || 0, } const toolCalls = [] @@ -429,8 +429,8 @@ export const ollamaProvider: ProviderConfig = { } if (currentResponse.usage) { - tokens.prompt += currentResponse.usage.prompt_tokens || 0 - tokens.completion += currentResponse.usage.completion_tokens || 0 + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 tokens.total += currentResponse.usage.total_tokens || 0 } @@ -440,7 +440,7 @@ export const ollamaProvider: ProviderConfig = { if (request.stream) { logger.info('Using streaming for final response after tool processing') - const accumulatedCost = calculateCost(request.model, tokens.prompt, tokens.completion) + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) const streamingParams: ChatCompletionCreateParamsStreaming = { ...payload, @@ -462,8 +462,8 @@ export const ollamaProvider: ProviderConfig = { } streamingResult.execution.output.tokens = { - prompt: tokens.prompt + usage.prompt_tokens, - completion: tokens.completion + usage.completion_tokens, + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, total: tokens.total + usage.total_tokens, } @@ -484,8 +484,8 @@ export const ollamaProvider: ProviderConfig = { content: '', model: request.model, tokens: { - prompt: tokens.prompt, - completion: tokens.completion, + input: tokens.input, + output: tokens.output, total: tokens.total, }, toolCalls: diff --git a/apps/sim/providers/openai/index.ts b/apps/sim/providers/openai/index.ts index 700dc6aa67..74d8d5d712 100644 --- a/apps/sim/providers/openai/index.ts +++ b/apps/sim/providers/openai/index.ts @@ -144,8 +144,8 @@ export const openaiProvider: ProviderConfig = { stream: createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: usage.prompt_tokens, - completion: usage.completion_tokens, + input: usage.prompt_tokens, + output: usage.completion_tokens, total: usage.total_tokens, } @@ -181,7 +181,7 @@ export const openaiProvider: ProviderConfig = { output: { content: '', model: request.model, - tokens: { prompt: 0, completion: 0, total: 0 }, + tokens: { input: 0, output: 0, total: 0 }, toolCalls: undefined, providerTiming: { startTime: providerStartTimeISO, @@ -245,8 +245,8 @@ export const openaiProvider: ProviderConfig = { let content = currentResponse.choices[0]?.message?.content || '' const tokens = { - prompt: currentResponse.usage?.prompt_tokens || 0, - completion: currentResponse.usage?.completion_tokens || 0, + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, total: currentResponse.usage?.total_tokens || 0, } const toolCalls = [] @@ -433,8 +433,8 @@ export const openaiProvider: ProviderConfig = { modelTime += thisModelTime if (currentResponse.usage) { - tokens.prompt += currentResponse.usage.prompt_tokens || 0 - tokens.completion += currentResponse.usage.completion_tokens || 0 + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 tokens.total += currentResponse.usage.total_tokens || 0 } @@ -444,7 +444,7 @@ export const openaiProvider: ProviderConfig = { if (request.stream) { logger.info('Using streaming for final response after tool processing') - const accumulatedCost = calculateCost(request.model, tokens.prompt, tokens.completion) + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) const streamingParams: ChatCompletionCreateParamsStreaming = { ...payload, @@ -459,8 +459,8 @@ export const openaiProvider: ProviderConfig = { stream: createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: tokens.prompt + usage.prompt_tokens, - completion: tokens.completion + usage.completion_tokens, + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, total: tokens.total + usage.total_tokens, } @@ -481,8 +481,8 @@ export const openaiProvider: ProviderConfig = { content: '', model: request.model, tokens: { - prompt: tokens.prompt, - completion: tokens.completion, + input: tokens.input, + output: tokens.output, total: tokens.total, }, toolCalls: diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index abaf25a96d..ac2357656c 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -160,8 +160,8 @@ export const openRouterProvider: ProviderConfig = { stream: createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: usage.prompt_tokens, - completion: usage.completion_tokens, + input: usage.prompt_tokens, + output: usage.completion_tokens, total: usage.total_tokens, } @@ -193,7 +193,7 @@ export const openRouterProvider: ProviderConfig = { output: { content: '', model: requestedModel, - tokens: { prompt: 0, completion: 0, total: 0 }, + tokens: { input: 0, output: 0, total: 0 }, toolCalls: undefined, providerTiming: { startTime: providerStartTimeISO, @@ -233,8 +233,8 @@ export const openRouterProvider: ProviderConfig = { let content = currentResponse.choices[0]?.message?.content || '' const tokens = { - prompt: currentResponse.usage?.prompt_tokens || 0, - completion: currentResponse.usage?.completion_tokens || 0, + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, total: currentResponse.usage?.total_tokens || 0, } const toolCalls = [] as any[] @@ -420,15 +420,15 @@ export const openRouterProvider: ProviderConfig = { content = currentResponse.choices[0].message.content } if (currentResponse.usage) { - tokens.prompt += currentResponse.usage.prompt_tokens || 0 - tokens.completion += currentResponse.usage.completion_tokens || 0 + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 tokens.total += currentResponse.usage.total_tokens || 0 } iterationCount++ } if (request.stream) { - const accumulatedCost = calculateCost(requestedModel, tokens.prompt, tokens.completion) + const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) const streamingParams: ChatCompletionCreateParamsStreaming & { provider?: any } = { model: payload.model, @@ -459,8 +459,8 @@ export const openRouterProvider: ProviderConfig = { stream: createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: tokens.prompt + usage.prompt_tokens, - completion: tokens.completion + usage.completion_tokens, + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, total: tokens.total + usage.total_tokens, } @@ -480,7 +480,7 @@ export const openRouterProvider: ProviderConfig = { output: { content: '', model: requestedModel, - tokens: { prompt: tokens.prompt, completion: tokens.completion, total: tokens.total }, + tokens: { input: tokens.input, output: tokens.output, total: tokens.total }, toolCalls: toolCalls.length > 0 ? { @@ -553,8 +553,8 @@ export const openRouterProvider: ProviderConfig = { content = finalResponse.choices[0].message.content } if (finalResponse.usage) { - tokens.prompt += finalResponse.usage.prompt_tokens || 0 - tokens.completion += finalResponse.usage.completion_tokens || 0 + tokens.input += finalResponse.usage.prompt_tokens || 0 + tokens.output += finalResponse.usage.completion_tokens || 0 tokens.total += finalResponse.usage.total_tokens || 0 } } diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 9d83ec4581..df0e4b1f85 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -28,8 +28,8 @@ export interface ModelPricing { export type ModelPricingMap = Record export interface TokenInfo { - prompt?: number - completion?: number + input?: number + output?: number total?: number } @@ -74,8 +74,8 @@ export interface ProviderResponse { content: string model: string tokens?: { - prompt?: number - completion?: number + input?: number + output?: number total?: number } toolCalls?: FunctionCallResponse[] diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index 6fe2734ded..4984d8cec0 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -198,8 +198,8 @@ export const vllmProvider: ProviderConfig = { streamingResult.execution.output.content = cleanContent streamingResult.execution.output.tokens = { - prompt: usage.prompt_tokens, - completion: usage.completion_tokens, + input: usage.prompt_tokens, + output: usage.completion_tokens, total: usage.total_tokens, } @@ -235,7 +235,7 @@ export const vllmProvider: ProviderConfig = { output: { content: '', model: request.model, - tokens: { prompt: 0, completion: 0, total: 0 }, + tokens: { input: 0, output: 0, total: 0 }, toolCalls: undefined, providerTiming: { startTime: providerStartTimeISO, @@ -301,8 +301,8 @@ export const vllmProvider: ProviderConfig = { } const tokens = { - prompt: currentResponse.usage?.prompt_tokens || 0, - completion: currentResponse.usage?.completion_tokens || 0, + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, total: currentResponse.usage?.total_tokens || 0, } const toolCalls = [] @@ -497,8 +497,8 @@ export const vllmProvider: ProviderConfig = { } if (currentResponse.usage) { - tokens.prompt += currentResponse.usage.prompt_tokens || 0 - tokens.completion += currentResponse.usage.completion_tokens || 0 + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 tokens.total += currentResponse.usage.total_tokens || 0 } @@ -508,7 +508,7 @@ export const vllmProvider: ProviderConfig = { if (request.stream) { logger.info('Using streaming for final response after tool processing') - const accumulatedCost = calculateCost(request.model, tokens.prompt, tokens.completion) + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) const streamingParams: ChatCompletionCreateParamsStreaming = { ...payload, @@ -528,8 +528,8 @@ export const vllmProvider: ProviderConfig = { streamingResult.execution.output.content = cleanContent streamingResult.execution.output.tokens = { - prompt: tokens.prompt + usage.prompt_tokens, - completion: tokens.completion + usage.completion_tokens, + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, total: tokens.total + usage.total_tokens, } @@ -550,8 +550,8 @@ export const vllmProvider: ProviderConfig = { content: '', model: request.model, tokens: { - prompt: tokens.prompt, - completion: tokens.completion, + input: tokens.input, + output: tokens.output, total: tokens.total, }, toolCalls: diff --git a/apps/sim/providers/xai/index.ts b/apps/sim/providers/xai/index.ts index d57f282816..4d86efbd2b 100644 --- a/apps/sim/providers/xai/index.ts +++ b/apps/sim/providers/xai/index.ts @@ -119,8 +119,8 @@ export const xAIProvider: ProviderConfig = { stream: createReadableStreamFromXAIStream(streamResponse, (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: usage.prompt_tokens, - completion: usage.completion_tokens, + input: usage.prompt_tokens, + output: usage.completion_tokens, total: usage.total_tokens, } @@ -140,7 +140,7 @@ export const xAIProvider: ProviderConfig = { output: { content: '', model: request.model, - tokens: { prompt: 0, completion: 0, total: 0 }, + tokens: { input: 0, output: 0, total: 0 }, toolCalls: undefined, providerTiming: { startTime: providerStartTimeISO, @@ -202,8 +202,8 @@ export const xAIProvider: ProviderConfig = { let content = currentResponse.choices[0]?.message?.content || '' const tokens = { - prompt: currentResponse.usage?.prompt_tokens || 0, - completion: currentResponse.usage?.completion_tokens || 0, + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, total: currentResponse.usage?.total_tokens || 0, } const toolCalls = [] @@ -441,8 +441,8 @@ export const xAIProvider: ProviderConfig = { } if (currentResponse.usage) { - tokens.prompt += currentResponse.usage.prompt_tokens || 0 - tokens.completion += currentResponse.usage.completion_tokens || 0 + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 tokens.total += currentResponse.usage.total_tokens || 0 } @@ -479,14 +479,14 @@ export const xAIProvider: ProviderConfig = { const streamResponse = await xai.chat.completions.create(finalStreamingPayload as any) - const accumulatedCost = calculateCost(request.model, tokens.prompt, tokens.completion) + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) const streamingResult = { stream: createReadableStreamFromXAIStream(streamResponse as any, (content, usage) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { - prompt: tokens.prompt + usage.prompt_tokens, - completion: tokens.completion + usage.completion_tokens, + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, total: tokens.total + usage.total_tokens, } @@ -507,8 +507,8 @@ export const xAIProvider: ProviderConfig = { content: '', model: request.model, tokens: { - prompt: tokens.prompt, - completion: tokens.completion, + input: tokens.input, + output: tokens.output, total: tokens.total, }, toolCalls: diff --git a/apps/sim/stores/logs/filters/types.ts b/apps/sim/stores/logs/filters/types.ts index ca949a8190..72f18e5ead 100644 --- a/apps/sim/stores/logs/filters/types.ts +++ b/apps/sim/stores/logs/filters/types.ts @@ -29,6 +29,8 @@ export interface CostMetadata { output: number total: number tokens?: { + input?: number + output?: number prompt?: number completion?: number total?: number @@ -39,6 +41,8 @@ export interface CostMetadata { output?: number total?: number tokens?: { + input?: number + output?: number prompt?: number completion?: number total?: number diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 290c46327e..d273debd95 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -53,6 +53,8 @@ import { ManageMcpToolClientTool } from '@/lib/copilot/tools/client/workflow/man import { RunWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/run-workflow' import { SetGlobalWorkflowVariablesClientTool } from '@/lib/copilot/tools/client/workflow/set-global-workflow-variables' import { createLogger } from '@/lib/logs/console/logger' +import { getQueryClient } from '@/app/_shell/providers/query-provider' +import { subscriptionKeys } from '@/hooks/queries/subscription' import type { ChatContext, CopilotMessage, @@ -2663,6 +2665,12 @@ export const useCopilotStore = create()( // Fetch context usage after response completes logger.info('[Context Usage] Stream completed, fetching usage') await get().fetchContextUsage() + + // Invalidate subscription queries to update usage + setTimeout(() => { + const queryClient = getQueryClient() + queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() }) + }, 1000) } finally { clearTimeout(timeoutId) } From 89c1085950200fb89eb0d52853ce2d043a2f0e8e Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 23 Dec 2025 13:11:56 -0800 Subject: [PATCH 05/18] improvement(vertex): added vertex to all LLM-based blocks, fixed refresh (#2555) * improvement(vertex): added vertex to all LLM-based blocks, fixed refresh * fix build --- apps/sim/app/api/providers/route.ts | 37 +++++++++++++- apps/sim/blocks/blocks/agent.ts | 2 + apps/sim/blocks/blocks/evaluator.ts | 34 +++++++++++-- apps/sim/blocks/blocks/router.ts | 35 ++++++++++++-- apps/sim/blocks/blocks/translate.ts | 36 ++++++++++++-- .../executor/handlers/agent/agent-handler.ts | 1 - .../handlers/evaluator/evaluator-handler.ts | 47 +++++++++++++++++- .../handlers/router/router-handler.ts | 48 ++++++++++++++++++- apps/sim/lib/oauth/oauth.ts | 18 ++++++- apps/sim/tools/llm/chat.ts | 8 ++++ 10 files changed, 248 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index 9a52f0bd71..04910ed1c8 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -1,6 +1,10 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' +import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { StreamingExecution } from '@/executor/types' import { executeProviderRequest } from '@/providers' import { getApiKey } from '@/providers/utils' @@ -37,6 +41,7 @@ export async function POST(request: NextRequest) { azureApiVersion, vertexProject, vertexLocation, + vertexCredential, responseFormat, workflowId, workspaceId, @@ -62,6 +67,7 @@ export async function POST(request: NextRequest) { hasAzureApiVersion: !!azureApiVersion, hasVertexProject: !!vertexProject, hasVertexLocation: !!vertexLocation, + hasVertexCredential: !!vertexCredential, hasResponseFormat: !!responseFormat, workflowId, stream: !!stream, @@ -76,13 +82,18 @@ export async function POST(request: NextRequest) { let finalApiKey: string try { - finalApiKey = getApiKey(provider, model, apiKey) + if (provider === 'vertex' && vertexCredential) { + finalApiKey = await resolveVertexCredential(requestId, vertexCredential) + } else { + finalApiKey = getApiKey(provider, model, apiKey) + } } catch (error) { logger.error(`[${requestId}] Failed to get API key:`, { provider, model, error: error instanceof Error ? error.message : String(error), hasProvidedApiKey: !!apiKey, + hasVertexCredential: !!vertexCredential, }) return NextResponse.json( { error: error instanceof Error ? error.message : 'API key error' }, @@ -324,3 +335,27 @@ function sanitizeObject(obj: any): any { return result } + +/** + * Resolves a Vertex AI OAuth credential to an access token + */ +async function resolveVertexCredential(requestId: string, credentialId: string): Promise { + logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`) + + const credential = await db.query.account.findFirst({ + where: eq(account.id, credentialId), + }) + + if (!credential) { + throw new Error(`Vertex AI credential not found: ${credentialId}`) + } + + const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) + + if (!accessToken) { + throw new Error('Failed to get Vertex AI access token') + } + + logger.info(`[${requestId}] Successfully resolved Vertex AI credential`) + return accessToken +} diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index 16227a2902..75cee02002 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -310,6 +310,7 @@ export const AgentBlock: BlockConfig = { type: 'short-input', placeholder: 'your-gcp-project-id', connectionDroppable: false, + required: true, condition: { field: 'model', value: providers.vertex.models, @@ -321,6 +322,7 @@ export const AgentBlock: BlockConfig = { type: 'short-input', placeholder: 'us-central1', connectionDroppable: false, + required: true, condition: { field: 'model', value: providers.vertex.models, diff --git a/apps/sim/blocks/blocks/evaluator.ts b/apps/sim/blocks/blocks/evaluator.ts index 63ea9c74ce..47e3b895fc 100644 --- a/apps/sim/blocks/blocks/evaluator.ts +++ b/apps/sim/blocks/blocks/evaluator.ts @@ -18,6 +18,10 @@ const getCurrentOllamaModels = () => { return useProvidersStore.getState().providers.ollama.models } +const getCurrentVLLMModels = () => { + return useProvidersStore.getState().providers.vllm.models +} + interface Metric { name: string description: string @@ -196,6 +200,19 @@ export const EvaluatorBlock: BlockConfig = { }) }, }, + { + id: 'vertexCredential', + title: 'Google Cloud Account', + type: 'oauth-input', + serviceId: 'vertex-ai', + requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'], + placeholder: 'Select Google Cloud account', + required: true, + condition: { + field: 'model', + value: providers.vertex.models, + }, + }, { id: 'apiKey', title: 'API Key', @@ -204,16 +221,21 @@ export const EvaluatorBlock: BlockConfig = { password: true, connectionDroppable: false, required: true, + // Hide API key for hosted models, Ollama models, vLLM models, and Vertex models (uses OAuth) condition: isHosted ? { field: 'model', - value: getHostedModels(), + value: [...getHostedModels(), ...providers.vertex.models], not: true, // Show for all models EXCEPT those listed } : () => ({ field: 'model', - value: getCurrentOllamaModels(), - not: true, // Show for all models EXCEPT Ollama models + value: [ + ...getCurrentOllamaModels(), + ...getCurrentVLLMModels(), + ...providers.vertex.models, + ], + not: true, // Show for all models EXCEPT Ollama, vLLM, and Vertex models }), }, { @@ -245,6 +267,7 @@ export const EvaluatorBlock: BlockConfig = { type: 'short-input', placeholder: 'your-gcp-project-id', connectionDroppable: false, + required: true, condition: { field: 'model', value: providers.vertex.models, @@ -256,6 +279,7 @@ export const EvaluatorBlock: BlockConfig = { type: 'short-input', placeholder: 'us-central1', connectionDroppable: false, + required: true, condition: { field: 'model', value: providers.vertex.models, @@ -386,6 +410,10 @@ export const EvaluatorBlock: BlockConfig = { type: 'string' as ParamType, description: 'Google Cloud location for Vertex AI', }, + vertexCredential: { + type: 'string' as ParamType, + description: 'Google Cloud OAuth credential ID for Vertex AI', + }, temperature: { type: 'number' as ParamType, description: 'Response randomness level (low for consistent evaluation)', diff --git a/apps/sim/blocks/blocks/router.ts b/apps/sim/blocks/blocks/router.ts index 0c6006a43c..1549baa547 100644 --- a/apps/sim/blocks/blocks/router.ts +++ b/apps/sim/blocks/blocks/router.ts @@ -15,6 +15,10 @@ const getCurrentOllamaModels = () => { return useProvidersStore.getState().providers.ollama.models } +const getCurrentVLLMModels = () => { + return useProvidersStore.getState().providers.vllm.models +} + interface RouterResponse extends ToolResponse { output: { prompt: string @@ -144,6 +148,19 @@ export const RouterBlock: BlockConfig = { }) }, }, + { + id: 'vertexCredential', + title: 'Google Cloud Account', + type: 'oauth-input', + serviceId: 'vertex-ai', + requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'], + placeholder: 'Select Google Cloud account', + required: true, + condition: { + field: 'model', + value: providers.vertex.models, + }, + }, { id: 'apiKey', title: 'API Key', @@ -152,17 +169,21 @@ export const RouterBlock: BlockConfig = { password: true, connectionDroppable: false, required: true, - // Hide API key for hosted models and Ollama models + // Hide API key for hosted models, Ollama models, vLLM models, and Vertex models (uses OAuth) condition: isHosted ? { field: 'model', - value: getHostedModels(), + value: [...getHostedModels(), ...providers.vertex.models], not: true, // Show for all models EXCEPT those listed } : () => ({ field: 'model', - value: getCurrentOllamaModels(), - not: true, // Show for all models EXCEPT Ollama models + value: [ + ...getCurrentOllamaModels(), + ...getCurrentVLLMModels(), + ...providers.vertex.models, + ], + not: true, // Show for all models EXCEPT Ollama, vLLM, and Vertex models }), }, { @@ -194,6 +215,7 @@ export const RouterBlock: BlockConfig = { type: 'short-input', placeholder: 'your-gcp-project-id', connectionDroppable: false, + required: true, condition: { field: 'model', value: providers.vertex.models, @@ -205,6 +227,7 @@ export const RouterBlock: BlockConfig = { type: 'short-input', placeholder: 'us-central1', connectionDroppable: false, + required: true, condition: { field: 'model', value: providers.vertex.models, @@ -259,6 +282,10 @@ export const RouterBlock: BlockConfig = { azureApiVersion: { type: 'string', description: 'Azure API version' }, vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' }, vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' }, + vertexCredential: { + type: 'string', + description: 'Google Cloud OAuth credential ID for Vertex AI', + }, temperature: { type: 'number', description: 'Response randomness level (low for consistent routing)', diff --git a/apps/sim/blocks/blocks/translate.ts b/apps/sim/blocks/blocks/translate.ts index 1ecfc7a206..44c646608a 100644 --- a/apps/sim/blocks/blocks/translate.ts +++ b/apps/sim/blocks/blocks/translate.ts @@ -8,6 +8,10 @@ const getCurrentOllamaModels = () => { return useProvidersStore.getState().providers.ollama.models } +const getCurrentVLLMModels = () => { + return useProvidersStore.getState().providers.vllm.models +} + const getTranslationPrompt = (targetLanguage: string) => `Translate the following text into ${targetLanguage || 'English'}. Output ONLY the translated text with no additional commentary, explanations, or notes.` @@ -55,6 +59,19 @@ export const TranslateBlock: BlockConfig = { }) }, }, + { + id: 'vertexCredential', + title: 'Google Cloud Account', + type: 'oauth-input', + serviceId: 'vertex-ai', + requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'], + placeholder: 'Select Google Cloud account', + required: true, + condition: { + field: 'model', + value: providers.vertex.models, + }, + }, { id: 'apiKey', title: 'API Key', @@ -63,17 +80,21 @@ export const TranslateBlock: BlockConfig = { password: true, connectionDroppable: false, required: true, - // Hide API key for hosted models and Ollama models + // Hide API key for hosted models, Ollama models, vLLM models, and Vertex models (uses OAuth) condition: isHosted ? { field: 'model', - value: getHostedModels(), + value: [...getHostedModels(), ...providers.vertex.models], not: true, // Show for all models EXCEPT those listed } : () => ({ field: 'model', - value: getCurrentOllamaModels(), - not: true, // Show for all models EXCEPT Ollama models + value: [ + ...getCurrentOllamaModels(), + ...getCurrentVLLMModels(), + ...providers.vertex.models, + ], + not: true, // Show for all models EXCEPT Ollama, vLLM, and Vertex models }), }, { @@ -105,6 +126,7 @@ export const TranslateBlock: BlockConfig = { type: 'short-input', placeholder: 'your-gcp-project-id', connectionDroppable: false, + required: true, condition: { field: 'model', value: providers.vertex.models, @@ -116,6 +138,7 @@ export const TranslateBlock: BlockConfig = { type: 'short-input', placeholder: 'us-central1', connectionDroppable: false, + required: true, condition: { field: 'model', value: providers.vertex.models, @@ -144,6 +167,7 @@ export const TranslateBlock: BlockConfig = { azureApiVersion: params.azureApiVersion, vertexProject: params.vertexProject, vertexLocation: params.vertexLocation, + vertexCredential: params.vertexCredential, }), }, }, @@ -155,6 +179,10 @@ export const TranslateBlock: BlockConfig = { azureApiVersion: { type: 'string', description: 'Azure API version' }, vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' }, vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' }, + vertexCredential: { + type: 'string', + description: 'Google Cloud OAuth credential ID for Vertex AI', + }, systemPrompt: { type: 'string', description: 'Translation instructions' }, }, outputs: { diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 2f4a36332a..292a154f03 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -1001,7 +1001,6 @@ export class AgentBlockHandler implements BlockHandler { ) { let finalApiKey: string - // For Vertex AI, resolve OAuth credential to access token if (providerId === 'vertex' && providerRequest.vertexCredential) { finalApiKey = await this.resolveVertexCredential( providerRequest.vertexCredential, diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index 694cf885c5..ed370c4688 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -1,4 +1,8 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { eq } from 'drizzle-orm' import { createLogger } from '@/lib/logs/console/logger' +import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { BlockOutput } from '@/blocks/types' import { BlockType, DEFAULTS, EVALUATOR, HTTP } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' @@ -25,9 +29,17 @@ export class EvaluatorBlockHandler implements BlockHandler { const evaluatorConfig = { model: inputs.model || EVALUATOR.DEFAULT_MODEL, apiKey: inputs.apiKey, + vertexProject: inputs.vertexProject, + vertexLocation: inputs.vertexLocation, + vertexCredential: inputs.vertexCredential, } const providerId = getProviderFromModel(evaluatorConfig.model) + let finalApiKey = evaluatorConfig.apiKey + if (providerId === 'vertex' && evaluatorConfig.vertexCredential) { + finalApiKey = await this.resolveVertexCredential(evaluatorConfig.vertexCredential) + } + const processedContent = this.processContent(inputs.content) let systemPromptObj: { systemPrompt: string; responseFormat: any } = { @@ -87,7 +99,7 @@ export class EvaluatorBlockHandler implements BlockHandler { try { const url = buildAPIUrl('/api/providers') - const providerRequest = { + const providerRequest: Record = { provider: providerId, model: evaluatorConfig.model, systemPrompt: systemPromptObj.systemPrompt, @@ -101,10 +113,15 @@ export class EvaluatorBlockHandler implements BlockHandler { ]), temperature: EVALUATOR.DEFAULT_TEMPERATURE, - apiKey: evaluatorConfig.apiKey, + apiKey: finalApiKey, workflowId: ctx.workflowId, } + if (providerId === 'vertex') { + providerRequest.vertexProject = evaluatorConfig.vertexProject + providerRequest.vertexLocation = evaluatorConfig.vertexLocation + } + const response = await fetch(url.toString(), { method: 'POST', headers: { @@ -250,4 +267,30 @@ export class EvaluatorBlockHandler implements BlockHandler { logger.warn(`Metric "${metricName}" not found in LLM response`) return DEFAULTS.EXECUTION_TIME } + + /** + * Resolves a Vertex AI OAuth credential to an access token + */ + private async resolveVertexCredential(credentialId: string): Promise { + const requestId = `vertex-evaluator-${Date.now()}` + + logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`) + + const credential = await db.query.account.findFirst({ + where: eq(account.id, credentialId), + }) + + if (!credential) { + throw new Error(`Vertex AI credential not found: ${credentialId}`) + } + + const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) + + if (!accessToken) { + throw new Error('Failed to get Vertex AI access token') + } + + logger.info(`[${requestId}] Successfully resolved Vertex AI credential`) + return accessToken + } } diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 59c5e8291a..8b52d6217f 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -1,5 +1,9 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { eq } from 'drizzle-orm' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' +import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { generateRouterPrompt } from '@/blocks/blocks/router' import type { BlockOutput } from '@/blocks/types' import { BlockType, DEFAULTS, HTTP, isAgentBlockType, ROUTER } from '@/executor/constants' @@ -30,6 +34,9 @@ export class RouterBlockHandler implements BlockHandler { prompt: inputs.prompt, model: inputs.model || ROUTER.DEFAULT_MODEL, apiKey: inputs.apiKey, + vertexProject: inputs.vertexProject, + vertexLocation: inputs.vertexLocation, + vertexCredential: inputs.vertexCredential, } const providerId = getProviderFromModel(routerConfig.model) @@ -39,16 +46,27 @@ export class RouterBlockHandler implements BlockHandler { const messages = [{ role: 'user', content: routerConfig.prompt }] const systemPrompt = generateRouterPrompt(routerConfig.prompt, targetBlocks) - const providerRequest = { + + let finalApiKey = routerConfig.apiKey + if (providerId === 'vertex' && routerConfig.vertexCredential) { + finalApiKey = await this.resolveVertexCredential(routerConfig.vertexCredential) + } + + const providerRequest: Record = { provider: providerId, model: routerConfig.model, systemPrompt: systemPrompt, context: JSON.stringify(messages), temperature: ROUTER.INFERENCE_TEMPERATURE, - apiKey: routerConfig.apiKey, + apiKey: finalApiKey, workflowId: ctx.workflowId, } + if (providerId === 'vertex') { + providerRequest.vertexProject = routerConfig.vertexProject + providerRequest.vertexLocation = routerConfig.vertexLocation + } + const response = await fetch(url.toString(), { method: 'POST', headers: { @@ -152,4 +170,30 @@ export class RouterBlockHandler implements BlockHandler { } }) } + + /** + * Resolves a Vertex AI OAuth credential to an access token + */ + private async resolveVertexCredential(credentialId: string): Promise { + const requestId = `vertex-router-${Date.now()}` + + logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`) + + const credential = await db.query.account.findFirst({ + where: eq(account.id, credentialId), + }) + + if (!credential) { + throw new Error(`Vertex AI credential not found: ${credentialId}`) + } + + const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) + + if (!accessToken) { + throw new Error('Failed to get Vertex AI access token') + } + + logger.info(`[${requestId}] Successfully resolved Vertex AI credential`) + return accessToken + } } diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 5c1e99b931..e7a10ac060 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -1107,12 +1107,28 @@ function buildAuthRequest( * @param refreshToken The refresh token to use * @returns Object containing the new access token and expiration time in seconds, or null if refresh failed */ +function getBaseProviderForService(providerId: string): string { + if (providerId in OAUTH_PROVIDERS) { + return providerId + } + + for (const [baseProvider, config] of Object.entries(OAUTH_PROVIDERS)) { + for (const service of Object.values(config.services)) { + if (service.providerId === providerId) { + return baseProvider + } + } + } + + throw new Error(`Unknown OAuth provider: ${providerId}`) +} + export async function refreshOAuthToken( providerId: string, refreshToken: string ): Promise<{ accessToken: string; expiresIn: number; refreshToken: string } | null> { try { - const provider = providerId.split('-')[0] + const provider = getBaseProviderForService(providerId) const config = getProviderAuthConfig(provider) diff --git a/apps/sim/tools/llm/chat.ts b/apps/sim/tools/llm/chat.ts index 7af74232db..5f1bb3b2f6 100644 --- a/apps/sim/tools/llm/chat.ts +++ b/apps/sim/tools/llm/chat.ts @@ -15,6 +15,7 @@ interface LLMChatParams { azureApiVersion?: string vertexProject?: string vertexLocation?: string + vertexCredential?: string } interface LLMChatResponse extends ToolResponse { @@ -91,6 +92,12 @@ export const llmChatTool: ToolConfig = { visibility: 'hidden', description: 'Google Cloud location for Vertex AI (defaults to us-central1)', }, + vertexCredential: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Google Cloud OAuth credential ID for Vertex AI', + }, }, request: { @@ -114,6 +121,7 @@ export const llmChatTool: ToolConfig = { azureApiVersion: params.azureApiVersion, vertexProject: params.vertexProject, vertexLocation: params.vertexLocation, + vertexCredential: params.vertexCredential, } }, }, From 2c36926a4edbd3532cc3127619deda5265d38e35 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 23 Dec 2025 13:24:18 -0800 Subject: [PATCH 06/18] fix(perplexity): remove deprecated perplexity sonar reasoning model (#2556) --- apps/sim/blocks/blocks/perplexity.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/sim/blocks/blocks/perplexity.ts b/apps/sim/blocks/blocks/perplexity.ts index a779537560..5b4a208e32 100644 --- a/apps/sim/blocks/blocks/perplexity.ts +++ b/apps/sim/blocks/blocks/perplexity.ts @@ -50,7 +50,6 @@ export const PerplexityBlock: BlockConfig = { { label: 'Sonar', id: 'sonar' }, { label: 'Sonar Pro', id: 'sonar-pro' }, { label: 'Sonar Deep Research', id: 'sonar-deep-research' }, - { label: 'Sonar Reasoning', id: 'sonar-reasoning' }, { label: 'Sonar Reasoning Pro', id: 'sonar-reasoning-pro' }, ], value: () => 'sonar', From 6c8f1a81c1de737b0ba3bd4bafa8238d4556504a Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 23 Dec 2025 14:34:12 -0800 Subject: [PATCH 07/18] feat(tools): added grain and circleback (#2557) * feat(tools): added grain and circleback * oauth to pat and tool fix * updated docs * remove remaining oauth references * cleanup --------- Co-authored-by: aadamgough --- apps/docs/components/icons.tsx | 43 ++ apps/docs/components/ui/icon-mapping.ts | 4 + .../docs/content/docs/en/tools/circleback.mdx | 64 +++ apps/docs/content/docs/en/tools/grain.mdx | 222 ++++++++++ apps/docs/content/docs/en/tools/meta.json | 2 + apps/sim/blocks/blocks/circleback.ts | 49 +++ apps/sim/blocks/blocks/grain.ts | 395 ++++++++++++++++++ apps/sim/blocks/registry.ts | 4 + apps/sim/components/icons.tsx | 43 ++ apps/sim/lib/webhooks/processor.ts | 27 ++ apps/sim/lib/webhooks/utils.server.ts | 106 +++++ apps/sim/tools/grain/create_hook.ts | 163 ++++++++ apps/sim/tools/grain/delete_hook.ts | 55 +++ apps/sim/tools/grain/get_recording.ts | 180 ++++++++ apps/sim/tools/grain/get_transcript.ts | 71 ++++ apps/sim/tools/grain/index.ts | 8 + apps/sim/tools/grain/list_hooks.ts | 61 +++ apps/sim/tools/grain/list_meeting_types.ts | 64 +++ apps/sim/tools/grain/list_recordings.ts | 182 ++++++++ apps/sim/tools/grain/list_teams.ts | 57 +++ apps/sim/tools/grain/types.ts | 228 ++++++++++ apps/sim/tools/registry.ts | 18 + apps/sim/triggers/circleback/index.ts | 3 + .../triggers/circleback/meeting_completed.ts | 76 ++++ apps/sim/triggers/circleback/meeting_notes.ts | 76 ++++ apps/sim/triggers/circleback/utils.ts | 118 ++++++ apps/sim/triggers/circleback/webhook.ts | 85 ++++ apps/sim/triggers/grain/highlight_created.ts | 76 ++++ apps/sim/triggers/grain/highlight_updated.ts | 76 ++++ apps/sim/triggers/grain/index.ts | 6 + apps/sim/triggers/grain/recording_created.ts | 76 ++++ apps/sim/triggers/grain/recording_updated.ts | 76 ++++ apps/sim/triggers/grain/story_created.ts | 76 ++++ apps/sim/triggers/grain/utils.ts | 239 +++++++++++ apps/sim/triggers/grain/webhook.ts | 85 ++++ apps/sim/triggers/registry.ts | 22 + 36 files changed, 3136 insertions(+) create mode 100644 apps/docs/content/docs/en/tools/circleback.mdx create mode 100644 apps/docs/content/docs/en/tools/grain.mdx create mode 100644 apps/sim/blocks/blocks/circleback.ts create mode 100644 apps/sim/blocks/blocks/grain.ts create mode 100644 apps/sim/tools/grain/create_hook.ts create mode 100644 apps/sim/tools/grain/delete_hook.ts create mode 100644 apps/sim/tools/grain/get_recording.ts create mode 100644 apps/sim/tools/grain/get_transcript.ts create mode 100644 apps/sim/tools/grain/index.ts create mode 100644 apps/sim/tools/grain/list_hooks.ts create mode 100644 apps/sim/tools/grain/list_meeting_types.ts create mode 100644 apps/sim/tools/grain/list_recordings.ts create mode 100644 apps/sim/tools/grain/list_teams.ts create mode 100644 apps/sim/tools/grain/types.ts create mode 100644 apps/sim/triggers/circleback/index.ts create mode 100644 apps/sim/triggers/circleback/meeting_completed.ts create mode 100644 apps/sim/triggers/circleback/meeting_notes.ts create mode 100644 apps/sim/triggers/circleback/utils.ts create mode 100644 apps/sim/triggers/circleback/webhook.ts create mode 100644 apps/sim/triggers/grain/highlight_created.ts create mode 100644 apps/sim/triggers/grain/highlight_updated.ts create mode 100644 apps/sim/triggers/grain/index.ts create mode 100644 apps/sim/triggers/grain/recording_created.ts create mode 100644 apps/sim/triggers/grain/recording_updated.ts create mode 100644 apps/sim/triggers/grain/story_created.ts create mode 100644 apps/sim/triggers/grain/utils.ts create mode 100644 apps/sim/triggers/grain/webhook.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index ddaf0f95ee..5f5e0c2bb4 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -4288,3 +4288,46 @@ export function SpotifyIcon(props: SVGProps) { ) } + +export function GrainIcon(props: SVGProps) { + return ( + + + + + ) +} + +export function CirclebackIcon(props: SVGProps) { + return ( + + + + + + + + + + ) +} diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 6e4dd1d323..b6ea44ee48 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -13,6 +13,7 @@ import { BrainIcon, BrowserUseIcon, CalendlyIcon, + CirclebackIcon, ClayIcon, ConfluenceIcon, CursorIcon, @@ -40,6 +41,7 @@ import { GoogleSlidesIcon, GoogleVaultIcon, GrafanaIcon, + GrainIcon, HubspotIcon, HuggingFaceIcon, HunterIOIcon, @@ -128,6 +130,7 @@ export const blockTypeToIconMap: Record = { asana: AsanaIcon, browser_use: BrowserUseIcon, calendly: CalendlyIcon, + circleback: CirclebackIcon, clay: ClayIcon, confluence: ConfluenceIcon, cursor: CursorIcon, @@ -154,6 +157,7 @@ export const blockTypeToIconMap: Record = { google_slides: GoogleSlidesIcon, google_vault: GoogleVaultIcon, grafana: GrafanaIcon, + grain: GrainIcon, hubspot: HubspotIcon, huggingface: HuggingFaceIcon, hunter: HunterIOIcon, diff --git a/apps/docs/content/docs/en/tools/circleback.mdx b/apps/docs/content/docs/en/tools/circleback.mdx new file mode 100644 index 0000000000..04810df96d --- /dev/null +++ b/apps/docs/content/docs/en/tools/circleback.mdx @@ -0,0 +1,64 @@ +--- +title: Circleback +description: AI-powered meeting notes and action items +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Circleback](https://circleback.ai/) is an AI-powered platform that automates meeting notes, action items, transcripts, and recordings for your team. When a meeting is completed, Circleback processes the conversation and provides detailed notes and action items, along with a transcript and a recording (when available). This helps teams efficiently capture insights, distribute action items, and ensure nothing is missed—all seamlessly integrated into your workflows. + +With the Sim Circleback integration, you can: + +- **Receive detailed meeting notes and action items**: Automatically collect well-formatted meeting summaries and track actionable tasks discussed during your calls. +- **Access complete meeting recordings and transcripts**: Get the full conversation and the associated recording, making it easy to review key moments or share with colleagues. +- **Capture attendee information and meeting context**: Attendee lists, meeting metadata, and tags help keep your data organized and actionable. +- **Deliver insights directly into your workflows**: Trigger automations or send Circleback data to other systems the moment a meeting is done, using Sim’s powerful webhook triggers. + +**How it works in Sim:** +Circleback uses webhook triggers: whenever a meeting is processed, data is pushed automatically to your agent or automation. You can build further automations based on: + +- Meeting completed (all processed data available) +- New notes (notes ready even before full meeting is processed) +- Raw webhook integration for advanced use cases + +**The following information is available in the Circleback meeting webhook payload:** + +| Field | Type | Description | +|----------------|---------|----------------------------------------------------| +| `id` | number | Circleback meeting ID | +| `name` | string | Meeting title | +| `url` | string | Virtual meeting URL (Zoom, Meet, Teams, etc.) | +| `createdAt` | string | Meeting creation timestamp | +| `duration` | number | Duration in seconds | +| `recordingUrl` | string | Recording URL (valid 24 hours) | +| `tags` | json | Array of tags | +| `icalUid` | string | Calendar event ID | +| `attendees` | json | Array of attendee objects | +| `notes` | string | Meeting notes in Markdown | +| `actionItems` | json | Array of action items | +| `transcript` | json | Array of transcript segments | +| `insights` | json | User-created insights | +| `meeting` | json | Full meeting payload | + +Whether you want to distribute instant summaries, log action items, or build custom workflows triggered by new meeting data, Circleback and Sim make it seamless to handle everything related to your meetings—automatically. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Receive meeting notes, action items, transcripts, and recordings when meetings are processed. Circleback uses webhooks to push data to your workflows. + + + + + +## Notes + +- Category: `triggers` +- Type: `circleback` diff --git a/apps/docs/content/docs/en/tools/grain.mdx b/apps/docs/content/docs/en/tools/grain.mdx new file mode 100644 index 0000000000..845b3cb44d --- /dev/null +++ b/apps/docs/content/docs/en/tools/grain.mdx @@ -0,0 +1,222 @@ +--- +title: Grain +description: Access meeting recordings, transcripts, and AI summaries +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Grain](https://grain.com/) is a modern platform for capturing, storing, and sharing meeting recordings, transcripts, highlights, and AI-powered summaries. Grain enables teams to turn conversations into actionable insights and keep everyone aligned on key moments from meetings. + +With Grain, you can: + +- **Access searchable recordings and transcripts**: Find and review every meeting by keyword, participant, or topic. +- **Share highlights and clips**: Capture important moments and share short video/audio highlights across your team or workflows. +- **Get AI-generated summaries**: Automatically produce meeting summaries, action items, and key insights using Grain’s advanced AI. +- **Organize meetings by team or type**: Tag and categorize recordings for easy access and reporting. + +The Sim Grain integration empowers your agents to: + +- List, search, and retrieve meeting recordings and details by flexible filters (datetime, participant, team, etc). +- Access AI summaries, participants, highlights, and other metadata for meetings to power automations or analysis. +- Trigger workflows whenever new meetings are processed, summaries are generated, or highlights are created via Grain webhooks. +- Easily bridge Grain data into other tools or notify teammates the moment something important happens in a meeting. + +Whether you want to automate follow-up actions, keep records of important conversations, or surface insights across your organization, Grain and Sim make it easy to connect meeting intelligence to your workflows. +{/* MANUAL-CONTENT-END */} + +## Usage Instructions + +Integrate Grain into your workflow. Access meeting recordings, transcripts, highlights, and AI-generated summaries. Can also trigger workflows based on Grain webhook events. + + + +## Tools + +### `grain_list_recordings` + +List recordings from Grain with optional filters and pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) | +| `cursor` | string | No | Pagination cursor for next page | +| `beforeDatetime` | string | No | Only recordings before this ISO8601 timestamp | +| `afterDatetime` | string | No | Only recordings after this ISO8601 timestamp | +| `participantScope` | string | No | Filter: "internal" or "external" | +| `titleSearch` | string | No | Search term to filter by recording title | +| `teamId` | string | No | Filter by team UUID | +| `meetingTypeId` | string | No | Filter by meeting type UUID | +| `includeHighlights` | boolean | No | Include highlights/clips in response | +| `includeParticipants` | boolean | No | Include participant list in response | +| `includeAiSummary` | boolean | No | Include AI-generated summary | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordings` | array | Array of recording objects | + +### `grain_get_recording` + +Get details of a single recording by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) | +| `recordingId` | string | Yes | The recording UUID | +| `includeHighlights` | boolean | No | Include highlights/clips | +| `includeParticipants` | boolean | No | Include participant list | +| `includeAiSummary` | boolean | No | Include AI summary | +| `includeCalendarEvent` | boolean | No | Include calendar event data | +| `includeHubspot` | boolean | No | Include HubSpot associations | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Recording UUID | +| `title` | string | Recording title | +| `start_datetime` | string | ISO8601 start timestamp | +| `end_datetime` | string | ISO8601 end timestamp | +| `duration_ms` | number | Duration in milliseconds | +| `media_type` | string | audio, transcript, or video | +| `source` | string | Recording source \(zoom, meet, teams, etc.\) | +| `url` | string | URL to view in Grain | +| `thumbnail_url` | string | Thumbnail image URL | +| `tags` | array | Array of tag strings | +| `teams` | array | Teams the recording belongs to | +| `meeting_type` | object | Meeting type info \(id, name, scope\) | +| `highlights` | array | Highlights \(if included\) | +| `participants` | array | Participants \(if included\) | +| `ai_summary` | object | AI summary text \(if included\) | +| `calendar_event` | object | Calendar event data \(if included\) | +| `hubspot` | object | HubSpot associations \(if included\) | + +### `grain_get_transcript` + +Get the full transcript of a recording + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) | +| `recordingId` | string | Yes | The recording UUID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transcript` | array | Array of transcript sections | + +### `grain_list_teams` + +List all teams in the workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `teams` | array | Array of team objects | + +### `grain_list_meeting_types` + +List all meeting types in the workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `meeting_types` | array | Array of meeting type objects | + +### `grain_create_hook` + +Create a webhook to receive recording events + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) | +| `hookUrl` | string | Yes | Webhook endpoint URL \(must respond 2xx\) | +| `filterBeforeDatetime` | string | No | Filter: recordings before this date | +| `filterAfterDatetime` | string | No | Filter: recordings after this date | +| `filterParticipantScope` | string | No | Filter: "internal" or "external" | +| `filterTeamId` | string | No | Filter: specific team UUID | +| `filterMeetingTypeId` | string | No | Filter: specific meeting type | +| `includeHighlights` | boolean | No | Include highlights in webhook payload | +| `includeParticipants` | boolean | No | Include participants in webhook payload | +| `includeAiSummary` | boolean | No | Include AI summary in webhook payload | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Hook UUID | +| `enabled` | boolean | Whether hook is active | +| `hook_url` | string | The webhook URL | +| `filter` | object | Applied filters | +| `include` | object | Included fields | +| `inserted_at` | string | ISO8601 creation timestamp | + +### `grain_list_hooks` + +List all webhooks for the account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `hooks` | array | Array of hook objects | + +### `grain_delete_hook` + +Delete a webhook by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) | +| `hookId` | string | Yes | The hook UUID to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | True when webhook was successfully deleted | + + + +## Notes + +- Category: `tools` +- Type: `grain` diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 42771ff867..17b1a4fa95 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -9,6 +9,7 @@ "asana", "browser_use", "calendly", + "circleback", "clay", "confluence", "cursor", @@ -35,6 +36,7 @@ "google_slides", "google_vault", "grafana", + "grain", "hubspot", "huggingface", "hunter", diff --git a/apps/sim/blocks/blocks/circleback.ts b/apps/sim/blocks/blocks/circleback.ts new file mode 100644 index 0000000000..847dabd275 --- /dev/null +++ b/apps/sim/blocks/blocks/circleback.ts @@ -0,0 +1,49 @@ +import { CirclebackIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { getTrigger } from '@/triggers' + +export const CirclebackBlock: BlockConfig = { + type: 'circleback', + name: 'Circleback', + description: 'AI-powered meeting notes and action items', + longDescription: + 'Receive meeting notes, action items, transcripts, and recordings when meetings are processed. Circleback uses webhooks to push data to your workflows.', + category: 'triggers', + bgColor: 'linear-gradient(180deg, #E0F7FA 0%, #FFFFFF 100%)', + icon: CirclebackIcon, + triggerAllowed: true, + + subBlocks: [ + ...getTrigger('circleback_meeting_completed').subBlocks, + ...getTrigger('circleback_meeting_notes').subBlocks, + ...getTrigger('circleback_webhook').subBlocks, + ], + + tools: { + access: [], + }, + + inputs: {}, + + outputs: { + id: { type: 'number', description: 'Circleback meeting ID' }, + name: { type: 'string', description: 'Meeting title' }, + url: { type: 'string', description: 'Virtual meeting URL (Zoom, Meet, Teams)' }, + createdAt: { type: 'string', description: 'Meeting creation timestamp' }, + duration: { type: 'number', description: 'Duration in seconds' }, + recordingUrl: { type: 'string', description: 'Recording URL (valid 24 hours)' }, + tags: { type: 'json', description: 'Array of tags' }, + icalUid: { type: 'string', description: 'Calendar event ID' }, + attendees: { type: 'json', description: 'Array of attendee objects' }, + notes: { type: 'string', description: 'Meeting notes in Markdown' }, + actionItems: { type: 'json', description: 'Array of action items' }, + transcript: { type: 'json', description: 'Array of transcript segments' }, + insights: { type: 'json', description: 'User-created insights' }, + meeting: { type: 'json', description: 'Full meeting payload' }, + }, + + triggers: { + enabled: true, + available: ['circleback_meeting_completed', 'circleback_meeting_notes', 'circleback_webhook'], + }, +} diff --git a/apps/sim/blocks/blocks/grain.ts b/apps/sim/blocks/blocks/grain.ts new file mode 100644 index 0000000000..01e52e083c --- /dev/null +++ b/apps/sim/blocks/blocks/grain.ts @@ -0,0 +1,395 @@ +import { GrainIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' +import { getTrigger } from '@/triggers' + +export const GrainBlock: BlockConfig = { + type: 'grain', + name: 'Grain', + description: 'Access meeting recordings, transcripts, and AI summaries', + authMode: AuthMode.ApiKey, + triggerAllowed: true, + longDescription: + 'Integrate Grain into your workflow. Access meeting recordings, transcripts, highlights, and AI-generated summaries. Can also trigger workflows based on Grain webhook events.', + category: 'tools', + icon: GrainIcon, + bgColor: '#F6FAF9', + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'List Recordings', id: 'grain_list_recordings' }, + { label: 'Get Recording', id: 'grain_get_recording' }, + { label: 'Get Transcript', id: 'grain_get_transcript' }, + { label: 'List Teams', id: 'grain_list_teams' }, + { label: 'List Meeting Types', id: 'grain_list_meeting_types' }, + { label: 'Create Webhook', id: 'grain_create_hook' }, + { label: 'List Webhooks', id: 'grain_list_hooks' }, + { label: 'Delete Webhook', id: 'grain_delete_hook' }, + ], + value: () => 'grain_list_recordings', + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your Grain API key', + password: true, + required: true, + }, + // Recording ID (for get_recording and get_transcript) + { + id: 'recordingId', + title: 'Recording ID', + type: 'short-input', + placeholder: 'Enter recording UUID', + required: true, + condition: { + field: 'operation', + value: ['grain_get_recording', 'grain_get_transcript'], + }, + }, + // Pagination cursor + { + id: 'cursor', + title: 'Pagination Cursor', + type: 'short-input', + placeholder: 'Cursor for next page (optional)', + condition: { + field: 'operation', + value: ['grain_list_recordings'], + }, + }, + // Before datetime filter + { + id: 'beforeDatetime', + title: 'Before Date', + type: 'short-input', + placeholder: 'ISO8601 timestamp (e.g., 2024-01-01T00:00:00Z)', + condition: { + field: 'operation', + value: ['grain_list_recordings', 'grain_create_hook'], + }, + }, + // After datetime filter + { + id: 'afterDatetime', + title: 'After Date', + type: 'short-input', + placeholder: 'ISO8601 timestamp (e.g., 2024-01-01T00:00:00Z)', + condition: { + field: 'operation', + value: ['grain_list_recordings', 'grain_create_hook'], + }, + }, + // Participant scope filter + { + id: 'participantScope', + title: 'Participant Scope', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'Internal', id: 'internal' }, + { label: 'External', id: 'external' }, + ], + value: () => '', + condition: { + field: 'operation', + value: ['grain_list_recordings', 'grain_create_hook'], + }, + }, + // Title search + { + id: 'titleSearch', + title: 'Title Search', + type: 'short-input', + placeholder: 'Search by recording title', + condition: { + field: 'operation', + value: ['grain_list_recordings'], + }, + }, + // Team ID filter + { + id: 'teamId', + title: 'Team ID', + type: 'short-input', + placeholder: 'Filter by team UUID (optional)', + condition: { + field: 'operation', + value: ['grain_list_recordings', 'grain_create_hook'], + }, + }, + // Meeting type ID filter + { + id: 'meetingTypeId', + title: 'Meeting Type ID', + type: 'short-input', + placeholder: 'Filter by meeting type UUID (optional)', + condition: { + field: 'operation', + value: ['grain_list_recordings', 'grain_create_hook'], + }, + }, + // Include highlights + { + id: 'includeHighlights', + title: 'Include Highlights', + type: 'switch', + condition: { + field: 'operation', + value: ['grain_list_recordings', 'grain_get_recording', 'grain_create_hook'], + }, + }, + // Include participants + { + id: 'includeParticipants', + title: 'Include Participants', + type: 'switch', + condition: { + field: 'operation', + value: ['grain_list_recordings', 'grain_get_recording', 'grain_create_hook'], + }, + }, + // Include AI summary + { + id: 'includeAiSummary', + title: 'Include AI Summary', + type: 'switch', + condition: { + field: 'operation', + value: ['grain_list_recordings', 'grain_get_recording', 'grain_create_hook'], + }, + }, + // Include calendar event (get_recording only) + { + id: 'includeCalendarEvent', + title: 'Include Calendar Event', + type: 'switch', + condition: { + field: 'operation', + value: ['grain_get_recording'], + }, + }, + // Include HubSpot (get_recording only) + { + id: 'includeHubspot', + title: 'Include HubSpot Data', + type: 'switch', + condition: { + field: 'operation', + value: ['grain_get_recording'], + }, + }, + // Webhook URL (for create_hook) + { + id: 'hookUrl', + title: 'Webhook URL', + type: 'short-input', + placeholder: 'Enter webhook endpoint URL', + required: true, + condition: { + field: 'operation', + value: ['grain_create_hook'], + }, + }, + // Hook ID (for delete_hook) + { + id: 'hookId', + title: 'Webhook ID', + type: 'short-input', + placeholder: 'Enter webhook UUID to delete', + required: true, + condition: { + field: 'operation', + value: ['grain_delete_hook'], + }, + }, + // Trigger SubBlocks + ...getTrigger('grain_recording_created').subBlocks, + ...getTrigger('grain_recording_updated').subBlocks, + ...getTrigger('grain_highlight_created').subBlocks, + ...getTrigger('grain_highlight_updated').subBlocks, + ...getTrigger('grain_story_created').subBlocks, + ...getTrigger('grain_webhook').subBlocks, + ], + tools: { + access: [ + 'grain_list_recordings', + 'grain_get_recording', + 'grain_get_transcript', + 'grain_list_teams', + 'grain_list_meeting_types', + 'grain_create_hook', + 'grain_list_hooks', + 'grain_delete_hook', + ], + config: { + tool: (params) => { + return params.operation || 'grain_list_recordings' + }, + params: (params) => { + const baseParams: Record = { + apiKey: params.apiKey, + } + + switch (params.operation) { + case 'grain_list_recordings': + return { + ...baseParams, + cursor: params.cursor || undefined, + beforeDatetime: params.beforeDatetime || undefined, + afterDatetime: params.afterDatetime || undefined, + participantScope: params.participantScope || undefined, + titleSearch: params.titleSearch || undefined, + teamId: params.teamId || undefined, + meetingTypeId: params.meetingTypeId || undefined, + includeHighlights: params.includeHighlights || false, + includeParticipants: params.includeParticipants || false, + includeAiSummary: params.includeAiSummary || false, + } + + case 'grain_get_recording': + if (!params.recordingId?.trim()) { + throw new Error('Recording ID is required.') + } + return { + ...baseParams, + recordingId: params.recordingId.trim(), + includeHighlights: params.includeHighlights || false, + includeParticipants: params.includeParticipants || false, + includeAiSummary: params.includeAiSummary || false, + includeCalendarEvent: params.includeCalendarEvent || false, + includeHubspot: params.includeHubspot || false, + } + + case 'grain_get_transcript': + if (!params.recordingId?.trim()) { + throw new Error('Recording ID is required.') + } + return { + ...baseParams, + recordingId: params.recordingId.trim(), + } + + case 'grain_list_teams': + case 'grain_list_meeting_types': + case 'grain_list_hooks': + return baseParams + + case 'grain_create_hook': + if (!params.hookUrl?.trim()) { + throw new Error('Webhook URL is required.') + } + return { + ...baseParams, + hookUrl: params.hookUrl.trim(), + filterBeforeDatetime: params.beforeDatetime || undefined, + filterAfterDatetime: params.afterDatetime || undefined, + filterParticipantScope: params.participantScope || undefined, + filterTeamId: params.teamId || undefined, + filterMeetingTypeId: params.meetingTypeId || undefined, + includeHighlights: params.includeHighlights || false, + includeParticipants: params.includeParticipants || false, + includeAiSummary: params.includeAiSummary || false, + } + + case 'grain_delete_hook': + if (!params.hookId?.trim()) { + throw new Error('Webhook ID is required.') + } + return { + ...baseParams, + hookId: params.hookId.trim(), + } + + default: + return baseParams + } + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + apiKey: { type: 'string', description: 'Grain API key (Personal Access Token)' }, + recordingId: { type: 'string', description: 'Recording UUID' }, + cursor: { type: 'string', description: 'Pagination cursor' }, + beforeDatetime: { + type: 'string', + description: 'Filter recordings before this ISO8601 timestamp', + }, + afterDatetime: { + type: 'string', + description: 'Filter recordings after this ISO8601 timestamp', + }, + participantScope: { + type: 'string', + description: 'Filter by participant scope (internal/external)', + }, + titleSearch: { type: 'string', description: 'Search recordings by title' }, + teamId: { type: 'string', description: 'Filter by team UUID' }, + meetingTypeId: { type: 'string', description: 'Filter by meeting type UUID' }, + includeHighlights: { type: 'boolean', description: 'Include highlights/clips in response' }, + includeParticipants: { type: 'boolean', description: 'Include participant list in response' }, + includeAiSummary: { type: 'boolean', description: 'Include AI-generated summary' }, + includeCalendarEvent: { type: 'boolean', description: 'Include calendar event data' }, + includeHubspot: { type: 'boolean', description: 'Include HubSpot associations' }, + hookUrl: { type: 'string', description: 'Webhook endpoint URL' }, + hookId: { type: 'string', description: 'Webhook UUID to delete' }, + }, + outputs: { + // Recording outputs + recordings: { type: 'json', description: 'Array of recording objects' }, + recording: { type: 'json', description: 'Single recording data' }, + id: { type: 'string', description: 'Recording UUID' }, + title: { type: 'string', description: 'Recording title' }, + startDatetime: { type: 'string', description: 'Recording start timestamp' }, + endDatetime: { type: 'string', description: 'Recording end timestamp' }, + durationMs: { type: 'number', description: 'Duration in milliseconds' }, + mediaType: { type: 'string', description: 'Media type (audio/transcript/video)' }, + source: { type: 'string', description: 'Recording source (zoom/meet/teams/etc)' }, + url: { type: 'string', description: 'URL to view in Grain' }, + thumbnailUrl: { type: 'string', description: 'Thumbnail image URL' }, + tags: { type: 'json', description: 'Array of tag strings' }, + teams: { type: 'json', description: 'Teams the recording belongs to' }, + meetingType: { type: 'json', description: 'Meeting type info' }, + highlights: { type: 'json', description: 'Highlights/clips (if included)' }, + participants: { type: 'json', description: 'Participants (if included)' }, + aiSummary: { type: 'json', description: 'AI summary (if included)' }, + calendarEvent: { type: 'json', description: 'Calendar event data (if included)' }, + // Transcript outputs + transcript: { type: 'json', description: 'Array of transcript sections' }, + // Team outputs + teamsList: { type: 'json', description: 'Array of team objects' }, + // Meeting type outputs + meetingTypes: { type: 'json', description: 'Array of meeting type objects' }, + // Hook outputs + hooks: { type: 'json', description: 'Array of webhook objects' }, + hook: { type: 'json', description: 'Created webhook data' }, + // Pagination + nextCursor: { type: 'string', description: 'Cursor for next page' }, + hasMore: { type: 'boolean', description: 'Whether more results exist' }, + // Success indicator + success: { type: 'boolean', description: 'Operation success status' }, + // Trigger outputs + event: { type: 'string', description: 'Webhook event type' }, + highlight: { type: 'json', description: 'Highlight data from webhook' }, + story: { type: 'json', description: 'Story data from webhook' }, + payload: { type: 'json', description: 'Raw webhook payload' }, + headers: { type: 'json', description: 'Webhook request headers' }, + timestamp: { type: 'string', description: 'Webhook received timestamp' }, + }, + triggers: { + enabled: true, + available: [ + 'grain_recording_created', + 'grain_recording_updated', + 'grain_highlight_created', + 'grain_highlight_updated', + 'grain_story_created', + 'grain_webhook', + ], + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index bd5b96f6bd..b9b30e5fa7 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -11,6 +11,7 @@ import { AsanaBlock } from '@/blocks/blocks/asana' import { BrowserUseBlock } from '@/blocks/blocks/browser_use' import { CalendlyBlock } from '@/blocks/blocks/calendly' import { ChatTriggerBlock } from '@/blocks/blocks/chat_trigger' +import { CirclebackBlock } from '@/blocks/blocks/circleback' import { ClayBlock } from '@/blocks/blocks/clay' import { ConditionBlock } from '@/blocks/blocks/condition' import { ConfluenceBlock } from '@/blocks/blocks/confluence' @@ -41,6 +42,7 @@ import { GoogleSheetsBlock } from '@/blocks/blocks/google_sheets' import { GoogleSlidesBlock } from '@/blocks/blocks/google_slides' import { GoogleVaultBlock } from '@/blocks/blocks/google_vault' import { GrafanaBlock } from '@/blocks/blocks/grafana' +import { GrainBlock } from '@/blocks/blocks/grain' import { GuardrailsBlock } from '@/blocks/blocks/guardrails' import { HubSpotBlock } from '@/blocks/blocks/hubspot' import { HuggingFaceBlock } from '@/blocks/blocks/huggingface' @@ -154,6 +156,7 @@ export const registry: Record = { browser_use: BrowserUseBlock, calendly: CalendlyBlock, chat_trigger: ChatTriggerBlock, + circleback: CirclebackBlock, clay: ClayBlock, condition: ConditionBlock, confluence: ConfluenceBlock, @@ -173,6 +176,7 @@ export const registry: Record = { github: GitHubBlock, gitlab: GitLabBlock, gmail: GmailBlock, + grain: GrainBlock, grafana: GrafanaBlock, guardrails: GuardrailsBlock, google_calendar: GoogleCalendarBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index ddaf0f95ee..5f5e0c2bb4 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -4288,3 +4288,46 @@ export function SpotifyIcon(props: SVGProps) { ) } + +export function GrainIcon(props: SVGProps) { + return ( + + + + + ) +} + +export function CirclebackIcon(props: SVGProps) { + return ( + + + + + + + + + + ) +} diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index d7cb0f65f6..47a84cc33e 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -394,6 +394,33 @@ export async function verifyProviderAuth( } } + if (foundWebhook.provider === 'circleback') { + const secret = providerConfig.webhookSecret as string | undefined + + if (secret) { + const signature = request.headers.get('x-signature') + + if (!signature) { + logger.warn(`[${requestId}] Circleback webhook missing signature header`) + return new NextResponse('Unauthorized - Missing Circleback signature', { status: 401 }) + } + + const { validateCirclebackSignature } = await import('@/lib/webhooks/utils.server') + + const isValidSignature = validateCirclebackSignature(secret, signature, rawBody) + + if (!isValidSignature) { + logger.warn(`[${requestId}] Circleback signature verification failed`, { + signatureLength: signature.length, + secretLength: secret.length, + }) + return new NextResponse('Unauthorized - Invalid Circleback signature', { status: 401 }) + } + + logger.debug(`[${requestId}] Circleback signature verified successfully`) + } + } + if (foundWebhook.provider === 'jira') { const secret = providerConfig.secret as string | undefined diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 1dd03562f9..844b457f84 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -1432,6 +1432,63 @@ export async function formatWebhookInput( } } + if (foundWebhook.provider === 'circleback') { + // Circleback webhook payload - meeting notes, action items, transcript + return { + // Top-level fields from Circleback payload + id: body.id, + name: body.name, + createdAt: body.createdAt, + duration: body.duration, + url: body.url, + recordingUrl: body.recordingUrl, + tags: body.tags || [], + icalUid: body.icalUid, + attendees: body.attendees || [], + notes: body.notes || '', + actionItems: body.actionItems || [], + transcript: body.transcript || [], + insights: body.insights || {}, + + // Full meeting object for convenience + meeting: body, + + webhook: { + data: { + provider: 'circleback', + path: foundWebhook.path, + providerConfig: foundWebhook.providerConfig, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + + if (foundWebhook.provider === 'grain') { + // Grain webhook payload structure: { type, user_id, data: {...} } + return { + // Top-level fields from Grain payload + type: body.type, + user_id: body.user_id, + data: body.data || {}, + + webhook: { + data: { + provider: 'grain', + path: foundWebhook.path, + providerConfig: foundWebhook.providerConfig, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + // Generic format for other providers return { webhook: { @@ -1579,6 +1636,55 @@ export function validateLinearSignature(secret: string, signature: string, body: } } +/** + * Validates a Circleback webhook request signature using HMAC SHA-256 + * @param secret - Circleback signing secret (plain text) + * @param signature - x-signature header value (hex-encoded HMAC SHA-256 signature) + * @param body - Raw request body string + * @returns Whether the signature is valid + */ +export function validateCirclebackSignature( + secret: string, + signature: string, + body: string +): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Circleback signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + + const crypto = require('crypto') + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + + logger.debug('Circleback signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${signature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: signature.length, + match: computedHash === signature, + }) + + if (computedHash.length !== signature.length) { + return false + } + + let result = 0 + for (let i = 0; i < computedHash.length; i++) { + result |= computedHash.charCodeAt(i) ^ signature.charCodeAt(i) + } + + return result === 0 + } catch (error) { + logger.error('Error validating Circleback signature:', error) + return false + } +} + /** * Validates a Jira webhook request signature using HMAC SHA-256 * @param secret - Jira webhook secret (plain text) diff --git a/apps/sim/tools/grain/create_hook.ts b/apps/sim/tools/grain/create_hook.ts new file mode 100644 index 0000000000..b1b4709f24 --- /dev/null +++ b/apps/sim/tools/grain/create_hook.ts @@ -0,0 +1,163 @@ +import type { GrainCreateHookParams, GrainCreateHookResponse } from '@/tools/grain/types' +import type { ToolConfig } from '@/tools/types' + +export const grainCreateHookTool: ToolConfig = { + id: 'grain_create_hook', + name: 'Grain Create Webhook', + description: 'Create a webhook to receive recording events', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grain API key (Personal Access Token)', + }, + hookUrl: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Webhook endpoint URL (must respond 2xx)', + }, + filterBeforeDatetime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter: recordings before this date', + }, + filterAfterDatetime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter: recordings after this date', + }, + filterParticipantScope: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter: "internal" or "external"', + }, + filterTeamId: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Filter: specific team UUID', + }, + filterMeetingTypeId: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Filter: specific meeting type', + }, + includeHighlights: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Include highlights in webhook payload', + }, + includeParticipants: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Include participants in webhook payload', + }, + includeAiSummary: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Include AI summary in webhook payload', + }, + }, + + request: { + url: 'https://api.grain.com/_/public-api/v2/hooks/create', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + 'Public-Api-Version': '2025-10-31', + }), + body: (params) => { + const body: Record = { + hook_url: params.hookUrl, + } + + const filter: Record = {} + if (params.filterBeforeDatetime) { + filter.before_datetime = params.filterBeforeDatetime + } + if (params.filterAfterDatetime) { + filter.after_datetime = params.filterAfterDatetime + } + if (params.filterParticipantScope) { + filter.participant_scope = params.filterParticipantScope + } + if (params.filterTeamId) { + filter.team = params.filterTeamId + } + if (params.filterMeetingTypeId) { + filter.meeting_type = params.filterMeetingTypeId + } + if (Object.keys(filter).length > 0) { + body.filter = filter + } + + const include: Record = {} + if (params.includeHighlights) { + include.highlights = true + } + if (params.includeParticipants) { + include.participants = true + } + if (params.includeAiSummary) { + include.ai_summary = true + } + if (Object.keys(include).length > 0) { + body.include = include + } + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || data.message || 'Failed to create webhook') + } + + return { + success: true, + output: data, + } + }, + + outputs: { + id: { + type: 'string', + description: 'Hook UUID', + }, + enabled: { + type: 'boolean', + description: 'Whether hook is active', + }, + hook_url: { + type: 'string', + description: 'The webhook URL', + }, + filter: { + type: 'object', + description: 'Applied filters', + }, + include: { + type: 'object', + description: 'Included fields', + }, + inserted_at: { + type: 'string', + description: 'ISO8601 creation timestamp', + }, + }, +} diff --git a/apps/sim/tools/grain/delete_hook.ts b/apps/sim/tools/grain/delete_hook.ts new file mode 100644 index 0000000000..3ccdb31b4a --- /dev/null +++ b/apps/sim/tools/grain/delete_hook.ts @@ -0,0 +1,55 @@ +import type { GrainDeleteHookParams, GrainDeleteHookResponse } from '@/tools/grain/types' +import type { ToolConfig } from '@/tools/types' + +export const grainDeleteHookTool: ToolConfig = { + id: 'grain_delete_hook', + name: 'Grain Delete Webhook', + description: 'Delete a webhook by ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grain API key (Personal Access Token)', + }, + hookId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The hook UUID to delete', + }, + }, + + request: { + url: (params) => `https://api.grain.com/_/public-api/v2/hooks/${params.hookId}`, + method: 'DELETE', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + 'Public-Api-Version': '2025-10-31', + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || data.message || 'Failed to delete webhook') + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'True when webhook was successfully deleted', + }, + }, +} diff --git a/apps/sim/tools/grain/get_recording.ts b/apps/sim/tools/grain/get_recording.ts new file mode 100644 index 0000000000..b75e261f69 --- /dev/null +++ b/apps/sim/tools/grain/get_recording.ts @@ -0,0 +1,180 @@ +import type { GrainGetRecordingParams, GrainGetRecordingResponse } from '@/tools/grain/types' +import type { ToolConfig } from '@/tools/types' + +export const grainGetRecordingTool: ToolConfig = + { + id: 'grain_get_recording', + name: 'Grain Get Recording', + description: 'Get details of a single recording by ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grain API key (Personal Access Token)', + }, + recordingId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The recording UUID', + }, + includeHighlights: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Include highlights/clips', + }, + includeParticipants: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Include participant list', + }, + includeAiSummary: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Include AI summary', + }, + includeCalendarEvent: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Include calendar event data', + }, + includeHubspot: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Include HubSpot associations', + }, + }, + + request: { + url: (params) => `https://api.grain.com/_/public-api/v2/recordings/${params.recordingId}`, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + 'Public-Api-Version': '2025-10-31', + }), + body: (params) => { + const include: Record = {} + + if (params.includeHighlights) { + include.highlights = true + } + if (params.includeParticipants) { + include.participants = true + } + if (params.includeAiSummary) { + include.ai_summary = true + } + if (params.includeCalendarEvent) { + include.calendar_event = true + } + if (params.includeHubspot) { + include.hubspot = true + } + + if (Object.keys(include).length > 0) { + return { include } + } + return {} + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || data.message || 'Failed to get recording') + } + + return { + success: true, + output: data, + } + }, + + outputs: { + id: { + type: 'string', + description: 'Recording UUID', + }, + title: { + type: 'string', + description: 'Recording title', + }, + start_datetime: { + type: 'string', + description: 'ISO8601 start timestamp', + }, + end_datetime: { + type: 'string', + description: 'ISO8601 end timestamp', + }, + duration_ms: { + type: 'number', + description: 'Duration in milliseconds', + }, + media_type: { + type: 'string', + description: 'audio, transcript, or video', + }, + source: { + type: 'string', + description: 'Recording source (zoom, meet, teams, etc.)', + }, + url: { + type: 'string', + description: 'URL to view in Grain', + }, + thumbnail_url: { + type: 'string', + description: 'Thumbnail image URL', + optional: true, + }, + tags: { + type: 'array', + description: 'Array of tag strings', + }, + teams: { + type: 'array', + description: 'Teams the recording belongs to', + }, + meeting_type: { + type: 'object', + description: 'Meeting type info (id, name, scope)', + optional: true, + }, + highlights: { + type: 'array', + description: 'Highlights (if included)', + optional: true, + }, + participants: { + type: 'array', + description: 'Participants (if included)', + optional: true, + }, + ai_summary: { + type: 'object', + description: 'AI summary text (if included)', + optional: true, + }, + calendar_event: { + type: 'object', + description: 'Calendar event data (if included)', + optional: true, + }, + hubspot: { + type: 'object', + description: 'HubSpot associations (if included)', + optional: true, + }, + }, + } diff --git a/apps/sim/tools/grain/get_transcript.ts b/apps/sim/tools/grain/get_transcript.ts new file mode 100644 index 0000000000..9d6885c8c2 --- /dev/null +++ b/apps/sim/tools/grain/get_transcript.ts @@ -0,0 +1,71 @@ +import type { GrainGetTranscriptParams, GrainGetTranscriptResponse } from '@/tools/grain/types' +import type { ToolConfig } from '@/tools/types' + +export const grainGetTranscriptTool: ToolConfig< + GrainGetTranscriptParams, + GrainGetTranscriptResponse +> = { + id: 'grain_get_transcript', + name: 'Grain Get Transcript', + description: 'Get the full transcript of a recording', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grain API key (Personal Access Token)', + }, + recordingId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The recording UUID', + }, + }, + + request: { + url: (params) => + `https://api.grain.com/_/public-api/v2/recordings/${params.recordingId}/transcript`, + method: 'GET', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + 'Public-Api-Version': '2025-10-31', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || data.message || 'Failed to get transcript') + } + + // API returns array directly + return { + success: true, + output: { + transcript: Array.isArray(data) ? data : [], + }, + } + }, + + outputs: { + transcript: { + type: 'array', + description: 'Array of transcript sections', + items: { + type: 'object', + properties: { + participant_id: { type: 'string', description: 'Participant UUID (nullable)' }, + speaker: { type: 'string', description: 'Speaker name' }, + start: { type: 'number', description: 'Start timestamp in ms' }, + end: { type: 'number', description: 'End timestamp in ms' }, + text: { type: 'string', description: 'Transcript text' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/grain/index.ts b/apps/sim/tools/grain/index.ts new file mode 100644 index 0000000000..d27baa4543 --- /dev/null +++ b/apps/sim/tools/grain/index.ts @@ -0,0 +1,8 @@ +export { grainCreateHookTool } from './create_hook' +export { grainDeleteHookTool } from './delete_hook' +export { grainGetRecordingTool } from './get_recording' +export { grainGetTranscriptTool } from './get_transcript' +export { grainListHooksTool } from './list_hooks' +export { grainListMeetingTypesTool } from './list_meeting_types' +export { grainListRecordingsTool } from './list_recordings' +export { grainListTeamsTool } from './list_teams' diff --git a/apps/sim/tools/grain/list_hooks.ts b/apps/sim/tools/grain/list_hooks.ts new file mode 100644 index 0000000000..73e0fbb33b --- /dev/null +++ b/apps/sim/tools/grain/list_hooks.ts @@ -0,0 +1,61 @@ +import type { GrainListHooksParams, GrainListHooksResponse } from '@/tools/grain/types' +import type { ToolConfig } from '@/tools/types' + +export const grainListHooksTool: ToolConfig = { + id: 'grain_list_hooks', + name: 'Grain List Webhooks', + description: 'List all webhooks for the account', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grain API key (Personal Access Token)', + }, + }, + + request: { + url: 'https://api.grain.com/_/public-api/v2/hooks', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + 'Public-Api-Version': '2025-10-31', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || data.message || 'Failed to list webhooks') + } + + return { + success: true, + output: { + hooks: data.hooks || data || [], + }, + } + }, + + outputs: { + hooks: { + type: 'array', + description: 'Array of hook objects', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Hook UUID' }, + enabled: { type: 'boolean', description: 'Whether hook is active' }, + hook_url: { type: 'string', description: 'Webhook URL' }, + filter: { type: 'object', description: 'Applied filters' }, + include: { type: 'object', description: 'Included fields' }, + inserted_at: { type: 'string', description: 'Creation timestamp' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/grain/list_meeting_types.ts b/apps/sim/tools/grain/list_meeting_types.ts new file mode 100644 index 0000000000..dd12d11ba1 --- /dev/null +++ b/apps/sim/tools/grain/list_meeting_types.ts @@ -0,0 +1,64 @@ +import type { + GrainListMeetingTypesParams, + GrainListMeetingTypesResponse, +} from '@/tools/grain/types' +import type { ToolConfig } from '@/tools/types' + +export const grainListMeetingTypesTool: ToolConfig< + GrainListMeetingTypesParams, + GrainListMeetingTypesResponse +> = { + id: 'grain_list_meeting_types', + name: 'Grain List Meeting Types', + description: 'List all meeting types in the workspace', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grain API key (Personal Access Token)', + }, + }, + + request: { + url: 'https://api.grain.com/_/public-api/v2/meeting_types', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + 'Public-Api-Version': '2025-10-31', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || data.message || 'Failed to list meeting types') + } + + return { + success: true, + output: { + meeting_types: data.meeting_types || data || [], + }, + } + }, + + outputs: { + meeting_types: { + type: 'array', + description: 'Array of meeting type objects', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Meeting type UUID' }, + name: { type: 'string', description: 'Meeting type name' }, + scope: { type: 'string', description: 'internal or external' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/grain/list_recordings.ts b/apps/sim/tools/grain/list_recordings.ts new file mode 100644 index 0000000000..fb612757a6 --- /dev/null +++ b/apps/sim/tools/grain/list_recordings.ts @@ -0,0 +1,182 @@ +import type { GrainListRecordingsParams, GrainListRecordingsResponse } from '@/tools/grain/types' +import type { ToolConfig } from '@/tools/types' + +export const grainListRecordingsTool: ToolConfig< + GrainListRecordingsParams, + GrainListRecordingsResponse +> = { + id: 'grain_list_recordings', + name: 'Grain List Recordings', + description: 'List recordings from Grain with optional filters and pagination', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grain API key (Personal Access Token)', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor for next page', + }, + beforeDatetime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only recordings before this ISO8601 timestamp', + }, + afterDatetime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only recordings after this ISO8601 timestamp', + }, + participantScope: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter: "internal" or "external"', + }, + titleSearch: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Search term to filter by recording title', + }, + teamId: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Filter by team UUID', + }, + meetingTypeId: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Filter by meeting type UUID', + }, + includeHighlights: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Include highlights/clips in response', + }, + includeParticipants: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Include participant list in response', + }, + includeAiSummary: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Include AI-generated summary', + }, + }, + + request: { + url: 'https://api.grain.com/_/public-api/v2/recordings', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + 'Public-Api-Version': '2025-10-31', + }), + body: (params) => { + const body: Record = {} + + if (params.cursor) { + body.cursor = params.cursor + } + + const filter: Record = {} + if (params.beforeDatetime) { + filter.before_datetime = params.beforeDatetime + } + if (params.afterDatetime) { + filter.after_datetime = params.afterDatetime + } + if (params.participantScope) { + filter.participant_scope = params.participantScope + } + if (params.titleSearch) { + filter.title_search = params.titleSearch + } + if (params.teamId) { + filter.team = params.teamId + } + if (params.meetingTypeId) { + filter.meeting_type = params.meetingTypeId + } + if (Object.keys(filter).length > 0) { + body.filter = filter + } + + const include: Record = {} + if (params.includeHighlights) { + include.highlights = true + } + if (params.includeParticipants) { + include.participants = true + } + if (params.includeAiSummary) { + include.ai_summary = true + } + if (Object.keys(include).length > 0) { + body.include = include + } + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || data.message || 'Failed to list recordings') + } + + return { + success: true, + output: { + recordings: data.recordings || [], + cursor: data.cursor || null, + }, + } + }, + + outputs: { + recordings: { + type: 'array', + description: 'Array of recording objects', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Recording UUID' }, + title: { type: 'string', description: 'Recording title' }, + start_datetime: { type: 'string', description: 'ISO8601 start timestamp' }, + end_datetime: { type: 'string', description: 'ISO8601 end timestamp' }, + duration_ms: { type: 'number', description: 'Duration in milliseconds' }, + media_type: { type: 'string', description: 'audio, transcript, or video' }, + source: { type: 'string', description: 'Recording source' }, + url: { type: 'string', description: 'URL to view in Grain' }, + thumbnail_url: { type: 'string', description: 'Thumbnail URL' }, + tags: { type: 'array', description: 'Array of tags' }, + teams: { type: 'array', description: 'Teams the recording belongs to' }, + meeting_type: { type: 'object', description: 'Meeting type info' }, + }, + }, + }, + cursor: { + type: 'string', + description: 'Cursor for next page (null if no more)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/grain/list_teams.ts b/apps/sim/tools/grain/list_teams.ts new file mode 100644 index 0000000000..4ec8da6ecd --- /dev/null +++ b/apps/sim/tools/grain/list_teams.ts @@ -0,0 +1,57 @@ +import type { GrainListTeamsParams, GrainListTeamsResponse } from '@/tools/grain/types' +import type { ToolConfig } from '@/tools/types' + +export const grainListTeamsTool: ToolConfig = { + id: 'grain_list_teams', + name: 'Grain List Teams', + description: 'List all teams in the workspace', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grain API key (Personal Access Token)', + }, + }, + + request: { + url: 'https://api.grain.com/_/public-api/v2/teams', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + 'Public-Api-Version': '2025-10-31', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || data.message || 'Failed to list teams') + } + + return { + success: true, + output: { + teams: data.teams || data || [], + }, + } + }, + + outputs: { + teams: { + type: 'array', + description: 'Array of team objects', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Team UUID' }, + name: { type: 'string', description: 'Team name' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/grain/types.ts b/apps/sim/tools/grain/types.ts new file mode 100644 index 0000000000..584b86a408 --- /dev/null +++ b/apps/sim/tools/grain/types.ts @@ -0,0 +1,228 @@ +/** + * Grain API Types + * Base URL: https://api.grain.com/_/public-api + * API Version: 2025-10-31 + */ + +import type { ToolResponse } from '@/tools/types' +export interface GrainTeam { + id: string + name: string +} + +export interface GrainMeetingType { + id: string + name: string + scope: 'internal' | 'external' +} + +export interface GrainParticipant { + id: string + name: string + email: string | null + scope: 'internal' | 'external' | 'unknown' + confirmed_attendee: boolean + hs_contact_id?: string | null +} + +export interface GrainHighlight { + id: string + recording_id: string + text: string + transcript: string + speakers: string[] + timestamp: number + duration: number + tags: string[] + url: string + thumbnail_url: string + created_datetime: string +} + +export interface GrainAiSummary { + text: string +} + +export interface GrainCalendarEvent { + ical_uid: string +} + +export interface GrainHubspotData { + hubspot_company_ids: string[] + hubspot_deal_ids: string[] +} + +export interface GrainAiTemplateSection { + title: string + data: Record +} + +export interface GrainPrivateNotes { + text: string +} + +export interface GrainTranscriptSection { + participant_id: string | null + speaker: string + start: number + end: number + text: string +} + +export interface GrainRecording { + id: string + title: string + start_datetime: string + end_datetime: string + duration_ms: number + media_type: 'audio' | 'transcript' | 'video' + source: 'aircall' | 'local_capture' | 'meet' | 'teams' | 'upload' | 'webex' | 'zoom' | 'other' + url: string + thumbnail_url: string | null + tags: string[] + teams: GrainTeam[] + meeting_type: GrainMeetingType | null + highlights?: GrainHighlight[] + participants?: GrainParticipant[] + ai_summary?: GrainAiSummary + calendar_event?: GrainCalendarEvent | null + hubspot?: GrainHubspotData + private_notes?: GrainPrivateNotes | null + ai_template_sections?: GrainAiTemplateSection[] +} + +export interface GrainHook { + id: string + enabled: boolean + hook_url: string + filter: GrainRecordingFilter + include: GrainRecordingInclude + inserted_at: string +} + +export interface GrainRecordingFilter { + before_datetime?: string + after_datetime?: string + attendance?: 'hosted' | 'attended' + participant_scope?: 'internal' | 'external' + title_search?: string + team?: string + meeting_type?: string +} + +export interface GrainRecordingInclude { + highlights?: boolean + participants?: boolean + ai_summary?: boolean + private_notes?: boolean + calendar_event?: boolean + hubspot?: boolean + ai_template_sections?: { + format?: 'json' | 'markdown' | 'text' + allowed_sections?: string[] + } +} + +export interface GrainListRecordingsParams { + apiKey: string + cursor?: string + beforeDatetime?: string + afterDatetime?: string + participantScope?: 'internal' | 'external' + titleSearch?: string + teamId?: string + meetingTypeId?: string + includeHighlights?: boolean + includeParticipants?: boolean + includeAiSummary?: boolean +} + +export interface GrainListRecordingsResponse extends ToolResponse { + output: { + recordings: GrainRecording[] + cursor: string | null + } +} + +export interface GrainGetRecordingParams { + apiKey: string + recordingId: string + includeHighlights?: boolean + includeParticipants?: boolean + includeAiSummary?: boolean + includeCalendarEvent?: boolean + includeHubspot?: boolean +} + +export interface GrainGetRecordingResponse extends ToolResponse { + output: GrainRecording +} + +export interface GrainGetTranscriptParams { + apiKey: string + recordingId: string +} + +export interface GrainGetTranscriptResponse extends ToolResponse { + output: { + transcript: GrainTranscriptSection[] + } +} + +export interface GrainListTeamsParams { + apiKey: string +} + +export interface GrainListTeamsResponse extends ToolResponse { + output: { + teams: GrainTeam[] + } +} + +export interface GrainListMeetingTypesParams { + apiKey: string +} + +export interface GrainListMeetingTypesResponse extends ToolResponse { + output: { + meeting_types: GrainMeetingType[] + } +} + +export interface GrainCreateHookParams { + apiKey: string + hookUrl: string + filterBeforeDatetime?: string + filterAfterDatetime?: string + filterParticipantScope?: 'internal' | 'external' + filterTeamId?: string + filterMeetingTypeId?: string + includeHighlights?: boolean + includeParticipants?: boolean + includeAiSummary?: boolean +} + +export interface GrainCreateHookResponse extends ToolResponse { + output: GrainHook +} + +export interface GrainListHooksParams { + apiKey: string +} + +export interface GrainListHooksResponse extends ToolResponse { + output: { + hooks: GrainHook[] + } +} + +export interface GrainDeleteHookParams { + apiKey: string + hookId: string +} + +export interface GrainDeleteHookResponse extends ToolResponse { + output: { + success: true + } +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 88197c9157..005e922cad 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -358,6 +358,16 @@ import { grafanaUpdateAnnotationTool, grafanaUpdateDashboardTool, } from '@/tools/grafana' +import { + grainCreateHookTool, + grainDeleteHookTool, + grainGetRecordingTool, + grainGetTranscriptTool, + grainListHooksTool, + grainListMeetingTypesTool, + grainListRecordingsTool, + grainListTeamsTool, +} from '@/tools/grain' import { guardrailsValidateTool } from '@/tools/guardrails' import { httpRequestTool } from '@/tools/http' import { @@ -1741,6 +1751,14 @@ export const tools: Record = { gitlab_create_pipeline: gitlabCreatePipelineTool, gitlab_retry_pipeline: gitlabRetryPipelineTool, gitlab_cancel_pipeline: gitlabCancelPipelineTool, + grain_list_recordings: grainListRecordingsTool, + grain_get_recording: grainGetRecordingTool, + grain_get_transcript: grainGetTranscriptTool, + grain_list_teams: grainListTeamsTool, + grain_list_meeting_types: grainListMeetingTypesTool, + grain_create_hook: grainCreateHookTool, + grain_list_hooks: grainListHooksTool, + grain_delete_hook: grainDeleteHookTool, elasticsearch_search: elasticsearchSearchTool, elasticsearch_index_document: elasticsearchIndexDocumentTool, elasticsearch_get_document: elasticsearchGetDocumentTool, diff --git a/apps/sim/triggers/circleback/index.ts b/apps/sim/triggers/circleback/index.ts new file mode 100644 index 0000000000..c333d61549 --- /dev/null +++ b/apps/sim/triggers/circleback/index.ts @@ -0,0 +1,3 @@ +export { circlebackMeetingCompletedTrigger } from './meeting_completed' +export { circlebackMeetingNotesTrigger } from './meeting_notes' +export { circlebackWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/circleback/meeting_completed.ts b/apps/sim/triggers/circleback/meeting_completed.ts new file mode 100644 index 0000000000..904ee70865 --- /dev/null +++ b/apps/sim/triggers/circleback/meeting_completed.ts @@ -0,0 +1,76 @@ +import { CirclebackIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' +import { buildMeetingOutputs, circlebackSetupInstructions } from './utils' + +export const circlebackMeetingCompletedTrigger: TriggerConfig = { + id: 'circleback_meeting_completed', + name: 'Circleback Meeting Completed', + provider: 'circleback', + description: 'Trigger workflow when a meeting is processed and ready in Circleback', + version: '1.0.0', + icon: CirclebackIcon, + + subBlocks: [ + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'circleback_meeting_completed', + }, + }, + { + id: 'webhookSecret', + title: 'Signing Secret', + type: 'short-input', + placeholder: 'Paste signing secret from Circleback (optional)', + description: 'Validates that webhook deliveries originate from Circleback using HMAC-SHA256.', + password: true, + required: false, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'circleback_meeting_completed', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: circlebackSetupInstructions('All meeting data'), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'circleback_meeting_completed', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + hideFromPreview: true, + mode: 'trigger', + triggerId: 'circleback_meeting_completed', + condition: { + field: 'selectedTriggerId', + value: 'circleback_meeting_completed', + }, + }, + ], + + outputs: buildMeetingOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/circleback/meeting_notes.ts b/apps/sim/triggers/circleback/meeting_notes.ts new file mode 100644 index 0000000000..d8b3af7afd --- /dev/null +++ b/apps/sim/triggers/circleback/meeting_notes.ts @@ -0,0 +1,76 @@ +import { CirclebackIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' +import { buildMeetingOutputs, circlebackSetupInstructions } from './utils' + +export const circlebackMeetingNotesTrigger: TriggerConfig = { + id: 'circleback_meeting_notes', + name: 'Circleback Meeting Notes Ready', + provider: 'circleback', + description: 'Trigger workflow when meeting notes and action items are ready', + version: '1.0.0', + icon: CirclebackIcon, + + subBlocks: [ + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'circleback_meeting_notes', + }, + }, + { + id: 'webhookSecret', + title: 'Signing Secret', + type: 'short-input', + placeholder: 'Paste signing secret from Circleback (optional)', + description: 'Validates that webhook deliveries originate from Circleback using HMAC-SHA256.', + password: true, + required: false, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'circleback_meeting_notes', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: circlebackSetupInstructions('Meeting notes and action items'), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'circleback_meeting_notes', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + hideFromPreview: true, + mode: 'trigger', + triggerId: 'circleback_meeting_notes', + condition: { + field: 'selectedTriggerId', + value: 'circleback_meeting_notes', + }, + }, + ], + + outputs: buildMeetingOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/circleback/utils.ts b/apps/sim/triggers/circleback/utils.ts new file mode 100644 index 0000000000..a9480cc0a8 --- /dev/null +++ b/apps/sim/triggers/circleback/utils.ts @@ -0,0 +1,118 @@ +import type { TriggerOutput } from '@/triggers/types' + +/** + * Shared trigger dropdown options for all Circleback triggers + */ +export const circlebackTriggerOptions = [ + { label: 'General Webhook', id: 'circleback_webhook' }, + { label: 'Meeting Completed', id: 'circleback_meeting_completed' }, + { label: 'Meeting Notes Ready', id: 'circleback_meeting_notes' }, +] + +/** + * Generate setup instructions for a specific Circleback event type + */ +export function circlebackSetupInstructions(eventType: string): string { + const instructions = [ + 'Note: You need access to Circleback automations to set up webhooks.', + 'In Circleback, click Automations in the sidebar.', + 'Create a new automation or edit an existing one.', + 'Add a Send webhook request step to your automation.', + 'Paste the Webhook URL from above into the Endpoint field.', + 'Optionally, copy the Signing Secret from Circleback and paste it above to verify webhook signatures.', + `Toggle what to include in the request (Meeting notes, Action items, Transcript). For this trigger: ${eventType}.`, + 'Click "Done" and save the automation.', + ] + + return instructions + .map( + (instruction, index) => + `
${index === 0 ? instruction : `${index}. ${instruction}`}
` + ) + .join('') +} + +/** + * Build output schema for meeting events + */ +export function buildMeetingOutputs(): Record { + return { + id: { + type: 'number', + description: 'Circleback meeting ID', + }, + name: { + type: 'string', + description: 'Meeting title/name', + }, + url: { + type: 'string', + description: 'URL of the virtual meeting (Zoom, Google Meet, Teams, etc.)', + }, + createdAt: { + type: 'string', + description: 'ISO8601 timestamp when meeting was created', + }, + duration: { + type: 'number', + description: 'Meeting duration in seconds', + }, + recordingUrl: { + type: 'string', + description: 'Recording URL (valid for 24 hours, if enabled)', + }, + tags: { + type: 'array', + description: 'Array of tag strings', + }, + icalUid: { + type: 'string', + description: 'Calendar event identifier', + }, + attendees: { + type: 'array', + description: 'Array of attendee objects with name and email', + }, + notes: { + type: 'string', + description: 'Meeting notes in Markdown format', + }, + actionItems: { + type: 'array', + description: 'Array of action item objects with id, title, description, assignee, status', + }, + transcript: { + type: 'array', + description: 'Array of transcript segments with speaker, text, and timestamp (in seconds)', + }, + insights: { + type: 'object', + description: + 'User-created insights keyed by insight name, each containing array of insight results', + }, + meeting: { + type: 'object', + description: 'Full meeting payload object', + }, + } as Record +} + +/** + * Build output schema for generic webhook events + */ +export function buildGenericOutputs(): Record { + return { + payload: { + type: 'object', + description: 'Raw webhook payload', + }, + headers: { + type: 'object', + description: 'Request headers', + }, + timestamp: { + type: 'string', + description: 'ISO8601 received timestamp', + }, + } as Record +} diff --git a/apps/sim/triggers/circleback/webhook.ts b/apps/sim/triggers/circleback/webhook.ts new file mode 100644 index 0000000000..b1c6f5ee79 --- /dev/null +++ b/apps/sim/triggers/circleback/webhook.ts @@ -0,0 +1,85 @@ +import { CirclebackIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' +import { buildGenericOutputs, circlebackSetupInstructions, circlebackTriggerOptions } from './utils' + +export const circlebackWebhookTrigger: TriggerConfig = { + id: 'circleback_webhook', + name: 'Circleback Webhook', + provider: 'circleback', + description: 'Generic webhook trigger for all Circleback events', + version: '1.0.0', + icon: CirclebackIcon, + + subBlocks: [ + { + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: circlebackTriggerOptions, + value: () => 'circleback_webhook', + required: true, + }, + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'circleback_webhook', + }, + }, + { + id: 'webhookSecret', + title: 'Signing Secret', + type: 'short-input', + placeholder: 'Paste signing secret from Circleback (optional)', + description: 'Validates that webhook deliveries originate from Circleback using HMAC-SHA256.', + password: true, + required: false, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'circleback_webhook', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: circlebackSetupInstructions('All events'), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'circleback_webhook', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + hideFromPreview: true, + mode: 'trigger', + triggerId: 'circleback_webhook', + condition: { + field: 'selectedTriggerId', + value: 'circleback_webhook', + }, + }, + ], + + outputs: buildGenericOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/grain/highlight_created.ts b/apps/sim/triggers/grain/highlight_created.ts new file mode 100644 index 0000000000..b59338ac70 --- /dev/null +++ b/apps/sim/triggers/grain/highlight_created.ts @@ -0,0 +1,76 @@ +import { GrainIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' +import { buildHighlightOutputs, grainSetupInstructions } from './utils' + +export const grainHighlightCreatedTrigger: TriggerConfig = { + id: 'grain_highlight_created', + name: 'Grain Highlight Created', + provider: 'grain', + description: 'Trigger workflow when a new highlight/clip is created in Grain', + version: '1.0.0', + icon: GrainIcon, + + subBlocks: [ + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_highlight_created', + }, + }, + { + id: 'webhookSecret', + title: 'Webhook Secret', + type: 'short-input', + placeholder: 'Enter a strong secret', + description: 'Validates that webhook deliveries originate from Grain.', + password: true, + required: false, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_highlight_created', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: grainSetupInstructions('Highlight (new)'), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_highlight_created', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + hideFromPreview: true, + mode: 'trigger', + triggerId: 'grain_highlight_created', + condition: { + field: 'selectedTriggerId', + value: 'grain_highlight_created', + }, + }, + ], + + outputs: buildHighlightOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/grain/highlight_updated.ts b/apps/sim/triggers/grain/highlight_updated.ts new file mode 100644 index 0000000000..e3c7f73781 --- /dev/null +++ b/apps/sim/triggers/grain/highlight_updated.ts @@ -0,0 +1,76 @@ +import { GrainIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' +import { buildHighlightOutputs, grainSetupInstructions } from './utils' + +export const grainHighlightUpdatedTrigger: TriggerConfig = { + id: 'grain_highlight_updated', + name: 'Grain Highlight Updated', + provider: 'grain', + description: 'Trigger workflow when a highlight/clip is updated in Grain', + version: '1.0.0', + icon: GrainIcon, + + subBlocks: [ + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_highlight_updated', + }, + }, + { + id: 'webhookSecret', + title: 'Webhook Secret', + type: 'short-input', + placeholder: 'Enter a strong secret', + description: 'Validates that webhook deliveries originate from Grain.', + password: true, + required: false, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_highlight_updated', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: grainSetupInstructions('Highlight (updated)'), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_highlight_updated', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + hideFromPreview: true, + mode: 'trigger', + triggerId: 'grain_highlight_updated', + condition: { + field: 'selectedTriggerId', + value: 'grain_highlight_updated', + }, + }, + ], + + outputs: buildHighlightOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/grain/index.ts b/apps/sim/triggers/grain/index.ts new file mode 100644 index 0000000000..2a4d165413 --- /dev/null +++ b/apps/sim/triggers/grain/index.ts @@ -0,0 +1,6 @@ +export { grainHighlightCreatedTrigger } from './highlight_created' +export { grainHighlightUpdatedTrigger } from './highlight_updated' +export { grainRecordingCreatedTrigger } from './recording_created' +export { grainRecordingUpdatedTrigger } from './recording_updated' +export { grainStoryCreatedTrigger } from './story_created' +export { grainWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/grain/recording_created.ts b/apps/sim/triggers/grain/recording_created.ts new file mode 100644 index 0000000000..6fe67aa5ee --- /dev/null +++ b/apps/sim/triggers/grain/recording_created.ts @@ -0,0 +1,76 @@ +import { GrainIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' +import { buildRecordingOutputs, grainSetupInstructions } from './utils' + +export const grainRecordingCreatedTrigger: TriggerConfig = { + id: 'grain_recording_created', + name: 'Grain Recording Created', + provider: 'grain', + description: 'Trigger workflow when a new recording is added in Grain', + version: '1.0.0', + icon: GrainIcon, + + subBlocks: [ + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_recording_created', + }, + }, + { + id: 'webhookSecret', + title: 'Webhook Secret', + type: 'short-input', + placeholder: 'Enter a strong secret', + description: 'Validates that webhook deliveries originate from Grain.', + password: true, + required: false, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_recording_created', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: grainSetupInstructions('Recording (new)'), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_recording_created', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + hideFromPreview: true, + mode: 'trigger', + triggerId: 'grain_recording_created', + condition: { + field: 'selectedTriggerId', + value: 'grain_recording_created', + }, + }, + ], + + outputs: buildRecordingOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/grain/recording_updated.ts b/apps/sim/triggers/grain/recording_updated.ts new file mode 100644 index 0000000000..d541fe1322 --- /dev/null +++ b/apps/sim/triggers/grain/recording_updated.ts @@ -0,0 +1,76 @@ +import { GrainIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' +import { buildRecordingOutputs, grainSetupInstructions } from './utils' + +export const grainRecordingUpdatedTrigger: TriggerConfig = { + id: 'grain_recording_updated', + name: 'Grain Recording Updated', + provider: 'grain', + description: 'Trigger workflow when a recording is updated in Grain', + version: '1.0.0', + icon: GrainIcon, + + subBlocks: [ + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_recording_updated', + }, + }, + { + id: 'webhookSecret', + title: 'Webhook Secret', + type: 'short-input', + placeholder: 'Enter a strong secret', + description: 'Validates that webhook deliveries originate from Grain.', + password: true, + required: false, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_recording_updated', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: grainSetupInstructions('Recording (updated)'), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_recording_updated', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + hideFromPreview: true, + mode: 'trigger', + triggerId: 'grain_recording_updated', + condition: { + field: 'selectedTriggerId', + value: 'grain_recording_updated', + }, + }, + ], + + outputs: buildRecordingOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/grain/story_created.ts b/apps/sim/triggers/grain/story_created.ts new file mode 100644 index 0000000000..8e43051be4 --- /dev/null +++ b/apps/sim/triggers/grain/story_created.ts @@ -0,0 +1,76 @@ +import { GrainIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' +import { buildStoryOutputs, grainSetupInstructions } from './utils' + +export const grainStoryCreatedTrigger: TriggerConfig = { + id: 'grain_story_created', + name: 'Grain Story Created', + provider: 'grain', + description: 'Trigger workflow when a new story is created in Grain', + version: '1.0.0', + icon: GrainIcon, + + subBlocks: [ + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_story_created', + }, + }, + { + id: 'webhookSecret', + title: 'Webhook Secret', + type: 'short-input', + placeholder: 'Enter a strong secret', + description: 'Validates that webhook deliveries originate from Grain.', + password: true, + required: false, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_story_created', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: grainSetupInstructions('Story (new)'), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_story_created', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + hideFromPreview: true, + mode: 'trigger', + triggerId: 'grain_story_created', + condition: { + field: 'selectedTriggerId', + value: 'grain_story_created', + }, + }, + ], + + outputs: buildStoryOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/grain/utils.ts b/apps/sim/triggers/grain/utils.ts new file mode 100644 index 0000000000..c9fa593ece --- /dev/null +++ b/apps/sim/triggers/grain/utils.ts @@ -0,0 +1,239 @@ +import type { TriggerOutput } from '@/triggers/types' + +/** + * Shared trigger dropdown options for all Grain triggers + */ +export const grainTriggerOptions = [ + { label: 'General Webhook (All Events)', id: 'grain_webhook' }, + { label: 'Recording Created', id: 'grain_recording_created' }, + { label: 'Recording Updated', id: 'grain_recording_updated' }, + { label: 'Highlight Created', id: 'grain_highlight_created' }, + { label: 'Highlight Updated', id: 'grain_highlight_updated' }, + { label: 'Story Created', id: 'grain_story_created' }, +] + +/** + * Generate setup instructions for a specific Grain event type + */ +export function grainSetupInstructions(eventType: string): string { + const instructions = [ + 'Note: You need admin permissions in your Grain workspace to create webhooks.', + 'In Grain, navigate to Settings > Integrations > Webhooks.', + 'Click "Create webhook" or "Add webhook".', + 'Paste the Webhook URL from above into the URL field.', + 'Optionally, enter the Webhook Secret from above for signature validation.', + `Select the event types this webhook should listen to. For this trigger, select ${eventType}.`, + 'Click "Save" to activate the webhook.', + ] + + return instructions + .map( + (instruction, index) => + `
${index === 0 ? instruction : `${index}. ${instruction}`}
` + ) + .join('') +} + +/** + * Build output schema for recording events + * Webhook payload structure: { type, user_id, data: { ...recording } } + */ +export function buildRecordingOutputs(): Record { + return { + type: { + type: 'string', + description: 'Event type (recording_added)', + }, + user_id: { + type: 'string', + description: 'User UUID who triggered the event', + }, + data: { + type: 'object', + description: 'Recording data object', + }, + 'data.id': { + type: 'string', + description: 'Recording UUID', + }, + 'data.title': { + type: 'string', + description: 'Recording title', + }, + 'data.start_datetime': { + type: 'string', + description: 'ISO8601 start timestamp', + }, + 'data.end_datetime': { + type: 'string', + description: 'ISO8601 end timestamp', + }, + 'data.duration_ms': { + type: 'number', + description: 'Duration in milliseconds', + }, + 'data.media_type': { + type: 'string', + description: 'audio, transcript, or video', + }, + 'data.source': { + type: 'string', + description: 'Recording source (zoom, meet, teams, etc.)', + }, + 'data.url': { + type: 'string', + description: 'URL to view in Grain', + }, + 'data.thumbnail_url': { + type: 'string', + description: 'Thumbnail URL (nullable)', + }, + 'data.tags': { + type: 'array', + description: 'Array of tag strings', + }, + 'data.teams': { + type: 'array', + description: 'Teams the recording belongs to', + }, + 'data.meeting_type': { + type: 'object', + description: 'Meeting type info (nullable)', + }, + 'data.highlights': { + type: 'array', + description: 'Highlights (if configured in hook)', + }, + 'data.participants': { + type: 'array', + description: 'Participants (if configured in hook)', + }, + 'data.ai_summary': { + type: 'object', + description: 'AI summary (if configured in hook)', + }, + } as Record +} + +/** + * Build output schema for highlight events + * Note: Grain API docs only show recording webhooks. Highlight webhooks may have similar structure. + */ +export function buildHighlightOutputs(): Record { + return { + type: { + type: 'string', + description: 'Event type', + }, + user_id: { + type: 'string', + description: 'User UUID who triggered the event', + }, + data: { + type: 'object', + description: 'Highlight data object', + }, + 'data.id': { + type: 'string', + description: 'Highlight UUID', + }, + 'data.recording_id': { + type: 'string', + description: 'Parent recording UUID', + }, + 'data.text': { + type: 'string', + description: 'Highlight title/description', + }, + 'data.transcript': { + type: 'string', + description: 'Transcript text of the clip', + }, + 'data.speakers': { + type: 'array', + description: 'Array of speaker names', + }, + 'data.timestamp': { + type: 'number', + description: 'Start timestamp in ms', + }, + 'data.duration': { + type: 'number', + description: 'Duration in ms', + }, + 'data.tags': { + type: 'array', + description: 'Array of tag strings', + }, + 'data.url': { + type: 'string', + description: 'URL to view in Grain', + }, + 'data.thumbnail_url': { + type: 'string', + description: 'Thumbnail URL', + }, + 'data.created_datetime': { + type: 'string', + description: 'ISO8601 creation timestamp', + }, + } as Record +} + +/** + * Build output schema for story events + * Note: Grain API docs only show recording webhooks. Story webhooks may have similar structure. + */ +export function buildStoryOutputs(): Record { + return { + type: { + type: 'string', + description: 'Event type', + }, + user_id: { + type: 'string', + description: 'User UUID who triggered the event', + }, + data: { + type: 'object', + description: 'Story data object', + }, + 'data.id': { + type: 'string', + description: 'Story UUID', + }, + 'data.title': { + type: 'string', + description: 'Story title', + }, + 'data.url': { + type: 'string', + description: 'URL to view in Grain', + }, + 'data.created_datetime': { + type: 'string', + description: 'ISO8601 creation timestamp', + }, + } as Record +} + +/** + * Build output schema for generic webhook events + * Webhook payload structure: { type, user_id, data: { ... } } + */ +export function buildGenericOutputs(): Record { + return { + type: { + type: 'string', + description: 'Event type (e.g., recording_added)', + }, + user_id: { + type: 'string', + description: 'User UUID who triggered the event', + }, + data: { + type: 'object', + description: 'Event data object (recording, highlight, etc.)', + }, + } as Record +} diff --git a/apps/sim/triggers/grain/webhook.ts b/apps/sim/triggers/grain/webhook.ts new file mode 100644 index 0000000000..872a2ea93d --- /dev/null +++ b/apps/sim/triggers/grain/webhook.ts @@ -0,0 +1,85 @@ +import { GrainIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' +import { buildGenericOutputs, grainSetupInstructions, grainTriggerOptions } from './utils' + +export const grainWebhookTrigger: TriggerConfig = { + id: 'grain_webhook', + name: 'Grain Webhook', + provider: 'grain', + description: 'Generic webhook trigger for all Grain events', + version: '1.0.0', + icon: GrainIcon, + + subBlocks: [ + { + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: grainTriggerOptions, + value: () => 'grain_webhook', + required: true, + }, + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_webhook', + }, + }, + { + id: 'webhookSecret', + title: 'Webhook Secret', + type: 'short-input', + placeholder: 'Enter a strong secret', + description: 'Validates that webhook deliveries originate from Grain.', + password: true, + required: false, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_webhook', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: grainSetupInstructions('All events'), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'grain_webhook', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + hideFromPreview: true, + mode: 'trigger', + triggerId: 'grain_webhook', + condition: { + field: 'selectedTriggerId', + value: 'grain_webhook', + }, + }, + ], + + outputs: buildGenericOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index aaac4c782c..942ff0e018 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -5,6 +5,11 @@ import { calendlyRoutingFormSubmittedTrigger, calendlyWebhookTrigger, } from '@/triggers/calendly' +import { + circlebackMeetingCompletedTrigger, + circlebackMeetingNotesTrigger, + circlebackWebhookTrigger, +} from '@/triggers/circleback' import { genericWebhookTrigger } from '@/triggers/generic' import { githubIssueClosedTrigger, @@ -22,6 +27,14 @@ import { } from '@/triggers/github' import { gmailPollingTrigger } from '@/triggers/gmail' import { googleFormsWebhookTrigger } from '@/triggers/googleforms' +import { + grainHighlightCreatedTrigger, + grainHighlightUpdatedTrigger, + grainRecordingCreatedTrigger, + grainRecordingUpdatedTrigger, + grainStoryCreatedTrigger, + grainWebhookTrigger, +} from '@/triggers/grain' import { hubspotCompanyCreatedTrigger, hubspotCompanyDeletedTrigger, @@ -108,6 +121,15 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { github_release_published: githubReleasePublishedTrigger, github_workflow_run: githubWorkflowRunTrigger, gmail_poller: gmailPollingTrigger, + grain_webhook: grainWebhookTrigger, + grain_recording_created: grainRecordingCreatedTrigger, + grain_recording_updated: grainRecordingUpdatedTrigger, + grain_highlight_created: grainHighlightCreatedTrigger, + grain_highlight_updated: grainHighlightUpdatedTrigger, + grain_story_created: grainStoryCreatedTrigger, + circleback_meeting_completed: circlebackMeetingCompletedTrigger, + circleback_meeting_notes: circlebackMeetingNotesTrigger, + circleback_webhook: circlebackWebhookTrigger, jira_webhook: jiraWebhookTrigger, jira_issue_created: jiraIssueCreatedTrigger, jira_issue_updated: jiraIssueUpdatedTrigger, From b23299dae4d226b39001511772b4e4cf1691635a Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 23 Dec 2025 14:54:44 -0800 Subject: [PATCH 08/18] feat(i18n): update translations (#2558) Co-authored-by: waleedlatif1 --- .../docs/content/docs/de/tools/circleback.mdx | 59 +++++ apps/docs/content/docs/de/tools/grain.mdx | 218 ++++++++++++++++++ .../docs/content/docs/es/tools/circleback.mdx | 59 +++++ apps/docs/content/docs/es/tools/grain.mdx | 218 ++++++++++++++++++ .../docs/content/docs/fr/tools/circleback.mdx | 59 +++++ apps/docs/content/docs/fr/tools/grain.mdx | 218 ++++++++++++++++++ .../docs/content/docs/ja/tools/circleback.mdx | 59 +++++ apps/docs/content/docs/ja/tools/grain.mdx | 218 ++++++++++++++++++ .../docs/content/docs/zh/tools/circleback.mdx | 58 +++++ apps/docs/content/docs/zh/tools/grain.mdx | 218 ++++++++++++++++++ apps/docs/i18n.lock | 81 +++++++ 11 files changed, 1465 insertions(+) create mode 100644 apps/docs/content/docs/de/tools/circleback.mdx create mode 100644 apps/docs/content/docs/de/tools/grain.mdx create mode 100644 apps/docs/content/docs/es/tools/circleback.mdx create mode 100644 apps/docs/content/docs/es/tools/grain.mdx create mode 100644 apps/docs/content/docs/fr/tools/circleback.mdx create mode 100644 apps/docs/content/docs/fr/tools/grain.mdx create mode 100644 apps/docs/content/docs/ja/tools/circleback.mdx create mode 100644 apps/docs/content/docs/ja/tools/grain.mdx create mode 100644 apps/docs/content/docs/zh/tools/circleback.mdx create mode 100644 apps/docs/content/docs/zh/tools/grain.mdx diff --git a/apps/docs/content/docs/de/tools/circleback.mdx b/apps/docs/content/docs/de/tools/circleback.mdx new file mode 100644 index 0000000000..2e97359aed --- /dev/null +++ b/apps/docs/content/docs/de/tools/circleback.mdx @@ -0,0 +1,59 @@ +--- +title: Circleback +description: KI-gestützte Meeting-Notizen und Aufgaben +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Circleback](https://circleback.ai/) ist eine KI-gestützte Plattform, die Meeting-Notizen, Aufgaben, Transkripte und Aufzeichnungen für Ihr Team automatisiert. Wenn ein Meeting abgeschlossen ist, verarbeitet Circleback die Konversation und liefert detaillierte Notizen und Aufgaben sowie ein Transkript und eine Aufzeichnung (sofern verfügbar). Dies hilft Teams dabei, Erkenntnisse effizient zu erfassen, Aufgaben zu verteilen und sicherzustellen, dass nichts übersehen wird – alles nahtlos in Ihre Workflows integriert. + +Mit der Sim Circleback-Integration können Sie: + +- **Detaillierte Meeting-Notizen und Aufgaben erhalten**: Sammeln Sie automatisch gut formatierte Meeting-Zusammenfassungen und verfolgen Sie umsetzbare Aufgaben, die während Ihrer Anrufe besprochen wurden. +- **Auf vollständige Meeting-Aufzeichnungen und Transkripte zugreifen**: Erhalten Sie die vollständige Konversation und die zugehörige Aufzeichnung, um wichtige Momente einfach zu überprüfen oder mit Kollegen zu teilen. +- **Teilnehmerinformationen und Meeting-Kontext erfassen**: Teilnehmerlisten, Meeting-Metadaten und Tags helfen dabei, Ihre Daten organisiert und umsetzbar zu halten. +- **Erkenntnisse direkt in Ihre Workflows liefern**: Lösen Sie Automatisierungen aus oder senden Sie Circleback-Daten an andere Systeme, sobald ein Meeting beendet ist, mithilfe der leistungsstarken Webhook-Trigger von Sim. + +**So funktioniert es in Sim:** +Circleback verwendet Webhook-Trigger: Sobald ein Meeting verarbeitet wurde, werden die Daten automatisch an Ihren Agenten oder Ihre Automatisierung übertragen. Sie können weitere Automatisierungen basierend auf folgenden Ereignissen erstellen: + +- Meeting abgeschlossen (alle verarbeiteten Daten verfügbar) +- Neue Notizen (Notizen sind verfügbar, noch bevor das Meeting vollständig verarbeitet ist) +- Raw-Webhook-Integration für erweiterte Anwendungsfälle + +**Die folgenden Informationen sind in der Circleback-Meeting-Webhook-Payload verfügbar:** + +| Feld | Typ | Beschreibung | +|----------------|---------|----------------------------------------------------| +| `id` | number | Circleback Meeting-ID | +| `name` | string | Meeting-Titel | +| `url` | string | Virtueller Meeting-Link (Zoom, Meet, Teams usw.) | +| `createdAt` | string | Zeitstempel der Meeting-Erstellung | +| `duration` | number | Dauer in Sekunden | +| `recordingUrl` | string | Aufzeichnungs-URL (24 Stunden gültig) | +| `tags` | json | Array von Tags | +| `icalUid` | string | Kalender-Event-ID | +| `attendees` | json | Array von Teilnehmer-Objekten | +| `notes` | string | Meeting-Notizen in Markdown | +| `actionItems` | json | Array von Aufgaben | +| `transcript` | json | Array von Transkript-Segmenten | +| `insights` | json | Vom Nutzer erstellte Insights | +| `meeting` | json | Vollständige Meeting-Daten | + +Egal, ob Sie sofortige Zusammenfassungen verteilen, Aufgaben protokollieren oder benutzerdefinierte Workflows erstellen möchten, die durch neue Meeting-Daten ausgelöst werden – Circleback und Sim machen es nahtlos, alles rund um Ihre Meetings automatisch zu verwalten. +{/* MANUAL-CONTENT-END */} + +## Nutzungsanleitung + +Erhalten Sie Meeting-Notizen, Aufgaben, Transkripte und Aufzeichnungen, wenn Meetings verarbeitet werden. Circleback nutzt Webhooks, um Daten an Ihre Workflows zu übermitteln. + +## Hinweise + +- Kategorie: `triggers` +- Typ: `circleback` diff --git a/apps/docs/content/docs/de/tools/grain.mdx b/apps/docs/content/docs/de/tools/grain.mdx new file mode 100644 index 0000000000..d8c0eec36c --- /dev/null +++ b/apps/docs/content/docs/de/tools/grain.mdx @@ -0,0 +1,218 @@ +--- +title: Grain +description: Zugriff auf Meeting-Aufzeichnungen, Transkripte und KI-Zusammenfassungen +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Grain](https://grain.com/) ist eine moderne Plattform zum Erfassen, Speichern und Teilen von Meeting-Aufzeichnungen, Transkripten, Highlights und KI-gestützten Zusammenfassungen. Grain ermöglicht es Teams, Gespräche in umsetzbare Erkenntnisse zu verwandeln und alle über wichtige Momente aus Meetings auf dem Laufenden zu halten. + +Mit Grain können Sie: + +- **Auf durchsuchbare Aufzeichnungen und Transkripte zugreifen**: Finden und überprüfen Sie jedes Meeting nach Stichwort, Teilnehmer oder Thema. +- **Highlights und Clips teilen**: Erfassen Sie wichtige Momente und teilen Sie kurze Video-/Audio-Highlights in Ihrem Team oder in Workflows. +- **KI-generierte Zusammenfassungen erhalten**: Erstellen Sie automatisch Meeting-Zusammenfassungen, Aktionspunkte und wichtige Erkenntnisse mithilfe der fortschrittlichen KI von Grain. +- **Meetings nach Team oder Typ organisieren**: Taggen und kategorisieren Sie Aufzeichnungen für einfachen Zugriff und Reporting. + +Die Sim-Grain-Integration ermöglicht es Ihren Agenten: + +- Meeting-Aufzeichnungen und Details nach flexiblen Filtern (Datum/Uhrzeit, Teilnehmer, Team usw.) aufzulisten, zu suchen und abzurufen. +- Auf KI-Zusammenfassungen, Teilnehmer, Highlights und andere Metadaten für Meetings zuzugreifen, um Automatisierungen oder Analysen zu unterstützen. +- Workflows auszulösen, sobald neue Meetings verarbeitet, Zusammenfassungen generiert oder Highlights über Grain-Webhooks erstellt werden. +- Grain-Daten einfach in andere Tools zu überführen oder Teammitglieder zu benachrichtigen, sobald etwas Wichtiges in einem Meeting passiert. + +Ob Sie Follow-up-Aktionen automatisieren, wichtige Gespräche dokumentieren oder Erkenntnisse in Ihrer Organisation sichtbar machen möchten – Grain und Sim machen es einfach, Meeting-Intelligence mit Ihren Workflows zu verbinden. +{/* MANUAL-CONTENT-END */} + +## Nutzungsanweisungen + +Integrieren Sie Grain in Ihren Workflow. Greifen Sie auf Meeting-Aufzeichnungen, Transkripte, Highlights und KI-generierte Zusammenfassungen zu. Kann auch Workflows basierend auf Grain-Webhook-Ereignissen auslösen. + +## Tools + +### `grain_list_recordings` + +Aufzeichnungen von Grain mit optionalen Filtern und Paginierung auflisten + +#### Eingabe + +| Parameter | Typ | Erforderlich | Beschreibung | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Ja | Grain API-Schlüssel \(Personal Access Token\) | +| `cursor` | string | Nein | Paginierungs-Cursor für nächste Seite | +| `beforeDatetime` | string | Nein | Nur Aufzeichnungen vor diesem ISO8601-Zeitstempel | +| `afterDatetime` | string | Nein | Nur Aufzeichnungen nach diesem ISO8601-Zeitstempel | +| `participantScope` | string | Nein | Filter: "internal" oder "external" | +| `titleSearch` | string | Nein | Suchbegriff zum Filtern nach Aufzeichnungstitel | +| `teamId` | string | Nein | Nach Team-UUID filtern | +| `meetingTypeId` | string | Nein | Nach Meeting-Typ-UUID filtern | +| `includeHighlights` | boolean | Nein | Highlights/Clips in Antwort einschließen | +| `includeParticipants` | boolean | Nein | Teilnehmerliste in Antwort einschließen | +| `includeAiSummary` | boolean | Nein | KI-generierte Zusammenfassung einschließen | + +#### Ausgabe + +| Parameter | Typ | Beschreibung | +| --------- | ---- | ----------- | +| `recordings` | array | Array von Aufzeichnungsobjekten | + +### `grain_get_recording` + +Details einer einzelnen Aufzeichnung nach ID abrufen + +#### Eingabe + +| Parameter | Typ | Erforderlich | Beschreibung | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Ja | Grain API-Schlüssel \(Personal Access Token\) | +| `recordingId` | string | Ja | Die Aufzeichnungs-UUID | +| `includeHighlights` | boolean | Nein | Highlights/Clips einschließen | +| `includeParticipants` | boolean | Nein | Teilnehmerliste einschließen | +| `includeAiSummary` | boolean | Nein | KI-Zusammenfassung einschließen | +| `includeCalendarEvent` | boolean | Nein | Kalenderereignisdaten einschließen | +| `includeHubspot` | boolean | Nein | HubSpot-Verknüpfungen einschließen | + +#### Ausgabe + +| Parameter | Typ | Beschreibung | +| --------- | ---- | ----------- | +| `id` | string | Aufnahme-UUID | +| `title` | string | Aufnahmetitel | +| `start_datetime` | string | ISO8601-Startzeitstempel | +| `end_datetime` | string | ISO8601-Endzeitstempel | +| `duration_ms` | number | Dauer in Millisekunden | +| `media_type` | string | audio, transcript oder video | +| `source` | string | Aufnahmequelle \(zoom, meet, teams, etc.\) | +| `url` | string | URL zur Ansicht in Grain | +| `thumbnail_url` | string | Vorschaubild-URL | +| `tags` | array | Array von Tag-Strings | +| `teams` | array | Teams, zu denen die Aufnahme gehört | +| `meeting_type` | object | Meeting-Typ-Informationen \(id, name, scope\) | +| `highlights` | array | Highlights \(falls enthalten\) | +| `participants` | array | Teilnehmer \(falls enthalten\) | +| `ai_summary` | object | KI-Zusammenfassungstext \(falls enthalten\) | +| `calendar_event` | object | Kalenderereignisdaten \(falls enthalten\) | +| `hubspot` | object | HubSpot-Verknüpfungen \(falls enthalten\) | + +### `grain_get_transcript` + +Vollständiges Transkript einer Aufnahme abrufen + +#### Eingabe + +| Parameter | Typ | Erforderlich | Beschreibung | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Ja | Grain-API-Schlüssel \(Personal Access Token\) | +| `recordingId` | string | Ja | Die Aufnahme-UUID | + +#### Ausgabe + +| Parameter | Typ | Beschreibung | +| --------- | ---- | ----------- | +| `transcript` | array | Array von Transkriptabschnitten | + +### `grain_list_teams` + +Alle Teams im Workspace auflisten + +#### Eingabe + +| Parameter | Typ | Erforderlich | Beschreibung | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Ja | Grain API-Schlüssel \(Personal Access Token\) | + +#### Ausgabe + +| Parameter | Typ | Beschreibung | +| --------- | ---- | ----------- | +| `teams` | array | Array von Team-Objekten | + +### `grain_list_meeting_types` + +Alle Meeting-Typen im Workspace auflisten + +#### Eingabe + +| Parameter | Typ | Erforderlich | Beschreibung | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Ja | Grain API-Schlüssel \(Personal Access Token\) | + +#### Ausgabe + +| Parameter | Typ | Beschreibung | +| --------- | ---- | ----------- | +| `meeting_types` | array | Array von Meeting-Typ-Objekten | + +### `grain_create_hook` + +Einen Webhook erstellen, um Aufzeichnungs-Events zu empfangen + +#### Eingabe + +| Parameter | Typ | Erforderlich | Beschreibung | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Ja | Grain API-Schlüssel \(Personal Access Token\) | +| `hookUrl` | string | Ja | Webhook-Endpunkt-URL \(muss mit 2xx antworten\) | +| `filterBeforeDatetime` | string | Nein | Filter: Aufzeichnungen vor diesem Datum | +| `filterAfterDatetime` | string | Nein | Filter: Aufzeichnungen nach diesem Datum | +| `filterParticipantScope` | string | Nein | Filter: "internal" oder "external" | +| `filterTeamId` | string | Nein | Filter: spezifische Team-UUID | +| `filterMeetingTypeId` | string | Nein | Filter: spezifischer Meeting-Typ | +| `includeHighlights` | boolean | Nein | Highlights in Webhook-Payload einschließen | +| `includeParticipants` | boolean | Nein | Teilnehmer in Webhook-Payload einschließen | +| `includeAiSummary` | boolean | Nein | KI-Zusammenfassung in Webhook-Payload einschließen | + +#### Ausgabe + +| Parameter | Typ | Beschreibung | +| --------- | ---- | ----------- | +| `id` | string | Hook-UUID | +| `enabled` | boolean | Ob der Hook aktiv ist | +| `hook_url` | string | Die Webhook-URL | +| `filter` | object | Angewendete Filter | +| `include` | object | Enthaltene Felder | +| `inserted_at` | string | ISO8601-Erstellungszeitstempel | + +### `grain_list_hooks` + +Alle Webhooks für das Konto auflisten + +#### Eingabe + +| Parameter | Typ | Erforderlich | Beschreibung | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Ja | Grain-API-Schlüssel \(Personal Access Token\) | + +#### Ausgabe + +| Parameter | Typ | Beschreibung | +| --------- | ---- | ----------- | +| `hooks` | array | Array von Hook-Objekten | + +### `grain_delete_hook` + +Einen Webhook anhand der ID löschen + +#### Eingabe + +| Parameter | Typ | Erforderlich | Beschreibung | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Ja | Grain-API-Schlüssel \(Personal Access Token\) | +| `hookId` | string | Ja | Die zu löschende Hook-UUID | + +#### Ausgabe + +| Parameter | Typ | Beschreibung | +| --------- | ---- | ----------- | +| `success` | boolean | True, wenn der Webhook erfolgreich gelöscht wurde | + +## Hinweise + +- Kategorie: `tools` +- Typ: `grain` diff --git a/apps/docs/content/docs/es/tools/circleback.mdx b/apps/docs/content/docs/es/tools/circleback.mdx new file mode 100644 index 0000000000..8b7062728a --- /dev/null +++ b/apps/docs/content/docs/es/tools/circleback.mdx @@ -0,0 +1,59 @@ +--- +title: Circleback +description: Notas de reuniones e ítems de acción impulsados por IA +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Circleback](https://circleback.ai/) es una plataforma impulsada por IA que automatiza las notas de reuniones, ítems de acción, transcripciones y grabaciones para tu equipo. Cuando se completa una reunión, Circleback procesa la conversación y proporciona notas detalladas e ítems de acción, junto con una transcripción y una grabación (cuando está disponible). Esto ayuda a los equipos a capturar información de manera eficiente, distribuir ítems de acción y asegurar que no se pierda nada, todo integrado sin problemas en tus flujos de trabajo. + +Con la integración de Sim Circleback, puedes: + +- **Recibir notas detalladas de reuniones e ítems de acción**: Recopila automáticamente resúmenes de reuniones bien formateados y realiza seguimiento de las tareas accionables discutidas durante tus llamadas. +- **Acceder a grabaciones y transcripciones completas de reuniones**: Obtén la conversación completa y la grabación asociada, facilitando la revisión de momentos clave o compartir con colegas. +- **Capturar información de asistentes y contexto de la reunión**: Las listas de asistentes, metadatos de reuniones y etiquetas ayudan a mantener tus datos organizados y accionables. +- **Entregar información directamente en tus flujos de trabajo**: Activa automatizaciones o envía datos de Circleback a otros sistemas en el momento en que finaliza una reunión, usando los potentes activadores webhook de Sim. + +**Cómo funciona en Sim:** +Circleback utiliza activadores webhook: cada vez que se procesa una reunión, los datos se envían automáticamente a tu agente o automatización. Puedes crear más automatizaciones basadas en: + +- Reunión completada (todos los datos procesados disponibles) +- Nuevas notas (notas listas incluso antes de que se procese la reunión completa) +- Integración webhook sin procesar para casos de uso avanzados + +**La siguiente información está disponible en la carga útil del webhook de reunión de Circleback:** + +| Campo | Tipo | Descripción | +|----------------|---------|----------------------------------------------------| +| `id` | number | ID de reunión de Circleback | +| `name` | string | Título de la reunión | +| `url` | string | URL de reunión virtual (Zoom, Meet, Teams, etc.) | +| `createdAt` | string | Marca de tiempo de creación de la reunión | +| `duration` | number | Duración en segundos | +| `recordingUrl` | string | URL de grabación (válida 24 horas) | +| `tags` | json | Array de etiquetas | +| `icalUid` | string | ID de evento de calendario | +| `attendees` | json | Array de objetos de asistentes | +| `notes` | string | Notas de la reunión en Markdown | +| `actionItems` | json | Array de elementos de acción | +| `transcript` | json | Array de segmentos de transcripción | +| `insights` | json | Insights creados por el usuario | +| `meeting` | json | Payload completo de la reunión | + +Ya sea que quieras distribuir resúmenes instantáneos, registrar elementos de acción o crear flujos de trabajo personalizados activados por nuevos datos de reuniones, Circleback y Sim hacen que sea sencillo manejar todo lo relacionado con tus reuniones, automáticamente. +{/* MANUAL-CONTENT-END */} + +## Instrucciones de uso + +Recibe notas de reuniones, elementos de acción, transcripciones y grabaciones cuando se procesen las reuniones. Circleback utiliza webhooks para enviar datos a tus flujos de trabajo. + +## Notas + +- Categoría: `triggers` +- Tipo: `circleback` diff --git a/apps/docs/content/docs/es/tools/grain.mdx b/apps/docs/content/docs/es/tools/grain.mdx new file mode 100644 index 0000000000..94e242287d --- /dev/null +++ b/apps/docs/content/docs/es/tools/grain.mdx @@ -0,0 +1,218 @@ +--- +title: Grain +description: Accede a grabaciones de reuniones, transcripciones y resúmenes de IA +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Grain](https://grain.com/) es una plataforma moderna para capturar, almacenar y compartir grabaciones de reuniones, transcripciones, momentos destacados y resúmenes generados por IA. Grain permite a los equipos convertir conversaciones en información procesable y mantener a todos alineados con los momentos clave de las reuniones. + +Con Grain, puedes: + +- **Acceder a grabaciones y transcripciones con búsqueda**: Encuentra y revisa cada reunión por palabra clave, participante o tema. +- **Compartir momentos destacados y clips**: Captura momentos importantes y comparte fragmentos cortos de video/audio en tu equipo o flujos de trabajo. +- **Obtener resúmenes generados por IA**: Produce automáticamente resúmenes de reuniones, elementos de acción e información clave utilizando la IA avanzada de Grain. +- **Organizar reuniones por equipo o tipo**: Etiqueta y categoriza grabaciones para facilitar el acceso y la generación de informes. + +La integración de Sim con Grain permite a tus agentes: + +- Listar, buscar y recuperar grabaciones de reuniones y detalles mediante filtros flexibles (fecha y hora, participante, equipo, etc.). +- Acceder a resúmenes de IA, participantes, momentos destacados y otros metadatos de reuniones para impulsar automatizaciones o análisis. +- Activar flujos de trabajo cada vez que se procesen nuevas reuniones, se generen resúmenes o se creen momentos destacados a través de webhooks de Grain. +- Conectar fácilmente los datos de Grain con otras herramientas o notificar a los compañeros de equipo en el momento en que sucede algo importante en una reunión. + +Ya sea que desees automatizar acciones de seguimiento, mantener registros de conversaciones importantes o destacar información en toda tu organización, Grain y Sim facilitan la conexión de la inteligencia de reuniones con tus flujos de trabajo. +{/* MANUAL-CONTENT-END */} + +## Instrucciones de uso + +Integra Grain en tu flujo de trabajo. Accede a grabaciones de reuniones, transcripciones, momentos destacados y resúmenes generados por IA. También puede activar flujos de trabajo basados en eventos de webhook de Grain. + +## Herramientas + +### `grain_list_recordings` + +Lista las grabaciones de Grain con filtros opcionales y paginación + +#### Entrada + +| Parámetro | Tipo | Requerido | Descripción | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Sí | Clave API de Grain \(token de acceso personal\) | +| `cursor` | string | No | Cursor de paginación para la siguiente página | +| `beforeDatetime` | string | No | Solo grabaciones anteriores a esta marca de tiempo ISO8601 | +| `afterDatetime` | string | No | Solo grabaciones posteriores a esta marca de tiempo ISO8601 | +| `participantScope` | string | No | Filtro: "internal" o "external" | +| `titleSearch` | string | No | Término de búsqueda para filtrar por título de grabación | +| `teamId` | string | No | Filtrar por UUID de equipo | +| `meetingTypeId` | string | No | Filtrar por UUID de tipo de reunión | +| `includeHighlights` | boolean | No | Incluir destacados/clips en la respuesta | +| `includeParticipants` | boolean | No | Incluir lista de participantes en la respuesta | +| `includeAiSummary` | boolean | No | Incluir resumen generado por IA | + +#### Salida + +| Parámetro | Tipo | Descripción | +| --------- | ---- | ----------- | +| `recordings` | array | Array de objetos de grabación | + +### `grain_get_recording` + +Obtiene los detalles de una única grabación por ID + +#### Entrada + +| Parámetro | Tipo | Requerido | Descripción | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Sí | Clave API de Grain \(token de acceso personal\) | +| `recordingId` | string | Sí | El UUID de la grabación | +| `includeHighlights` | boolean | No | Incluir destacados/clips | +| `includeParticipants` | boolean | No | Incluir lista de participantes | +| `includeAiSummary` | boolean | No | Incluir resumen de IA | +| `includeCalendarEvent` | boolean | No | Incluir datos del evento de calendario | +| `includeHubspot` | boolean | No | Incluir asociaciones de HubSpot | + +#### Salida + +| Parámetro | Tipo | Descripción | +| --------- | ---- | ----------- | +| `id` | string | UUID de la grabación | +| `title` | string | Título de la grabación | +| `start_datetime` | string | Marca de tiempo de inicio ISO8601 | +| `end_datetime` | string | Marca de tiempo de finalización ISO8601 | +| `duration_ms` | number | Duración en milisegundos | +| `media_type` | string | audio, transcript o video | +| `source` | string | Fuente de la grabación \(zoom, meet, teams, etc.\) | +| `url` | string | URL para ver en Grain | +| `thumbnail_url` | string | URL de la imagen en miniatura | +| `tags` | array | Array de cadenas de etiquetas | +| `teams` | array | Equipos a los que pertenece la grabación | +| `meeting_type` | object | Información del tipo de reunión \(id, nombre, alcance\) | +| `highlights` | array | Destacados \(si se incluyen\) | +| `participants` | array | Participantes \(si se incluyen\) | +| `ai_summary` | object | Texto del resumen de IA \(si se incluye\) | +| `calendar_event` | object | Datos del evento de calendario \(si se incluyen\) | +| `hubspot` | object | Asociaciones de HubSpot \(si se incluyen\) | + +### `grain_get_transcript` + +Obtener la transcripción completa de una grabación + +#### Entrada + +| Parámetro | Tipo | Requerido | Descripción | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Sí | Clave de API de Grain \(token de acceso personal\) | +| `recordingId` | string | Sí | El UUID de la grabación | + +#### Salida + +| Parámetro | Tipo | Descripción | +| --------- | ---- | ----------- | +| `transcript` | array | Array de secciones de transcripción | + +### `grain_list_teams` + +Listar todos los equipos en el espacio de trabajo + +#### Entrada + +| Parámetro | Tipo | Requerido | Descripción | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Sí | Clave API de Grain \(token de acceso personal\) | + +#### Salida + +| Parámetro | Tipo | Descripción | +| --------- | ---- | ----------- | +| `teams` | array | Array de objetos de equipo | + +### `grain_list_meeting_types` + +Listar todos los tipos de reunión en el espacio de trabajo + +#### Entrada + +| Parámetro | Tipo | Requerido | Descripción | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Sí | Clave API de Grain \(token de acceso personal\) | + +#### Salida + +| Parámetro | Tipo | Descripción | +| --------- | ---- | ----------- | +| `meeting_types` | array | Array de objetos de tipo de reunión | + +### `grain_create_hook` + +Crear un webhook para recibir eventos de grabación + +#### Entrada + +| Parámetro | Tipo | Requerido | Descripción | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Sí | Clave API de Grain \(token de acceso personal\) | +| `hookUrl` | string | Sí | URL del endpoint del webhook \(debe responder 2xx\) | +| `filterBeforeDatetime` | string | No | Filtro: grabaciones antes de esta fecha | +| `filterAfterDatetime` | string | No | Filtro: grabaciones después de esta fecha | +| `filterParticipantScope` | string | No | Filtro: "internal" o "external" | +| `filterTeamId` | string | No | Filtro: UUID de equipo específico | +| `filterMeetingTypeId` | string | No | Filtro: tipo de reunión específico | +| `includeHighlights` | boolean | No | Incluir destacados en la carga del webhook | +| `includeParticipants` | boolean | No | Incluir participantes en la carga del webhook | +| `includeAiSummary` | boolean | No | Incluir resumen de IA en la carga del webhook | + +#### Salida + +| Parámetro | Tipo | Descripción | +| --------- | ---- | ----------- | +| `id` | string | UUID del hook | +| `enabled` | boolean | Si el hook está activo | +| `hook_url` | string | La URL del webhook | +| `filter` | object | Filtros aplicados | +| `include` | object | Campos incluidos | +| `inserted_at` | string | Marca de tiempo de creación ISO8601 | + +### `grain_list_hooks` + +Listar todos los webhooks de la cuenta + +#### Entrada + +| Parámetro | Tipo | Requerido | Descripción | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Sí | Clave API de Grain \(token de acceso personal\) | + +#### Salida + +| Parámetro | Tipo | Descripción | +| --------- | ---- | ----------- | +| `hooks` | array | Array de objetos hook | + +### `grain_delete_hook` + +Eliminar un webhook por ID + +#### Entrada + +| Parámetro | Tipo | Requerido | Descripción | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Sí | Clave API de Grain \(token de acceso personal\) | +| `hookId` | string | Sí | El UUID del hook a eliminar | + +#### Salida + +| Parámetro | Tipo | Descripción | +| --------- | ---- | ----------- | +| `success` | boolean | Verdadero cuando el webhook se eliminó correctamente | + +## Notas + +- Categoría: `tools` +- Tipo: `grain` diff --git a/apps/docs/content/docs/fr/tools/circleback.mdx b/apps/docs/content/docs/fr/tools/circleback.mdx new file mode 100644 index 0000000000..cfa2852067 --- /dev/null +++ b/apps/docs/content/docs/fr/tools/circleback.mdx @@ -0,0 +1,59 @@ +--- +title: Circleback +description: Notes de réunion et tâches générées par IA +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Circleback](https://circleback.ai/) est une plateforme alimentée par IA qui automatise les notes de réunion, les tâches, les transcriptions et les enregistrements pour votre équipe. Lorsqu'une réunion est terminée, Circleback traite la conversation et fournit des notes détaillées et des tâches, accompagnées d'une transcription et d'un enregistrement (lorsque disponible). Cela aide les équipes à capturer efficacement les informations, à distribuer les tâches et à s'assurer que rien n'est oublié, le tout intégré de manière transparente dans vos flux de travail. + +Avec l'intégration Sim Circleback, vous pouvez : + +- **Recevoir des notes de réunion détaillées et des tâches** : collectez automatiquement des résumés de réunion bien formatés et suivez les tâches discutées lors de vos appels. +- **Accéder aux enregistrements et transcriptions complètes des réunions** : obtenez la conversation complète et l'enregistrement associé, facilitant la révision des moments clés ou le partage avec des collègues. +- **Capturer les informations sur les participants et le contexte de la réunion** : les listes de participants, les métadonnées de réunion et les tags aident à garder vos données organisées et exploitables. +- **Transmettre les informations directement dans vos flux de travail** : déclenchez des automatisations ou envoyez les données Circleback vers d'autres systèmes dès qu'une réunion est terminée, en utilisant les puissants déclencheurs webhook de Sim. + +**Comment cela fonctionne dans Sim :** +Circleback utilise des déclencheurs webhook : chaque fois qu'une réunion est traitée, les données sont automatiquement transmises à votre agent ou automatisation. Vous pouvez créer d'autres automatisations basées sur : + +- Réunion terminée (toutes les données traitées disponibles) +- Nouvelles notes (notes prêtes avant même que la réunion complète ne soit traitée) +- Intégration webhook brute pour des cas d'usage avancés + +**Les informations suivantes sont disponibles dans la charge utile du webhook de réunion Circleback :** + +| Champ | Type | Description | +|----------------|---------|----------------------------------------------------| +| `id` | number | ID de réunion Circleback | +| `name` | string | Titre de la réunion | +| `url` | string | URL de réunion virtuelle (Zoom, Meet, Teams, etc.) | +| `createdAt` | string | Horodatage de création de la réunion | +| `duration` | number | Durée en secondes | +| `recordingUrl` | string | URL d'enregistrement (valide 24 heures) | +| `tags` | json | Tableau d'étiquettes | +| `icalUid` | string | ID d'événement de calendrier | +| `attendees` | json | Tableau d'objets participants | +| `notes` | string | Notes de réunion en Markdown | +| `actionItems` | json | Tableau d'éléments d'action | +| `transcript` | json | Tableau de segments de transcription | +| `insights` | json | Informations créées par l'utilisateur | +| `meeting` | json | Charge utile complète de la réunion | + +Que vous souhaitiez distribuer des résumés instantanés, enregistrer des éléments d'action ou créer des workflows personnalisés déclenchés par de nouvelles données de réunion, Circleback et Sim facilitent la gestion automatique de tout ce qui concerne vos réunions. +{/* MANUAL-CONTENT-END */} + +## Instructions d'utilisation + +Recevez les notes de réunion, les éléments d'action, les transcriptions et les enregistrements lorsque les réunions sont traitées. Circleback utilise des webhooks pour transmettre les données à vos workflows. + +## Remarques + +- Catégorie : `triggers` +- Type : `circleback` diff --git a/apps/docs/content/docs/fr/tools/grain.mdx b/apps/docs/content/docs/fr/tools/grain.mdx new file mode 100644 index 0000000000..4753858a19 --- /dev/null +++ b/apps/docs/content/docs/fr/tools/grain.mdx @@ -0,0 +1,218 @@ +--- +title: Grain +description: Accédez aux enregistrements de réunions, transcriptions et résumés IA +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Grain](https://grain.com/) est une plateforme moderne pour capturer, stocker et partager des enregistrements de réunions, des transcriptions, des moments clés et des résumés générés par IA. Grain permet aux équipes de transformer les conversations en informations exploitables et de maintenir tout le monde aligné sur les moments importants des réunions. + +Avec Grain, vous pouvez : + +- **Accéder aux enregistrements et transcriptions consultables** : trouvez et consultez chaque réunion par mot-clé, participant ou sujet. +- **Partager des moments clés et des extraits** : capturez les moments importants et partagez de courts extraits vidéo/audio avec votre équipe ou dans vos workflows. +- **Obtenir des résumés générés par IA** : produisez automatiquement des résumés de réunions, des actions à entreprendre et des informations clés grâce à l'IA avancée de Grain. +- **Organiser les réunions par équipe ou par type** : étiquetez et catégorisez les enregistrements pour un accès et un reporting faciles. + +L'intégration Sim Grain permet à vos agents de : + +- Lister, rechercher et récupérer les enregistrements de réunions et leurs détails selon des filtres flexibles (date/heure, participant, équipe, etc.). +- Accéder aux résumés IA, participants, moments clés et autres métadonnées des réunions pour alimenter des automatisations ou des analyses. +- Déclencher des workflows dès que de nouvelles réunions sont traitées, que des résumés sont générés ou que des moments clés sont créés via les webhooks Grain. +- Connecter facilement les données Grain à d'autres outils ou notifier les membres de l'équipe dès qu'un événement important se produit dans une réunion. + +Que vous souhaitiez automatiser les actions de suivi, conserver des traces de conversations importantes ou faire remonter des informations dans toute votre organisation, Grain et Sim facilitent la connexion de l'intelligence des réunions à vos workflows. +{/* MANUAL-CONTENT-END */} + +## Instructions d'utilisation + +Intégrez Grain dans votre workflow. Accédez aux enregistrements de réunions, transcriptions, moments clés et résumés générés par IA. Peut également déclencher des workflows basés sur les événements webhook de Grain. + +## Outils + +### `grain_list_recordings` + +Liste les enregistrements de Grain avec des filtres optionnels et une pagination + +#### Entrée + +| Paramètre | Type | Requis | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Oui | Clé API Grain (jeton d'accès personnel) | +| `cursor` | string | Non | Curseur de pagination pour la page suivante | +| `beforeDatetime` | string | Non | Uniquement les enregistrements avant cet horodatage ISO8601 | +| `afterDatetime` | string | Non | Uniquement les enregistrements après cet horodatage ISO8601 | +| `participantScope` | string | Non | Filtre : « internal » ou « external » | +| `titleSearch` | string | Non | Terme de recherche pour filtrer par titre d'enregistrement | +| `teamId` | string | Non | Filtrer par UUID d'équipe | +| `meetingTypeId` | string | Non | Filtrer par UUID de type de réunion | +| `includeHighlights` | boolean | Non | Inclure les moments forts/extraits dans la réponse | +| `includeParticipants` | boolean | Non | Inclure la liste des participants dans la réponse | +| `includeAiSummary` | boolean | Non | Inclure le résumé généré par IA | + +#### Sortie + +| Paramètre | Type | Description | +| --------- | ---- | ----------- | +| `recordings` | array | Tableau d'objets d'enregistrement | + +### `grain_get_recording` + +Obtient les détails d'un seul enregistrement par ID + +#### Entrée + +| Paramètre | Type | Requis | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Oui | Clé API Grain (jeton d'accès personnel) | +| `recordingId` | string | Oui | L'UUID de l'enregistrement | +| `includeHighlights` | boolean | Non | Inclure les moments forts/extraits | +| `includeParticipants` | boolean | Non | Inclure la liste des participants | +| `includeAiSummary` | boolean | Non | Inclure le résumé IA | +| `includeCalendarEvent` | boolean | Non | Inclure les données d'événement de calendrier | +| `includeHubspot` | boolean | Non | Inclure les associations HubSpot | + +#### Sortie + +| Paramètre | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | UUID de l'enregistrement | +| `title` | string | Titre de l'enregistrement | +| `start_datetime` | string | Horodatage de début ISO8601 | +| `end_datetime` | string | Horodatage de fin ISO8601 | +| `duration_ms` | number | Durée en millisecondes | +| `media_type` | string | audio, transcript ou video | +| `source` | string | Source de l'enregistrement \(zoom, meet, teams, etc.\) | +| `url` | string | URL pour visualiser dans Grain | +| `thumbnail_url` | string | URL de l'image miniature | +| `tags` | array | Tableau de chaînes de tags | +| `teams` | array | Équipes auxquelles appartient l'enregistrement | +| `meeting_type` | object | Informations sur le type de réunion \(id, nom, portée\) | +| `highlights` | array | Points forts \(si inclus\) | +| `participants` | array | Participants \(si inclus\) | +| `ai_summary` | object | Texte du résumé IA \(si inclus\) | +| `calendar_event` | object | Données de l'événement de calendrier \(si incluses\) | +| `hubspot` | object | Associations HubSpot \(si incluses\) | + +### `grain_get_transcript` + +Obtenir la transcription complète d'un enregistrement + +#### Entrée + +| Paramètre | Type | Requis | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Oui | Clé API Grain \(jeton d'accès personnel\) | +| `recordingId` | string | Oui | UUID de l'enregistrement | + +#### Sortie + +| Paramètre | Type | Description | +| --------- | ---- | ----------- | +| `transcript` | array | Tableau de sections de transcription | + +### `grain_list_teams` + +Lister toutes les équipes dans l'espace de travail + +#### Entrée + +| Paramètre | Type | Requis | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Oui | Clé API Grain (jeton d'accès personnel) | + +#### Sortie + +| Paramètre | Type | Description | +| --------- | ---- | ----------- | +| `teams` | array | Tableau d'objets équipe | + +### `grain_list_meeting_types` + +Lister tous les types de réunion dans l'espace de travail + +#### Entrée + +| Paramètre | Type | Requis | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Oui | Clé API Grain (jeton d'accès personnel) | + +#### Sortie + +| Paramètre | Type | Description | +| --------- | ---- | ----------- | +| `meeting_types` | array | Tableau d'objets type de réunion | + +### `grain_create_hook` + +Créer un webhook pour recevoir les événements d'enregistrement + +#### Entrée + +| Paramètre | Type | Requis | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Oui | Clé API Grain (jeton d'accès personnel) | +| `hookUrl` | string | Oui | URL du point de terminaison webhook (doit répondre 2xx) | +| `filterBeforeDatetime` | string | Non | Filtre : enregistrements avant cette date | +| `filterAfterDatetime` | string | Non | Filtre : enregistrements après cette date | +| `filterParticipantScope` | string | Non | Filtre : « internal » ou « external » | +| `filterTeamId` | string | Non | Filtre : UUID d'équipe spécifique | +| `filterMeetingTypeId` | string | Non | Filtre : type de réunion spécifique | +| `includeHighlights` | boolean | Non | Inclure les moments forts dans la charge utile du webhook | +| `includeParticipants` | boolean | Non | Inclure les participants dans la charge utile du webhook | +| `includeAiSummary` | boolean | Non | Inclure le résumé IA dans la charge utile du webhook | + +#### Sortie + +| Paramètre | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | UUID du hook | +| `enabled` | boolean | Indique si le hook est actif | +| `hook_url` | string | L'URL du webhook | +| `filter` | object | Filtres appliqués | +| `include` | object | Champs inclus | +| `inserted_at` | string | Horodatage de création ISO8601 | + +### `grain_list_hooks` + +Lister tous les webhooks du compte + +#### Entrée + +| Paramètre | Type | Requis | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Oui | Clé API Grain \(jeton d'accès personnel\) | + +#### Sortie + +| Paramètre | Type | Description | +| --------- | ---- | ----------- | +| `hooks` | array | Tableau d'objets hook | + +### `grain_delete_hook` + +Supprimer un webhook par ID + +#### Entrée + +| Paramètre | Type | Requis | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Oui | Clé API Grain \(jeton d'accès personnel\) | +| `hookId` | string | Oui | L'UUID du hook à supprimer | + +#### Sortie + +| Paramètre | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Vrai lorsque le webhook a été supprimé avec succès | + +## Remarques + +- Catégorie : `tools` +- Type : `grain` diff --git a/apps/docs/content/docs/ja/tools/circleback.mdx b/apps/docs/content/docs/ja/tools/circleback.mdx new file mode 100644 index 0000000000..e5edb784ad --- /dev/null +++ b/apps/docs/content/docs/ja/tools/circleback.mdx @@ -0,0 +1,59 @@ +--- +title: Circleback +description: AI搭載の議事録とアクションアイテム +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Circleback](https://circleback.ai/)は、チームの議事録、アクションアイテム、文字起こし、録音を自動化するAI搭載プラットフォームです。会議が終了すると、Circlebackが会話を処理し、詳細な議事録とアクションアイテム、文字起こしと録音(利用可能な場合)を提供します。これにより、チームは効率的に洞察を記録し、アクションアイテムを配布し、見落としがないことを確認できます。すべてがワークフローにシームレスに統合されます。 + +Sim Circleback統合により、次のことが可能になります。 + +- **詳細な議事録とアクションアイテムの受信**: 通話中に議論された実行可能なタスクを追跡し、整形された会議サマリーを自動的に収集します。 +- **完全な会議録音と文字起こしへのアクセス**: 会話全体と関連する録音を取得し、重要な瞬間を簡単に確認したり、同僚と共有したりできます。 +- **参加者情報と会議コンテキストの記録**: 参加者リスト、会議メタデータ、タグにより、データを整理して実行可能な状態に保ちます。 +- **ワークフローに直接洞察を配信**: 会議が終了した瞬間に、Simの強力なWebhookトリガーを使用して、自動化をトリガーしたり、Circlebackデータを他のシステムに送信したりできます。 + +**Simでの動作方法:** +CirclebackはWebhookトリガーを使用します。会議が処理されるたびに、データが自動的にエージェントまたは自動化にプッシュされます。次の条件に基づいてさらなる自動化を構築できます。 + +- 会議完了(すべての処理済みデータが利用可能) +- 新しいノート(会議全体が処理される前にノートが準備完了) +- 高度なユースケース向けの生のWebhook統合 + +**Circleback会議Webhookペイロードでは、次の情報が利用可能です:** + +| フィールド | タイプ | 説明 | +|----------------|---------|----------------------------------------------------| +| `id` | number | CirclebackミーティングID | +| `name` | string | ミーティングタイトル | +| `url` | string | バーチャルミーティングURL(Zoom、Meet、Teamsなど) | +| `createdAt` | string | ミーティング作成タイムスタンプ | +| `duration` | number | 秒単位の長さ | +| `recordingUrl` | string | 録画URL(24時間有効) | +| `tags` | json | タグの配列 | +| `icalUid` | string | カレンダーイベントID | +| `attendees` | json | 参加者オブジェクトの配列 | +| `notes` | string | Markdown形式のミーティングノート | +| `actionItems` | json | アクションアイテムの配列 | +| `transcript` | json | トランスクリプトセグメントの配列 | +| `insights` | json | ユーザー作成のインサイト | +| `meeting` | json | 完全なミーティングペイロード | + +即座にサマリーを配信したい場合でも、アクションアイテムを記録したい場合でも、新しいミーティングデータによってトリガーされるカスタムワークフローを構築したい場合でも、CirclebackとSimを使えば、ミーティングに関連するすべてを自動的にシームレスに処理できます。 +{/* MANUAL-CONTENT-END */} + +## 使用方法 + +ミーティングが処理されると、ミーティングノート、アクションアイテム、トランスクリプト、録画を受信します。Circlebackはwebhookを使用してワークフローにデータをプッシュします。 + +## 注意事項 + +- カテゴリー: `triggers` +- タイプ: `circleback` diff --git a/apps/docs/content/docs/ja/tools/grain.mdx b/apps/docs/content/docs/ja/tools/grain.mdx new file mode 100644 index 0000000000..0718ccb86f --- /dev/null +++ b/apps/docs/content/docs/ja/tools/grain.mdx @@ -0,0 +1,218 @@ +--- +title: Grain +description: 会議の録画、文字起こし、AI要約にアクセス +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Grain](https://grain.com/)は、会議の録画、文字起こし、ハイライト、AI搭載の要約を記録、保存、共有するための最新プラットフォームです。Grainを使用すると、チームは会話を実用的なインサイトに変換し、会議の重要な瞬間について全員の認識を一致させることができます。 + +Grainでできること: + +- **検索可能な録画と文字起こしへのアクセス**: キーワード、参加者、トピックで会議を検索して確認できます。 +- **ハイライトとクリップの共有**: 重要な瞬間を記録し、短い動画/音声のハイライトをチームやワークフロー全体で共有できます。 +- **AI生成の要約を取得**: Grainの高度なAIを使用して、会議の要約、アクションアイテム、主要なインサイトを自動的に作成します。 +- **チームやタイプ別に会議を整理**: 録画にタグを付けて分類し、簡単にアクセスしてレポートを作成できます。 + +Sim Grain統合により、エージェントは次のことが可能になります: + +- 柔軟なフィルター(日時、参加者、チームなど)で会議の録画と詳細を一覧表示、検索、取得できます。 +- 会議のAI要約、参加者、ハイライト、その他のメタデータにアクセスして、自動化や分析を強化できます。 +- Grain Webhookを介して、新しい会議が処理されたとき、要約が生成されたとき、またはハイライトが作成されたときにワークフローをトリガーできます。 +- Grainのデータを他のツールに簡単に連携したり、会議で重要なことが発生した瞬間にチームメイトに通知したりできます。 + +フォローアップアクションを自動化したり、重要な会話の記録を保持したり、組織全体でインサイトを表示したりする場合でも、GrainとSimを使用すると、会議のインテリジェンスをワークフローに簡単に接続できます。 +{/* MANUAL-CONTENT-END */} + +## 使用方法 + +Grainをワークフローに統合します。会議の録画、文字起こし、ハイライト、AI生成の要約にアクセスできます。Grain Webhookイベントに基づいてワークフローをトリガーすることもできます。 + +## ツール + +### `grain_list_recordings` + +オプションのフィルターとページネーションを使用してGrainから録画を一覧表示 + +#### 入力 + +| パラメータ | 型 | 必須 | 説明 | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | はい | Grain APIキー(個人アクセストークン) | +| `cursor` | string | いいえ | 次のページのページネーションカーソル | +| `beforeDatetime` | string | いいえ | このISO8601タイムスタンプより前の録画のみ | +| `afterDatetime` | string | いいえ | このISO8601タイムスタンプより後の録画のみ | +| `participantScope` | string | いいえ | フィルター:「internal」または「external」 | +| `titleSearch` | string | いいえ | 録画タイトルでフィルタリングする検索語 | +| `teamId` | string | いいえ | チームUUIDでフィルタリング | +| `meetingTypeId` | string | いいえ | ミーティングタイプUUIDでフィルタリング | +| `includeHighlights` | boolean | いいえ | レスポンスにハイライト/クリップを含める | +| `includeParticipants` | boolean | いいえ | レスポンスに参加者リストを含める | +| `includeAiSummary` | boolean | いいえ | AI生成サマリーを含める | + +#### 出力 + +| パラメータ | 型 | 説明 | +| --------- | ---- | ----------- | +| `recordings` | array | 録画オブジェクトの配列 | + +### `grain_get_recording` + +IDで単一の録画の詳細を取得 + +#### 入力 + +| パラメータ | 型 | 必須 | 説明 | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | はい | Grain APIキー(個人アクセストークン) | +| `recordingId` | string | はい | 録画UUID | +| `includeHighlights` | boolean | いいえ | ハイライト/クリップを含める | +| `includeParticipants` | boolean | いいえ | 参加者リストを含める | +| `includeAiSummary` | boolean | いいえ | AIサマリーを含める | +| `includeCalendarEvent` | boolean | いいえ | カレンダーイベントデータを含める | +| `includeHubspot` | boolean | いいえ | HubSpot関連付けを含める | + +#### 出力 + +| パラメータ | 型 | 説明 | +| --------- | ---- | ----------- | +| `id` | string | 録画UUID | +| `title` | string | 録画タイトル | +| `start_datetime` | string | ISO8601形式の開始タイムスタンプ | +| `end_datetime` | string | ISO8601形式の終了タイムスタンプ | +| `duration_ms` | number | ミリ秒単位の長さ | +| `media_type` | string | audio、transcript、またはvideo | +| `source` | string | 録画ソース(zoom、meet、teamsなど) | +| `url` | string | Grainで表示するためのURL | +| `thumbnail_url` | string | サムネイル画像URL | +| `tags` | array | タグ文字列の配列 | +| `teams` | array | 録画が属するチーム | +| `meeting_type` | object | ミーティングタイプ情報(id、name、scope) | +| `highlights` | array | ハイライト(含まれる場合) | +| `participants` | array | 参加者(含まれる場合) | +| `ai_summary` | object | AI要約テキスト(含まれる場合) | +| `calendar_event` | object | カレンダーイベントデータ(含まれる場合) | +| `hubspot` | object | HubSpot関連付け(含まれる場合) | + +### `grain_get_transcript` + +録画の完全なトランスクリプトを取得 + +#### 入力 + +| パラメータ | 型 | 必須 | 説明 | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | はい | Grain APIキー(パーソナルアクセストークン) | +| `recordingId` | string | はい | 録画UUID | + +#### 出力 + +| パラメータ | 型 | 説明 | +| --------- | ---- | ----------- | +| `transcript` | array | トランスクリプトセクションの配列 | + +### `grain_list_teams` + +ワークスペース内のすべてのチームを一覧表示 + +#### 入力 + +| パラメータ | 型 | 必須 | 説明 | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | はい | Grain APIキー(パーソナルアクセストークン) | + +#### 出力 + +| パラメータ | 型 | 説明 | +| --------- | ---- | ----------- | +| `teams` | array | チームオブジェクトの配列 | + +### `grain_list_meeting_types` + +ワークスペース内のすべてのミーティングタイプを一覧表示 + +#### 入力 + +| パラメータ | 型 | 必須 | 説明 | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | はい | Grain APIキー(パーソナルアクセストークン) | + +#### 出力 + +| パラメータ | 型 | 説明 | +| --------- | ---- | ----------- | +| `meeting_types` | array | ミーティングタイプオブジェクトの配列 | + +### `grain_create_hook` + +録画イベントを受信するためのWebhookを作成 + +#### 入力 + +| パラメータ | 型 | 必須 | 説明 | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | はい | Grain APIキー(パーソナルアクセストークン) | +| `hookUrl` | string | はい | WebhookエンドポイントURL(2xxを返す必要があります) | +| `filterBeforeDatetime` | string | いいえ | フィルタ: この日付より前の録画 | +| `filterAfterDatetime` | string | いいえ | フィルタ: この日付より後の録画 | +| `filterParticipantScope` | string | いいえ | フィルタ: "internal"または"external" | +| `filterTeamId` | string | いいえ | フィルタ: 特定のチームUUID | +| `filterMeetingTypeId` | string | いいえ | フィルタ: 特定のミーティングタイプ | +| `includeHighlights` | boolean | いいえ | Webhookペイロードにハイライトを含める | +| `includeParticipants` | boolean | いいえ | Webhookペイロードに参加者を含める | +| `includeAiSummary` | boolean | いいえ | WebhookペイロードにAIサマリーを含める | + +#### 出力 + +| パラメータ | 型 | 説明 | +| --------- | ---- | ----------- | +| `id` | string | フックUUID | +| `enabled` | boolean | フックがアクティブかどうか | +| `hook_url` | string | WebフックURL | +| `filter` | object | 適用されたフィルタ | +| `include` | object | 含まれるフィールド | +| `inserted_at` | string | ISO8601形式の作成タイムスタンプ | + +### `grain_list_hooks` + +アカウントのすべてのWebフックを一覧表示 + +#### 入力 + +| パラメータ | 型 | 必須 | 説明 | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | はい | Grain APIキー(個人アクセストークン) | + +#### 出力 + +| パラメータ | 型 | 説明 | +| --------- | ---- | ----------- | +| `hooks` | array | フックオブジェクトの配列 | + +### `grain_delete_hook` + +IDでWebフックを削除 + +#### 入力 + +| パラメータ | 型 | 必須 | 説明 | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | はい | Grain APIキー(個人アクセストークン) | +| `hookId` | string | はい | 削除するフックUUID | + +#### 出力 + +| パラメータ | 型 | 説明 | +| --------- | ---- | ----------- | +| `success` | boolean | Webフックが正常に削除された場合はtrue | + +## 注記 + +- カテゴリ: `tools` +- タイプ: `grain` diff --git a/apps/docs/content/docs/zh/tools/circleback.mdx b/apps/docs/content/docs/zh/tools/circleback.mdx new file mode 100644 index 0000000000..bdc2240c86 --- /dev/null +++ b/apps/docs/content/docs/zh/tools/circleback.mdx @@ -0,0 +1,58 @@ +--- +title: Circleback +description: AI 驱动的会议记录与行动项 +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Circleback](https://circleback.ai/) 是一个 AI 驱动的平台,可为您的团队自动生成会议记录、行动项、文字稿和录音。每当会议结束后,Circleback 会处理对话内容,提供详细的会议纪要和行动项,同时附上文字稿和录音(如有)。这有助于团队高效捕捉洞见、分发行动项,并确保不会遗漏任何重要信息——所有内容都能无缝集成到您的工作流程中。 + +通过 Sim Circleback 集成,您可以: + +- **获取详细的会议记录和行动项**:自动收集格式良好的会议摘要,并跟踪通话中讨论的可执行任务。 +- **访问完整的会议录音和文字稿**:获取完整对话及相关录音,便于回顾关键时刻或与同事分享。 +- **捕捉与会者信息和会议背景**:与会者名单、会议元数据和标签帮助您有序管理和利用数据。 +- **将洞见直接推送到您的工作流程**:会议结束后,利用 Sim 强大的 webhook 触发器,自动触发自动化流程或将 Circleback 数据发送到其他系统。 + +**在 Sim 中的工作方式:** +Circleback 使用 webhook 触发器:每当会议处理完成,数据会自动推送到您的代理或自动化流程。您可以基于以下内容构建更多自动化: + +- 会议完成(所有处理数据可用) +- 新会议记录(即使会议尚未全部处理,会议纪要也可提前获取) +- 原始 webhook 集成,适用于高级用例 + +**Circleback 会议 webhook 有效载荷中包含以下信息:** + +| 字段 | 类型 | 描述 | +|----------------|---------|----------------------------------------------------| +| `id` | number | Circleback 会议 ID | +| `name` | string | 会议标题 | +| `url` | string | 虚拟会议 URL(Zoom、Meet、Teams 等) | +| `createdAt` | string | 会议创建时间戳 | +| `duration` | number | 时长(秒) | +| `recordingUrl` | string | 录制文件 URL(有效期 24 小时) | +| `tags` | json | 标签数组 | +| `icalUid` | string | 日历事件 ID | +| `attendees` | json | 参会者对象数组 | +| `notes` | string | Markdown 格式会议记录 | +| `actionItems` | json | 行动项数组 | +| `transcript` | json | 会议记录片段数组 | +| `insights` | json | 用户创建的洞见 | +| `meeting` | json | 完整会议数据 | + +无论你是想分发即时摘要、记录行动项,还是基于新会议数据构建自定义工作流,Circleback 和 Sim 都能让你自动无缝处理所有与会议相关的事务。 + +## 使用说明 + +当会议被处理时,你将收到会议记录、行动项、转录和录音。Circleback 使用 webhook 将数据推送到你的工作流。 + +## 备注 + +- 分类:`triggers` +- 类型:`circleback` diff --git a/apps/docs/content/docs/zh/tools/grain.mdx b/apps/docs/content/docs/zh/tools/grain.mdx new file mode 100644 index 0000000000..fecd230eb7 --- /dev/null +++ b/apps/docs/content/docs/zh/tools/grain.mdx @@ -0,0 +1,218 @@ +--- +title: Grain +description: 访问会议录音、转录文本和 AI 摘要 +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Grain](https://grain.com/) 是一个现代化平台,用于捕捉、存储和分享会议录音、转录文本、重点片段以及 AI 驱动的摘要。Grain 帮助团队将对话转化为可执行的洞察,让每个人都能对会议中的关键时刻保持一致。 + +使用 Grain,您可以: + +- **访问可搜索的录音和转录文本**:可按关键词、参与者或主题查找和回顾每场会议。 +- **分享重点片段和剪辑**:捕捉重要时刻,并在团队或工作流中分享短视频/音频片段。 +- **获取 AI 生成的摘要**:利用 Grain 的先进 AI 自动生成会议摘要、行动项和关键洞察。 +- **按团队或类型组织会议**:为录音打标签和分类,便于访问和报告。 + +Sim Grain 集成让您的坐席能够: + +- 通过灵活的筛选条件(日期时间、参与者、团队等)列出、搜索和获取会议录音及详细信息。 +- 获取会议的 AI 摘要、参与者、重点片段及其他元数据,以支持自动化或分析。 +- 通过 Grain webhook,在新会议被处理、摘要生成或重点片段创建时触发工作流。 +- 轻松将 Grain 数据桥接到其他工具,或在会议中有重要事件发生时即时通知团队成员。 + +无论您是想自动化后续操作、保留重要对话记录,还是在组织内挖掘洞察,Grain 和 Sim 都能让您轻松将会议智能连接到工作流中。 +{/* MANUAL-CONTENT-END */} + +## 使用说明 + +将 Grain 集成到您的工作流中。访问会议录音、转录文本、重点片段和 AI 生成的摘要。还可以基于 Grain webhook 事件触发工作流。 + +## 工具 + +### `grain_list_recordings` + +从 Grain 获取录音列表,可选过滤和分页 + +#### 输入 + +| 参数 | 类型 | 必填 | 说明 | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | 是 | Grain API key(个人访问令牌) | +| `cursor` | string | 否 | 下一页的分页游标 | +| `beforeDatetime` | string | 否 | 仅包含此 ISO8601 时间戳之前的录音 | +| `afterDatetime` | string | 否 | 仅包含此 ISO8601 时间戳之后的录音 | +| `participantScope` | string | 否 | 过滤条件:“internal” 或 “external” | +| `titleSearch` | string | 否 | 按录音标题搜索过滤 | +| `teamId` | string | 否 | 按团队 UUID 过滤 | +| `meetingTypeId` | string | 否 | 按会议类型 UUID 过滤 | +| `includeHighlights` | boolean | 否 | 响应中包含重点/片段 | +| `includeParticipants` | boolean | 否 | 响应中包含参与者列表 | +| `includeAiSummary` | boolean | 否 | 包含 AI 生成的摘要 | + +#### 输出 + +| 参数 | 类型 | 说明 | +| --------- | ---- | ----------- | +| `recordings` | array | 录音对象数组 | + +### `grain_get_recording` + +根据 ID 获取单个录音的详细信息 + +#### 输入 + +| 参数 | 类型 | 必填 | 说明 | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | 是 | Grain API key(个人访问令牌) | +| `recordingId` | string | 是 | 录音 UUID | +| `includeHighlights` | boolean | 否 | 包含重点/片段 | +| `includeParticipants` | boolean | 否 | 包含参与者列表 | +| `includeAiSummary` | boolean | 否 | 包含 AI 摘要 | +| `includeCalendarEvent` | boolean | 否 | 包含日历事件数据 | +| `includeHubspot` | boolean | 否 | 包含 HubSpot 关联 | + +#### 输出 + +| 参数 | 类型 | 说明 | +| --------- | ---- | ----------- | +| `id` | string | 录音 UUID | +| `title` | string | 录音标题 | +| `start_datetime` | string | ISO8601 开始时间戳 | +| `end_datetime` | string | ISO8601 结束时间戳 | +| `duration_ms` | number | 持续时间(毫秒) | +| `media_type` | string | 音频、转录或视频 | +| `source` | string | 录音来源(zoom、meet、teams 等) | +| `url` | string | 在 Grain 中查看的 URL | +| `thumbnail_url` | string | 缩略图 URL | +| `tags` | array | 标签字符串数组 | +| `teams` | array | 录音所属团队 | +| `meeting_type` | object | 会议类型信息(id、name、scope) | +| `highlights` | array | 高亮内容(如有) | +| `participants` | array | 参与者(如有) | +| `ai_summary` | object | AI 摘要文本(如有) | +| `calendar_event` | object | 日历事件数据(如有) | +| `hubspot` | object | HubSpot 关联信息(如有) | + +### `grain_get_transcript` + +获取录音的完整转录文本 + +#### 输入 + +| 参数 | 类型 | 必填 | 说明 | +| --------- | ---- | ---- | ----------- | +| `apiKey` | string | 是 | Grain API 密钥(个人访问令牌) | +| `recordingId` | string | 是 | 录音 UUID | + +#### 输出 + +| 参数 | 类型 | 说明 | +| --------- | ---- | ----------- | +| `transcript` | array | 转录片段数组 | + +### `grain_list_teams` + +列出工作区中的所有团队 + +#### 输入 + +| 参数 | 类型 | 必填 | 说明 | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | 是 | Grain API key(个人访问令牌) | + +#### 输出 + +| 参数 | 类型 | 说明 | +| --------- | ---- | ----------- | +| `teams` | array | 团队对象数组 | + +### `grain_list_meeting_types` + +列出工作区中的所有会议类型 + +#### 输入 + +| 参数 | 类型 | 必填 | 说明 | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | 是 | Grain API key(个人访问令牌) | + +#### 输出 + +| 参数 | 类型 | 说明 | +| --------- | ---- | ----------- | +| `meeting_types` | array | 会议类型对象数组 | + +### `grain_create_hook` + +创建一个 webhook 以接收录制事件 + +#### 输入 + +| 参数 | 类型 | 必填 | 说明 | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | 是 | Grain API key(个人访问令牌) | +| `hookUrl` | string | 是 | Webhook endpoint URL(必须响应 2xx) | +| `filterBeforeDatetime` | string | 否 | 筛选:此日期之前的录制 | +| `filterAfterDatetime` | string | 否 | 筛选:此日期之后的录制 | +| `filterParticipantScope` | string | 否 | 筛选:“internal” 或 “external” | +| `filterTeamId` | string | 否 | 筛选:指定团队 UUID | +| `filterMeetingTypeId` | string | 否 | 筛选:指定会议类型 | +| `includeHighlights` | boolean | 否 | 在 webhook 负载中包含重点内容 | +| `includeParticipants` | boolean | 否 | 在 webhook 负载中包含参与者 | +| `includeAiSummary` | boolean | 否 | 在 webhook 负载中包含 AI 摘要 | + +#### 输出 + +| 参数 | 类型 | 说明 | +| --------- | ---- | ----------- | +| `id` | string | Hook UUID | +| `enabled` | boolean | Hook 是否激活 | +| `hook_url` | string | webhook URL | +| `filter` | object | 已应用的过滤器 | +| `include` | object | 包含的字段 | +| `inserted_at` | string | ISO8601 创建时间戳 | + +### `grain_list_hooks` + +列出该账户下的所有 webhook + +#### 输入 + +| 参数 | 类型 | 必填 | 说明 | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | 是 | Grain API key(个人访问令牌) | + +#### 输出 + +| 参数 | 类型 | 说明 | +| --------- | ---- | ----------- | +| `hooks` | array | Hook 对象数组 | + +### `grain_delete_hook` + +根据 ID 删除 webhook + +#### 输入 + +| 参数 | 类型 | 必填 | 说明 | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | 是 | Grain API key(个人访问令牌) | +| `hookId` | string | 是 | 要删除的 Hook UUID | + +#### 输出 + +| 参数 | 类型 | 说明 | +| --------- | ---- | ----------- | +| `success` | boolean | webhook 删除成功时为 true | + +## 备注 + +- 分类:`tools` +- 类型:`grain` diff --git a/apps/docs/i18n.lock b/apps/docs/i18n.lock index 0909887e14..84726da579 100644 --- a/apps/docs/i18n.lock +++ b/apps/docs/i18n.lock @@ -49869,3 +49869,84 @@ checksums: content/32: fd0f38eb3fe5cf95be366a4ff6b4fb90 content/33: b3f310d5ef115bea5a8b75bf25d7ea9a content/34: 4a7b2c644e487f3d12b6a6b54f8c6773 + d75b83c6e1f54ba41b8cd27960256f4e: + meta/title: 63d9b961cc414fe48ed3a117b1849ac0 + meta/description: 0828295c4f8482d4ab18ae67cefb3efa + content/0: 1b031fb0c62c46b177aeed5c3d3f8f80 + content/1: ce93512e241ca1ac9723d797d937e8d6 + content/2: 4539a8e7b9a0b8c570e8b2261e6d53e8 + content/3: 05d783b8313bd21464edbc35f72acda7 + content/4: aadfc263ce44fb67b5ec899cf7034707 + content/5: 7feedc49fa38d45979f4ae3685e2a2e8 + content/6: 6d8ac64adb588d4675e8ad779861cf79 + content/7: 9b55ef7d0cb63e28ac9aa5b71ca5611e + content/8: 821e6394b0a953e2b0842b04ae8f3105 + content/9: 3e3c921ad486b0390454b325a0ecab98 + content/10: 9c8aa3f09c9b2bd50ea4cdff3598ea4e + content/11: 3e12916db64b7037df05c733542689b8 + content/12: bf76a8fa5e9be0ad03d4a25fc1cd5d2c + content/13: 371d0e46b4bd2c23f559b8bc112f6955 + content/14: 7476e5130f17fef0005e9eb79a288a4b + content/15: bcadfc362b69078beee0088e5936c98b + content/16: 921522dc74bcfe253933280a44e32325 + content/17: 5c91a98c8c182a86561bdc2bb55d52fb + content/18: 5a003869e25c931a6a39e75f1fbb331e + content/19: 371d0e46b4bd2c23f559b8bc112f6955 + content/20: c03a1ad5898fb9592c47f9fef3a443f9 + content/21: bcadfc362b69078beee0088e5936c98b + content/22: 966dd802eb6aa5f8a6d37be800aa0476 + content/23: d7f931ee4088a41234a19dbc070bbb06 + content/24: b7a5a66f81700ac0f58f0f417a090db1 + content/25: 371d0e46b4bd2c23f559b8bc112f6955 + content/26: 8dddce76764cf0050ac4f8cb88cbf3b7 + content/27: bcadfc362b69078beee0088e5936c98b + content/28: b30efde22ffd4646ac11e1b7053b2f71 + content/29: e12dd4d7b99e7c7038b8935f48fbed29 + content/30: fd9b29ad276abb6ffbb5350d71fb174a + content/31: 371d0e46b4bd2c23f559b8bc112f6955 + content/32: 2e9928cbf2e736fc61f08d4339ccae59 + content/33: bcadfc362b69078beee0088e5936c98b + content/34: 99b063108e07f3350f6ec02ce632d682 + content/35: c1d1369970a7430014aa1f70a75e1b56 + content/36: 25adb991028a92365272704d5921c0fe + content/37: 371d0e46b4bd2c23f559b8bc112f6955 + content/38: 2e9928cbf2e736fc61f08d4339ccae59 + content/39: bcadfc362b69078beee0088e5936c98b + content/40: 852dffd5402c58c35f6abfd6b8046585 + content/41: 66a326fe86b5ff7c12f097bae8917018 + content/42: a58bde3efd6164d3541047bd97cee6fe + content/43: 371d0e46b4bd2c23f559b8bc112f6955 + content/44: 1c98f5538b8b37801da7f5e8c5912219 + content/45: bcadfc362b69078beee0088e5936c98b + content/46: a84d2702883c8af99a401582e2192d39 + content/47: e3dd3df817017359361432029b0c5ef1 + content/48: 557fb6942a695af69e94fbd7692590e6 + content/49: 371d0e46b4bd2c23f559b8bc112f6955 + content/50: 2e9928cbf2e736fc61f08d4339ccae59 + content/51: bcadfc362b69078beee0088e5936c98b + content/52: 69a6fbea11482ab284195984788c9710 + content/53: dafa30ae47d52c901b756bd8bd0ae2fd + content/54: 70e8dcde230d0cd4a9b7b18c8df043cd + content/55: 371d0e46b4bd2c23f559b8bc112f6955 + content/56: 97a4116e8509aede52ea1801656a7671 + content/57: bcadfc362b69078beee0088e5936c98b + content/58: 2b73b3348aa37da99e35d12e892197f2 + content/59: b3f310d5ef115bea5a8b75bf25d7ea9a + content/60: 11e67a936d6e434842446342f83b5289 + 753fe021f7c0fca8dc429f2e971fae5a: + meta/title: b3498307d692252f1286175a18b62e16 + meta/description: 5f52444a2ad126633723bb44d06e7638 + content/0: 1b031fb0c62c46b177aeed5c3d3f8f80 + content/1: 28c5925266bfcf8a373b0490f6e63c8c + content/2: 76482724500904d534bc171470aa5594 + content/3: 5e43b6ea89ab6aa913524b5db4c4f2f3 + content/4: aa6b3680f93d09752072d278d8d3e6bb + content/5: 49686bd2f0af8b45071a5e11a47df85e + content/6: 4fcff29464aac96e894b0e9da8b7aac5 + content/7: 89255fc21a3a429f27d1f2cdfe065235 + content/8: 51dac9c1f218035a3f23137588eca5b6 + content/9: 2ddb58bd6414897d33c6cb1590558749 + content/10: 821e6394b0a953e2b0842b04ae8f3105 + content/11: 972721b310d5e3e6e08ec33dc9630f62 + content/12: b3f310d5ef115bea5a8b75bf25d7ea9a + content/13: 06a9cbcec05366fe1c873c90c36b4f44 From bf8fbebe22c337aae59069e517f3cb71d5fb8e41 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 23 Dec 2025 15:12:04 -0800 Subject: [PATCH 09/18] improvement(code-quality): centralize regex checks, normalization (#2554) * improvement(code-quality): centralize regex checks, normalization * simplify resolution * fix(copilot): don't allow duplicate name blocks * centralize uuid check --- .../api/copilot/checkpoints/revert/route.ts | 7 +- .../sim/app/api/copilot/execute-tool/route.ts | 10 ++- apps/sim/app/api/files/authorization.ts | 5 +- apps/sim/app/api/files/upload/route.ts | 6 +- apps/sim/app/api/function/execute/route.ts | 35 +++----- .../api/mcp/servers/test-connection/route.ts | 7 +- .../tools/google_calendar/calendars/route.ts | 16 ++-- .../app/api/workflows/[id]/execute/route.ts | 6 +- .../components/deploy-modal/deploy-modal.tsx | 4 +- .../editor/hooks/use-subflow-editor.ts | 8 +- .../use-accessible-reference-prefixes.ts | 2 +- .../hooks/use-block-connections.ts | 3 +- .../components/environment/environment.tsx | 6 +- apps/sim/background/schedule-execution.ts | 16 +++- apps/sim/executor/constants.ts | 61 ++++++++++++++ .../handlers/agent/agent-handler.test.ts | 6 +- .../executor/handlers/agent/agent-handler.ts | 19 +++-- .../condition/condition-handler.test.ts | 4 +- .../handlers/generic/generic-handler.ts | 5 +- .../human-in-the-loop-handler.ts | 10 ++- .../handlers/response/response-handler.ts | 8 +- apps/sim/executor/utils/block-data.ts | 5 +- apps/sim/executor/variables/resolver.ts | 10 +-- .../sim/executor/variables/resolvers/block.ts | 22 +++-- .../executor/variables/resolvers/parallel.ts | 2 +- .../executor/variables/resolvers/workflow.ts | 3 +- apps/sim/lib/copilot/process-contents.ts | 5 +- .../client/workflow/block-output-utils.ts | 2 +- .../client/workflow/get-block-outputs.ts | 2 +- .../tools/server/workflow/edit-workflow.ts | 82 +++++++++++++++++-- .../core/security/input-validation.test.ts | 49 ----------- .../sim/lib/core/security/input-validation.ts | 40 --------- .../execution/trace-spans/trace-spans.test.ts | 6 +- .../logs/execution/trace-spans/trace-spans.ts | 6 +- apps/sim/lib/mcp/service.ts | 9 +- apps/sim/lib/mcp/utils.ts | 3 +- .../lib/uploads/contexts/execution/utils.ts | 15 +--- .../workspace/workspace-file-manager.ts | 10 +-- apps/sim/lib/uploads/providers/blob/client.ts | 5 +- apps/sim/lib/uploads/providers/s3/client.ts | 5 +- apps/sim/lib/uploads/utils/file-utils.ts | 5 +- apps/sim/lib/webhooks/processor.ts | 7 +- apps/sim/lib/workflows/autolayout/core.ts | 5 +- .../workflows/blocks/block-path-calculator.ts | 37 --------- .../lib/workflows/executor/execution-core.ts | 18 +++- .../workflows/operations/deployment-utils.ts | 11 +-- .../lib/workflows/sanitization/references.ts | 23 +++--- .../lib/workflows/sanitization/validation.ts | 7 +- apps/sim/lib/workflows/utils.ts | 4 - apps/sim/providers/utils.ts | 3 +- apps/sim/serializer/index.ts | 3 +- apps/sim/stores/variables/store.ts | 5 +- apps/sim/stores/workflows/subblock/utils.ts | 20 ++--- apps/sim/stores/workflows/utils.ts | 11 +-- .../stores/workflows/workflow/store.test.ts | 10 +-- apps/sim/stores/workflows/workflow/store.ts | 26 +++--- apps/sim/stores/workflows/yaml/importer.ts | 36 +++++++- .../stores/workflows/yaml/parsing-utils.ts | 13 +-- apps/sim/tools/index.ts | 7 +- apps/sim/tools/reddit/get_comments.ts | 4 +- apps/sim/tools/reddit/get_controversial.ts | 4 +- apps/sim/tools/reddit/get_posts.ts | 4 +- apps/sim/tools/reddit/hot_posts.ts | 4 +- apps/sim/tools/reddit/search.ts | 4 +- apps/sim/tools/reddit/submit_post.ts | 4 +- apps/sim/tools/reddit/subscribe.ts | 4 +- apps/sim/tools/reddit/utils.ts | 10 +++ apps/sim/tools/utils.ts | 7 +- 68 files changed, 425 insertions(+), 396 deletions(-) create mode 100644 apps/sim/tools/reddit/utils.ts diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts index e47f2f6d11..f0b635f20e 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.ts @@ -10,9 +10,9 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' -import { validateUUID } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' +import { isUuidV4 } from '@/executor/constants' const logger = createLogger('CheckpointRevertAPI') @@ -87,9 +87,8 @@ export async function POST(request: NextRequest) { isDeployed: cleanedState.isDeployed, }) - const workflowIdValidation = validateUUID(checkpoint.workflowId, 'workflowId') - if (!workflowIdValidation.isValid) { - logger.error(`[${tracker.requestId}] Invalid workflow ID: ${workflowIdValidation.error}`) + if (!isUuidV4(checkpoint.workflowId)) { + logger.error(`[${tracker.requestId}] Invalid workflow ID format`) return NextResponse.json({ error: 'Invalid workflow ID format' }, { status: 400 }) } diff --git a/apps/sim/app/api/copilot/execute-tool/route.ts b/apps/sim/app/api/copilot/execute-tool/route.ts index c836851529..e5cb66095f 100644 --- a/apps/sim/app/api/copilot/execute-tool/route.ts +++ b/apps/sim/app/api/copilot/execute-tool/route.ts @@ -14,6 +14,8 @@ import { generateRequestId } from '@/lib/core/utils/request' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { createLogger } from '@/lib/logs/console/logger' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { REFERENCE } from '@/executor/constants' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' import { executeTool } from '@/tools' import { getTool } from '@/tools/utils' @@ -33,14 +35,18 @@ const ExecuteToolSchema = z.object({ function resolveEnvVarReferences(value: any, envVars: Record): any { if (typeof value === 'string') { // Check for exact match: entire string is "{{VAR_NAME}}" - const exactMatch = /^\{\{([^}]+)\}\}$/.exec(value) + const exactMatchPattern = new RegExp( + `^\\${REFERENCE.ENV_VAR_START}([^}]+)\\${REFERENCE.ENV_VAR_END}$` + ) + const exactMatch = exactMatchPattern.exec(value) if (exactMatch) { const envVarName = exactMatch[1].trim() return envVars[envVarName] ?? value } // Check for embedded references: "prefix {{VAR}} suffix" - return value.replace(/\{\{([^}]+)\}\}/g, (match, varName) => { + const envVarPattern = createEnvVarPattern() + return value.replace(envVarPattern, (match, varName) => { const trimmedName = varName.trim() return envVars[trimmedName] ?? match }) diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index 6083a92c74..65b3381a19 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -14,6 +14,7 @@ import type { StorageConfig } from '@/lib/uploads/core/storage-client' import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { isUuid } from '@/executor/constants' const logger = createLogger('FileAuthorization') @@ -85,9 +86,7 @@ function extractWorkspaceIdFromKey(key: string): string | null { const parts = key.split('/') const workspaceId = parts[0] - // Validate UUID format - const UUID_PATTERN = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i - if (workspaceId && UUID_PATTERN.test(workspaceId)) { + if (workspaceId && isUuid(workspaceId)) { return workspaceId } diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index 89d911e89d..c23f46ec84 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' +import { sanitizeFileName } from '@/executor/constants' import '@/lib/uploads/core/setup.server' import { getSession } from '@/lib/auth' import type { StorageContext } from '@/lib/uploads/config' @@ -154,7 +155,7 @@ export async function POST(request: NextRequest) { logger.info(`Uploading knowledge-base file: ${originalName}`) const timestamp = Date.now() - const safeFileName = originalName.replace(/\s+/g, '-') + const safeFileName = sanitizeFileName(originalName) const storageKey = `kb/${timestamp}-${safeFileName}` const metadata: Record = { @@ -267,9 +268,8 @@ export async function POST(request: NextRequest) { logger.info(`Uploading ${context} file: ${originalName}`) - // Generate storage key with context prefix and timestamp to ensure uniqueness const timestamp = Date.now() - const safeFileName = originalName.replace(/\s+/g, '-') + const safeFileName = sanitizeFileName(originalName) const storageKey = `${context}/${timestamp}-${safeFileName}` const metadata: Record = { diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index b09fde257f..ce42d5e67f 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -5,6 +5,7 @@ import { executeInE2B } from '@/lib/execution/e2b' import { executeInIsolatedVM } from '@/lib/execution/isolated-vm' import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' import { createLogger } from '@/lib/logs/console/logger' +import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { createEnvVarPattern, createWorkflowVariablePattern, @@ -405,7 +406,7 @@ function resolveWorkflowVariables( // Find the variable by name (workflowVariables is indexed by ID, values are variable objects) const foundVariable = Object.entries(workflowVariables).find( - ([_, variable]) => (variable.name || '').replace(/\s+/g, '') === variableName + ([_, variable]) => normalizeName(variable.name || '') === variableName ) let variableValue: unknown = '' @@ -513,31 +514,26 @@ function resolveTagVariables( ): string { let resolvedCode = code - const tagMatches = resolvedCode.match(/<([a-zA-Z_][a-zA-Z0-9_.]*[a-zA-Z0-9_])>/g) || [] + const tagPattern = new RegExp( + `${REFERENCE.START}([a-zA-Z_][a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])${REFERENCE.END}`, + 'g' + ) + const tagMatches = resolvedCode.match(tagPattern) || [] for (const match of tagMatches) { - const tagName = match.slice(1, -1).trim() + const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim() // Handle nested paths like "getrecord.response.data" or "function1.response.result" // First try params, then blockData directly, then try with block name mapping let tagValue = getNestedValue(params, tagName) || getNestedValue(blockData, tagName) || '' // If not found and the path starts with a block name, try mapping the block name to ID - if (!tagValue && tagName.includes('.')) { - const pathParts = tagName.split('.') + if (!tagValue && tagName.includes(REFERENCE.PATH_DELIMITER)) { + const pathParts = tagName.split(REFERENCE.PATH_DELIMITER) const normalizedBlockName = pathParts[0] // This should already be normalized like "function1" - // Find the block ID by looking for a block name that normalizes to this value - let blockId = null - - for (const [blockName, id] of Object.entries(blockNameMapping)) { - // Apply the same normalization logic as the UI: remove spaces and lowercase - const normalizedName = blockName.replace(/\s+/g, '').toLowerCase() - if (normalizedName === normalizedBlockName) { - blockId = id - break - } - } + // Direct lookup using normalized block name + const blockId = blockNameMapping[normalizedBlockName] ?? null if (blockId) { const remainingPath = pathParts.slice(1).join('.') @@ -617,13 +613,6 @@ function getNestedValue(obj: any, path: string): any { }, obj) } -/** - * Escape special regex characters in a string - */ -function escapeRegExp(string: string): string { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - /** * Remove one trailing newline from stdout * This handles the common case where print() or console.log() adds a trailing \n diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.ts b/apps/sim/app/api/mcp/servers/test-connection/route.ts index 840fb4aaeb..1c4add215e 100644 --- a/apps/sim/app/api/mcp/servers/test-connection/route.ts +++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts @@ -6,6 +6,8 @@ import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import type { McpServerConfig, McpTransport } from '@/lib/mcp/types' import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { REFERENCE } from '@/executor/constants' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' const logger = createLogger('McpServerTestAPI') @@ -23,12 +25,13 @@ function isUrlBasedTransport(transport: McpTransport): boolean { * Resolve environment variables in strings */ function resolveEnvVars(value: string, envVars: Record): string { - const envMatches = value.match(/\{\{([^}]+)\}\}/g) + const envVarPattern = createEnvVarPattern() + const envMatches = value.match(envVarPattern) if (!envMatches) return value let resolvedValue = value for (const match of envMatches) { - const envKey = match.slice(2, -2).trim() + const envKey = match.slice(REFERENCE.ENV_VAR_START.length, -REFERENCE.ENV_VAR_END.length).trim() const envValue = envVars[envKey] if (envValue === undefined) { diff --git a/apps/sim/app/api/tools/google_calendar/calendars/route.ts b/apps/sim/app/api/tools/google_calendar/calendars/route.ts index 7fc17db6ee..77b6291bfe 100644 --- a/apps/sim/app/api/tools/google_calendar/calendars/route.ts +++ b/apps/sim/app/api/tools/google_calendar/calendars/route.ts @@ -1,9 +1,9 @@ import { type NextRequest, NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateUUID } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { isUuidV4 } from '@/executor/constants' export const dynamic = 'force-dynamic' const logger = createLogger('GoogleCalendarAPI') @@ -35,18 +35,14 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) } - const credentialValidation = validateUUID(credentialId, 'credentialId') - if (!credentialValidation.isValid) { + if (!isUuidV4(credentialId)) { logger.warn(`[${requestId}] Invalid credentialId format`, { credentialId }) - return NextResponse.json({ error: credentialValidation.error }, { status: 400 }) + return NextResponse.json({ error: 'Invalid credential ID format' }, { status: 400 }) } - if (workflowId) { - const workflowValidation = validateUUID(workflowId, 'workflowId') - if (!workflowValidation.isValid) { - logger.warn(`[${requestId}] Invalid workflowId format`, { workflowId }) - return NextResponse.json({ error: workflowValidation.error }, { status: 400 }) - } + if (workflowId && !isUuidV4(workflowId)) { + logger.warn(`[${requestId}] Invalid workflowId format`, { workflowId }) + return NextResponse.json({ error: 'Invalid workflow ID format' }, { status: 400 }) } const authz = await authorizeCredentialUse(request, { credentialId, workflowId }) if (!authz.ok || !authz.credentialOwnerUserId) { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index acf3015ab3..df35fc3cae 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -22,6 +22,7 @@ import { import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils' import type { WorkflowExecutionPayload } from '@/background/workflow-execution' +import { normalizeName } from '@/executor/constants' import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot' import type { StreamingExecution } from '@/executor/types' import { Serializer } from '@/serializer' @@ -86,10 +87,9 @@ function resolveOutputIds( const blockName = outputId.substring(0, dotIndex) const path = outputId.substring(dotIndex + 1) - const normalizedBlockName = blockName.toLowerCase().replace(/\s+/g, '') + const normalizedBlockName = normalizeName(blockName) const block = Object.values(blocks).find((b: any) => { - const normalized = (b.name || '').toLowerCase().replace(/\s+/g, '') - return normalized === normalizedBlockName + return normalizeName(b.name || '') === normalizedBlockName }) if (!block) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 84b3996fbb..7ea3acfd59 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -18,6 +18,7 @@ import { getEnv } from '@/lib/core/config/env' import { createLogger } from '@/lib/logs/console/logger' import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' +import { startsWithUuid } from '@/executor/constants' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -289,10 +290,9 @@ export function DeployModal({ if (!open || selectedStreamingOutputs.length === 0) return const blocks = Object.values(useWorkflowStore.getState().blocks) - const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i const validOutputs = selectedStreamingOutputs.filter((outputId) => { - if (UUID_REGEX.test(outputId)) { + if (startsWithUuid(outputId)) { const underscoreIndex = outputId.indexOf('_') if (underscoreIndex === -1) return false diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-subflow-editor.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-subflow-editor.ts index 64503e03ef..caaa597490 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-subflow-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-subflow-editor.ts @@ -7,9 +7,9 @@ import { } from '@/lib/workflows/sanitization/references' import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' +import { normalizeName, REFERENCE } from '@/executor/constants' import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' -import { normalizeName } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -89,7 +89,7 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId */ const shouldHighlightReference = useCallback( (part: string): boolean => { - if (!part.startsWith('<') || !part.endsWith('>')) { + if (!part.startsWith(REFERENCE.START) || !part.endsWith(REFERENCE.END)) { return false } @@ -108,8 +108,8 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId return true } - const inner = reference.slice(1, -1) - const [prefix] = inner.split('.') + const inner = reference.slice(REFERENCE.START.length, -REFERENCE.END.length) + const [prefix] = inner.split(REFERENCE.PATH_DELIMITER) const normalizedPrefix = normalizeName(prefix) if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts index b589972e17..55aa01c217 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator' import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/sanitization/references' -import { normalizeName } from '@/stores/workflows/utils' +import { normalizeName } from '@/executor/constants' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { Loop, Parallel } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-connections.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-connections.ts index 5671a4caa9..95c78a3696 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-connections.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-connections.ts @@ -3,6 +3,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' +import { REFERENCE } from '@/executor/constants' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -44,7 +45,7 @@ function parseResponseFormatSafely(responseFormatValue: any, blockId: string): a if (typeof responseFormatValue === 'string') { const trimmedValue = responseFormatValue.trim() - if (trimmedValue.startsWith('<') && trimmedValue.includes('>')) { + if (trimmedValue.startsWith(REFERENCE.START) && trimmedValue.includes(REFERENCE.END)) { return trimmedValue } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx index a3a535e73f..e0497d27e1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx @@ -16,6 +16,7 @@ import { import { Trash } from '@/components/emcn/icons/trash' import { Input, Skeleton } from '@/components/ui' import { createLogger } from '@/lib/logs/console/logger' +import { isValidEnvVarName } from '@/executor/constants' import { usePersonalEnvironment, useRemoveWorkspaceEnvironment, @@ -28,7 +29,6 @@ import { const logger = createLogger('EnvironmentVariables') const GRID_COLS = 'grid grid-cols-[minmax(0,1fr)_8px_minmax(0,1fr)_auto] items-center' -const ENV_VAR_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ const PRIMARY_BUTTON_STYLES = '!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90' @@ -59,7 +59,7 @@ interface UIEnvironmentVariable { function validateEnvVarKey(key: string): string | undefined { if (!key) return undefined if (key.includes(' ')) return 'Spaces are not allowed' - if (!ENV_VAR_PATTERN.test(key)) return 'Only letters, numbers, and underscores allowed' + if (!isValidEnvVarName(key)) return 'Only letters, numbers, and underscores allowed' return undefined } @@ -377,7 +377,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment if (equalIndex === -1 || equalIndex === 0) return null const potentialKey = withoutExport.substring(0, equalIndex).trim() - if (!ENV_VAR_PATTERN.test(potentialKey)) return null + if (!isValidEnvVarName(potentialKey)) return null let value = withoutExport.substring(equalIndex + 1) diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index 34253bb7b9..f0e778f79c 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -22,8 +22,10 @@ import { getScheduleTimeValues, getSubBlockValue, } from '@/lib/workflows/schedules/utils' +import { REFERENCE } from '@/executor/constants' import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionResult } from '@/executor/types' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' import { mergeSubblockState } from '@/stores/workflows/server-utils' const logger = createLogger('TriggerScheduleExecution') @@ -128,17 +130,25 @@ async function ensureBlockVariablesResolvable( await Promise.all( Object.values(subBlocks).map(async (subBlock) => { const value = subBlock.value - if (typeof value !== 'string' || !value.includes('{{') || !value.includes('}}')) { + if ( + typeof value !== 'string' || + !value.includes(REFERENCE.ENV_VAR_START) || + !value.includes(REFERENCE.ENV_VAR_END) + ) { return } - const matches = value.match(/{{([^}]+)}}/g) + const envVarPattern = createEnvVarPattern() + const matches = value.match(envVarPattern) if (!matches) { return } for (const match of matches) { - const varName = match.slice(2, -2) + const varName = match.slice( + REFERENCE.ENV_VAR_START.length, + -REFERENCE.ENV_VAR_END.length + ) const encryptedValue = variables[varName] if (!encryptedValue) { throw new Error(`Environment variable "${varName}" was not found`) diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts index 99d0b5b164..ecf9e4ddf2 100644 --- a/apps/sim/executor/constants.ts +++ b/apps/sim/executor/constants.ts @@ -165,6 +165,10 @@ export const AGENT = { CUSTOM_TOOL_PREFIX: 'custom_', } as const +export const MCP = { + TOOL_PREFIX: 'mcp-', +} as const + export const MEMORY = { DEFAULT_SLIDING_WINDOW_SIZE: 10, DEFAULT_SLIDING_WINDOW_TOKENS: 4000, @@ -338,3 +342,60 @@ export function parseReferencePath(reference: string): string[] { const content = extractReferenceContent(reference) return content.split(REFERENCE.PATH_DELIMITER) } + +export const PATTERNS = { + UUID: /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i, + UUID_V4: /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + UUID_PREFIX: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i, + ENV_VAR_NAME: /^[A-Za-z_][A-Za-z0-9_]*$/, +} as const + +export function isUuid(value: string): boolean { + return PATTERNS.UUID.test(value) +} + +export function isUuidV4(value: string): boolean { + return PATTERNS.UUID_V4.test(value) +} + +export function startsWithUuid(value: string): boolean { + return PATTERNS.UUID_PREFIX.test(value) +} + +export function isValidEnvVarName(name: string): boolean { + return PATTERNS.ENV_VAR_NAME.test(name) +} + +export function sanitizeFileName(fileName: string): string { + return fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_') +} + +export function isCustomTool(toolId: string): boolean { + return toolId.startsWith(AGENT.CUSTOM_TOOL_PREFIX) +} + +export function isMcpTool(toolId: string): boolean { + return toolId.startsWith(MCP.TOOL_PREFIX) +} + +export function stripCustomToolPrefix(name: string): string { + return name.startsWith(AGENT.CUSTOM_TOOL_PREFIX) + ? name.slice(AGENT.CUSTOM_TOOL_PREFIX.length) + : name +} + +export function stripMcpToolPrefix(name: string): string { + return name.startsWith(MCP.TOOL_PREFIX) ? name.slice(MCP.TOOL_PREFIX.length) : name +} + +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Normalizes a name for comparison by converting to lowercase and removing spaces. + * Used for both block names and variable names to ensure consistent matching. + */ +export function normalizeName(name: string): string { + return name.toLowerCase().replace(/\s+/g, '') +} diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index e6e5e95e92..d0e2595333 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { getAllBlocks } from '@/blocks' -import { BlockType } from '@/executor/constants' +import { BlockType, isMcpTool } from '@/executor/constants' import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler' import type { ExecutionContext, StreamingExecution } from '@/executor/types' import { executeProviderRequest } from '@/providers' @@ -1384,7 +1384,7 @@ describe('AgentBlockHandler', () => { it('should handle MCP tools in agent execution', async () => { mockExecuteTool.mockImplementation((toolId, params, skipProxy, skipPostProcess, context) => { - if (toolId.startsWith('mcp-')) { + if (isMcpTool(toolId)) { return Promise.resolve({ success: true, output: { @@ -1660,7 +1660,7 @@ describe('AgentBlockHandler', () => { let capturedContext: any mockExecuteTool.mockImplementation((toolId, params, skipProxy, skipPostProcess, context) => { capturedContext = context - if (toolId.startsWith('mcp-')) { + if (isMcpTool(toolId)) { return Promise.resolve({ success: true, output: { content: [{ type: 'text', text: 'Success' }] }, diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 292a154f03..4f70ebdf09 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -6,7 +6,14 @@ import { createMcpToolId } from '@/lib/mcp/utils' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { getAllBlocks } from '@/blocks' import type { BlockOutput } from '@/blocks/types' -import { AGENT, BlockType, DEFAULTS, HTTP } from '@/executor/constants' +import { + AGENT, + BlockType, + DEFAULTS, + HTTP, + REFERENCE, + stripCustomToolPrefix, +} from '@/executor/constants' import { memoryService } from '@/executor/handlers/agent/memory' import type { AgentInputs, @@ -105,7 +112,7 @@ export class AgentBlockHandler implements BlockHandler { if (typeof responseFormat === 'string') { const trimmedValue = responseFormat.trim() - if (trimmedValue.startsWith('<') && trimmedValue.includes('>')) { + if (trimmedValue.startsWith(REFERENCE.START) && trimmedValue.includes(REFERENCE.END)) { return undefined } @@ -1337,7 +1344,7 @@ export class AgentBlockHandler implements BlockHandler { } private formatToolCall(tc: any) { - const toolName = this.stripCustomToolPrefix(tc.name) + const toolName = stripCustomToolPrefix(tc.name) return { ...tc, @@ -1349,10 +1356,4 @@ export class AgentBlockHandler implements BlockHandler { result: tc.result || tc.output, } } - - private stripCustomToolPrefix(name: string): string { - return name.startsWith(AGENT.CUSTOM_TOOL_PREFIX) - ? name.replace(AGENT.CUSTOM_TOOL_PREFIX, '') - : name - } } diff --git a/apps/sim/executor/handlers/condition/condition-handler.test.ts b/apps/sim/executor/handlers/condition/condition-handler.test.ts index abc4159482..b0e1c103a6 100644 --- a/apps/sim/executor/handlers/condition/condition-handler.test.ts +++ b/apps/sim/executor/handlers/condition/condition-handler.test.ts @@ -24,7 +24,7 @@ vi.mock('@/tools', () => ({ vi.mock('@/executor/utils/block-data', () => ({ collectBlockData: vi.fn(() => ({ blockData: { 'source-block-1': { value: 10, text: 'hello' } }, - blockNameMapping: { 'Source Block': 'source-block-1' }, + blockNameMapping: { sourceblock: 'source-block-1' }, })), })) @@ -200,7 +200,7 @@ describe('ConditionBlockHandler', () => { envVars: mockContext.environmentVariables, workflowVariables: mockContext.workflowVariables, blockData: { 'source-block-1': { value: 10, text: 'hello' } }, - blockNameMapping: { 'Source Block': 'source-block-1' }, + blockNameMapping: { sourceblock: 'source-block-1' }, _context: { workflowId: 'test-workflow-id', workspaceId: 'test-workspace-id', diff --git a/apps/sim/executor/handlers/generic/generic-handler.ts b/apps/sim/executor/handlers/generic/generic-handler.ts index c875ab1402..4d23721e35 100644 --- a/apps/sim/executor/handlers/generic/generic-handler.ts +++ b/apps/sim/executor/handlers/generic/generic-handler.ts @@ -1,5 +1,6 @@ import { createLogger } from '@/lib/logs/console/logger' import { getBlock } from '@/blocks/index' +import { isMcpTool } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' @@ -17,10 +18,10 @@ export class GenericBlockHandler implements BlockHandler { block: SerializedBlock, inputs: Record ): Promise { - const isMcpTool = block.config.tool?.startsWith('mcp-') + const isMcp = block.config.tool ? isMcpTool(block.config.tool) : false let tool = null - if (!isMcpTool) { + if (!isMcp) { tool = getTool(block.config.tool) if (!tool) { throw new Error(`Tool not found: ${block.config.tool}`) diff --git a/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts b/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts index b94a0a203c..5764f59fb9 100644 --- a/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts +++ b/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts @@ -7,7 +7,9 @@ import { buildResumeUiUrl, type FieldType, HTTP, + normalizeName, PAUSE_RESUME, + REFERENCE, } from '@/executor/constants' import { generatePauseContextId, @@ -16,7 +18,6 @@ import { import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types' import { collectBlockData } from '@/executor/utils/block-data' import type { SerializedBlock } from '@/serializer/types' -import { normalizeName } from '@/stores/workflows/utils' import { executeTool } from '@/tools' const logger = createLogger('HumanInTheLoopBlockHandler') @@ -477,7 +478,11 @@ export class HumanInTheLoopBlockHandler implements BlockHandler { } private isVariableReference(value: any): boolean { - return typeof value === 'string' && value.trim().startsWith('<') && value.trim().includes('>') + return ( + typeof value === 'string' && + value.trim().startsWith(REFERENCE.START) && + value.trim().includes(REFERENCE.END) + ) } private parseObjectStrings(data: any): any { @@ -590,7 +595,6 @@ export class HumanInTheLoopBlockHandler implements BlockHandler { blockDataWithPause[pauseBlockId] = pauseOutput if (pauseBlockName) { - blockNameMappingWithPause[pauseBlockName] = pauseBlockId blockNameMappingWithPause[normalizeName(pauseBlockName)] = pauseBlockId } diff --git a/apps/sim/executor/handlers/response/response-handler.ts b/apps/sim/executor/handlers/response/response-handler.ts index 1af1bd9f3c..94bcf35e4c 100644 --- a/apps/sim/executor/handlers/response/response-handler.ts +++ b/apps/sim/executor/handlers/response/response-handler.ts @@ -1,6 +1,6 @@ import { createLogger } from '@/lib/logs/console/logger' import type { BlockOutput } from '@/blocks/types' -import { BlockType, HTTP } from '@/executor/constants' +import { BlockType, HTTP, REFERENCE } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' @@ -220,7 +220,11 @@ export class ResponseBlockHandler implements BlockHandler { } private isVariableReference(value: any): boolean { - return typeof value === 'string' && value.trim().startsWith('<') && value.trim().includes('>') + return ( + typeof value === 'string' && + value.trim().startsWith(REFERENCE.START) && + value.trim().includes(REFERENCE.END) + ) } private parseObjectStrings(data: any): any { diff --git a/apps/sim/executor/utils/block-data.ts b/apps/sim/executor/utils/block-data.ts index 64ae2097a8..fc7b26ae32 100644 --- a/apps/sim/executor/utils/block-data.ts +++ b/apps/sim/executor/utils/block-data.ts @@ -1,3 +1,4 @@ +import { normalizeName } from '@/executor/constants' import type { ExecutionContext } from '@/executor/types' export interface BlockDataCollection { @@ -14,9 +15,7 @@ export function collectBlockData(ctx: ExecutionContext): BlockDataCollection { blockData[id] = state.output const workflowBlock = ctx.workflow?.blocks?.find((b) => b.id === id) if (workflowBlock?.metadata?.name) { - blockNameMapping[workflowBlock.metadata.name] = id - const normalized = workflowBlock.metadata.name.replace(/\s+/g, '').toLowerCase() - blockNameMapping[normalized] = id + blockNameMapping[normalizeName(workflowBlock.metadata.name)] = id } } } diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index bcf34d82e4..9080faab71 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -1,8 +1,8 @@ import { createLogger } from '@/lib/logs/console/logger' -import { BlockType, REFERENCE } from '@/executor/constants' +import { BlockType } from '@/executor/constants' import type { ExecutionState, LoopScope } from '@/executor/execution/state' import type { ExecutionContext } from '@/executor/types' -import { replaceValidReferences } from '@/executor/utils/reference-validation' +import { createEnvVarPattern, replaceValidReferences } from '@/executor/utils/reference-validation' import { BlockResolver } from '@/executor/variables/resolvers/block' import { EnvResolver } from '@/executor/variables/resolvers/env' import { LoopResolver } from '@/executor/variables/resolvers/loop' @@ -185,8 +185,7 @@ export class VariableResolver { throw replacementError } - const envRegex = new RegExp(`${REFERENCE.ENV_VAR_START}([^}]+)${REFERENCE.ENV_VAR_END}`, 'g') - result = result.replace(envRegex, (match) => { + result = result.replace(createEnvVarPattern(), (match) => { const resolved = this.resolveReference(match, resolutionContext) return typeof resolved === 'string' ? resolved : match }) @@ -236,8 +235,7 @@ export class VariableResolver { throw replacementError } - const envRegex = new RegExp(`${REFERENCE.ENV_VAR_START}([^}]+)${REFERENCE.ENV_VAR_END}`, 'g') - result = result.replace(envRegex, (match) => { + result = result.replace(createEnvVarPattern(), (match) => { const resolved = this.resolveReference(match, resolutionContext) return typeof resolved === 'string' ? resolved : match }) diff --git a/apps/sim/executor/variables/resolvers/block.ts b/apps/sim/executor/variables/resolvers/block.ts index 253498ed3a..5bd9fd6e67 100644 --- a/apps/sim/executor/variables/resolvers/block.ts +++ b/apps/sim/executor/variables/resolvers/block.ts @@ -1,22 +1,24 @@ -import { isReference, parseReferencePath, SPECIAL_REFERENCE_PREFIXES } from '@/executor/constants' +import { + isReference, + normalizeName, + parseReferencePath, + SPECIAL_REFERENCE_PREFIXES, +} from '@/executor/constants' import { navigatePath, type ResolutionContext, type Resolver, } from '@/executor/variables/resolvers/reference' import type { SerializedWorkflow } from '@/serializer/types' -import { normalizeName } from '@/stores/workflows/utils' export class BlockResolver implements Resolver { - private blockByNormalizedName: Map + private nameToBlockId: Map constructor(private workflow: SerializedWorkflow) { - this.blockByNormalizedName = new Map() + this.nameToBlockId = new Map() for (const block of workflow.blocks) { - this.blockByNormalizedName.set(block.id, block.id) if (block.metadata?.name) { - const normalized = normalizeName(block.metadata.name) - this.blockByNormalizedName.set(normalized, block.id) + this.nameToBlockId.set(normalizeName(block.metadata.name), block.id) } } } @@ -80,11 +82,7 @@ export class BlockResolver implements Resolver { } private findBlockIdByName(name: string): string | undefined { - if (this.blockByNormalizedName.has(name)) { - return this.blockByNormalizedName.get(name) - } - const normalized = normalizeName(name) - return this.blockByNormalizedName.get(normalized) + return this.nameToBlockId.get(normalizeName(name)) } public formatValueForBlock( diff --git a/apps/sim/executor/variables/resolvers/parallel.ts b/apps/sim/executor/variables/resolvers/parallel.ts index 78fca1f9f6..1f992a023b 100644 --- a/apps/sim/executor/variables/resolvers/parallel.ts +++ b/apps/sim/executor/variables/resolvers/parallel.ts @@ -117,7 +117,7 @@ export class ParallelResolver implements Resolver { // String handling if (typeof rawItems === 'string') { // Skip references - they should be resolved by the variable resolver - if (rawItems.startsWith('<')) { + if (rawItems.startsWith(REFERENCE.START)) { return [] } diff --git a/apps/sim/executor/variables/resolvers/workflow.ts b/apps/sim/executor/variables/resolvers/workflow.ts index 2e00912df1..c2acf26aaa 100644 --- a/apps/sim/executor/variables/resolvers/workflow.ts +++ b/apps/sim/executor/variables/resolvers/workflow.ts @@ -1,12 +1,11 @@ import { createLogger } from '@/lib/logs/console/logger' import { VariableManager } from '@/lib/workflows/variables/variable-manager' -import { isReference, parseReferencePath, REFERENCE } from '@/executor/constants' +import { isReference, normalizeName, parseReferencePath, REFERENCE } from '@/executor/constants' import { navigatePath, type ResolutionContext, type Resolver, } from '@/executor/variables/resolvers/reference' -import { normalizeName } from '@/stores/workflows/utils' const logger = createLogger('WorkflowResolver') diff --git a/apps/sim/lib/copilot/process-contents.ts b/apps/sim/lib/copilot/process-contents.ts index 3bf6cf564f..6c362a2d54 100644 --- a/apps/sim/lib/copilot/process-contents.ts +++ b/apps/sim/lib/copilot/process-contents.ts @@ -4,6 +4,7 @@ import { and, eq, isNull } from 'drizzle-orm' import { createLogger } from '@/lib/logs/console/logger' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' +import { escapeRegExp } from '@/executor/constants' import type { ChatContext } from '@/stores/panel/copilot/types' export type AgentContextType = @@ -153,10 +154,6 @@ export async function processContextsServer( return filtered } -function escapeRegExp(input: string): string { - return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - function sanitizeMessageForDocs(rawMessage: string, contexts: ChatContext[] | undefined): string { if (!rawMessage) return '' if (!Array.isArray(contexts) || contexts.length === 0) { diff --git a/apps/sim/lib/copilot/tools/client/workflow/block-output-utils.ts b/apps/sim/lib/copilot/tools/client/workflow/block-output-utils.ts index a30ca07a57..4916cb7700 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/block-output-utils.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/block-output-utils.ts @@ -4,10 +4,10 @@ import { } from '@/lib/core/utils/response-format' import { getBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs' import { getBlock } from '@/blocks' +import { normalizeName } from '@/executor/constants' import { useVariablesStore } from '@/stores/panel/variables/store' import type { Variable } from '@/stores/panel/variables/types' import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { normalizeName } from '@/stores/workflows/utils' import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' export interface WorkflowContext { diff --git a/apps/sim/lib/copilot/tools/client/workflow/get-block-outputs.ts b/apps/sim/lib/copilot/tools/client/workflow/get-block-outputs.ts index b27ebd402c..d99ecf94dc 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/get-block-outputs.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/get-block-outputs.ts @@ -16,8 +16,8 @@ import { type GetBlockOutputsResultType, } from '@/lib/copilot/tools/shared/schemas' import { createLogger } from '@/lib/logs/console/logger' +import { normalizeName } from '@/executor/constants' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { normalizeName } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('GetBlockOutputsClientTool') diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts index 909f3ee74f..d12c554a09 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -12,6 +12,7 @@ import { isValidKey } from '@/lib/workflows/sanitization/key-validation' import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' import { getAllBlocks, getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' +import { EDGE, normalizeName } from '@/executor/constants' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' @@ -55,6 +56,7 @@ type SkippedItemType = | 'invalid_subblock_field' | 'missing_required_params' | 'invalid_subflow_parent' + | 'duplicate_block_name' /** * Represents an item that was skipped during operation application @@ -80,6 +82,21 @@ function logSkippedItem(skippedItems: SkippedItem[], item: SkippedItem): void { skippedItems.push(item) } +/** + * Finds an existing block with the same normalized name. + */ +function findBlockWithDuplicateNormalizedName( + blocks: Record, + name: string, + excludeBlockId: string +): [string, any] | undefined { + const normalizedName = normalizeName(name) + return Object.entries(blocks).find( + ([blockId, block]: [string, any]) => + blockId !== excludeBlockId && normalizeName(block.name || '') === normalizedName + ) +} + /** * Result of input validation */ @@ -773,10 +790,10 @@ function validateSourceHandleForBlock( } case 'condition': { - if (!sourceHandle.startsWith('condition-')) { + if (!sourceHandle.startsWith(EDGE.CONDITION_PREFIX)) { return { valid: false, - error: `Invalid source handle "${sourceHandle}" for condition block. Must start with "condition-"`, + error: `Invalid source handle "${sourceHandle}" for condition block. Must start with "${EDGE.CONDITION_PREFIX}"`, } } @@ -792,12 +809,12 @@ function validateSourceHandleForBlock( } case 'router': - if (sourceHandle === 'source' || sourceHandle.startsWith('router-')) { + if (sourceHandle === 'source' || sourceHandle.startsWith(EDGE.ROUTER_PREFIX)) { return { valid: true } } return { valid: false, - error: `Invalid source handle "${sourceHandle}" for router block. Valid handles: source, router-{targetId}, error`, + error: `Invalid source handle "${sourceHandle}" for router block. Valid handles: source, ${EDGE.ROUTER_PREFIX}{targetId}, error`, } default: @@ -1387,7 +1404,39 @@ function applyOperationsToWorkflowState( block.type = params.type } } - if (params?.name !== undefined) block.name = params.name + if (params?.name !== undefined) { + if (!normalizeName(params.name)) { + logSkippedItem(skippedItems, { + type: 'missing_required_params', + operationType: 'edit', + blockId: block_id, + reason: `Cannot rename to empty name`, + details: { requestedName: params.name }, + }) + } else { + const conflictingBlock = findBlockWithDuplicateNormalizedName( + modifiedState.blocks, + params.name, + block_id + ) + + if (conflictingBlock) { + logSkippedItem(skippedItems, { + type: 'duplicate_block_name', + operationType: 'edit', + blockId: block_id, + reason: `Cannot rename to "${params.name}" - conflicts with "${conflictingBlock[1].name}"`, + details: { + requestedName: params.name, + conflictingBlockId: conflictingBlock[0], + conflictingBlockName: conflictingBlock[1].name, + }, + }) + } else { + block.name = params.name + } + } + } // Handle trigger mode toggle if (typeof params?.triggerMode === 'boolean') { @@ -1571,7 +1620,7 @@ function applyOperationsToWorkflowState( } case 'add': { - if (!params?.type || !params?.name) { + if (!params?.type || !params?.name || !normalizeName(params.name)) { logSkippedItem(skippedItems, { type: 'missing_required_params', operationType: 'add', @@ -1582,6 +1631,27 @@ function applyOperationsToWorkflowState( break } + const conflictingBlock = findBlockWithDuplicateNormalizedName( + modifiedState.blocks, + params.name, + block_id + ) + + if (conflictingBlock) { + logSkippedItem(skippedItems, { + type: 'duplicate_block_name', + operationType: 'add', + blockId: block_id, + reason: `Block name "${params.name}" conflicts with existing block "${conflictingBlock[1].name}"`, + details: { + requestedName: params.name, + conflictingBlockId: conflictingBlock[0], + conflictingBlockName: conflictingBlock[1].name, + }, + }) + break + } + // Special container types (loop, parallel) are not in the block registry but are valid const isContainerType = params.type === 'loop' || params.type === 'parallel' diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index 616751995d..4af48e331e 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -8,7 +8,6 @@ import { validateNumericId, validatePathSegment, validateUrlWithDNS, - validateUUID, } from '@/lib/core/security/input-validation' import { sanitizeForLogging } from '@/lib/core/security/redaction' @@ -194,54 +193,6 @@ describe('validatePathSegment', () => { }) }) -describe('validateUUID', () => { - describe('valid UUIDs', () => { - it.concurrent('should accept valid UUID v4', () => { - const result = validateUUID('550e8400-e29b-41d4-a716-446655440000') - expect(result.isValid).toBe(true) - }) - - it.concurrent('should accept UUID with uppercase letters', () => { - const result = validateUUID('550E8400-E29B-41D4-A716-446655440000') - expect(result.isValid).toBe(true) - expect(result.sanitized).toBe('550e8400-e29b-41d4-a716-446655440000') - }) - - it.concurrent('should normalize UUID to lowercase', () => { - const result = validateUUID('550E8400-E29B-41D4-A716-446655440000') - expect(result.sanitized).toBe('550e8400-e29b-41d4-a716-446655440000') - }) - }) - - describe('invalid UUIDs', () => { - it.concurrent('should reject non-UUID strings', () => { - const result = validateUUID('not-a-uuid') - expect(result.isValid).toBe(false) - expect(result.error).toContain('valid UUID') - }) - - it.concurrent('should reject UUID with wrong version', () => { - const result = validateUUID('550e8400-e29b-31d4-a716-446655440000') // version 3 - expect(result.isValid).toBe(false) - }) - - it.concurrent('should reject UUID with wrong variant', () => { - const result = validateUUID('550e8400-e29b-41d4-1716-446655440000') // wrong variant - expect(result.isValid).toBe(false) - }) - - it.concurrent('should reject empty string', () => { - const result = validateUUID('') - expect(result.isValid).toBe(false) - }) - - it.concurrent('should reject null', () => { - const result = validateUUID(null) - expect(result.isValid).toBe(false) - }) - }) -}) - describe('validateAlphanumericId', () => { it.concurrent('should accept alphanumeric IDs', () => { const result = validateAlphanumericId('user123') diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index e84c7f8f42..8fd18e533e 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -171,46 +171,6 @@ export function validatePathSegment( return { isValid: true, sanitized: value } } -/** - * Validates a UUID (v4 format) - * - * @param value - The UUID to validate - * @param paramName - Name of the parameter for error messages - * @returns ValidationResult - * - * @example - * ```typescript - * const result = validateUUID(workflowId, 'workflowId') - * if (!result.isValid) { - * return NextResponse.json({ error: result.error }, { status: 400 }) - * } - * ``` - */ -export function validateUUID( - value: string | null | undefined, - paramName = 'UUID' -): ValidationResult { - if (value === null || value === undefined || value === '') { - return { - isValid: false, - error: `${paramName} is required`, - } - } - - // UUID v4 pattern - const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - - if (!uuidPattern.test(value)) { - logger.warn('Invalid UUID format', { paramName, value: value.substring(0, 50) }) - return { - isValid: false, - error: `${paramName} must be a valid UUID`, - } - } - - return { isValid: true, sanitized: value.toLowerCase() } -} - /** * Validates an alphanumeric ID (letters, numbers, hyphens, underscores only) * diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts index 829f8cad63..c92507ecff 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts @@ -1,8 +1,6 @@ import { describe, expect, test } from 'vitest' -import { - buildTraceSpans, - stripCustomToolPrefix, -} from '@/lib/logs/execution/trace-spans/trace-spans' +import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' +import { stripCustomToolPrefix } from '@/executor/constants' import type { ExecutionResult } from '@/executor/types' describe('buildTraceSpans', () => { diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts index 0f35e1d8d3..da02077296 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts @@ -1,6 +1,6 @@ import { createLogger } from '@/lib/logs/console/logger' import type { ToolCall, TraceSpan } from '@/lib/logs/types' -import { isWorkflowBlockType } from '@/executor/constants' +import { isWorkflowBlockType, stripCustomToolPrefix } from '@/executor/constants' import type { ExecutionResult } from '@/executor/types' const logger = createLogger('TraceSpans') @@ -769,7 +769,3 @@ function ensureNestedWorkflowsProcessed(span: TraceSpan): TraceSpan { return processedSpan } - -export function stripCustomToolPrefix(name: string) { - return name.startsWith('custom_') ? name.replace('custom_', '') : name -} diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index 1e95dd7063..3626c04123 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -25,6 +25,8 @@ import type { McpTransport, } from '@/lib/mcp/types' import { MCP_CONSTANTS } from '@/lib/mcp/utils' +import { REFERENCE } from '@/executor/constants' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' const logger = createLogger('McpService') @@ -49,14 +51,17 @@ class McpService { * Resolve environment variables in strings */ private resolveEnvVars(value: string, envVars: Record): string { - const envMatches = value.match(/\{\{([^}]+)\}\}/g) + const envVarPattern = createEnvVarPattern() + const envMatches = value.match(envVarPattern) if (!envMatches) return value let resolvedValue = value const missingVars: string[] = [] for (const match of envMatches) { - const envKey = match.slice(2, -2).trim() + const envKey = match + .slice(REFERENCE.ENV_VAR_START.length, -REFERENCE.ENV_VAR_END.length) + .trim() const envValue = envVars[envKey] if (envValue === undefined) { diff --git a/apps/sim/lib/mcp/utils.ts b/apps/sim/lib/mcp/utils.ts index eee16742f3..1a446b675e 100644 --- a/apps/sim/lib/mcp/utils.ts +++ b/apps/sim/lib/mcp/utils.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' import type { McpApiResponse } from '@/lib/mcp/types' +import { isMcpTool, MCP } from '@/executor/constants' /** * MCP-specific constants @@ -124,7 +125,7 @@ export function categorizeError(error: unknown): { message: string; status: numb * Create standardized MCP tool ID from server ID and tool name */ export function createMcpToolId(serverId: string, toolName: string): string { - const normalizedServerId = serverId.startsWith('mcp-') ? serverId : `mcp-${serverId}` + const normalizedServerId = isMcpTool(serverId) ? serverId : `${MCP.TOOL_PREFIX}${serverId}` return `${normalizedServerId}-${toolName}` } diff --git a/apps/sim/lib/uploads/contexts/execution/utils.ts b/apps/sim/lib/uploads/contexts/execution/utils.ts index 9e36ec72e5..ab4b98aad3 100644 --- a/apps/sim/lib/uploads/contexts/execution/utils.ts +++ b/apps/sim/lib/uploads/contexts/execution/utils.ts @@ -1,3 +1,4 @@ +import { isUuid, sanitizeFileName } from '@/executor/constants' import type { UserFile } from '@/executor/types' /** @@ -15,7 +16,7 @@ export interface ExecutionContext { */ export function generateExecutionFileKey(context: ExecutionContext, fileName: string): string { const { workspaceId, workflowId, executionId } = context - const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_') + const safeFileName = sanitizeFileName(fileName) return `execution/${workspaceId}/${workflowId}/${executionId}/${safeFileName}` } @@ -26,17 +27,7 @@ export function generateFileId(): string { return `file_${Date.now()}_${Math.random().toString(36).substring(2, 9)}` } -/** - * UUID pattern for validating execution context IDs - */ -const UUID_PATTERN = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i - -/** - * Check if a string matches UUID pattern - */ -export function isUuid(str: string): boolean { - return UUID_PATTERN.test(str) -} +export { isUuid } /** * Check if a key matches execution file pattern diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index fc4cf1f29f..d4a0d17a07 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -19,6 +19,7 @@ import { uploadFile, } from '@/lib/uploads/core/storage-service' import { getFileMetadataByKey, insertFileMetadata } from '@/lib/uploads/server/metadata' +import { isUuid, sanitizeFileName } from '@/executor/constants' import type { UserFile } from '@/executor/types' const logger = createLogger('WorkspaceFileStorage') @@ -36,11 +37,6 @@ export interface WorkspaceFileRecord { uploadedAt: Date } -/** - * UUID pattern for validating workspace IDs - */ -const UUID_PATTERN = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i - /** * Workspace file key pattern: workspace/{workspaceId}/{timestamp}-{random}-{filename} */ @@ -73,7 +69,7 @@ export function parseWorkspaceFileKey(key: string): string | null { } const workspaceId = match[1] - return UUID_PATTERN.test(workspaceId) ? workspaceId : null + return isUuid(workspaceId) ? workspaceId : null } /** @@ -83,7 +79,7 @@ export function parseWorkspaceFileKey(key: string): string | null { export function generateWorkspaceFileKey(workspaceId: string, fileName: string): string { const timestamp = Date.now() const random = Math.random().toString(36).substring(2, 9) - const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_') + const safeFileName = sanitizeFileName(fileName) return `workspace/${workspaceId}/${timestamp}-${random}-${safeFileName}` } diff --git a/apps/sim/lib/uploads/providers/blob/client.ts b/apps/sim/lib/uploads/providers/blob/client.ts index 32751e2329..0b2cc89d10 100644 --- a/apps/sim/lib/uploads/providers/blob/client.ts +++ b/apps/sim/lib/uploads/providers/blob/client.ts @@ -8,6 +8,7 @@ import type { } from '@/lib/uploads/providers/blob/types' import type { FileInfo } from '@/lib/uploads/shared/types' import { sanitizeStorageMetadata } from '@/lib/uploads/utils/file-utils' +import { sanitizeFileName } from '@/executor/constants' type BlobServiceClientInstance = Awaited< ReturnType @@ -79,7 +80,7 @@ export async function uploadToBlob( shouldPreserveKey = preserveKey ?? false } - const safeFileName = fileName.replace(/\s+/g, '-') // Replace spaces with hyphens + const safeFileName = sanitizeFileName(fileName) const uniqueKey = shouldPreserveKey ? fileName : `${Date.now()}-${safeFileName}` const blobServiceClient = await getBlobServiceClient() @@ -357,7 +358,7 @@ export async function initiateMultipartUpload( containerName = BLOB_CONFIG.containerName } - const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_') + const safeFileName = sanitizeFileName(fileName) const { v4: uuidv4 } = await import('uuid') const uniqueKey = `kb/${uuidv4()}-${safeFileName}` diff --git a/apps/sim/lib/uploads/providers/s3/client.ts b/apps/sim/lib/uploads/providers/s3/client.ts index 800477b1a9..1a6e27e2eb 100644 --- a/apps/sim/lib/uploads/providers/s3/client.ts +++ b/apps/sim/lib/uploads/providers/s3/client.ts @@ -22,6 +22,7 @@ import { sanitizeFilenameForMetadata, sanitizeStorageMetadata, } from '@/lib/uploads/utils/file-utils' +import { sanitizeFileName } from '@/executor/constants' let _s3Client: S3Client | null = null @@ -84,7 +85,7 @@ export async function uploadToS3( shouldSkipTimestamp = skipTimestampPrefix ?? false } - const safeFileName = fileName.replace(/\s+/g, '-') // Replace spaces with hyphens + const safeFileName = sanitizeFileName(fileName) const uniqueKey = shouldSkipTimestamp ? fileName : `${Date.now()}-${safeFileName}` const s3Client = getS3Client() @@ -223,7 +224,7 @@ export async function initiateS3MultipartUpload( const config = customConfig || { bucket: S3_KB_CONFIG.bucket, region: S3_KB_CONFIG.region } const s3Client = getS3Client() - const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_') + const safeFileName = sanitizeFileName(fileName) const { v4: uuidv4 } = await import('uuid') const uniqueKey = `kb/${uuidv4()}-${safeFileName}` diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 69042f7ef2..623c1bf3eb 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -1,6 +1,7 @@ import type { Logger } from '@/lib/logs/console/logger' import type { StorageContext } from '@/lib/uploads' import { ACCEPTED_FILE_TYPES, SUPPORTED_DOCUMENT_EXTENSIONS } from '@/lib/uploads/utils/validation' +import { isUuid } from '@/executor/constants' import type { UserFile } from '@/executor/types' export interface FileAttachment { @@ -625,11 +626,9 @@ export function extractCleanFilename(urlOrPath: string): string { export function extractWorkspaceIdFromExecutionKey(key: string): string | null { const segments = key.split('/') - const UUID_PATTERN = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i - if (segments[0] === 'execution' && segments.length >= 5) { const workspaceId = segments[1] - if (workspaceId && UUID_PATTERN.test(workspaceId)) { + if (workspaceId && isUuid(workspaceId)) { return workspaceId } } diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 47a84cc33e..2873b59fd3 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -14,6 +14,8 @@ import { verifyProviderWebhook, } from '@/lib/webhooks/utils.server' import { executeWebhookJob } from '@/background/webhook-execution' +import { REFERENCE } from '@/executor/constants' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' const logger = createLogger('WebhookProcessor') @@ -170,12 +172,13 @@ export async function findWebhookAndWorkflow( * @returns String with all {{VARIABLE}} references replaced */ function resolveEnvVars(value: string, envVars: Record): string { - const envMatches = value.match(/\{\{([^}]+)\}\}/g) + const envVarPattern = createEnvVarPattern() + const envMatches = value.match(envVarPattern) if (!envMatches) return value let resolvedValue = value for (const match of envMatches) { - const envKey = match.slice(2, -2).trim() + const envKey = match.slice(REFERENCE.ENV_VAR_START.length, -REFERENCE.ENV_VAR_END.length).trim() const envValue = envVars[envKey] if (envValue !== undefined) { resolvedValue = resolvedValue.replaceAll(match, envValue) diff --git a/apps/sim/lib/workflows/autolayout/core.ts b/apps/sim/lib/workflows/autolayout/core.ts index 745b4865ef..1fde838212 100644 --- a/apps/sim/lib/workflows/autolayout/core.ts +++ b/apps/sim/lib/workflows/autolayout/core.ts @@ -11,6 +11,7 @@ import { prepareBlockMetrics, } from '@/lib/workflows/autolayout/utils' import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions' +import { EDGE } from '@/executor/constants' import type { BlockState } from '@/stores/workflows/workflow/types' const logger = createLogger('AutoLayout:Core') @@ -31,8 +32,8 @@ function getSourceHandleYOffset(block: BlockState, sourceHandle?: string | null) return HANDLE_POSITIONS.SUBFLOW_START_Y_OFFSET } - if (block.type === 'condition' && sourceHandle?.startsWith('condition-')) { - const conditionId = sourceHandle.replace('condition-', '') + if (block.type === 'condition' && sourceHandle?.startsWith(EDGE.CONDITION_PREFIX)) { + const conditionId = sourceHandle.replace(EDGE.CONDITION_PREFIX, '') try { const conditionsValue = block.subBlocks?.conditions?.value if (typeof conditionsValue === 'string' && conditionsValue) { diff --git a/apps/sim/lib/workflows/blocks/block-path-calculator.ts b/apps/sim/lib/workflows/blocks/block-path-calculator.ts index 939e474684..a67094480a 100644 --- a/apps/sim/lib/workflows/blocks/block-path-calculator.ts +++ b/apps/sim/lib/workflows/blocks/block-path-calculator.ts @@ -97,41 +97,4 @@ export class BlockPathCalculator { return accessibleMap } - - /** - * Gets accessible block names for a specific block (for error messages). - * - * @param blockId - The block ID to get accessible names for - * @param workflow - The serialized workflow - * @param accessibleBlocksMap - Pre-calculated accessible blocks map - * @returns Array of accessible block names and aliases - */ - static getAccessibleBlockNames( - blockId: string, - workflow: SerializedWorkflow, - accessibleBlocksMap: Map> - ): string[] { - const accessibleBlockIds = accessibleBlocksMap.get(blockId) || new Set() - const names: string[] = [] - - // Create a map of block IDs to blocks for efficient lookup - const blockById = new Map(workflow.blocks.map((block) => [block.id, block])) - - for (const accessibleBlockId of accessibleBlockIds) { - const block = blockById.get(accessibleBlockId) - if (block) { - // Add both the actual name and the normalized name - if (block.metadata?.name) { - names.push(block.metadata.name) - names.push(block.metadata.name.toLowerCase().replace(/\s+/g, '')) - } - names.push(accessibleBlockId) - } - } - - // Add special aliases - names.push('start') // Always allow start alias - - return [...new Set(names)] // Remove duplicates - } } diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 11dc2a1f4f..0607d01acc 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -16,8 +16,10 @@ import { import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { updateWorkflowRunCounts } from '@/lib/workflows/utils' import { Executor } from '@/executor' +import { REFERENCE } from '@/executor/constants' import type { ExecutionCallbacks, ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionResult } from '@/executor/types' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' import { Serializer } from '@/serializer' import { mergeSubblockState } from '@/stores/workflows/server-utils' @@ -190,11 +192,19 @@ export async function executeWorkflowCore( (subAcc, [key, subBlock]) => { let value = subBlock.value - if (typeof value === 'string' && value.includes('{{') && value.includes('}}')) { - const matches = value.match(/{{([^}]+)}}/g) + if ( + typeof value === 'string' && + value.includes(REFERENCE.ENV_VAR_START) && + value.includes(REFERENCE.ENV_VAR_END) + ) { + const envVarPattern = createEnvVarPattern() + const matches = value.match(envVarPattern) if (matches) { for (const match of matches) { - const varName = match.slice(2, -2) + const varName = match.slice( + REFERENCE.ENV_VAR_START.length, + -REFERENCE.ENV_VAR_END.length + ) const decryptedValue = decryptedEnvVars[varName] if (decryptedValue !== undefined) { value = (value as string).replace(match, decryptedValue) @@ -218,7 +228,7 @@ export async function executeWorkflowCore( (acc, [blockId, blockState]) => { if (blockState.responseFormat && typeof blockState.responseFormat === 'string') { const responseFormatValue = blockState.responseFormat.trim() - if (responseFormatValue && !responseFormatValue.startsWith('<')) { + if (responseFormatValue && !responseFormatValue.startsWith(REFERENCE.START)) { try { acc[blockId] = { ...blockState, diff --git a/apps/sim/lib/workflows/operations/deployment-utils.ts b/apps/sim/lib/workflows/operations/deployment-utils.ts index f46238bad0..b5fcc35143 100644 --- a/apps/sim/lib/workflows/operations/deployment-utils.ts +++ b/apps/sim/lib/workflows/operations/deployment-utils.ts @@ -1,5 +1,6 @@ import { createLogger } from '@/lib/logs/console/logger' import { resolveStartCandidates, StartBlockPath } from '@/lib/workflows/triggers/triggers' +import { normalizeName, startsWithUuid } from '@/executor/constants' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -74,13 +75,11 @@ export function getInputFormatExample( // Add streaming parameters if enabled and outputs are selected if (includeStreaming && selectedStreamingOutputs.length > 0) { exampleData.stream = true - // Convert blockId_attribute format to blockName.attribute format for display - const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i const convertedOutputs = selectedStreamingOutputs .map((outputId) => { // If it starts with a UUID, convert to blockName.attribute format - if (UUID_REGEX.test(outputId)) { + if (startsWithUuid(outputId)) { const underscoreIndex = outputId.indexOf('_') if (underscoreIndex === -1) return null @@ -90,9 +89,7 @@ export function getInputFormatExample( // Find the block by ID and get its name const block = blocks.find((b) => b.id === blockId) if (block?.name) { - // Normalize block name: lowercase and remove spaces - const normalizedBlockName = block.name.toLowerCase().replace(/\s+/g, '') - return `${normalizedBlockName}.${attribute}` + return `${normalizeName(block.name)}.${attribute}` } // Block not found (deleted), return null to filter out return null @@ -104,7 +101,7 @@ export function getInputFormatExample( const blockName = parts[0] // Check if a block with this name exists const block = blocks.find( - (b) => b.name?.toLowerCase().replace(/\s+/g, '') === blockName.toLowerCase() + (b) => b.name && normalizeName(b.name) === normalizeName(blockName) ) if (!block) { // Block not found (deleted), return null to filter out diff --git a/apps/sim/lib/workflows/sanitization/references.ts b/apps/sim/lib/workflows/sanitization/references.ts index 8dc978aecd..2290f150fe 100644 --- a/apps/sim/lib/workflows/sanitization/references.ts +++ b/apps/sim/lib/workflows/sanitization/references.ts @@ -1,4 +1,4 @@ -import { normalizeName } from '@/stores/workflows/utils' +import { normalizeName, REFERENCE } from '@/executor/constants' export const SYSTEM_REFERENCE_PREFIXES = new Set(['start', 'loop', 'parallel', 'variable']) @@ -9,11 +9,11 @@ const LEADING_REFERENCE_PATTERN = /^[<>=!\s]*$/ export function splitReferenceSegment( segment: string ): { leading: string; reference: string } | null { - if (!segment.startsWith('<') || !segment.endsWith('>')) { + if (!segment.startsWith(REFERENCE.START) || !segment.endsWith(REFERENCE.END)) { return null } - const lastOpenBracket = segment.lastIndexOf('<') + const lastOpenBracket = segment.lastIndexOf(REFERENCE.START) if (lastOpenBracket === -1) { return null } @@ -21,7 +21,7 @@ export function splitReferenceSegment( const leading = lastOpenBracket > 0 ? segment.slice(0, lastOpenBracket) : '' const reference = segment.slice(lastOpenBracket) - if (!reference.startsWith('<') || !reference.endsWith('>')) { + if (!reference.startsWith(REFERENCE.START) || !reference.endsWith(REFERENCE.END)) { return null } @@ -40,7 +40,7 @@ export function isLikelyReferenceSegment(segment: string): boolean { return false } - const inner = reference.slice(1, -1) + const inner = reference.slice(REFERENCE.START.length, -REFERENCE.END.length) if (!inner) { return false @@ -58,10 +58,10 @@ export function isLikelyReferenceSegment(segment: string): boolean { return false } - if (inner.includes('.')) { - const dotIndex = inner.indexOf('.') + if (inner.includes(REFERENCE.PATH_DELIMITER)) { + const dotIndex = inner.indexOf(REFERENCE.PATH_DELIMITER) const beforeDot = inner.substring(0, dotIndex) - const afterDot = inner.substring(dotIndex + 1) + const afterDot = inner.substring(dotIndex + REFERENCE.PATH_DELIMITER.length) if (afterDot.includes(' ')) { return false @@ -82,7 +82,8 @@ export function extractReferencePrefixes(value: string): Array<{ raw: string; pr return [] } - const matches = value.match(/<[^>]+>/g) + const referencePattern = new RegExp(`${REFERENCE.START}[^${REFERENCE.END}]+${REFERENCE.END}`, 'g') + const matches = value.match(referencePattern) if (!matches) { return [] } @@ -105,8 +106,8 @@ export function extractReferencePrefixes(value: string): Array<{ raw: string; pr continue } - const inner = referenceSegment.slice(1, -1) - const [rawPrefix] = inner.split('.') + const inner = referenceSegment.slice(REFERENCE.START.length, -REFERENCE.END.length) + const [rawPrefix] = inner.split(REFERENCE.PATH_DELIMITER) if (!rawPrefix) { continue } diff --git a/apps/sim/lib/workflows/sanitization/validation.ts b/apps/sim/lib/workflows/sanitization/validation.ts index 9423b0113a..7519383d48 100644 --- a/apps/sim/lib/workflows/sanitization/validation.ts +++ b/apps/sim/lib/workflows/sanitization/validation.ts @@ -1,5 +1,6 @@ import { createLogger } from '@/lib/logs/console/logger' import { getBlock } from '@/blocks/registry' +import { isCustomTool, isMcpTool } from '@/executor/constants' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { getTool } from '@/tools/utils' @@ -299,11 +300,7 @@ export function validateToolReference( ): string | null { if (!toolId) return null - // Check if it's a custom tool or MCP tool - const isCustomTool = toolId.startsWith('custom_') - const isMcpTool = toolId.startsWith('mcp-') - - if (!isCustomTool && !isMcpTool) { + if (!isCustomTool(toolId) && !isMcpTool(toolId)) { // For built-in tools, verify they exist const tool = getTool(toolId) if (!tool) { diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index 4c69e23c10..21036588ec 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -458,10 +458,6 @@ export function hasWorkflowChanged( return false } -export function stripCustomToolPrefix(name: string) { - return name.startsWith('custom_') ? name.replace('custom_', '') : name -} - export const workflowHasResponseBlock = (executionResult: ExecutionResult): boolean => { if ( !executionResult?.logs || diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 9344d39717..33485ac561 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -3,6 +3,7 @@ import type { CompletionUsage } from 'openai/resources/completions' import { getEnv, isTruthy } from '@/lib/core/config/env' import { isHosted } from '@/lib/core/config/feature-flags' import { createLogger, type Logger } from '@/lib/logs/console/logger' +import { isCustomTool } from '@/executor/constants' import { getComputerUseModels, getEmbeddingModelPricing, @@ -431,7 +432,7 @@ export async function transformBlockTool( let toolConfig: any - if (toolId.startsWith('custom_') && getToolAsync) { + if (isCustomTool(toolId) && getToolAsync) { toolConfig = await getToolAsync(toolId) } else { toolConfig = getTool(toolId) diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index 6500f7fb22..1e0425179e 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -3,6 +3,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' +import { REFERENCE } from '@/executor/constants' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' @@ -351,7 +352,7 @@ export class Serializer { const trimmedValue = responseFormat.trim() // Check for variable references like - if (trimmedValue.startsWith('<') && trimmedValue.includes('>')) { + if (trimmedValue.startsWith(REFERENCE.START) && trimmedValue.includes(REFERENCE.END)) { // Keep variable references as-is return trimmedValue } diff --git a/apps/sim/stores/variables/store.ts b/apps/sim/stores/variables/store.ts index aa3c54e01f..c4bcb89ff8 100644 --- a/apps/sim/stores/variables/store.ts +++ b/apps/sim/stores/variables/store.ts @@ -11,6 +11,7 @@ import type { } from '@/stores/variables/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { normalizeName } from '@/stores/workflows/utils' const logger = createLogger('VariablesModalStore') @@ -303,8 +304,8 @@ export const useVariablesStore = create()( Object.entries(workflowValues).forEach(([blockId, blockValues]) => { Object.entries(blockValues as Record).forEach( ([subBlockId, value]) => { - const oldVarName = oldVariableName.replace(/\s+/g, '').toLowerCase() - const newVarName = newName.replace(/\s+/g, '').toLowerCase() + const oldVarName = normalizeName(oldVariableName) + const newVarName = normalizeName(newName) const regex = new RegExp(``, 'gi') updatedWorkflowValues[blockId][subBlockId] = updateReferences( diff --git a/apps/sim/stores/workflows/subblock/utils.ts b/apps/sim/stores/workflows/subblock/utils.ts index 578ebe1417..b95b050d82 100644 --- a/apps/sim/stores/workflows/subblock/utils.ts +++ b/apps/sim/stores/workflows/subblock/utils.ts @@ -1,17 +1,7 @@ -// DEPRECATED: useEnvironmentStore import removed as autofill functions were removed - /** - * Checks if a value is an environment variable reference in the format {{ENV_VAR}} + * Re-exports env var utilities from executor constants for backward compatibility */ -export const isEnvVarReference = (value: string): boolean => { - // Check if the value looks like {{ENV_VAR}} - return /^\{\{[a-zA-Z0-9_-]+\}\}$/.test(value) -} - -/** - * Extracts the environment variable name from a reference like {{ENV_VAR}} - */ -export const extractEnvVarName = (value: string): string | null => { - if (!isEnvVarReference(value)) return null - return value.slice(2, -2) -} +export { + extractEnvVarName, + isEnvVarReference, +} from '@/executor/constants' diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index e7abd56031..a0da2e3e75 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -1,15 +1,8 @@ +import { normalizeName } from '@/executor/constants' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types' -/** - * Normalizes a name for comparison by converting to lowercase and removing spaces. - * Used for both block names and variable names to ensure consistent matching. - * @param name - The name to normalize - * @returns The normalized name - */ -export function normalizeName(name: string): string { - return name.toLowerCase().replace(/\s+/g, '') -} +export { normalizeName } /** * Generates a unique block name by finding the highest number suffix among existing blocks diff --git a/apps/sim/stores/workflows/workflow/store.test.ts b/apps/sim/stores/workflows/workflow/store.test.ts index 1f18f8110a..b8c5cd9ef8 100644 --- a/apps/sim/stores/workflows/workflow/store.test.ts +++ b/apps/sim/stores/workflows/workflow/store.test.ts @@ -594,18 +594,18 @@ describe('workflow store', () => { } ) - it.concurrent('should handle edge cases with empty or whitespace-only names', () => { + it.concurrent('should reject empty or whitespace-only names', () => { const { updateBlockName } = useWorkflowStore.getState() const result1 = updateBlockName('block1', '') - expect(result1.success).toBe(true) + expect(result1.success).toBe(false) const result2 = updateBlockName('block2', ' ') - expect(result2.success).toBe(true) + expect(result2.success).toBe(false) const state = useWorkflowStore.getState() - expect(state.blocks.block1.name).toBe('') - expect(state.blocks.block2.name).toBe(' ') + expect(state.blocks.block1.name).toBe('column ad') + expect(state.blocks.block2.name).toBe('Employee Length') }) it.concurrent('should return false when trying to rename a non-existent block', () => { diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index cc5f412927..9504bf7c48 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -671,23 +671,21 @@ export const useWorkflowStore = create()( const oldBlock = get().blocks[id] if (!oldBlock) return { success: false, changedSubblocks: [] } - // Check for normalized name collisions const normalizedNewName = normalizeName(name) - const currentBlocks = get().blocks - // Find any other block with the same normalized name - const conflictingBlock = Object.entries(currentBlocks).find(([blockId, block]) => { - return ( - blockId !== id && // Different block - block.name && // Has a name - normalizeName(block.name) === normalizedNewName // Same normalized name - ) - }) + if (!normalizedNewName) { + logger.error(`Cannot rename block to empty name`) + return { success: false, changedSubblocks: [] } + } + + const currentBlocks = get().blocks + const conflictingBlock = Object.entries(currentBlocks).find( + ([blockId, block]) => blockId !== id && normalizeName(block.name) === normalizedNewName + ) if (conflictingBlock) { - // Don't allow the rename - another block already uses this normalized name logger.error( - `Cannot rename block to "${name}" - another block "${conflictingBlock[1].name}" already uses the normalized name "${normalizedNewName}"` + `Cannot rename block to "${name}" - conflicts with "${conflictingBlock[1].name}"` ) return { success: false, changedSubblocks: [] } } @@ -723,8 +721,8 @@ export const useWorkflowStore = create()( // Loop through subblocks and update references Object.entries(blockValues).forEach(([subBlockId, value]) => { - const oldBlockName = oldBlock.name.replace(/\s+/g, '').toLowerCase() - const newBlockName = name.replace(/\s+/g, '').toLowerCase() + const oldBlockName = normalizeName(oldBlock.name) + const newBlockName = normalizeName(name) const regex = new RegExp(`<${oldBlockName}\\.`, 'g') // Use a recursive function to handle all object types diff --git a/apps/sim/stores/workflows/yaml/importer.ts b/apps/sim/stores/workflows/yaml/importer.ts index dc2a6b5fdf..fe9260a150 100644 --- a/apps/sim/stores/workflows/yaml/importer.ts +++ b/apps/sim/stores/workflows/yaml/importer.ts @@ -2,6 +2,7 @@ import { load as yamlParse } from 'js-yaml' import { v4 as uuidv4 } from 'uuid' import { createLogger } from '@/lib/logs/console/logger' import { getBlock } from '@/blocks' +import { normalizeName } from '@/executor/constants' import { type ConnectionsFormat, expandConditionInputs, @@ -172,6 +173,34 @@ function validateBlockTypes(yamlWorkflow: YamlWorkflow): { errors: string[]; war return { errors, warnings } } +/** + * Validates block names are non-empty and unique (by normalized name). + */ +function validateBlockNames(blocks: Record): string[] { + const errors: string[] = [] + const seen = new Map() + + for (const [blockId, block] of Object.entries(blocks)) { + const normalized = normalizeName(block.name) + + if (!normalized) { + errors.push(`Block "${blockId}" has empty name`) + continue + } + + const existingBlockId = seen.get(normalized) + if (existingBlockId) { + errors.push( + `Block "${blockId}" has same name as "${existingBlockId}" (normalized: "${normalized}")` + ) + } else { + seen.set(normalized, blockId) + } + } + + return errors +} + /** * Calculate positions for blocks based on their connections * Uses a simple layered approach similar to the auto-layout algorithm @@ -334,18 +363,19 @@ export function convertYamlToWorkflow(yamlWorkflow: YamlWorkflow): ImportResult errors.push(...typeErrors) warnings.push(...typeWarnings) + // Validate block names (non-empty and unique) + const nameErrors = validateBlockNames(yamlWorkflow.blocks) + errors.push(...nameErrors) + if (errors.length > 0) { return { blocks: [], edges: [], errors, warnings } } - // Calculate positions const positions = calculateBlockPositions(yamlWorkflow) - // Convert blocks Object.entries(yamlWorkflow.blocks).forEach(([blockId, yamlBlock]) => { const position = positions[blockId] || { x: 100, y: 100 } - // Expand condition inputs from clean format to internal format const processedInputs = yamlBlock.type === 'condition' ? expandConditionInputs(blockId, yamlBlock.inputs || {}) diff --git a/apps/sim/stores/workflows/yaml/parsing-utils.ts b/apps/sim/stores/workflows/yaml/parsing-utils.ts index db97b0cee6..a88e406e61 100644 --- a/apps/sim/stores/workflows/yaml/parsing-utils.ts +++ b/apps/sim/stores/workflows/yaml/parsing-utils.ts @@ -1,5 +1,6 @@ import { v4 as uuidv4 } from 'uuid' import { createLogger } from '@/lib/logs/console/logger' +import { EDGE } from '@/executor/constants' const logger = createLogger('YamlParsingUtils') @@ -110,7 +111,7 @@ export function generateBlockConnections( successTargets.push(edge.target) } else if (handle === 'error') { errorTargets.push(edge.target) - } else if (handle.startsWith('condition-')) { + } else if (handle.startsWith(EDGE.CONDITION_PREFIX)) { const rawConditionId = extractConditionId(handle) rawConditionIds.push(rawConditionId) @@ -671,22 +672,22 @@ function createConditionHandle(blockId: string, conditionId: string, blockType?: if (blockType === 'condition') { // Map semantic condition IDs to the internal format the system expects const actualConditionId = `${blockId}-${conditionId}` - return `condition-${actualConditionId}` + return `${EDGE.CONDITION_PREFIX}${actualConditionId}` } // For other blocks that might have conditions, use a more explicit format - return `condition-${blockId}-${conditionId}` + return `${EDGE.CONDITION_PREFIX}${blockId}-${conditionId}` } function extractConditionId(sourceHandle: string): string { // Extract condition ID from handle like "condition-blockId-semantic-key" // Example: "condition-e23e6318-bcdc-4572-a76b-5015e3950121-else-if-1752111795510" - if (!sourceHandle.startsWith('condition-')) { + if (!sourceHandle.startsWith(EDGE.CONDITION_PREFIX)) { return sourceHandle } - // Remove "condition-" prefix - const withoutPrefix = sourceHandle.substring('condition-'.length) + // Remove condition prefix + const withoutPrefix = sourceHandle.substring(EDGE.CONDITION_PREFIX.length) // Special case: check if this ends with "-else" (the auto-added else condition) if (withoutPrefix.endsWith('-else')) { diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index b4898a6860..930e4e1891 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -3,6 +3,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' import { parseMcpToolId } from '@/lib/mcp/utils' +import { isCustomTool, isMcpTool } from '@/executor/constants' import type { ExecutionContext } from '@/executor/types' import type { ErrorInfo } from '@/tools/error-extractors' import { extractErrorMessage } from '@/tools/error-extractors' @@ -210,13 +211,13 @@ export async function executeTool( const normalizedToolId = normalizeToolId(toolId) // If it's a custom tool, use the async version with workflowId - if (normalizedToolId.startsWith('custom_')) { + if (isCustomTool(normalizedToolId)) { const workflowId = params._context?.workflowId tool = await getToolAsync(normalizedToolId, workflowId) if (!tool) { logger.error(`[${requestId}] Custom tool not found: ${normalizedToolId}`) } - } else if (normalizedToolId.startsWith('mcp-')) { + } else if (isMcpTool(normalizedToolId)) { return await executeMcpTool( normalizedToolId, params, @@ -615,7 +616,7 @@ async function handleInternalRequest( const fullUrl = fullUrlObj.toString() - if (toolId.startsWith('custom_') && tool.request.body) { + if (isCustomTool(toolId) && tool.request.body) { const requestBody = tool.request.body(params) if ( typeof requestBody === 'object' && diff --git a/apps/sim/tools/reddit/get_comments.ts b/apps/sim/tools/reddit/get_comments.ts index 0450293180..63cf662776 100644 --- a/apps/sim/tools/reddit/get_comments.ts +++ b/apps/sim/tools/reddit/get_comments.ts @@ -1,4 +1,5 @@ import type { RedditCommentsParams, RedditCommentsResponse } from '@/tools/reddit/types' +import { normalizeSubreddit } from '@/tools/reddit/utils' import type { ToolConfig } from '@/tools/types' export const getCommentsTool: ToolConfig = { @@ -108,8 +109,7 @@ export const getCommentsTool: ToolConfig { - // Sanitize inputs - const subreddit = params.subreddit.trim().replace(/^r\//, '') + const subreddit = normalizeSubreddit(params.subreddit) const sort = params.sort || 'confidence' const limit = Math.min(Math.max(1, params.limit || 50), 100) diff --git a/apps/sim/tools/reddit/get_controversial.ts b/apps/sim/tools/reddit/get_controversial.ts index 9e69b37e90..8729fad64e 100644 --- a/apps/sim/tools/reddit/get_controversial.ts +++ b/apps/sim/tools/reddit/get_controversial.ts @@ -1,4 +1,5 @@ import type { RedditControversialParams, RedditPostsResponse } from '@/tools/reddit/types' +import { normalizeSubreddit } from '@/tools/reddit/utils' import type { ToolConfig } from '@/tools/types' export const getControversialTool: ToolConfig = { @@ -72,8 +73,7 @@ export const getControversialTool: ToolConfig { - // Sanitize inputs - const subreddit = params.subreddit.trim().replace(/^r\//, '') + const subreddit = normalizeSubreddit(params.subreddit) const limit = Math.min(Math.max(1, params.limit || 10), 100) // Build URL with appropriate parameters using OAuth endpoint diff --git a/apps/sim/tools/reddit/get_posts.ts b/apps/sim/tools/reddit/get_posts.ts index bece117bb0..36179b7413 100644 --- a/apps/sim/tools/reddit/get_posts.ts +++ b/apps/sim/tools/reddit/get_posts.ts @@ -1,4 +1,5 @@ import type { RedditPostsParams, RedditPostsResponse } from '@/tools/reddit/types' +import { normalizeSubreddit } from '@/tools/reddit/utils' import type { ToolConfig } from '@/tools/types' export const getPostsTool: ToolConfig = { @@ -78,8 +79,7 @@ export const getPostsTool: ToolConfig = request: { url: (params: RedditPostsParams) => { - // Sanitize inputs - const subreddit = params.subreddit.trim().replace(/^r\//, '') + const subreddit = normalizeSubreddit(params.subreddit) const sort = params.sort || 'hot' const limit = Math.min(Math.max(1, params.limit || 10), 100) diff --git a/apps/sim/tools/reddit/hot_posts.ts b/apps/sim/tools/reddit/hot_posts.ts index 9465f27398..79e27248a4 100644 --- a/apps/sim/tools/reddit/hot_posts.ts +++ b/apps/sim/tools/reddit/hot_posts.ts @@ -1,4 +1,5 @@ import type { RedditHotPostsResponse, RedditPost } from '@/tools/reddit/types' +import { normalizeSubreddit } from '@/tools/reddit/utils' import type { ToolConfig } from '@/tools/types' interface HotPostsParams { @@ -41,8 +42,7 @@ export const hotPostsTool: ToolConfig = request: { url: (params) => { - // Sanitize inputs and enforce limits - const subreddit = params.subreddit.trim().replace(/^r\//, '') + const subreddit = normalizeSubreddit(params.subreddit) const limit = Math.min(Math.max(1, params.limit || 10), 100) return `https://oauth.reddit.com/r/${subreddit}/hot?limit=${limit}&raw_json=1` diff --git a/apps/sim/tools/reddit/search.ts b/apps/sim/tools/reddit/search.ts index f5a53c1634..05f6c13617 100644 --- a/apps/sim/tools/reddit/search.ts +++ b/apps/sim/tools/reddit/search.ts @@ -1,4 +1,5 @@ import type { RedditPostsResponse, RedditSearchParams } from '@/tools/reddit/types' +import { normalizeSubreddit } from '@/tools/reddit/utils' import type { ToolConfig } from '@/tools/types' export const searchTool: ToolConfig = { @@ -85,8 +86,7 @@ export const searchTool: ToolConfig = { request: { url: (params: RedditSearchParams) => { - // Sanitize inputs - const subreddit = params.subreddit.trim().replace(/^r\//, '') + const subreddit = normalizeSubreddit(params.subreddit) const sort = params.sort || 'relevance' const limit = Math.min(Math.max(1, params.limit || 10), 100) const restrict_sr = params.restrict_sr !== false // Default to true diff --git a/apps/sim/tools/reddit/submit_post.ts b/apps/sim/tools/reddit/submit_post.ts index 718bb3a037..24cc71f581 100644 --- a/apps/sim/tools/reddit/submit_post.ts +++ b/apps/sim/tools/reddit/submit_post.ts @@ -1,4 +1,5 @@ import type { RedditSubmitParams, RedditWriteResponse } from '@/tools/reddit/types' +import { normalizeSubreddit } from '@/tools/reddit/utils' import type { ToolConfig } from '@/tools/types' export const submitPostTool: ToolConfig = { @@ -78,8 +79,7 @@ export const submitPostTool: ToolConfig } }, body: (params: RedditSubmitParams) => { - // Sanitize subreddit - const subreddit = params.subreddit.trim().replace(/^r\//, '') + const subreddit = normalizeSubreddit(params.subreddit) // Build form data const formData = new URLSearchParams({ diff --git a/apps/sim/tools/reddit/subscribe.ts b/apps/sim/tools/reddit/subscribe.ts index c6cad76f6b..f15c501fe4 100644 --- a/apps/sim/tools/reddit/subscribe.ts +++ b/apps/sim/tools/reddit/subscribe.ts @@ -1,4 +1,5 @@ import type { RedditSubscribeParams, RedditWriteResponse } from '@/tools/reddit/types' +import { normalizeSubreddit } from '@/tools/reddit/utils' import type { ToolConfig } from '@/tools/types' export const subscribeTool: ToolConfig = { @@ -53,8 +54,7 @@ export const subscribeTool: ToolConfig Date: Tue, 23 Dec 2025 15:20:10 -0800 Subject: [PATCH 10/18] fix(jina): removed conditionally included outputs from jina (#2559) * fix(jina): removed conditionally included outputs from jina * ack PR comments --- apps/docs/content/docs/de/tools/jina.mdx | 2 -- apps/docs/content/docs/en/tools/grain.mdx | 1 + apps/docs/content/docs/en/tools/jina.mdx | 2 -- apps/docs/content/docs/en/tools/translate.mdx | 1 + apps/docs/content/docs/es/tools/jina.mdx | 2 -- apps/docs/content/docs/fr/tools/jina.mdx | 2 -- apps/docs/content/docs/ja/tools/jina.mdx | 2 -- apps/docs/content/docs/zh/tools/jina.mdx | 2 -- apps/sim/blocks/blocks/jina.ts | 2 -- apps/sim/tools/jina/read_url.ts | 11 ----------- apps/sim/tools/jina/types.ts | 2 -- 11 files changed, 2 insertions(+), 27 deletions(-) diff --git a/apps/docs/content/docs/de/tools/jina.mdx b/apps/docs/content/docs/de/tools/jina.mdx index f06a45ec48..5cbe314dea 100644 --- a/apps/docs/content/docs/de/tools/jina.mdx +++ b/apps/docs/content/docs/de/tools/jina.mdx @@ -61,8 +61,6 @@ Extrahieren und verarbeiten Sie Webinhalte in sauberen, LLM-freundlichen Text mi | Parameter | Typ | Beschreibung | | --------- | ---- | ----------- | | `content` | string | Der extrahierte Inhalt von der URL, verarbeitet zu sauberem, LLM-freundlichem Text | -| `links` | array | Liste der auf der Seite gefundenen Links (wenn gatherLinks oder withLinksummary aktiviert ist) | -| `images` | array | Liste der auf der Seite gefundenen Bilder (wenn withImagesummary aktiviert ist) | ### `jina_search` diff --git a/apps/docs/content/docs/en/tools/grain.mdx b/apps/docs/content/docs/en/tools/grain.mdx index 845b3cb44d..cd30c96139 100644 --- a/apps/docs/content/docs/en/tools/grain.mdx +++ b/apps/docs/content/docs/en/tools/grain.mdx @@ -30,6 +30,7 @@ The Sim Grain integration empowers your agents to: Whether you want to automate follow-up actions, keep records of important conversations, or surface insights across your organization, Grain and Sim make it easy to connect meeting intelligence to your workflows. {/* MANUAL-CONTENT-END */} + ## Usage Instructions Integrate Grain into your workflow. Access meeting recordings, transcripts, highlights, and AI-generated summaries. Can also trigger workflows based on Grain webhook events. diff --git a/apps/docs/content/docs/en/tools/jina.mdx b/apps/docs/content/docs/en/tools/jina.mdx index 49db094f0d..ec20f9ff85 100644 --- a/apps/docs/content/docs/en/tools/jina.mdx +++ b/apps/docs/content/docs/en/tools/jina.mdx @@ -64,8 +64,6 @@ Extract and process web content into clean, LLM-friendly text using Jina AI Read | Parameter | Type | Description | | --------- | ---- | ----------- | | `content` | string | The extracted content from the URL, processed into clean, LLM-friendly text | -| `links` | array | List of links found on the page \(when gatherLinks or withLinksummary is enabled\) | -| `images` | array | List of images found on the page \(when withImagesummary is enabled\) | ### `jina_search` diff --git a/apps/docs/content/docs/en/tools/translate.mdx b/apps/docs/content/docs/en/tools/translate.mdx index 8d6c497781..790cc4d8bc 100644 --- a/apps/docs/content/docs/en/tools/translate.mdx +++ b/apps/docs/content/docs/en/tools/translate.mdx @@ -52,6 +52,7 @@ Send a chat completion request to any supported LLM provider | `azureApiVersion` | string | No | Azure OpenAI API version | | `vertexProject` | string | No | Google Cloud project ID for Vertex AI | | `vertexLocation` | string | No | Google Cloud location for Vertex AI \(defaults to us-central1\) | +| `vertexCredential` | string | No | Google Cloud OAuth credential ID for Vertex AI | #### Output diff --git a/apps/docs/content/docs/es/tools/jina.mdx b/apps/docs/content/docs/es/tools/jina.mdx index f61a4c3fbf..fafd9c150e 100644 --- a/apps/docs/content/docs/es/tools/jina.mdx +++ b/apps/docs/content/docs/es/tools/jina.mdx @@ -61,8 +61,6 @@ Extrae y procesa contenido web en texto limpio y compatible con LLM usando Jina | Parámetro | Tipo | Descripción | | --------- | ---- | ----------- | | `content` | string | El contenido extraído de la URL, procesado en texto limpio y compatible con LLM | -| `links` | array | Lista de enlaces encontrados en la página (cuando gatherLinks o withLinksummary está activado) | -| `images` | array | Lista de imágenes encontradas en la página (cuando withImagesummary está activado) | ### `jina_search` diff --git a/apps/docs/content/docs/fr/tools/jina.mdx b/apps/docs/content/docs/fr/tools/jina.mdx index c1eeea1155..2220e08715 100644 --- a/apps/docs/content/docs/fr/tools/jina.mdx +++ b/apps/docs/content/docs/fr/tools/jina.mdx @@ -61,8 +61,6 @@ Extrayez et traitez le contenu web en texte propre et adapté aux LLM avec Jina | Paramètre | Type | Description | | --------- | ---- | ----------- | | `content` | string | Le contenu extrait de l'URL, traité en texte propre et adapté aux LLM | -| `links` | array | Liste des liens trouvés sur la page (lorsque gatherLinks ou withLinksummary est activé) | -| `images` | array | Liste des images trouvées sur la page (lorsque withImagesummary est activé) | ### `jina_search` diff --git a/apps/docs/content/docs/ja/tools/jina.mdx b/apps/docs/content/docs/ja/tools/jina.mdx index b6fc52b21d..c498788f5a 100644 --- a/apps/docs/content/docs/ja/tools/jina.mdx +++ b/apps/docs/content/docs/ja/tools/jina.mdx @@ -61,8 +61,6 @@ Jina AI Readerを使用してウェブコンテンツを抽出し、LLMフレン | パラメータ | 型 | 説明 | | --------- | ---- | ----------- | | `content` | string | URLから抽出されたコンテンツで、クリーンでLLMフレンドリーなテキストに処理されたもの | -| `links` | array | ページで見つかったリンクのリスト(gatherLinksまたはwithLinksummaryが有効な場合) | -| `images` | array | ページで見つかった画像のリスト(withImagesummaryが有効な場合) | ### `jina_search` diff --git a/apps/docs/content/docs/zh/tools/jina.mdx b/apps/docs/content/docs/zh/tools/jina.mdx index fbac156cde..7865469cc0 100644 --- a/apps/docs/content/docs/zh/tools/jina.mdx +++ b/apps/docs/content/docs/zh/tools/jina.mdx @@ -61,8 +61,6 @@ Jina AI Reader 专注于从网页中提取最相关的内容,去除杂乱、 | 参数 | 类型 | 描述 | | --------- | ---- | ----------- | | `content` | 字符串 | 从 URL 提取的内容,处理为干净且适合 LLM 的文本 | -| `links` | 数组 | 页面中找到的链接列表(当启用 gatherLinks 或 withLinksummary 时) | -| `images` | 数组 | 页面中找到的图片列表(当启用 withImagesummary 时) | ### `jina_search` diff --git a/apps/sim/blocks/blocks/jina.ts b/apps/sim/blocks/blocks/jina.ts index 481d9599a9..27f50cc1cf 100644 --- a/apps/sim/blocks/blocks/jina.ts +++ b/apps/sim/blocks/blocks/jina.ts @@ -185,8 +185,6 @@ export const JinaBlock: BlockConfig = { outputs: { // Read URL outputs content: { type: 'string', description: 'Extracted content' }, - links: { type: 'array', description: 'List of links from page' }, - images: { type: 'array', description: 'List of images from page' }, // Search outputs results: { type: 'array', diff --git a/apps/sim/tools/jina/read_url.ts b/apps/sim/tools/jina/read_url.ts index bd8621808c..98e2c3b7db 100644 --- a/apps/sim/tools/jina/read_url.ts +++ b/apps/sim/tools/jina/read_url.ts @@ -168,8 +168,6 @@ export const readUrlTool: ToolConfig = { success: response.ok, output: { content: data.data?.content || data.content || JSON.stringify(data), - links: data.data?.links || undefined, - images: data.data?.images || undefined, }, } } @@ -188,14 +186,5 @@ export const readUrlTool: ToolConfig = { type: 'string', description: 'The extracted content from the URL, processed into clean, LLM-friendly text', }, - links: { - type: 'array', - description: - 'List of links found on the page (when gatherLinks or withLinksummary is enabled)', - }, - images: { - type: 'array', - description: 'List of images found on the page (when withImagesummary is enabled)', - }, }, } diff --git a/apps/sim/tools/jina/types.ts b/apps/sim/tools/jina/types.ts index e03490c06c..a63359b2da 100644 --- a/apps/sim/tools/jina/types.ts +++ b/apps/sim/tools/jina/types.ts @@ -25,8 +25,6 @@ export interface ReadUrlParams { export interface ReadUrlResponse extends ToolResponse { output: { content: string - links?: string[] - images?: string[] } } From 2d26c0cb3210c9b804155eb0522cc4fe29327f8e Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 23 Dec 2025 15:42:54 -0800 Subject: [PATCH 11/18] feat(i18n): update translations (#2561) Co-authored-by: waleedlatif1 --- apps/docs/content/docs/de/tools/translate.mdx | 5 +++-- apps/docs/content/docs/es/tools/translate.mdx | 7 ++++--- apps/docs/content/docs/fr/tools/translate.mdx | 1 + apps/docs/content/docs/ja/tools/jina.mdx | 2 +- apps/docs/content/docs/ja/tools/translate.mdx | 1 + apps/docs/content/docs/zh/tools/jina.mdx | 2 +- apps/docs/content/docs/zh/tools/translate.mdx | 3 ++- apps/docs/i18n.lock | 4 ++-- 8 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/docs/content/docs/de/tools/translate.mdx b/apps/docs/content/docs/de/tools/translate.mdx index 4bc7fe3697..5256f622bd 100644 --- a/apps/docs/content/docs/de/tools/translate.mdx +++ b/apps/docs/content/docs/de/tools/translate.mdx @@ -42,13 +42,14 @@ Senden Sie eine Chat-Completion-Anfrage an jeden unterstützten LLM-Anbieter | `model` | string | Ja | Das zu verwendende Modell \(z. B. gpt-4o, claude-sonnet-4-5, gemini-2.0-flash\) | | `systemPrompt` | string | Nein | System-Prompt zur Festlegung des Verhaltens des Assistenten | | `context` | string | Ja | Die Benutzernachricht oder der Kontext, der an das Modell gesendet werden soll | -| `apiKey` | string | Nein | API-Schlüssel für den Anbieter \(verwendet Plattform-Schlüssel, falls nicht für gehostete Modelle angegeben\) | -| `temperature` | number | Nein | Temperatur für die Antwortgenerierung \(0-2\) | +| `apiKey` | string | Nein | API-Schlüssel für den Anbieter \(verwendet den Plattformschlüssel, falls nicht für gehostete Modelle angegeben\) | +| `temperature` | number | Nein | Temperatur für die Antwortgenerierung \(0–2\) | | `maxTokens` | number | Nein | Maximale Anzahl von Tokens in der Antwort | | `azureEndpoint` | string | Nein | Azure OpenAI-Endpunkt-URL | | `azureApiVersion` | string | Nein | Azure OpenAI-API-Version | | `vertexProject` | string | Nein | Google Cloud-Projekt-ID für Vertex AI | | `vertexLocation` | string | Nein | Google Cloud-Standort für Vertex AI \(Standard: us-central1\) | +| `vertexCredential` | string | Nein | Google Cloud OAuth-Anmeldeinformations-ID für Vertex AI | #### Ausgabe diff --git a/apps/docs/content/docs/es/tools/translate.mdx b/apps/docs/content/docs/es/tools/translate.mdx index fb58d14ed4..74f6fe1c20 100644 --- a/apps/docs/content/docs/es/tools/translate.mdx +++ b/apps/docs/content/docs/es/tools/translate.mdx @@ -38,17 +38,18 @@ Envía una solicitud de completado de chat a cualquier proveedor de LLM compatib #### Entrada | Parámetro | Tipo | Requerido | Descripción | -| --------- | ---- | -------- | ----------- | -| `model` | string | Sí | El modelo a utilizar \(ej., gpt-4o, claude-sonnet-4-5, gemini-2.0-flash\) | +| --------- | ---- | --------- | ----------- | +| `model` | string | Sí | El modelo a utilizar \(por ejemplo, gpt-4o, claude-sonnet-4-5, gemini-2.0-flash\) | | `systemPrompt` | string | No | Prompt del sistema para establecer el comportamiento del asistente | | `context` | string | Sí | El mensaje del usuario o contexto a enviar al modelo | | `apiKey` | string | No | Clave API del proveedor \(usa la clave de la plataforma si no se proporciona para modelos alojados\) | | `temperature` | number | No | Temperatura para la generación de respuestas \(0-2\) | -| `maxTokens` | number | No | Tokens máximos en la respuesta | +| `maxTokens` | number | No | Máximo de tokens en la respuesta | | `azureEndpoint` | string | No | URL del endpoint de Azure OpenAI | | `azureApiVersion` | string | No | Versión de la API de Azure OpenAI | | `vertexProject` | string | No | ID del proyecto de Google Cloud para Vertex AI | | `vertexLocation` | string | No | Ubicación de Google Cloud para Vertex AI \(por defecto us-central1\) | +| `vertexCredential` | string | No | ID de credencial OAuth de Google Cloud para Vertex AI | #### Salida diff --git a/apps/docs/content/docs/fr/tools/translate.mdx b/apps/docs/content/docs/fr/tools/translate.mdx index 114e11bb7b..05beb94b52 100644 --- a/apps/docs/content/docs/fr/tools/translate.mdx +++ b/apps/docs/content/docs/fr/tools/translate.mdx @@ -49,6 +49,7 @@ Envoyez une requête de complétion de chat à n'importe quel fournisseur de LLM | `azureApiVersion` | string | Non | Version de l'API Azure OpenAI | | `vertexProject` | string | Non | ID du projet Google Cloud pour Vertex AI | | `vertexLocation` | string | Non | Emplacement Google Cloud pour Vertex AI \(par défaut us-central1\) | +| `vertexCredential` | string | Non | ID des identifiants OAuth Google Cloud pour Vertex AI | #### Sortie diff --git a/apps/docs/content/docs/ja/tools/jina.mdx b/apps/docs/content/docs/ja/tools/jina.mdx index c498788f5a..0bf6636fde 100644 --- a/apps/docs/content/docs/ja/tools/jina.mdx +++ b/apps/docs/content/docs/ja/tools/jina.mdx @@ -60,7 +60,7 @@ Jina AI Readerを使用してウェブコンテンツを抽出し、LLMフレン | パラメータ | 型 | 説明 | | --------- | ---- | ----------- | -| `content` | string | URLから抽出されたコンテンツで、クリーンでLLMフレンドリーなテキストに処理されたもの | +| `content` | string | URLから抽出されたコンテンツ。クリーンでLLMフレンドリーなテキストに処理されています | ### `jina_search` diff --git a/apps/docs/content/docs/ja/tools/translate.mdx b/apps/docs/content/docs/ja/tools/translate.mdx index b69b971941..96524c9f52 100644 --- a/apps/docs/content/docs/ja/tools/translate.mdx +++ b/apps/docs/content/docs/ja/tools/translate.mdx @@ -49,6 +49,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" | `azureApiVersion` | string | いいえ | Azure OpenAI APIバージョン | | `vertexProject` | string | いいえ | Vertex AI用のGoogle CloudプロジェクトID | | `vertexLocation` | string | いいえ | Vertex AI用のGoogle Cloudロケーション(デフォルトはus-central1) | +| `vertexCredential` | string | いいえ | Vertex AI用のGoogle Cloud OAuth認証情報ID | #### 出力 diff --git a/apps/docs/content/docs/zh/tools/jina.mdx b/apps/docs/content/docs/zh/tools/jina.mdx index 7865469cc0..14a76b180e 100644 --- a/apps/docs/content/docs/zh/tools/jina.mdx +++ b/apps/docs/content/docs/zh/tools/jina.mdx @@ -60,7 +60,7 @@ Jina AI Reader 专注于从网页中提取最相关的内容,去除杂乱、 | 参数 | 类型 | 描述 | | --------- | ---- | ----------- | -| `content` | 字符串 | 从 URL 提取的内容,处理为干净且适合 LLM 的文本 | +| `content` | 字符串 | 从 URL 提取的内容,已处理为简洁、适合 LLM 的文本 | ### `jina_search` diff --git a/apps/docs/content/docs/zh/tools/translate.mdx b/apps/docs/content/docs/zh/tools/translate.mdx index 5388110daa..98ddf6de92 100644 --- a/apps/docs/content/docs/zh/tools/translate.mdx +++ b/apps/docs/content/docs/zh/tools/translate.mdx @@ -39,7 +39,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" | 参数 | 类型 | 必填 | 说明 | | --------- | ---- | -------- | ----------- | -| `model` | string | 是 | 要使用的模型(例如 gpt-4o、claude-sonnet-4-5、gemini-2.0-flash) | +| `model` | string | 是 | 要使用的模型(例如,gpt-4o、claude-sonnet-4-5、gemini-2.0-flash) | | `systemPrompt` | string | 否 | 设置助手行为的 system prompt | | `context` | string | 是 | 发送给模型的用户消息或上下文 | | `apiKey` | string | 否 | 提供方的 API key(如未提供,托管模型将使用平台密钥) | @@ -49,6 +49,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" | `azureApiVersion` | string | 否 | Azure OpenAI API 版本 | | `vertexProject` | string | 否 | Vertex AI 的 Google Cloud 项目 ID | | `vertexLocation` | string | 否 | Vertex AI 的 Google Cloud 区域(默认为 us-central1) | +| `vertexCredential` | string | 否 | Vertex AI 的 Google Cloud OAuth 凭证 ID | #### 输出 diff --git a/apps/docs/i18n.lock b/apps/docs/i18n.lock index 84726da579..ea5b814ac3 100644 --- a/apps/docs/i18n.lock +++ b/apps/docs/i18n.lock @@ -557,7 +557,7 @@ checksums: content/8: 6325adefb6e1520835225285b18b6a45 content/9: b7fa85fce9c7476fe132df189e27dac1 content/10: 371d0e46b4bd2c23f559b8bc112f6955 - content/11: 7ad14ccfe548588081626cfe769ad492 + content/11: a34c59648e0f7218a8e9b72c333366fb content/12: bcadfc362b69078beee0088e5936c98b content/13: 6af66efd0da20944a87fdb8d9defa358 content/14: b3f310d5ef115bea5a8b75bf25d7ea9a @@ -2664,7 +2664,7 @@ checksums: content/12: 371d0e46b4bd2c23f559b8bc112f6955 content/13: 6ad8fcd98fc25eab726d05f9e9ccc6a4 content/14: bcadfc362b69078beee0088e5936c98b - content/15: 0ac8cd06fceaf16c960de79f7df987ee + content/15: 1eb58de69f18ba555d7f349fed365de5 content/16: c340d51e1b2d05b9b68a79baa8e9481a content/17: 64d5a97527775c7bfcdcbb418a10ea35 content/18: 371d0e46b4bd2c23f559b8bc112f6955 From eaca49037d5bcb13fb47471647b7fc9be10c7ed8 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 23 Dec 2025 15:46:27 -0800 Subject: [PATCH 12/18] fix(ui): remove css transition on popover and dropdown items to avoid flicker (#2563) --- apps/sim/components/emcn/components/popover/popover.tsx | 3 +-- apps/sim/components/ui/dropdown-menu.tsx | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/sim/components/emcn/components/popover/popover.tsx b/apps/sim/components/emcn/components/popover/popover.tsx index 71f6f02252..7e4d749269 100644 --- a/apps/sim/components/emcn/components/popover/popover.tsx +++ b/apps/sim/components/emcn/components/popover/popover.tsx @@ -57,10 +57,9 @@ import { cn } from '@/lib/core/utils/cn' /** * Shared base styles for all popover interactive items. * Ensures consistent height and styling across items, folders, and back button. - * Uses fast transitions (duration-75) to prevent hover state "jumping" during rapid mouse movement. */ const POPOVER_ITEM_BASE_CLASSES = - 'flex h-[25px] min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] text-[12px] transition-colors duration-75 dark:text-[var(--text-primary)] [&_svg]:transition-colors [&_svg]:duration-75' + 'flex h-[25px] min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] text-[12px] dark:text-[var(--text-primary)]' /** * Variant-specific active state styles for popover items. diff --git a/apps/sim/components/ui/dropdown-menu.tsx b/apps/sim/components/ui/dropdown-menu.tsx index 6cea410abe..4fe1f06edc 100644 --- a/apps/sim/components/ui/dropdown-menu.tsx +++ b/apps/sim/components/ui/dropdown-menu.tsx @@ -82,7 +82,7 @@ const DropdownMenuItem = React.forwardRef< Date: Tue, 23 Dec 2025 16:27:00 -0800 Subject: [PATCH 13/18] feat(ux): add expandFolder to auto expand folders on nested folder creation (#2562) Co-authored-by: Cursor Agent --- .../components/folder-item/folder-item.tsx | 27 +++++++++++-------- .../sidebar/hooks/use-folder-expand.ts | 10 ++++++- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index f29103dd51..087a346337 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -75,6 +75,16 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { getFolderIds: () => folder.id, }) + // Folder expand hook - must be declared before callbacks that use expandFolder + const { + isExpanded, + handleToggleExpanded, + expandFolder, + handleKeyDown: handleExpandKeyDown, + } = useFolderExpand({ + folderId: folder.id, + }) + /** * Handle create workflow in folder using React Query mutation. * Generates name and color upfront for optimistic UI updates. @@ -95,6 +105,8 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { if (result.id) { router.push(`/workspace/${workspaceId}/w/${result.id}`) + // Expand the parent folder so the new workflow is visible + expandFolder() // Scroll to the newly created workflow window.dispatchEvent( new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: result.id } }) @@ -104,7 +116,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { // Error already handled by mutation's onError callback logger.error('Failed to create workflow in folder:', error) } - }, [createWorkflowMutation, workspaceId, folder.id, router]) + }, [createWorkflowMutation, workspaceId, folder.id, router, expandFolder]) /** * Handle create sub-folder using React Query mutation. @@ -118,6 +130,8 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { parentId: folder.id, }) if (result.id) { + // Expand the parent folder so the new folder is visible + expandFolder() // Scroll to the newly created folder window.dispatchEvent( new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: result.id } }) @@ -126,16 +140,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { } catch (error) { logger.error('Failed to create folder:', error) } - }, [createFolderMutation, workspaceId, folder.id]) - - // Folder expand hook - const { - isExpanded, - handleToggleExpanded, - handleKeyDown: handleExpandKeyDown, - } = useFolderExpand({ - folderId: folder.id, - }) + }, [createFolderMutation, workspaceId, folder.id, expandFolder]) /** * Drag start handler - sets folder data for drag operation diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-expand.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-expand.ts index 413fcf4a44..5d2624fdf4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-expand.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-expand.ts @@ -13,7 +13,7 @@ interface UseFolderExpandProps { * @returns Expansion state and event handlers */ export function useFolderExpand({ folderId }: UseFolderExpandProps) { - const { expandedFolders, toggleExpanded } = useFolderStore() + const { expandedFolders, toggleExpanded, setExpanded } = useFolderStore() const isExpanded = expandedFolders.has(folderId) /** @@ -23,6 +23,13 @@ export function useFolderExpand({ folderId }: UseFolderExpandProps) { toggleExpanded(folderId) }, [folderId, toggleExpanded]) + /** + * Expand the folder (useful when creating items inside) + */ + const expandFolder = useCallback(() => { + setExpanded(folderId, true) + }, [folderId, setExpanded]) + /** * Handle keyboard navigation (Enter/Space) */ @@ -39,6 +46,7 @@ export function useFolderExpand({ folderId }: UseFolderExpandProps) { return { isExpanded, handleToggleExpanded, + expandFolder, handleKeyDown, } } From dc4e5d3bdcbd0588812be7babb9988a18c813f09 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 23 Dec 2025 16:32:58 -0800 Subject: [PATCH 14/18] fix(dropbox): access type param pass through to get refresh token (#2564) --- apps/sim/lib/auth/auth.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 30796dbdcd..30b6f4ef9f 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1437,6 +1437,9 @@ export const auth = betterAuth({ pkce: true, accessType: 'offline', prompt: 'consent', + authorizationUrlParams: { + token_access_type: 'offline', + }, getUserInfo: async (tokens) => { try { const response = await fetch( From 169dd4a5035d1e9392e75527950046f95c302b24 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 23 Dec 2025 17:23:12 -0800 Subject: [PATCH 15/18] fix(grafana): tool outputs (#2565) * fix(grafana): list annotations outputs * fix more grafana tools --- apps/sim/tools/grafana/list_annotations.ts | 58 ++++++++++++---------- apps/sim/tools/grafana/list_folders.ts | 2 - apps/sim/tools/grafana/types.ts | 7 --- 3 files changed, 31 insertions(+), 36 deletions(-) diff --git a/apps/sim/tools/grafana/list_annotations.ts b/apps/sim/tools/grafana/list_annotations.ts index c62cc7ce53..15d9935544 100644 --- a/apps/sim/tools/grafana/list_annotations.ts +++ b/apps/sim/tools/grafana/list_annotations.ts @@ -110,32 +110,27 @@ export const listAnnotationsTool: ToolConfig< transformResponse: async (response: Response) => { const data = await response.json() + // Handle potential nested array structure + const rawAnnotations = Array.isArray(data) ? data.flat() : [] + return { success: true, output: { - annotations: Array.isArray(data) - ? data.map((a: any) => ({ - id: a.id, - alertId: a.alertId, - alertName: a.alertName, - dashboardId: a.dashboardId, - dashboardUID: a.dashboardUID, - panelId: a.panelId, - userId: a.userId, - newState: a.newState, - prevState: a.prevState, - created: a.created, - updated: a.updated, - time: a.time, - timeEnd: a.timeEnd, - text: a.text, - tags: a.tags || [], - login: a.login, - email: a.email, - avatarUrl: a.avatarUrl, - data: a.data, - })) - : [], + annotations: rawAnnotations.map((a: any) => ({ + id: a.id, + dashboardId: a.dashboardId, + dashboardUID: a.dashboardUID, + created: a.created, + updated: a.updated, + time: a.time, + timeEnd: a.timeEnd, + text: a.text, + tags: a.tags || [], + login: a.login, + email: a.email, + avatarUrl: a.avatarUrl, + data: a.data || {}, + })), }, } }, @@ -148,12 +143,21 @@ export const listAnnotationsTool: ToolConfig< type: 'object', properties: { id: { type: 'number', description: 'Annotation ID' }, - text: { type: 'string', description: 'Annotation text' }, - tags: { type: 'array', description: 'Annotation tags' }, + dashboardId: { type: 'number', description: 'Dashboard ID' }, + dashboardUID: { type: 'string', description: 'Dashboard UID' }, + created: { type: 'number', description: 'Creation timestamp in epoch ms' }, + updated: { type: 'number', description: 'Last update timestamp in epoch ms' }, time: { type: 'number', description: 'Start time in epoch ms' }, timeEnd: { type: 'number', description: 'End time in epoch ms' }, - dashboardUID: { type: 'string', description: 'Dashboard UID' }, - panelId: { type: 'number', description: 'Panel ID' }, + text: { type: 'string', description: 'Annotation text' }, + tags: { type: 'array', items: { type: 'string' }, description: 'Annotation tags' }, + login: { type: 'string', description: 'Login of the user who created the annotation' }, + email: { type: 'string', description: 'Email of the user who created the annotation' }, + avatarUrl: { type: 'string', description: 'Avatar URL of the user' }, + data: { + type: 'json', + description: 'Additional annotation data object from Grafana', + }, }, }, }, diff --git a/apps/sim/tools/grafana/list_folders.ts b/apps/sim/tools/grafana/list_folders.ts index a65427a05e..eda321ce99 100644 --- a/apps/sim/tools/grafana/list_folders.ts +++ b/apps/sim/tools/grafana/list_folders.ts @@ -75,7 +75,6 @@ export const listFoldersTool: ToolConfig Date: Tue, 23 Dec 2025 18:27:19 -0800 Subject: [PATCH 16/18] improvement(logs): state machine of workflow execution (#2560) * improvement(logs): state machine of workflow execution * cleanup more code * fallback consistency * fix labels * backfill in migration correctly * make streaming stop in chat window correctly --- apps/sim/app/api/logs/route.ts | 3 + .../app/api/workflows/[id]/execute/route.ts | 2 +- .../components/log-details/log-details.tsx | 17 +- .../logs/components/logs-list/logs-list.tsx | 21 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 4 +- .../app/workspace/[workspaceId]/logs/utils.ts | 27 +- .../w/[workflowId]/components/chat/chat.tsx | 89 +- .../hooks/use-workflow-execution.ts | 56 +- apps/sim/background/schedule-execution.ts | 44 +- apps/sim/background/webhook-execution.ts | 18 +- apps/sim/background/workflow-execution.ts | 25 +- apps/sim/executor/execution/engine.ts | 30 +- apps/sim/executor/types.ts | 2 +- apps/sim/lib/logs/execution/logger.ts | 9 +- .../sim/lib/logs/execution/logging-session.ts | 118 +- .../lib/workflows/executor/execution-core.ts | 23 +- .../executor/human-in-the-loop-manager.ts | 27 +- apps/sim/stores/logs/filters/types.ts | 1 + .../db/migrations/0132_dazzling_leech.sql | 7 + .../db/migrations/meta/0132_snapshot.json | 8458 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 5 +- 22 files changed, 8874 insertions(+), 119 deletions(-) create mode 100644 packages/db/migrations/0132_dazzling_leech.sql create mode 100644 packages/db/migrations/meta/0132_snapshot.json diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index 90184d9cde..6f1811fd64 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -49,6 +49,7 @@ export async function GET(request: NextRequest) { stateSnapshotId: workflowExecutionLogs.stateSnapshotId, deploymentVersionId: workflowExecutionLogs.deploymentVersionId, level: workflowExecutionLogs.level, + status: workflowExecutionLogs.status, trigger: workflowExecutionLogs.trigger, startedAt: workflowExecutionLogs.startedAt, endedAt: workflowExecutionLogs.endedAt, @@ -78,6 +79,7 @@ export async function GET(request: NextRequest) { stateSnapshotId: workflowExecutionLogs.stateSnapshotId, deploymentVersionId: workflowExecutionLogs.deploymentVersionId, level: workflowExecutionLogs.level, + status: workflowExecutionLogs.status, trigger: workflowExecutionLogs.trigger, startedAt: workflowExecutionLogs.startedAt, endedAt: workflowExecutionLogs.endedAt, @@ -332,6 +334,7 @@ export async function GET(request: NextRequest) { deploymentVersion: log.deploymentVersion ?? null, deploymentVersionName: log.deploymentVersionName ?? null, level: log.level, + status: log.status, duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, trigger: log.trigger, createdAt: log.startedAt.toISOString(), diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index df35fc3cae..dd70158d38 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -713,7 +713,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: await PauseResumeManager.processQueuedResumes(executionId) } - if (result.error === 'Workflow execution was cancelled') { + if (result.status === 'cancelled') { logger.info(`[${requestId}] Workflow execution was cancelled`) sendEvent({ type: 'execution:cancelled', diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index c6ecea76d8..a69d48814e 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -7,8 +7,12 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import { FileCards, FrozenCanvas, TraceSpans } from '@/app/workspace/[workspaceId]/logs/components' import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks' -import type { LogStatus } from '@/app/workspace/[workspaceId]/logs/utils' -import { formatDate, StatusBadge, TriggerBadge } from '@/app/workspace/[workspaceId]/logs/utils' +import { + formatDate, + getDisplayStatus, + StatusBadge, + TriggerBadge, +} from '@/app/workspace/[workspaceId]/logs/utils' import { formatCost } from '@/providers/utils' import type { WorkflowLog } from '@/stores/logs/filters/types' import { useLogDetailsUIStore } from '@/stores/logs/store' @@ -100,14 +104,7 @@ export const LogDetails = memo(function LogDetails({ [log?.createdAt] ) - const logStatus: LogStatus = useMemo(() => { - if (!log) return 'info' - const baseLevel = (log.level || 'info').toLowerCase() - const isError = baseLevel === 'error' - const isPending = !isError && log.hasPendingPause === true - const isRunning = !isError && !isPending && log.duration === null - return isError ? 'error' : isPending ? 'pending' : isRunning ? 'running' : 'info' - }, [log]) + const logStatus = useMemo(() => getDisplayStatus(log?.status), [log?.status]) return ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx index 65d07744a6..391671ac75 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx @@ -6,8 +6,14 @@ import Link from 'next/link' import { List, type RowComponentProps, useListRef } from 'react-window' import { Badge, buttonVariants } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' +import { + formatDate, + formatDuration, + getDisplayStatus, + StatusBadge, + TriggerBadge, +} from '@/app/workspace/[workspaceId]/logs/utils' import type { WorkflowLog } from '@/stores/logs/filters/types' -import { formatDate, formatDuration, StatusBadge, TriggerBadge } from '../../utils' const LOG_ROW_HEIGHT = 44 as const @@ -25,10 +31,6 @@ interface LogRowProps { const LogRow = memo( function LogRow({ log, isSelected, onClick, selectedRowRef }: LogRowProps) { const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt]) - const baseLevel = (log.level || 'info').toLowerCase() - const isError = baseLevel === 'error' - const isPending = !isError && log.hasPendingPause === true - const isRunning = !isError && !isPending && log.duration === null const handleClick = useCallback(() => onClick(log), [onClick, log]) @@ -54,9 +56,7 @@ const LogRow = memo( {/* Status */}
- +
{/* Workflow */} @@ -93,7 +93,7 @@ const LogRow = memo(
{/* Resume Link */} - {isPending && log.executionId && (log.workflow?.id || log.workflowId) && ( + {log.status === 'pending' && log.executionId && (log.workflow?.id || log.workflowId) && ( { color: lightenColor(RUNNING_COLOR, 65), label: 'Running', }, + cancelled: { + bg: 'var(--terminal-status-info-bg)', + color: 'var(--terminal-status-info-color)', + label: 'Cancelled', + }, info: { bg: 'var(--terminal-status-info-bg)', color: 'var(--terminal-status-info-color)', @@ -271,6 +290,7 @@ export interface ExecutionLog { executionId: string startedAt: string level: string + status: string trigger: string triggerUserId: string | null triggerInputs?: unknown @@ -291,6 +311,7 @@ interface RawLogResponse extends LogWithDuration, LogWithExecutionData { endedAt?: string createdAt?: string level?: string + status?: string trigger?: string triggerUserId?: string | null error?: string @@ -331,6 +352,7 @@ export function mapToExecutionLog(log: RawLogResponse): ExecutionLog { executionId: log.executionId, startedAt, level: log.level || 'info', + status: log.status || 'completed', trigger: log.trigger || 'manual', triggerUserId: log.triggerUserId || null, triggerInputs: undefined, @@ -365,6 +387,7 @@ export function mapToExecutionLogAlt(log: RawLogResponse): ExecutionLog { executionId: log.executionId, startedAt: log.createdAt || log.startedAt || new Date().toISOString(), level: log.level || 'info', + status: log.status || 'completed', trigger: log.trigger || 'manual', triggerUserId: log.triggerUserId || null, triggerInputs: undefined, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 7c6236b2be..f40e29617e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -1,7 +1,15 @@ 'use client' import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { AlertCircle, ArrowDownToLine, ArrowUp, MoreVertical, Paperclip, X } from 'lucide-react' +import { + AlertCircle, + ArrowDownToLine, + ArrowUp, + MoreVertical, + Paperclip, + Square, + X, +} from 'lucide-react' import { Badge, Button, @@ -211,7 +219,7 @@ export function Chat() { const { entries } = useTerminalConsoleStore() const { isExecuting } = useExecutionStore() - const { handleRunWorkflow } = useWorkflowExecution() + const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() const { data: session } = useSession() const { addToQueue } = useOperationQueue() @@ -224,7 +232,7 @@ export function Chat() { // Refs const inputRef = useRef(null) const timeoutRef = useRef(null) - const abortControllerRef = useRef(null) + const streamReaderRef = useRef | null>(null) // File upload hook const { @@ -436,10 +444,28 @@ export function Chat() { useEffect(() => { return () => { timeoutRef.current && clearTimeout(timeoutRef.current) - abortControllerRef.current?.abort() + streamReaderRef.current?.cancel() } }, []) + // React to execution cancellation from run button + useEffect(() => { + if (!isExecuting && isStreaming) { + const lastMessage = workflowMessages[workflowMessages.length - 1] + if (lastMessage?.isStreaming) { + streamReaderRef.current?.cancel() + streamReaderRef.current = null + finalizeMessageStream(lastMessage.id) + } + } + }, [isExecuting, isStreaming, workflowMessages, finalizeMessageStream]) + + const handleStopStreaming = useCallback(() => { + streamReaderRef.current?.cancel() + streamReaderRef.current = null + handleCancelExecution() + }, [handleCancelExecution]) + /** * Processes streaming response from workflow execution * Reads the stream chunk by chunk and updates the message content in real-time @@ -449,6 +475,7 @@ export function Chat() { const processStreamingResponse = useCallback( async (stream: ReadableStream, responseMessageId: string) => { const reader = stream.getReader() + streamReaderRef.current = reader const decoder = new TextDecoder() let accumulatedContent = '' let buffer = '' @@ -509,8 +536,15 @@ export function Chat() { } } } catch (error) { - logger.error('Error processing stream:', error) + if ((error as Error)?.name !== 'AbortError') { + logger.error('Error processing stream:', error) + } + finalizeMessageStream(responseMessageId) } finally { + // Only clear ref if it's still our reader (prevents clobbering a new stream) + if (streamReaderRef.current === reader) { + streamReaderRef.current = null + } focusInput(100) } }, @@ -590,10 +624,6 @@ export function Chat() { } setHistoryIndex(-1) - // Reset abort controller - abortControllerRef.current?.abort() - abortControllerRef.current = new AbortController() - const conversationId = getConversationId(activeWorkflowId) try { @@ -1022,22 +1052,31 @@ export function Chat() { - + {isStreaming ? ( + + ) : ( + + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 58cdfc4cc7..4e33ffe78e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { v4 as uuidv4 } from 'uuid' import { createLogger } from '@/lib/logs/console/logger' @@ -111,6 +111,7 @@ export function useWorkflowExecution() { } = useExecutionStore() const [executionResult, setExecutionResult] = useState(null) const executionStream = useExecutionStream() + const currentChatExecutionIdRef = useRef(null) const isViewingDiff = useWorkflowDiffStore((state) => state.isShowingDiff) /** @@ -312,13 +313,25 @@ export function useWorkflowExecution() { // For chat executions, we'll use a streaming approach if (isChatExecution) { + let isCancelled = false + const executionId = uuidv4() + currentChatExecutionIdRef.current = executionId const stream = new ReadableStream({ async start(controller) { const { encodeSSE } = await import('@/lib/core/utils/sse') - const executionId = uuidv4() const streamedContent = new Map() const streamReadingPromises: Promise[] = [] + const safeEnqueue = (data: Uint8Array) => { + if (!isCancelled) { + try { + controller.enqueue(data) + } catch { + isCancelled = true + } + } + } + // Handle file uploads if present const uploadedFiles: any[] = [] interface UploadErrorCapableInput { @@ -432,7 +445,7 @@ export function useWorkflowExecution() { } } - controller.enqueue(encodeSSE({ blockId, chunk: chunkToSend })) + safeEnqueue(encodeSSE({ blockId, chunk: chunkToSend })) } } catch (error) { logger.error('Error reading from stream:', error) @@ -485,7 +498,7 @@ export function useWorkflowExecution() { const separator = streamedContent.size > 0 ? '\n\n' : '' // Send the non-streaming block output as a chunk - controller.enqueue(encodeSSE({ blockId, chunk: separator + formattedOutput })) + safeEnqueue(encodeSSE({ blockId, chunk: separator + formattedOutput })) // Track that we've sent output for this block streamedContent.set(blockId, formattedOutput) @@ -503,13 +516,8 @@ export function useWorkflowExecution() { ) // Check if execution was cancelled - if ( - result && - 'success' in result && - !result.success && - result.error === 'Workflow execution was cancelled' - ) { - controller.enqueue(encodeSSE({ event: 'cancelled', data: result })) + if (result && 'status' in result && result.status === 'cancelled') { + safeEnqueue(encodeSSE({ event: 'cancelled', data: result })) return } @@ -568,8 +576,7 @@ export function useWorkflowExecution() { queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() }) }, 1000) - const { encodeSSE } = await import('@/lib/core/utils/sse') - controller.enqueue(encodeSSE({ event: 'final', data: result })) + safeEnqueue(encodeSSE({ event: 'final', data: result })) // Note: Logs are already persisted server-side via execution-core.ts } } catch (error: any) { @@ -587,17 +594,23 @@ export function useWorkflowExecution() { } // Send the error as final event so downstream handlers can treat it uniformly - const { encodeSSE } = await import('@/lib/core/utils/sse') - controller.enqueue(encodeSSE({ event: 'final', data: errorResult })) + safeEnqueue(encodeSSE({ event: 'final', data: errorResult })) // Do not error the controller to allow consumers to process the final event } finally { - controller.close() - setIsExecuting(false) - setIsDebugging(false) - setActiveBlocks(new Set()) + if (!isCancelled) { + controller.close() + } + if (currentChatExecutionIdRef.current === executionId) { + setIsExecuting(false) + setIsDebugging(false) + setActiveBlocks(new Set()) + } } }, + cancel() { + isCancelled = true + }, }) return { success: true, stream } } @@ -1317,7 +1330,10 @@ export function useWorkflowExecution() { // Cancel the execution stream (server-side) executionStream.cancel() - // Reset execution state + // Mark current chat execution as superseded so its cleanup won't affect new executions + currentChatExecutionIdRef.current = null + + // Reset execution state - this triggers chat stream cleanup via useEffect in chat.tsx setIsExecuting(false) setIsDebugging(false) setActiveBlocks(new Set()) diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index f0e778f79c..31ccf4db3b 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -309,30 +309,22 @@ async function runWorkflowExecution({ } return { status: 'failure', blocks, executionResult } - } catch (earlyError) { - logger.error( - `[${requestId}] Early failure in scheduled workflow ${payload.workflowId}`, - earlyError - ) + } catch (error: unknown) { + logger.error(`[${requestId}] Early failure in scheduled workflow ${payload.workflowId}`, error) - try { - const executionResult = (earlyError as any)?.executionResult as ExecutionResult | undefined - const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] } - - await loggingSession.safeCompleteWithError({ - error: { - message: `Schedule execution failed: ${ - earlyError instanceof Error ? earlyError.message : String(earlyError) - }`, - stackTrace: earlyError instanceof Error ? earlyError.stack : undefined, - }, - traceSpans, - }) - } catch (loggingError) { - logger.error(`[${requestId}] Failed to complete log entry for schedule failure`, loggingError) - } + const errorWithResult = error as { executionResult?: ExecutionResult } + const executionResult = errorWithResult?.executionResult + const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] } - throw earlyError + await loggingSession.safeCompleteWithError({ + error: { + message: error instanceof Error ? error.message : String(error), + stackTrace: error instanceof Error ? error.stack : undefined, + }, + traceSpans, + }) + + throw error } } @@ -606,8 +598,10 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { `Error updating schedule ${payload.scheduleId} after failure`, `Updated schedule ${payload.scheduleId} after failure` ) - } catch (error: any) { - if (error?.message?.includes('Service overloaded')) { + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error) + + if (errorMessage.includes('Service overloaded')) { logger.warn(`[${requestId}] Service overloaded, retrying schedule in 5 minutes`) const retryDelay = 5 * 60 * 1000 @@ -652,7 +646,7 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { `Updated schedule ${payload.scheduleId} after execution error` ) } - } catch (error: any) { + } catch (error: unknown) { logger.error(`[${requestId}] Error processing schedule ${payload.scheduleId}`, error) } } diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index f0dc7b05bb..1b22920ad4 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -536,10 +536,13 @@ async function executeWebhookJobInternal( executedAt: new Date().toISOString(), provider: payload.provider, } - } catch (error: any) { + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error) + const errorStack = error instanceof Error ? error.stack : undefined + logger.error(`[${requestId}] Webhook execution failed`, { - error: error.message, - stack: error.stack, + error: errorMessage, + stack: errorStack, workflowId: payload.workflowId, provider: payload.provider, }) @@ -567,10 +570,11 @@ async function executeWebhookJobInternal( isTest: payload.testMode === true, executionTarget: payload.executionTarget || 'deployed', }, - deploymentVersionId, // Pass if available (undefined for early errors) + deploymentVersionId, }) - const executionResult = (error?.executionResult as ExecutionResult | undefined) || { + const errorWithResult = error as { executionResult?: ExecutionResult } + const executionResult = errorWithResult?.executionResult || { success: false, output: {}, logs: [], @@ -581,8 +585,8 @@ async function executeWebhookJobInternal( endedAt: new Date().toISOString(), totalDurationMs: 0, error: { - message: error.message || 'Webhook execution failed', - stackTrace: error.stack, + message: errorMessage || 'Webhook execution failed', + stackTrace: errorStack, }, traceSpans, }) diff --git a/apps/sim/background/workflow-execution.ts b/apps/sim/background/workflow-execution.ts index c86f562259..7472ff23f8 100644 --- a/apps/sim/background/workflow-execution.ts +++ b/apps/sim/background/workflow-execution.ts @@ -3,10 +3,12 @@ import { v4 as uuidv4 } from 'uuid' import { preprocessExecution } from '@/lib/execution/preprocessing' import { createLogger } from '@/lib/logs/console/logger' import { LoggingSession } from '@/lib/logs/execution/logging-session' +import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { getWorkflowById } from '@/lib/workflows/utils' import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot' +import type { ExecutionResult } from '@/executor/types' const logger = createLogger('TriggerWorkflowExecution') @@ -66,6 +68,12 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) { logger.info(`[${requestId}] Preprocessing passed. Using actor: ${actorUserId}`) + await loggingSession.safeStart({ + userId: actorUserId, + workspaceId, + variables: {}, + }) + const workflow = await getWorkflowById(workflowId) if (!workflow) { throw new Error(`Workflow ${workflowId} not found after preprocessing`) @@ -131,11 +139,24 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) { executedAt: new Date().toISOString(), metadata: payload.metadata, } - } catch (error: any) { + } catch (error: unknown) { logger.error(`[${requestId}] Workflow execution failed: ${workflowId}`, { - error: error.message, + error: error instanceof Error ? error.message : String(error), executionId, }) + + const errorWithResult = error as { executionResult?: ExecutionResult } + const executionResult = errorWithResult?.executionResult + const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] } + + await loggingSession.safeCompleteWithError({ + error: { + message: error instanceof Error ? error.message : String(error), + stackTrace: error instanceof Error ? error.stack : undefined, + }, + traceSpans, + }) + throw error } } diff --git a/apps/sim/executor/execution/engine.ts b/apps/sim/executor/execution/engine.ts index 10eb0114c0..bf33df5961 100644 --- a/apps/sim/executor/execution/engine.ts +++ b/apps/sim/executor/execution/engine.ts @@ -39,6 +39,9 @@ export class ExecutionEngine { this.initializeQueue(triggerBlockId) while (this.hasWork()) { + if (this.context.isCancelled && this.executing.size === 0) { + break + } await this.processQueue() } await this.waitForAllExecutions() @@ -51,6 +54,16 @@ export class ExecutionEngine { this.context.metadata.endTime = new Date(endTime).toISOString() this.context.metadata.duration = endTime - startTime + if (this.context.isCancelled) { + return { + success: false, + output: this.finalOutput, + logs: this.context.blockLogs, + metadata: this.context.metadata, + status: 'cancelled', + } + } + return { success: true, output: this.finalOutput, @@ -62,6 +75,16 @@ export class ExecutionEngine { this.context.metadata.endTime = new Date(endTime).toISOString() this.context.metadata.duration = endTime - startTime + if (this.context.isCancelled) { + return { + success: false, + output: this.finalOutput, + logs: this.context.blockLogs, + metadata: this.context.metadata, + status: 'cancelled', + } + } + const errorMessage = normalizeError(error) logger.error('Execution failed', { error: errorMessage }) @@ -73,8 +96,6 @@ export class ExecutionEngine { metadata: this.context.metadata, } - // Attach executionResult to the original error instead of creating a new one - // This preserves block error metadata (blockId, blockName, blockType, etc.) if (error && typeof error === 'object') { ;(error as any).executionResult = executionResult } @@ -213,6 +234,9 @@ export class ExecutionEngine { private async processQueue(): Promise { while (this.readyQueue.length > 0) { + if (this.context.isCancelled) { + break + } const nodeId = this.dequeue() if (!nodeId) continue const promise = this.executeNodeAsync(nodeId) @@ -227,8 +251,6 @@ export class ExecutionEngine { private async executeNodeAsync(nodeId: string): Promise { try { const wasAlreadyExecuted = this.context.executedBlocks.has(nodeId) - const node = this.dag.nodes.get(nodeId) - const result = await this.nodeOrchestrator.executeNode(this.context, nodeId) if (!wasAlreadyExecuted) { diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index f565fad55a..cdfdd2478b 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -235,7 +235,7 @@ export interface ExecutionResult { error?: string logs?: BlockLog[] metadata?: ExecutionMetadata - status?: 'completed' | 'paused' + status?: 'completed' | 'paused' | 'cancelled' pausePoints?: PausePoint[] snapshotSeed?: SerializedSnapshot _streamingMetadata?: { diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 962eff8195..465e474406 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -149,6 +149,7 @@ export class ExecutionLogger implements IExecutionLoggerService { stateSnapshotId: snapshotResult.snapshot.id, deploymentVersionId: deploymentVersionId ?? null, level: 'info', + status: 'running', trigger: trigger.type, startedAt: startTime, endedAt: null, @@ -206,8 +207,9 @@ export class ExecutionLogger implements IExecutionLoggerService { finalOutput: BlockOutputData traceSpans?: TraceSpan[] workflowInput?: any - isResume?: boolean // If true, merge with existing data instead of replacing - level?: 'info' | 'error' // Optional override for log level (used in cost-only fallback) + isResume?: boolean + level?: 'info' | 'error' + status?: 'completed' | 'failed' | 'cancelled' }): Promise { const { executionId, @@ -219,6 +221,7 @@ export class ExecutionLogger implements IExecutionLoggerService { workflowInput, isResume, level: levelOverride, + status: statusOverride, } = params logger.debug(`Completing workflow execution ${executionId}`, { isResume }) @@ -248,6 +251,7 @@ export class ExecutionLogger implements IExecutionLoggerService { }) const level = levelOverride ?? (hasErrors ? 'error' : 'info') + const status = statusOverride ?? (hasErrors ? 'failed' : 'completed') // Extract files from trace spans, final output, and workflow input const executionFiles = this.extractFilesFromExecution(traceSpans, finalOutput, workflowInput) @@ -309,6 +313,7 @@ export class ExecutionLogger implements IExecutionLoggerService { .update(workflowExecutionLogs) .set({ level, + status, endedAt: new Date(endedAt), totalDurationMs: actualTotalDuration, files: mergedFiles.length > 0 ? mergedFiles : null, diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index 1005954e22..9f2c59f5ac 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -44,6 +44,12 @@ export interface SessionErrorCompleteParams { traceSpans?: TraceSpan[] } +export interface SessionCancelledParams { + endedAt?: string + totalDurationMs?: number + traceSpans?: TraceSpan[] +} + export class LoggingSession { private workflowId: string private executionId: string @@ -52,7 +58,8 @@ export class LoggingSession { private trigger?: ExecutionTrigger private environment?: ExecutionEnvironment private workflowState?: WorkflowState - private isResume = false // Track if this is a resume execution + private isResume = false + private completed = false constructor( workflowId: string, @@ -127,6 +134,10 @@ export class LoggingSession { } async complete(params: SessionCompleteParams = {}): Promise { + if (this.completed) { + return + } + const { endedAt, totalDurationMs, finalOutput, traceSpans, workflowInput } = params try { @@ -145,6 +156,8 @@ export class LoggingSession { isResume: this.isResume, }) + this.completed = true + // Track workflow execution outcome if (traceSpans && traceSpans.length > 0) { try { @@ -194,6 +207,10 @@ export class LoggingSession { } async completeWithError(params: SessionErrorCompleteParams = {}): Promise { + if (this.completed) { + return + } + try { const { endedAt, totalDurationMs, error, traceSpans } = params @@ -242,6 +259,8 @@ export class LoggingSession { traceSpans: spans, }) + this.completed = true + // Track workflow execution error outcome try { const { trackPlatformEvent } = await import('@/lib/core/telemetry') @@ -277,6 +296,74 @@ export class LoggingSession { } } + async completeWithCancellation(params: SessionCancelledParams = {}): Promise { + if (this.completed) { + return + } + + try { + const { endedAt, totalDurationMs, traceSpans } = params + + const endTime = endedAt ? new Date(endedAt) : new Date() + const durationMs = typeof totalDurationMs === 'number' ? totalDurationMs : 0 + + const costSummary = traceSpans?.length + ? calculateCostSummary(traceSpans) + : { + totalCost: BASE_EXECUTION_CHARGE, + totalInputCost: 0, + totalOutputCost: 0, + totalTokens: 0, + totalPromptTokens: 0, + totalCompletionTokens: 0, + baseExecutionCharge: BASE_EXECUTION_CHARGE, + modelCost: 0, + models: {}, + } + + await executionLogger.completeWorkflowExecution({ + executionId: this.executionId, + endedAt: endTime.toISOString(), + totalDurationMs: Math.max(1, durationMs), + costSummary, + finalOutput: { cancelled: true }, + traceSpans: traceSpans || [], + status: 'cancelled', + }) + + this.completed = true + + try { + const { trackPlatformEvent } = await import('@/lib/core/telemetry') + trackPlatformEvent('platform.workflow.executed', { + 'workflow.id': this.workflowId, + 'execution.duration_ms': Math.max(1, durationMs), + 'execution.status': 'cancelled', + 'execution.trigger': this.triggerType, + 'execution.blocks_executed': traceSpans?.length || 0, + 'execution.has_errors': false, + }) + } catch (_e) { + // Silently fail + } + + if (this.requestId) { + logger.debug( + `[${this.requestId}] Completed cancelled logging for execution ${this.executionId}` + ) + } + } catch (cancelError) { + logger.error(`Failed to complete cancelled logging for execution ${this.executionId}:`, { + requestId: this.requestId, + workflowId: this.workflowId, + executionId: this.executionId, + error: cancelError instanceof Error ? cancelError.message : String(cancelError), + stack: cancelError instanceof Error ? cancelError.stack : undefined, + }) + throw cancelError + } + } + async safeStart(params: SessionStartParams): Promise { try { await this.start(params) @@ -368,6 +455,27 @@ export class LoggingSession { errorMessage: params?.error?.message || `Execution failed to store trace spans: ${errorMsg}`, isError: true, + status: 'failed', + }) + } + } + + async safeCompleteWithCancellation(params?: SessionCancelledParams): Promise { + try { + await this.completeWithCancellation(params) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.warn( + `[${this.requestId || 'unknown'}] CompleteWithCancellation failed for execution ${this.executionId}, attempting fallback`, + { error: errorMsg } + ) + await this.completeWithCostOnlyLog({ + traceSpans: params?.traceSpans, + endedAt: params?.endedAt, + totalDurationMs: params?.totalDurationMs, + errorMessage: 'Execution was cancelled', + isError: false, + status: 'cancelled', }) } } @@ -378,7 +486,12 @@ export class LoggingSession { totalDurationMs?: number errorMessage: string isError: boolean + status?: 'completed' | 'failed' | 'cancelled' }): Promise { + if (this.completed) { + return + } + logger.warn( `[${this.requestId || 'unknown'}] Logging completion failed for execution ${this.executionId} - attempting cost-only fallback` ) @@ -407,8 +520,11 @@ export class LoggingSession { traceSpans: [], isResume: this.isResume, level: params.isError ? 'error' : 'info', + status: params.status, }) + this.completed = true + logger.info( `[${this.requestId || 'unknown'}] Cost-only fallback succeeded for execution ${this.executionId}` ) diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 0607d01acc..26673e831b 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -366,7 +366,28 @@ export async function executeWorkflowCore( await updateWorkflowRunCounts(workflowId) } - // Complete logging session + if (result.status === 'cancelled') { + await loggingSession.safeCompleteWithCancellation({ + endedAt: new Date().toISOString(), + totalDurationMs: totalDuration || 0, + traceSpans: traceSpans || [], + }) + + logger.info(`[${requestId}] Workflow execution cancelled`, { + duration: result.metadata?.duration, + }) + + return result + } + + if (result.status === 'paused') { + logger.info(`[${requestId}] Workflow execution paused`, { + duration: result.metadata?.duration, + }) + + return result + } + await loggingSession.safeComplete({ endedAt: new Date().toISOString(), totalDurationMs: totalDuration || 0, diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index db30f81766..bc619bcf4b 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'crypto' import { db } from '@sim/db' -import { pausedExecutions, resumeQueue } from '@sim/db/schema' +import { pausedExecutions, resumeQueue, workflowExecutionLogs } from '@sim/db/schema' import { and, asc, desc, eq, inArray, lt, sql } from 'drizzle-orm' import type { Edge } from 'reactflow' import { preprocessExecution } from '@/lib/execution/preprocessing' @@ -155,6 +155,11 @@ export class PauseResumeManager { }, }) + await db + .update(workflowExecutionLogs) + .set({ status: 'pending' }) + .where(eq(workflowExecutionLogs.executionId, executionId)) + await PauseResumeManager.processQueuedResumes(executionId) } @@ -330,6 +335,7 @@ export class PauseResumeManager { await PauseResumeManager.markResumeFailed({ resumeEntryId, pausedExecutionId: pausedExecution.id, + parentExecutionId: pausedExecution.executionId, contextId, failureReason: (error as Error).message, }) @@ -352,6 +358,12 @@ export class PauseResumeManager { userId: string }): Promise { const { resumeExecutionId, pausedExecution, contextId, resumeInput, userId } = args + const parentExecutionId = pausedExecution.executionId + + await db + .update(workflowExecutionLogs) + .set({ status: 'running' }) + .where(eq(workflowExecutionLogs.executionId, parentExecutionId)) logger.info('Starting resume execution', { resumeExecutionId, @@ -667,7 +679,7 @@ export class PauseResumeManager { 'manual' const loggingSession = new LoggingSession( metadata.workflowId, - resumeExecutionId, + parentExecutionId, triggerType, metadata.requestId ) @@ -765,6 +777,11 @@ export class PauseResumeManager { .update(pausedExecutions) .set({ status: 'fully_resumed', updatedAt: now }) .where(eq(pausedExecutions.executionId, parentExecutionId)) + } else { + await tx + .update(workflowExecutionLogs) + .set({ status: 'pending' }) + .where(eq(workflowExecutionLogs.executionId, parentExecutionId)) } }) } @@ -772,6 +789,7 @@ export class PauseResumeManager { private static async markResumeFailed(args: { resumeEntryId: string pausedExecutionId: string + parentExecutionId: string contextId: string failureReason: string }): Promise { @@ -789,6 +807,11 @@ export class PauseResumeManager { pausePoints: sql`jsonb_set(pause_points, ARRAY[${args.contextId}, 'resumeStatus'], '"failed"'::jsonb)`, }) .where(eq(pausedExecutions.id, args.pausedExecutionId)) + + await tx + .update(workflowExecutionLogs) + .set({ status: 'failed' }) + .where(eq(workflowExecutionLogs.executionId, args.parentExecutionId)) }) } diff --git a/apps/sim/stores/logs/filters/types.ts b/apps/sim/stores/logs/filters/types.ts index 72f18e5ead..0df25ccec3 100644 --- a/apps/sim/stores/logs/filters/types.ts +++ b/apps/sim/stores/logs/filters/types.ts @@ -107,6 +107,7 @@ export interface WorkflowLog { deploymentVersion?: number | null deploymentVersionName?: string | null level: string + status?: string | null duration: string | null trigger: string | null createdAt: string diff --git a/packages/db/migrations/0132_dazzling_leech.sql b/packages/db/migrations/0132_dazzling_leech.sql new file mode 100644 index 0000000000..c0e1c69b25 --- /dev/null +++ b/packages/db/migrations/0132_dazzling_leech.sql @@ -0,0 +1,7 @@ +ALTER TABLE "workflow_execution_logs" ADD COLUMN "status" text DEFAULT 'running' NOT NULL;--> statement-breakpoint +UPDATE "workflow_execution_logs" +SET "status" = CASE + WHEN "level" = 'error' THEN 'failed' + WHEN "ended_at" IS NOT NULL THEN 'completed' + ELSE 'running' +END; \ No newline at end of file diff --git a/packages/db/migrations/meta/0132_snapshot.json b/packages/db/migrations/meta/0132_snapshot.json new file mode 100644 index 0000000000..7ef326d3ea --- /dev/null +++ b/packages/db/migrations/meta/0132_snapshot.json @@ -0,0 +1,8458 @@ +{ + "id": "bd32d6f2-9dce-4afc-b3a5-48e5121e7d5d", + "prevId": "e401ec45-3e59-45d8-8e6c-d3169c33c320", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_user_provider_account_unique": { + "name": "account_user_provider_account_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_namespace_unique": { + "name": "idempotency_key_namespace_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idempotency_key_namespace_idx": { + "name": "idempotency_key_namespace_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_idx": { + "name": "mcp_servers_workspace_deleted_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.template_creators": { + "name": "template_creators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reference_type": { + "name": "reference_type", + "type": "template_creator_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_creators_reference_idx": { + "name": "template_creators_reference_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_reference_id_idx": { + "name": "template_creators_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_created_by_idx": { + "name": "template_creators_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_creators_created_by_user_id_fk": { + "name": "template_creators_created_by_user_id_fk", + "tableFrom": "template_creators", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "template_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "required_credentials": { + "name": "required_credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "og_image_url": { + "name": "og_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "templates_status_idx": { + "name": "templates_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_creator_id_idx": { + "name": "templates_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_views_idx": { + "name": "templates_status_views_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_stars_idx": { + "name": "templates_status_stars_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "templates_creator_id_template_creators_id_fk": { + "name": "templates_creator_id_template_creators_id_fk", + "tableFrom": "templates", + "tableTo": "template_creators", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_super_user": { + "name": "is_super_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'20'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_idx": { + "name": "path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id": { + "name": "idx_webhook_on_workflow_id_block_id", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_block_id_workflow_blocks_id_fk": { + "name": "webhook_block_id_workflow_blocks_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_blocks", + "columnsFrom": ["block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_unique": { + "name": "workflow_schedule_workflow_block_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_block_id_workflow_blocks_id_fk": { + "name": "workflow_schedule_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_blocks", + "columnsFrom": ["block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_files_key_unique": { + "name": "workspace_files_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitation": { + "name": "workspace_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "workspace_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "org_invitation_id": { + "name": "org_invitation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invitation_workspace_id_workspace_id_fk": { + "name": "workspace_invitation_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitation_inviter_id_user_id_fk": { + "name": "workspace_invitation_inviter_id_user_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitation_token_unique": { + "name": "workspace_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.template_creator_type": { + "name": "template_creator_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.template_status": { + "name": "template_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": ["workflow", "wand", "copilot"] + }, + "public.workspace_invitation_status": { + "name": "workspace_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 576a8ac08a..ca04f6a15e 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -918,6 +918,13 @@ "when": 1766460889694, "tag": "0131_illegal_nova", "breakpoints": true + }, + { + "idx": 132, + "version": "7", + "when": 1766529613309, + "tag": "0132_dazzling_leech", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 0bf8ab3b83..8f29ac523a 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -304,8 +304,9 @@ export const workflowExecutionLogs = pgTable( { onDelete: 'set null' } ), - level: text('level').notNull(), // 'info', 'error' - trigger: text('trigger').notNull(), // 'api', 'webhook', 'schedule', 'manual', 'chat' + level: text('level').notNull(), // 'info' | 'error' + status: text('status').notNull().default('running'), // 'running' | 'pending' | 'completed' | 'failed' | 'cancelled' + trigger: text('trigger').notNull(), // 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' startedAt: timestamp('started_at').notNull(), endedAt: timestamp('ended_at'), From 810d2089cfa16ce4cdecc7467dc4e99a2007547a Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 23 Dec 2025 18:53:40 -0800 Subject: [PATCH 17/18] feat(schedules): remove save button for schedules, couple schedule deployment with workflow deployment (#2566) * feat(schedules): remove save button for schedules, couple schedule deployment with workflow deployment * added tests * ack PR comments * update turborepo * cleanup, edge cases * ack PR comment --- .../content/docs/en/triggers/schedule.mdx | 50 +- apps/docs/public/static/blocks/schedule-2.png | Bin 141766 -> 0 bytes apps/sim/app/api/schedules/[id]/route.test.ts | 652 +++++++++++++++ apps/sim/app/api/schedules/[id]/route.ts | 169 +--- .../api/schedules/[id]/status/route.test.ts | 143 ---- .../app/api/schedules/[id]/status/route.ts | 84 -- apps/sim/app/api/schedules/route.test.ts | 305 ++----- apps/sim/app/api/schedules/route.ts | 312 ------- .../app/api/workflows/[id]/deploy/route.ts | 48 +- .../sim/app/api/workflows/[id]/state/route.ts | 158 +--- .../components/deploy-modal/deploy-modal.tsx | 2 + .../components/deploy/hooks/use-deployment.ts | 120 ++- .../deploy/hooks/use-predeploy-checks.ts | 65 ++ .../components/sub-block/components/index.ts | 2 +- .../schedule-info/schedule-info.tsx | 194 +++++ .../schedule-save/schedule-save.tsx | 499 ----------- .../editor/components/sub-block/sub-block.tsx | 6 +- .../workflow-block/hooks/use-schedule-info.ts | 38 +- .../components/workflow-block/types.ts | 1 + .../workflow-block/workflow-block.tsx | 1 - apps/sim/background/schedule-execution.ts | 1 + apps/sim/blocks/blocks/schedule.ts | 11 +- apps/sim/blocks/types.ts | 2 +- apps/sim/hooks/use-schedule-management.ts | 231 ----- .../tools/server/workflow/edit-workflow.ts | 2 +- .../lib/workflows/schedules/deploy.test.ts | 786 ++++++++++++++++++ apps/sim/lib/workflows/schedules/deploy.ts | 131 +++ apps/sim/lib/workflows/schedules/index.ts | 23 + .../sim/lib/workflows/schedules/utils.test.ts | 393 +++++++++ apps/sim/lib/workflows/schedules/utils.ts | 2 +- .../sim/lib/workflows/schedules/validation.ts | 184 ++++ apps/sim/stores/constants.ts | 1 - apps/sim/stores/workflows/registry/store.ts | 16 - apps/sim/stores/workflows/subblock/store.ts | 2 - apps/sim/stores/workflows/subblock/types.ts | 2 - apps/sim/triggers/constants.ts | 3 +- bun.lock | 16 +- package.json | 2 +- 38 files changed, 2735 insertions(+), 1922 deletions(-) delete mode 100644 apps/docs/public/static/blocks/schedule-2.png create mode 100644 apps/sim/app/api/schedules/[id]/route.test.ts delete mode 100644 apps/sim/app/api/schedules/[id]/status/route.test.ts delete mode 100644 apps/sim/app/api/schedules/[id]/status/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-info/schedule-info.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-save/schedule-save.tsx delete mode 100644 apps/sim/hooks/use-schedule-management.ts create mode 100644 apps/sim/lib/workflows/schedules/deploy.test.ts create mode 100644 apps/sim/lib/workflows/schedules/deploy.ts create mode 100644 apps/sim/lib/workflows/schedules/index.ts create mode 100644 apps/sim/lib/workflows/schedules/validation.ts diff --git a/apps/docs/content/docs/en/triggers/schedule.mdx b/apps/docs/content/docs/en/triggers/schedule.mdx index 584589b681..bb7bfbaa80 100644 --- a/apps/docs/content/docs/en/triggers/schedule.mdx +++ b/apps/docs/content/docs/en/triggers/schedule.mdx @@ -5,7 +5,6 @@ title: Schedule import { Callout } from 'fumadocs-ui/components/callout' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { Image } from '@/components/ui/image' -import { Video } from '@/components/ui/video' The Schedule block automatically triggers workflows on a recurring schedule at specified intervals or times. @@ -21,16 +20,16 @@ The Schedule block automatically triggers workflows on a recurring schedule at s ## Schedule Options -Configure when your workflow runs using the dropdown options: +Configure when your workflow runs:
    -
  • Every few minutes: 5, 15, 30 minute intervals
  • -
  • Hourly: Every hour or every few hours
  • -
  • Daily: Once or multiple times per day
  • -
  • Weekly: Specific days of the week
  • -
  • Monthly: Specific days of the month
  • +
  • Every X Minutes: Run at minute intervals (1-1440)
  • +
  • Hourly: Run at a specific minute each hour
  • +
  • Daily: Run at a specific time each day
  • +
  • Weekly: Run on a specific day and time each week
  • +
  • Monthly: Run on a specific day and time each month
@@ -43,24 +42,25 @@ Configure when your workflow runs using the dropdown options:
-## Configuring Schedules +## Activation -When a workflow is scheduled: -- The schedule becomes **active** and shows the next execution time -- Click the **"Scheduled"** button to deactivate the schedule -- Schedules automatically deactivate after **3 consecutive failures** +Schedules are tied to workflow deployment: -
- Active Schedule Block -
+- **Deploy workflow** → Schedule becomes active and starts running +- **Undeploy workflow** → Schedule is removed +- **Redeploy workflow** → Schedule is recreated with current configuration + + +You must deploy your workflow for the schedule to start running. Configure the schedule block, then deploy from the toolbar. + -## Disabled Schedules +## Automatic Disabling + +Schedules automatically disable after **10 consecutive failures** to prevent runaway errors. When disabled: + +- A warning badge appears on the schedule block +- The schedule stops executing +- Click the badge to reactivate the schedule
-Disabled schedules show when they were last active. Click the **"Disabled"** badge to reactivate the schedule. - -Schedule blocks cannot receive incoming connections and serve as pure workflow triggers. - \ No newline at end of file +Schedule blocks cannot receive incoming connections and serve as workflow entry points only. + diff --git a/apps/docs/public/static/blocks/schedule-2.png b/apps/docs/public/static/blocks/schedule-2.png deleted file mode 100644 index c6b97881a68108cfeec83291e1c1b9104efd1994..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 141766 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 RTT8)@`B9snXr`_|{~HQ}RB!+Q diff --git a/apps/sim/app/api/schedules/[id]/route.test.ts b/apps/sim/app/api/schedules/[id]/route.test.ts new file mode 100644 index 0000000000..a24fb07a78 --- /dev/null +++ b/apps/sim/app/api/schedules/[id]/route.test.ts @@ -0,0 +1,652 @@ +/** + * Tests for schedule reactivate PUT API route + * + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockGetUserEntityPermissions, mockDbSelect, mockDbUpdate } = vi.hoisted( + () => ({ + mockGetSession: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockDbSelect: vi.fn(), + mockDbUpdate: vi.fn(), + }) +) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@sim/db', () => ({ + db: { + select: mockDbSelect, + update: mockDbUpdate, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' }, + workflowSchedule: { id: 'id', workflowId: 'workflowId', status: 'status' }, +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn(), +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: () => 'test-request-id', +})) + +vi.mock('@/lib/logs/console/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +import { PUT } from './route' + +function createRequest(body: Record): NextRequest { + return new NextRequest(new URL('http://test/api/schedules/sched-1'), { + method: 'PUT', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }) +} + +function createParams(id: string): { params: Promise<{ id: string }> } { + return { params: Promise.resolve({ id }) } +} + +function mockDbChain(selectResults: unknown[][]) { + let selectCallIndex = 0 + mockDbSelect.mockImplementation(() => ({ + from: () => ({ + where: () => ({ + limit: () => selectResults[selectCallIndex++] || [], + }), + }), + })) + + mockDbUpdate.mockImplementation(() => ({ + set: () => ({ + where: vi.fn().mockResolvedValue({}), + }), + })) +} + +describe('Schedule PUT API (Reactivate)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('write') + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('Authentication', () => { + it('returns 401 when user is not authenticated', async () => { + mockGetSession.mockResolvedValue(null) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(401) + const data = await res.json() + expect(data.error).toBe('Unauthorized') + }) + }) + + describe('Request Validation', () => { + it('returns 400 when action is not reactivate', async () => { + mockDbChain([ + [{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const res = await PUT(createRequest({ action: 'disable' }), createParams('sched-1')) + + expect(res.status).toBe(400) + const data = await res.json() + expect(data.error).toBe('Invalid request body') + }) + + it('returns 400 when action is missing', async () => { + mockDbChain([ + [{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const res = await PUT(createRequest({}), createParams('sched-1')) + + expect(res.status).toBe(400) + const data = await res.json() + expect(data.error).toBe('Invalid request body') + }) + }) + + describe('Schedule Not Found', () => { + it('returns 404 when schedule does not exist', async () => { + mockDbChain([[]]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-999')) + + expect(res.status).toBe(404) + const data = await res.json() + expect(data.error).toBe('Schedule not found') + }) + + it('returns 404 when workflow does not exist for schedule', async () => { + mockDbChain([[{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], []]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(404) + const data = await res.json() + expect(data.error).toBe('Workflow not found') + }) + }) + + describe('Authorization', () => { + it('returns 403 when user is not workflow owner', async () => { + mockDbChain([ + [{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], + [{ userId: 'other-user', workspaceId: null }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(403) + const data = await res.json() + expect(data.error).toBe('Not authorized to modify this schedule') + }) + + it('returns 403 for workspace member with only read permission', async () => { + mockGetUserEntityPermissions.mockResolvedValue('read') + mockDbChain([ + [{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], + [{ userId: 'other-user', workspaceId: 'ws-1' }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(403) + }) + + it('allows workflow owner to reactivate', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '*/5 * * * *', + timezone: 'UTC', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + expect(data.message).toBe('Schedule activated successfully') + }) + + it('allows workspace member with write permission to reactivate', async () => { + mockGetUserEntityPermissions.mockResolvedValue('write') + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '*/5 * * * *', + timezone: 'UTC', + }, + ], + [{ userId: 'other-user', workspaceId: 'ws-1' }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + }) + + it('allows workspace admin to reactivate', async () => { + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '*/5 * * * *', + timezone: 'UTC', + }, + ], + [{ userId: 'other-user', workspaceId: 'ws-1' }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + }) + }) + + describe('Schedule State Handling', () => { + it('returns success message when schedule is already active', async () => { + mockDbChain([ + [{ id: 'sched-1', workflowId: 'wf-1', status: 'active' }], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + expect(data.message).toBe('Schedule is already active') + expect(mockDbUpdate).not.toHaveBeenCalled() + }) + + it('successfully reactivates disabled schedule', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '*/5 * * * *', + timezone: 'UTC', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + expect(data.message).toBe('Schedule activated successfully') + expect(data.nextRunAt).toBeDefined() + expect(mockDbUpdate).toHaveBeenCalled() + }) + + it('returns 400 when schedule has no cron expression', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: null, + timezone: 'UTC', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(400) + const data = await res.json() + expect(data.error).toBe('Schedule has no cron expression') + }) + + it('returns 400 when schedule has invalid cron expression', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: 'invalid-cron', + timezone: 'UTC', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(400) + const data = await res.json() + expect(data.error).toBe('Schedule has invalid cron expression') + }) + + it('calculates nextRunAt from stored cron expression (every 5 minutes)', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '*/5 * * * *', + timezone: 'UTC', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + const afterCall = Date.now() + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt).getTime() + + // nextRunAt should be within 0-5 minutes in the future + expect(nextRunAt).toBeGreaterThan(beforeCall) + expect(nextRunAt).toBeLessThanOrEqual(afterCall + 5 * 60 * 1000 + 1000) + // Should align with 5-minute intervals (minute divisible by 5) + expect(new Date(nextRunAt).getMinutes() % 5).toBe(0) + }) + + it('calculates nextRunAt from daily cron expression', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '30 14 * * *', // 2:30 PM daily + timezone: 'UTC', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date at 14:30 UTC + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + expect(nextRunAt.getUTCHours()).toBe(14) + expect(nextRunAt.getUTCMinutes()).toBe(30) + expect(nextRunAt.getUTCSeconds()).toBe(0) + }) + + it('calculates nextRunAt from weekly cron expression', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '0 9 * * 1', // Monday at 9:00 AM + timezone: 'UTC', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date on Monday at 09:00 UTC + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + expect(nextRunAt.getUTCDay()).toBe(1) // Monday + expect(nextRunAt.getUTCHours()).toBe(9) + expect(nextRunAt.getUTCMinutes()).toBe(0) + }) + + it('calculates nextRunAt from monthly cron expression', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '0 10 15 * *', // 15th of month at 10:00 AM + timezone: 'UTC', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date on the 15th at 10:00 UTC + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + expect(nextRunAt.getUTCDate()).toBe(15) + expect(nextRunAt.getUTCHours()).toBe(10) + expect(nextRunAt.getUTCMinutes()).toBe(0) + }) + }) + + describe('Timezone Handling in Reactivation', () => { + it('calculates nextRunAt with America/New_York timezone', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '0 9 * * *', // 9:00 AM Eastern + timezone: 'America/New_York', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + // The exact UTC hour will depend on DST, but it should be 13:00 or 14:00 UTC + const utcHour = nextRunAt.getUTCHours() + expect([13, 14]).toContain(utcHour) // 9 AM ET = 1-2 PM UTC depending on DST + expect(nextRunAt.getUTCMinutes()).toBe(0) + }) + + it('calculates nextRunAt with Asia/Tokyo timezone', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '30 15 * * *', // 3:30 PM Japan Time + timezone: 'Asia/Tokyo', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + // 3:30 PM JST (UTC+9) = 6:30 AM UTC + expect(nextRunAt.getUTCHours()).toBe(6) + expect(nextRunAt.getUTCMinutes()).toBe(30) + }) + + it('calculates nextRunAt with Europe/London timezone', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '0 12 * * 5', // Friday at noon London time + timezone: 'Europe/London', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date on Friday + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + expect(nextRunAt.getUTCDay()).toBe(5) // Friday + // UTC hour depends on BST/GMT (11:00 or 12:00 UTC) + const utcHour = nextRunAt.getUTCHours() + expect([11, 12]).toContain(utcHour) + expect(nextRunAt.getUTCMinutes()).toBe(0) + }) + + it('uses UTC as default timezone when timezone is not set', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '0 10 * * *', // 10:00 AM + timezone: null, + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date at 10:00 UTC + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + expect(nextRunAt.getUTCHours()).toBe(10) + expect(nextRunAt.getUTCMinutes()).toBe(0) + }) + + it('handles minutely schedules with timezone correctly', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '*/10 * * * *', // Every 10 minutes + timezone: 'America/Los_Angeles', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date within the next 10 minutes + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + expect(nextRunAt.getTime()).toBeLessThanOrEqual(beforeCall + 10 * 60 * 1000 + 1000) + // Should align with 10-minute intervals + expect(nextRunAt.getMinutes() % 10).toBe(0) + }) + + it('handles hourly schedules with timezone correctly', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '15 * * * *', // At minute 15 of every hour + timezone: 'America/Chicago', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date at minute 15 + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + expect(nextRunAt.getMinutes()).toBe(15) + expect(nextRunAt.getSeconds()).toBe(0) + }) + + it('handles custom cron expressions with complex patterns and timezone', async () => { + mockDbChain([ + [ + { + id: 'sched-1', + workflowId: 'wf-1', + status: 'disabled', + cronExpression: '0 9 * * 1-5', // Weekdays at 9 AM + timezone: 'America/New_York', + }, + ], + [{ userId: 'user-1', workspaceId: null }], + ]) + + const beforeCall = Date.now() + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(200) + const data = await res.json() + const nextRunAt = new Date(data.nextRunAt) + + // Should be a future date on a weekday (1-5) + expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) + const dayOfWeek = nextRunAt.getUTCDay() + expect([1, 2, 3, 4, 5]).toContain(dayOfWeek) + }) + }) + + describe('Error Handling', () => { + it('returns 500 when database operation fails', async () => { + mockDbSelect.mockImplementation(() => { + throw new Error('Database connection failed') + }) + + const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1')) + + expect(res.status).toBe(500) + const data = await res.json() + expect(data.error).toBe('Failed to update schedule') + }) + }) +}) diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index cd50005178..c3aa491e00 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -6,104 +6,26 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' +import { validateCronExpression } from '@/lib/workflows/schedules/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('ScheduleAPI') export const dynamic = 'force-dynamic' -const scheduleActionEnum = z.enum(['reactivate', 'disable']) -const scheduleStatusEnum = z.enum(['active', 'disabled']) - -const scheduleUpdateSchema = z - .object({ - action: scheduleActionEnum.optional(), - status: scheduleStatusEnum.optional(), - }) - .refine((data) => data.action || data.status, { - message: 'Either action or status must be provided', - }) - -/** - * Delete a schedule - */ -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - - try { - const { id } = await params - logger.debug(`[${requestId}] Deleting schedule with ID: ${id}`) - - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized schedule deletion attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Find the schedule and check ownership - const schedules = await db - .select({ - schedule: workflowSchedule, - workflow: { - id: workflow.id, - userId: workflow.userId, - workspaceId: workflow.workspaceId, - }, - }) - .from(workflowSchedule) - .innerJoin(workflow, eq(workflowSchedule.workflowId, workflow.id)) - .where(eq(workflowSchedule.id, id)) - .limit(1) - - if (schedules.length === 0) { - logger.warn(`[${requestId}] Schedule not found: ${id}`) - return NextResponse.json({ error: 'Schedule not found' }, { status: 404 }) - } - - const workflowRecord = schedules[0].workflow - - // Check authorization - either the user owns the workflow or has write/admin workspace permissions - let isAuthorized = workflowRecord.userId === session.user.id - - // If not authorized by ownership and the workflow belongs to a workspace, check workspace permissions - if (!isAuthorized && workflowRecord.workspaceId) { - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workflowRecord.workspaceId - ) - isAuthorized = userPermission === 'write' || userPermission === 'admin' - } - - if (!isAuthorized) { - logger.warn(`[${requestId}] Unauthorized schedule deletion attempt for schedule: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) - } - - // Delete the schedule - await db.delete(workflowSchedule).where(eq(workflowSchedule.id, id)) - - logger.info(`[${requestId}] Successfully deleted schedule: ${id}`) - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error deleting schedule`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} +const scheduleUpdateSchema = z.object({ + action: z.literal('reactivate'), +}) /** - * Update a schedule - can be used to reactivate a disabled schedule + * Reactivate a disabled schedule */ export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() try { - const { id } = await params - const scheduleId = id - logger.debug(`[${requestId}] Updating schedule with ID: ${scheduleId}`) + const { id: scheduleId } = await params + logger.debug(`[${requestId}] Reactivating schedule with ID: ${scheduleId}`) const session = await getSession() if (!session?.user?.id) { @@ -115,18 +37,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const validation = scheduleUpdateSchema.safeParse(body) if (!validation.success) { - const firstError = validation.error.errors[0] - logger.warn(`[${requestId}] Validation error:`, firstError) - return NextResponse.json({ error: firstError.message }, { status: 400 }) + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) } - const { action, status: requestedStatus } = validation.data - const [schedule] = await db .select({ id: workflowSchedule.id, workflowId: workflowSchedule.workflowId, status: workflowSchedule.status, + cronExpression: workflowSchedule.cronExpression, + timezone: workflowSchedule.timezone, }) .from(workflowSchedule) .where(eq(workflowSchedule.id, scheduleId)) @@ -164,57 +84,40 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Not authorized to modify this schedule' }, { status: 403 }) } - if (action === 'reactivate' || (requestedStatus && requestedStatus === 'active')) { - if (schedule.status === 'active') { - return NextResponse.json({ message: 'Schedule is already active' }, { status: 200 }) - } + if (schedule.status === 'active') { + return NextResponse.json({ message: 'Schedule is already active' }, { status: 200 }) + } - const now = new Date() - const nextRunAt = new Date(now.getTime() + 60 * 1000) // Schedule to run in 1 minute + if (!schedule.cronExpression) { + logger.error(`[${requestId}] Schedule has no cron expression: ${scheduleId}`) + return NextResponse.json({ error: 'Schedule has no cron expression' }, { status: 400 }) + } - await db - .update(workflowSchedule) - .set({ - status: 'active', - failedCount: 0, - updatedAt: now, - nextRunAt, - }) - .where(eq(workflowSchedule.id, scheduleId)) + const cronResult = validateCronExpression(schedule.cronExpression, schedule.timezone || 'UTC') + if (!cronResult.isValid || !cronResult.nextRun) { + logger.error(`[${requestId}] Invalid cron expression for schedule: ${scheduleId}`) + return NextResponse.json({ error: 'Schedule has invalid cron expression' }, { status: 400 }) + } - logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`) + const now = new Date() + const nextRunAt = cronResult.nextRun - return NextResponse.json({ - message: 'Schedule activated successfully', + await db + .update(workflowSchedule) + .set({ + status: 'active', + failedCount: 0, + updatedAt: now, nextRunAt, }) - } - - if (action === 'disable' || (requestedStatus && requestedStatus === 'disabled')) { - if (schedule.status === 'disabled') { - return NextResponse.json({ message: 'Schedule is already disabled' }, { status: 200 }) - } - - const now = new Date() - - await db - .update(workflowSchedule) - .set({ - status: 'disabled', - updatedAt: now, - nextRunAt: null, // Clear next run time when disabled - }) - .where(eq(workflowSchedule.id, scheduleId)) - - logger.info(`[${requestId}] Disabled schedule: ${scheduleId}`) + .where(eq(workflowSchedule.id, scheduleId)) - return NextResponse.json({ - message: 'Schedule disabled successfully', - }) - } + logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`) - logger.warn(`[${requestId}] Unsupported update action for schedule: ${scheduleId}`) - return NextResponse.json({ error: 'Unsupported update action' }, { status: 400 }) + return NextResponse.json({ + message: 'Schedule activated successfully', + nextRunAt, + }) } catch (error) { logger.error(`[${requestId}] Error updating schedule`, error) return NextResponse.json({ error: 'Failed to update schedule' }, { status: 500 }) diff --git a/apps/sim/app/api/schedules/[id]/status/route.test.ts b/apps/sim/app/api/schedules/[id]/status/route.test.ts deleted file mode 100644 index 29d269ab0a..0000000000 --- a/apps/sim/app/api/schedules/[id]/status/route.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Integration tests for schedule status API route - * - * @vitest-environment node - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockRequest, mockScheduleStatusDb } from '@/app/api/__test-utils__/utils' - -// Common mocks -const mockSchedule = { - id: 'schedule-id', - workflowId: 'workflow-id', - status: 'active', - failedCount: 0, - lastRanAt: new Date('2024-01-01T00:00:00.000Z'), - lastFailedAt: null, - nextRunAt: new Date('2024-01-02T00:00:00.000Z'), -} - -beforeEach(() => { - vi.resetModules() - - vi.doMock('@/lib/logs/console/logger', () => ({ - createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), - })) - - vi.doMock('crypto', () => ({ - randomUUID: vi.fn(() => 'test-uuid'), - default: { randomUUID: vi.fn(() => 'test-uuid') }, - })) -}) - -afterEach(() => { - vi.clearAllMocks() -}) - -describe('Schedule Status API Route', () => { - it('returns schedule status successfully', async () => { - mockScheduleStatusDb({}) // default mocks - - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ user: { id: 'user-id' } }), - })) - - const req = createMockRequest('GET') - - const { GET } = await import('@/app/api/schedules/[id]/status/route') - - const res = await GET(req, { params: Promise.resolve({ id: 'schedule-id' }) }) - - expect(res.status).toBe(200) - const data = await res.json() - - expect(data).toMatchObject({ - status: 'active', - failedCount: 0, - nextRunAt: mockSchedule.nextRunAt.toISOString(), - isDisabled: false, - }) - }) - - it('marks disabled schedules with isDisabled = true', async () => { - mockScheduleStatusDb({ schedule: [{ ...mockSchedule, status: 'disabled' }] }) - - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ user: { id: 'user-id' } }), - })) - - const req = createMockRequest('GET') - const { GET } = await import('@/app/api/schedules/[id]/status/route') - const res = await GET(req, { params: Promise.resolve({ id: 'schedule-id' }) }) - - expect(res.status).toBe(200) - const data = await res.json() - expect(data).toHaveProperty('status', 'disabled') - expect(data).toHaveProperty('isDisabled', true) - expect(data).toHaveProperty('lastFailedAt') - }) - - it('returns 404 if schedule not found', async () => { - mockScheduleStatusDb({ schedule: [] }) - - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ user: { id: 'user-id' } }), - })) - - const req = createMockRequest('GET') - const { GET } = await import('@/app/api/schedules/[id]/status/route') - const res = await GET(req, { params: Promise.resolve({ id: 'missing-id' }) }) - - expect(res.status).toBe(404) - const data = await res.json() - expect(data).toHaveProperty('error', 'Schedule not found') - }) - - it('returns 404 if related workflow not found', async () => { - mockScheduleStatusDb({ workflow: [] }) - - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ user: { id: 'user-id' } }), - })) - - const req = createMockRequest('GET') - const { GET } = await import('@/app/api/schedules/[id]/status/route') - const res = await GET(req, { params: Promise.resolve({ id: 'schedule-id' }) }) - - expect(res.status).toBe(404) - const data = await res.json() - expect(data).toHaveProperty('error', 'Workflow not found') - }) - - it('returns 403 when user is not owner of workflow', async () => { - mockScheduleStatusDb({ workflow: [{ userId: 'another-user' }] }) - - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ user: { id: 'user-id' } }), - })) - - const req = createMockRequest('GET') - const { GET } = await import('@/app/api/schedules/[id]/status/route') - const res = await GET(req, { params: Promise.resolve({ id: 'schedule-id' }) }) - - expect(res.status).toBe(403) - const data = await res.json() - expect(data).toHaveProperty('error', 'Not authorized to view this schedule') - }) - - it('returns 401 when user is not authenticated', async () => { - mockScheduleStatusDb({}) - - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue(null), - })) - - const req = createMockRequest('GET') - const { GET } = await import('@/app/api/schedules/[id]/status/route') - const res = await GET(req, { params: Promise.resolve({ id: 'schedule-id' }) }) - - expect(res.status).toBe(401) - const data = await res.json() - expect(data).toHaveProperty('error', 'Unauthorized') - }) -}) diff --git a/apps/sim/app/api/schedules/[id]/status/route.ts b/apps/sim/app/api/schedules/[id]/status/route.ts deleted file mode 100644 index 59c7533b6e..0000000000 --- a/apps/sim/app/api/schedules/[id]/status/route.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { db } from '@sim/db' -import { workflow, workflowSchedule } from '@sim/db/schema' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { createLogger } from '@/lib/logs/console/logger' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('ScheduleStatusAPI') - -export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - const scheduleId = id - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized schedule status request`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const [schedule] = await db - .select({ - id: workflowSchedule.id, - workflowId: workflowSchedule.workflowId, - status: workflowSchedule.status, - failedCount: workflowSchedule.failedCount, - lastRanAt: workflowSchedule.lastRanAt, - lastFailedAt: workflowSchedule.lastFailedAt, - nextRunAt: workflowSchedule.nextRunAt, - }) - .from(workflowSchedule) - .where(eq(workflowSchedule.id, scheduleId)) - .limit(1) - - if (!schedule) { - logger.warn(`[${requestId}] Schedule not found: ${scheduleId}`) - return NextResponse.json({ error: 'Schedule not found' }, { status: 404 }) - } - - const [workflowRecord] = await db - .select({ userId: workflow.userId, workspaceId: workflow.workspaceId }) - .from(workflow) - .where(eq(workflow.id, schedule.workflowId)) - .limit(1) - - if (!workflowRecord) { - logger.warn(`[${requestId}] Workflow not found for schedule: ${scheduleId}`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - // Check authorization - either the user owns the workflow or has workspace permissions - let isAuthorized = workflowRecord.userId === session.user.id - - // If not authorized by ownership and the workflow belongs to a workspace, check workspace permissions - if (!isAuthorized && workflowRecord.workspaceId) { - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workflowRecord.workspaceId - ) - isAuthorized = userPermission !== null - } - - if (!isAuthorized) { - logger.warn(`[${requestId}] User not authorized to view this schedule: ${scheduleId}`) - return NextResponse.json({ error: 'Not authorized to view this schedule' }, { status: 403 }) - } - - return NextResponse.json({ - status: schedule.status, - failedCount: schedule.failedCount, - lastRanAt: schedule.lastRanAt, - lastFailedAt: schedule.lastFailedAt, - nextRunAt: schedule.nextRunAt, - isDisabled: schedule.status === 'disabled', - }) - } catch (error) { - logger.error(`[${requestId}] Error retrieving schedule status: ${scheduleId}`, error) - return NextResponse.json({ error: 'Failed to retrieve schedule status' }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/schedules/route.test.ts b/apps/sim/app/api/schedules/route.test.ts index bfc65ec1c2..ac1ece178d 100644 --- a/apps/sim/app/api/schedules/route.test.ts +++ b/apps/sim/app/api/schedules/route.test.ts @@ -1,43 +1,15 @@ /** - * Integration tests for schedule configuration API route + * Tests for schedule GET API route * * @vitest-environment node */ +import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockRequest, mockExecutionDependencies } from '@/app/api/__test-utils__/utils' - -const { - mockGetSession, - mockGetUserEntityPermissions, - mockSelectLimit, - mockInsertValues, - mockOnConflictDoUpdate, - mockInsert, - mockUpdate, - mockDelete, - mockTransaction, - mockRandomUUID, - mockGetScheduleTimeValues, - mockGetSubBlockValue, - mockGenerateCronExpression, - mockCalculateNextRunTime, - mockValidateCronExpression, -} = vi.hoisted(() => ({ + +const { mockGetSession, mockGetUserEntityPermissions, mockDbSelect } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockGetUserEntityPermissions: vi.fn(), - mockSelectLimit: vi.fn(), - mockInsertValues: vi.fn(), - mockOnConflictDoUpdate: vi.fn(), - mockInsert: vi.fn(), - mockUpdate: vi.fn(), - mockDelete: vi.fn(), - mockTransaction: vi.fn(), - mockRandomUUID: vi.fn(), - mockGetScheduleTimeValues: vi.fn(), - mockGetSubBlockValue: vi.fn(), - mockGenerateCronExpression: vi.fn(), - mockCalculateNextRunTime: vi.fn(), - mockValidateCronExpression: vi.fn(), + mockDbSelect: vi.fn(), })) vi.mock('@/lib/auth', () => ({ @@ -50,231 +22,136 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ vi.mock('@sim/db', () => ({ db: { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: mockSelectLimit, - }), - }), - }), - insert: mockInsert, - update: mockUpdate, - delete: mockDelete, + select: mockDbSelect, }, })) vi.mock('@sim/db/schema', () => ({ - workflow: { - id: 'workflow_id', - userId: 'user_id', - workspaceId: 'workspace_id', - }, - workflowSchedule: { - id: 'schedule_id', - workflowId: 'workflow_id', - blockId: 'block_id', - cronExpression: 'cron_expression', - nextRunAt: 'next_run_at', - status: 'status', - }, + workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' }, + workflowSchedule: { workflowId: 'workflowId', blockId: 'blockId' }, })) vi.mock('drizzle-orm', () => ({ - eq: vi.fn((...args) => ({ type: 'eq', args })), - and: vi.fn((...args) => ({ type: 'and', args })), -})) - -vi.mock('crypto', () => ({ - randomUUID: mockRandomUUID, - default: { - randomUUID: mockRandomUUID, - }, -})) - -vi.mock('@/lib/workflows/schedules/utils', () => ({ - getScheduleTimeValues: mockGetScheduleTimeValues, - getSubBlockValue: mockGetSubBlockValue, - generateCronExpression: mockGenerateCronExpression, - calculateNextRunTime: mockCalculateNextRunTime, - validateCronExpression: mockValidateCronExpression, - BlockState: {}, + eq: vi.fn(), + and: vi.fn(), })) vi.mock('@/lib/core/utils/request', () => ({ - generateRequestId: vi.fn(() => 'test-request-id'), + generateRequestId: () => 'test-request-id', })) vi.mock('@/lib/logs/console/logger', () => ({ - createLogger: vi.fn(() => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), - debug: vi.fn(), - })), + }), })) -vi.mock('@/lib/core/telemetry', () => ({ - trackPlatformEvent: vi.fn(), -})) +import { GET } from '@/app/api/schedules/route' + +function createRequest(url: string): NextRequest { + return new NextRequest(new URL(url), { method: 'GET' }) +} -import { db } from '@sim/db' -import { POST } from '@/app/api/schedules/route' +function mockDbChain(results: any[]) { + let callIndex = 0 + mockDbSelect.mockImplementation(() => ({ + from: () => ({ + where: () => ({ + limit: () => results[callIndex++] || [], + }), + }), + })) +} -describe('Schedule Configuration API Route', () => { +describe('Schedule GET API', () => { beforeEach(() => { vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('read') + }) - ;(db as any).transaction = mockTransaction + afterEach(() => { + vi.clearAllMocks() + }) - mockExecutionDependencies() + it('returns schedule data for authorized user', async () => { + mockDbChain([ + [{ userId: 'user-1', workspaceId: null }], + [{ id: 'sched-1', cronExpression: '0 9 * * *', status: 'active', failedCount: 0 }], + ]) - mockGetSession.mockResolvedValue({ - user: { - id: 'user-id', - email: 'test@example.com', - }, - }) + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) + const data = await res.json() - mockGetUserEntityPermissions.mockResolvedValue('admin') + expect(res.status).toBe(200) + expect(data.schedule.cronExpression).toBe('0 9 * * *') + expect(data.isDisabled).toBe(false) + }) - mockSelectLimit.mockReturnValue([ - { - id: 'workflow-id', - userId: 'user-id', - workspaceId: null, - }, - ]) + it('returns null when no schedule exists', async () => { + mockDbChain([[{ userId: 'user-1', workspaceId: null }], []]) - mockInsertValues.mockImplementation(() => ({ - onConflictDoUpdate: mockOnConflictDoUpdate, - })) - mockOnConflictDoUpdate.mockResolvedValue({}) - - mockInsert.mockReturnValue({ - values: mockInsertValues, - }) - - mockUpdate.mockImplementation(() => ({ - set: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockResolvedValue([]), - })), - })) - - mockDelete.mockImplementation(() => ({ - where: vi.fn().mockResolvedValue([]), - })) - - mockTransaction.mockImplementation(async (callback) => { - const tx = { - insert: vi.fn().mockReturnValue({ - values: mockInsertValues, - }), - } - return callback(tx) - }) - - mockRandomUUID.mockReturnValue('test-uuid') - - mockGetScheduleTimeValues.mockReturnValue({ - scheduleTime: '09:30', - minutesInterval: 15, - hourlyMinute: 0, - dailyTime: [9, 30], - weeklyDay: 1, - weeklyTime: [9, 30], - monthlyDay: 1, - monthlyTime: [9, 30], - }) - - mockGetSubBlockValue.mockImplementation((block: any, id: string) => { - const subBlocks = { - startWorkflow: 'schedule', - scheduleType: 'daily', - scheduleTime: '09:30', - dailyTime: '09:30', - } - return subBlocks[id as keyof typeof subBlocks] || '' - }) - - mockGenerateCronExpression.mockReturnValue('0 9 * * *') - mockCalculateNextRunTime.mockReturnValue(new Date()) - mockValidateCronExpression.mockReturnValue({ isValid: true }) - }) + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) + const data = await res.json() - afterEach(() => { - vi.clearAllMocks() + expect(res.status).toBe(200) + expect(data.schedule).toBeNull() }) - it('should create a new schedule successfully', async () => { - const req = createMockRequest('POST', { - workflowId: 'workflow-id', - state: { - blocks: { - 'starter-id': { - type: 'starter', - subBlocks: { - startWorkflow: { value: 'schedule' }, - scheduleType: { value: 'daily' }, - scheduleTime: { value: '09:30' }, - dailyTime: { value: '09:30' }, - }, - }, - }, - edges: [], - loops: {}, - }, - }) - - const response = await POST(req) - - expect(response).toBeDefined() - expect(response.status).toBe(200) - - const responseData = await response.json() - expect(responseData).toHaveProperty('message', 'Schedule updated') - expect(responseData).toHaveProperty('cronExpression', '0 9 * * *') - expect(responseData).toHaveProperty('nextRunAt') + it('requires authentication', async () => { + mockGetSession.mockResolvedValue(null) + + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) + + expect(res.status).toBe(401) }) - it('should handle errors gracefully', async () => { - mockSelectLimit.mockReturnValue([]) + it('requires workflowId parameter', async () => { + const res = await GET(createRequest('http://test/api/schedules')) - const req = createMockRequest('POST', { - workflowId: 'workflow-id', - state: { blocks: {}, edges: [], loops: {} }, - }) + expect(res.status).toBe(400) + }) + + it('returns 404 for non-existent workflow', async () => { + mockDbChain([[]]) - const response = await POST(req) + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) - expect(response.status).toBeGreaterThanOrEqual(400) - const data = await response.json() - expect(data).toHaveProperty('error') + expect(res.status).toBe(404) }) - it('should require authentication', async () => { - mockGetSession.mockResolvedValue(null) + it('denies access for unauthorized user', async () => { + mockDbChain([[{ userId: 'other-user', workspaceId: null }]]) + + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) - const req = createMockRequest('POST', { - workflowId: 'workflow-id', - state: { blocks: {}, edges: [], loops: {} }, - }) + expect(res.status).toBe(403) + }) + + it('allows workspace members to view', async () => { + mockDbChain([ + [{ userId: 'other-user', workspaceId: 'ws-1' }], + [{ id: 'sched-1', status: 'active', failedCount: 0 }], + ]) - const response = await POST(req) + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) - expect(response.status).toBe(401) - const data = await response.json() - expect(data).toHaveProperty('error', 'Unauthorized') + expect(res.status).toBe(200) }) - it('should validate input data', async () => { - const req = createMockRequest('POST', { - workflowId: 'workflow-id', - }) + it('indicates disabled schedule with failures', async () => { + mockDbChain([ + [{ userId: 'user-1', workspaceId: null }], + [{ id: 'sched-1', status: 'disabled', failedCount: 10 }], + ]) - const response = await POST(req) + const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) + const data = await res.json() - expect(response.status).toBe(400) - const data = await response.json() - expect(data).toHaveProperty('error', 'Invalid request data') + expect(res.status).toBe(200) + expect(data.isDisabled).toBe(true) + expect(data.hasFailures).toBe(true) }) }) diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index 65c0ad30c1..07f8cbc952 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -2,61 +2,13 @@ import { db } from '@sim/db' import { workflow, workflowSchedule } from '@sim/db/schema' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' -import { - type BlockState, - calculateNextRunTime, - generateCronExpression, - getScheduleTimeValues, - getSubBlockValue, - validateCronExpression, -} from '@/lib/workflows/schedules/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('ScheduledAPI') -const ScheduleRequestSchema = z.object({ - workflowId: z.string(), - blockId: z.string().optional(), - state: z.object({ - blocks: z.record(z.any()), - edges: z.array(z.any()), - loops: z.record(z.any()), - }), -}) - -function hasValidScheduleConfig( - scheduleType: string | undefined, - scheduleValues: ReturnType, - starterBlock: BlockState -): boolean { - switch (scheduleType) { - case 'minutes': - return !!scheduleValues.minutesInterval - case 'hourly': - return scheduleValues.hourlyMinute !== undefined - case 'daily': - return !!scheduleValues.dailyTime[0] || !!scheduleValues.dailyTime[1] - case 'weekly': - return ( - !!scheduleValues.weeklyDay && - (!!scheduleValues.weeklyTime[0] || !!scheduleValues.weeklyTime[1]) - ) - case 'monthly': - return ( - !!scheduleValues.monthlyDay && - (!!scheduleValues.monthlyTime[0] || !!scheduleValues.monthlyTime[1]) - ) - case 'custom': - return !!getSubBlockValue(starterBlock, 'cronExpression') - default: - return false - } -} - /** * Get schedule information for a workflow */ @@ -65,11 +17,6 @@ export async function GET(req: NextRequest) { const url = new URL(req.url) const workflowId = url.searchParams.get('workflowId') const blockId = url.searchParams.get('blockId') - const mode = url.searchParams.get('mode') - - if (mode && mode !== 'schedule') { - return NextResponse.json({ schedule: null }) - } try { const session = await getSession() @@ -145,262 +92,3 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Failed to retrieve workflow schedule' }, { status: 500 }) } } - -const saveAttempts = new Map() -const RATE_LIMIT_WINDOW = 60000 // 1 minute -const RATE_LIMIT_MAX = 10 // 10 saves per minute - -/** - * Create or update a schedule for a workflow - */ -export async function POST(req: NextRequest) { - const requestId = generateRequestId() - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized schedule update attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const now = Date.now() - const userKey = session.user.id - const limit = saveAttempts.get(userKey) - - if (limit && limit.resetAt > now) { - if (limit.count >= RATE_LIMIT_MAX) { - logger.warn(`[${requestId}] Rate limit exceeded for user: ${userKey}`) - return NextResponse.json( - { error: 'Too many save attempts. Please wait a moment and try again.' }, - { status: 429 } - ) - } - limit.count++ - } else { - saveAttempts.set(userKey, { count: 1, resetAt: now + RATE_LIMIT_WINDOW }) - } - - const body = await req.json() - const { workflowId, blockId, state } = ScheduleRequestSchema.parse(body) - - logger.info(`[${requestId}] Processing schedule update for workflow ${workflowId}`) - - const [workflowRecord] = await db - .select({ userId: workflow.userId, workspaceId: workflow.workspaceId }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (!workflowRecord) { - logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - let isAuthorized = workflowRecord.userId === session.user.id - - if (!isAuthorized && workflowRecord.workspaceId) { - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workflowRecord.workspaceId - ) - isAuthorized = userPermission === 'write' || userPermission === 'admin' - } - - if (!isAuthorized) { - logger.warn( - `[${requestId}] User not authorized to modify schedule for workflow: ${workflowId}` - ) - return NextResponse.json({ error: 'Not authorized to modify this workflow' }, { status: 403 }) - } - - let targetBlock: BlockState | undefined - if (blockId) { - targetBlock = Object.values(state.blocks).find((block: any) => block.id === blockId) as - | BlockState - | undefined - } else { - targetBlock = Object.values(state.blocks).find( - (block: any) => block.type === 'starter' || block.type === 'schedule' - ) as BlockState | undefined - } - - if (!targetBlock) { - logger.warn(`[${requestId}] No starter or schedule block found in workflow ${workflowId}`) - return NextResponse.json( - { error: 'No starter or schedule block found in workflow' }, - { status: 400 } - ) - } - - const startWorkflow = getSubBlockValue(targetBlock, 'startWorkflow') - const scheduleType = getSubBlockValue(targetBlock, 'scheduleType') - - const scheduleValues = getScheduleTimeValues(targetBlock) - - const hasScheduleConfig = hasValidScheduleConfig(scheduleType, scheduleValues, targetBlock) - - const isScheduleBlock = targetBlock.type === 'schedule' - const hasValidConfig = isScheduleBlock || (startWorkflow === 'schedule' && hasScheduleConfig) - - logger.info(`[${requestId}] Schedule validation debug:`, { - workflowId, - blockId, - blockType: targetBlock.type, - isScheduleBlock, - startWorkflow, - scheduleType, - hasScheduleConfig, - hasValidConfig, - scheduleValues: { - minutesInterval: scheduleValues.minutesInterval, - dailyTime: scheduleValues.dailyTime, - cronExpression: scheduleValues.cronExpression, - }, - }) - - if (!hasValidConfig) { - logger.info( - `[${requestId}] Removing schedule for workflow ${workflowId} - no valid configuration found` - ) - const deleteConditions = [eq(workflowSchedule.workflowId, workflowId)] - if (blockId) { - deleteConditions.push(eq(workflowSchedule.blockId, blockId)) - } - - await db - .delete(workflowSchedule) - .where(deleteConditions.length > 1 ? and(...deleteConditions) : deleteConditions[0]) - - return NextResponse.json({ message: 'Schedule removed' }) - } - - if (isScheduleBlock) { - logger.info(`[${requestId}] Processing schedule trigger block for workflow ${workflowId}`) - } else if (startWorkflow !== 'schedule') { - logger.info( - `[${requestId}] Setting workflow to scheduled mode based on schedule configuration` - ) - } - - logger.debug(`[${requestId}] Schedule type for workflow ${workflowId}: ${scheduleType}`) - - let cronExpression: string | null = null - let nextRunAt: Date | undefined - const timezone = getSubBlockValue(targetBlock, 'timezone') || 'UTC' - - try { - const defaultScheduleType = scheduleType || 'daily' - const scheduleStartAt = getSubBlockValue(targetBlock, 'scheduleStartAt') - const scheduleTime = getSubBlockValue(targetBlock, 'scheduleTime') - - logger.debug(`[${requestId}] Schedule configuration:`, { - type: defaultScheduleType, - timezone, - startDate: scheduleStartAt || 'not specified', - time: scheduleTime || 'not specified', - }) - - const sanitizedScheduleValues = - defaultScheduleType !== 'custom' - ? { ...scheduleValues, cronExpression: null } - : scheduleValues - - cronExpression = generateCronExpression(defaultScheduleType, sanitizedScheduleValues) - - if (cronExpression) { - const validation = validateCronExpression(cronExpression, timezone) - if (!validation.isValid) { - logger.error(`[${requestId}] Invalid cron expression: ${validation.error}`, { - scheduleType: defaultScheduleType, - cronExpression, - }) - return NextResponse.json( - { error: `Invalid schedule configuration: ${validation.error}` }, - { status: 400 } - ) - } - } - - nextRunAt = calculateNextRunTime(defaultScheduleType, sanitizedScheduleValues) - - logger.debug( - `[${requestId}] Generated cron: ${cronExpression}, next run at: ${nextRunAt.toISOString()}` - ) - } catch (error: any) { - logger.error(`[${requestId}] Error generating schedule: ${error}`) - const errorMessage = error?.message || 'Failed to generate schedule' - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } - - const values = { - id: crypto.randomUUID(), - workflowId, - blockId, - cronExpression, - triggerType: 'schedule', - createdAt: new Date(), - updatedAt: new Date(), - nextRunAt, - timezone, - status: 'active', // Ensure new schedules are active - failedCount: 0, // Reset failure count for new schedules - } - - const setValues = { - blockId, - cronExpression, - updatedAt: new Date(), - nextRunAt, - timezone, - status: 'active', // Reactivate if previously disabled - failedCount: 0, // Reset failure count on reconfiguration - } - - await db.transaction(async (tx) => { - await tx - .insert(workflowSchedule) - .values(values) - .onConflictDoUpdate({ - target: [workflowSchedule.workflowId, workflowSchedule.blockId], - set: setValues, - }) - }) - - logger.info(`[${requestId}] Schedule updated for workflow ${workflowId}`, { - nextRunAt: nextRunAt?.toISOString(), - cronExpression, - }) - - try { - const { trackPlatformEvent } = await import('@/lib/core/telemetry') - trackPlatformEvent('platform.schedule.created', { - 'workflow.id': workflowId, - 'schedule.type': scheduleType || 'daily', - 'schedule.timezone': timezone, - 'schedule.is_custom': scheduleType === 'custom', - }) - } catch (_e) { - // Silently fail - } - - return NextResponse.json({ - message: 'Schedule updated', - schedule: { id: values.id }, - nextRunAt, - cronExpression, - }) - } catch (error: any) { - logger.error(`[${requestId}] Error updating workflow schedule`, error) - - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - - const errorMessage = error?.message || 'Failed to update workflow schedule' - return NextResponse.json({ error: errorMessage }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 58a291ea26..cb898ff5d3 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -3,7 +3,12 @@ import { and, desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' -import { deployWorkflow } from '@/lib/workflows/persistence/utils' +import { deployWorkflow, loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { + createSchedulesForDeploy, + deleteSchedulesForWorkflow, + validateWorkflowSchedules, +} from '@/lib/workflows/schedules' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -98,13 +103,25 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return createErrorResponse(error.message, error.status) } - // Attribution: this route is UI-only; require session user as actor const actorUserId: string | null = session?.user?.id ?? null if (!actorUserId) { logger.warn(`[${requestId}] Unable to resolve actor user for workflow deployment: ${id}`) return createErrorResponse('Unable to determine deploying user', 400) } + const normalizedData = await loadWorkflowFromNormalizedTables(id) + if (!normalizedData) { + return createErrorResponse('Failed to load workflow state', 500) + } + + const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks) + if (!scheduleValidation.isValid) { + logger.warn( + `[${requestId}] Schedule validation failed for workflow ${id}: ${scheduleValidation.error}` + ) + return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400) + } + const deployResult = await deployWorkflow({ workflowId: id, deployedBy: actorUserId, @@ -117,6 +134,23 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const deployedAt = deployResult.deployedAt! + let scheduleInfo: { scheduleId?: string; cronExpression?: string; nextRunAt?: Date } = {} + const scheduleResult = await createSchedulesForDeploy(id, normalizedData.blocks, db) + if (!scheduleResult.success) { + logger.error( + `[${requestId}] Failed to create schedule for workflow ${id}: ${scheduleResult.error}` + ) + } else if (scheduleResult.scheduleId) { + scheduleInfo = { + scheduleId: scheduleResult.scheduleId, + cronExpression: scheduleResult.cronExpression, + nextRunAt: scheduleResult.nextRunAt, + } + logger.info( + `[${requestId}] Schedule created for workflow ${id}: ${scheduleResult.scheduleId}` + ) + } + logger.info(`[${requestId}] Workflow deployed successfully: ${id}`) const responseApiKeyInfo = workflowData!.workspaceId @@ -127,6 +161,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ apiKey: responseApiKeyInfo, isDeployed: true, deployedAt, + schedule: scheduleInfo.scheduleId + ? { + id: scheduleInfo.scheduleId, + cronExpression: scheduleInfo.cronExpression, + nextRunAt: scheduleInfo.nextRunAt, + } + : undefined, }) } catch (error: any) { logger.error(`[${requestId}] Error deploying workflow: ${id}`, { @@ -156,6 +197,8 @@ export async function DELETE( } await db.transaction(async (tx) => { + await deleteSchedulesForWorkflow(id, tx) + await tx .update(workflowDeploymentVersion) .set({ isActive: false }) @@ -169,7 +212,6 @@ export async function DELETE( logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`) - // Track workflow undeployment try { const { trackPlatformEvent } = await import('@/lib/core/telemetry') trackPlatformEvent('platform.workflow.undeployed', { diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index 0d85044b2b..ba68cb6966 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { webhook, workflow, workflowSchedule } from '@sim/db/schema' +import { webhook, workflow } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -10,12 +10,6 @@ import { createLogger } from '@/lib/logs/console/logger' import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation' -import { - calculateNextRunTime, - generateCronExpression, - getScheduleTimeValues, - validateCronExpression, -} from '@/lib/workflows/schedules/utils' import { getWorkflowAccessContext } from '@/lib/workflows/utils' import type { BlockState } from '@/stores/workflows/workflow/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' @@ -210,7 +204,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } await syncWorkflowWebhooks(workflowId, workflowState.blocks) - await syncWorkflowSchedules(workflowId, workflowState.blocks) // Extract and persist custom tools to database try { @@ -318,79 +311,6 @@ async function syncWorkflowWebhooks( }) } -type ScheduleBlockInput = Parameters[0] - -async function syncWorkflowSchedules( - workflowId: string, - blocks: Record -): Promise { - await syncBlockResources(workflowId, blocks, { - resourceName: 'schedule', - subBlockId: 'scheduleId', - buildMetadata: buildScheduleMetadata, - applyMetadata: upsertScheduleRecord, - }) -} - -interface ScheduleMetadata { - cronExpression: string | null - nextRunAt: Date | null - timezone: string -} - -function buildScheduleMetadata(block: BlockState): ScheduleMetadata | null { - const scheduleType = getSubBlockValue(block, 'scheduleType') || 'daily' - const scheduleBlock = convertToScheduleBlock(block) - - const scheduleValues = getScheduleTimeValues(scheduleBlock) - const sanitizedValues = - scheduleType !== 'custom' ? { ...scheduleValues, cronExpression: null } : scheduleValues - - try { - const cronExpression = generateCronExpression(scheduleType, sanitizedValues) - const timezone = scheduleValues.timezone || 'UTC' - - if (cronExpression) { - const validation = validateCronExpression(cronExpression, timezone) - if (!validation.isValid) { - logger.warn('Invalid cron expression while syncing schedule', { - blockId: block.id, - cronExpression, - error: validation.error, - }) - return null - } - } - - const nextRunAt = calculateNextRunTime(scheduleType, sanitizedValues) - - return { - cronExpression, - timezone, - nextRunAt, - } - } catch (error) { - logger.error('Failed to build schedule metadata during sync', { - blockId: block.id, - error, - }) - return null - } -} - -function convertToScheduleBlock(block: BlockState): ScheduleBlockInput { - const subBlocks: ScheduleBlockInput['subBlocks'] = {} - - Object.entries(block.subBlocks || {}).forEach(([id, subBlock]) => { - subBlocks[id] = { value: stringifySubBlockValue(subBlock?.value) } - }) - - return { - type: block.type, - subBlocks, - } -} - interface WebhookMetadata { triggerPath: string provider: string | null @@ -473,58 +393,6 @@ async function upsertWebhookRecord( }) } -async function upsertScheduleRecord( - workflowId: string, - block: BlockState, - scheduleId: string, - metadata: ScheduleMetadata -): Promise { - const now = new Date() - const [existing] = await db - .select({ - id: workflowSchedule.id, - nextRunAt: workflowSchedule.nextRunAt, - }) - .from(workflowSchedule) - .where(eq(workflowSchedule.id, scheduleId)) - .limit(1) - - if (existing) { - await db - .update(workflowSchedule) - .set({ - workflowId, - blockId: block.id, - cronExpression: metadata.cronExpression, - nextRunAt: metadata.nextRunAt ?? existing.nextRunAt, - timezone: metadata.timezone, - updatedAt: now, - }) - .where(eq(workflowSchedule.id, scheduleId)) - return - } - - await db.insert(workflowSchedule).values({ - id: scheduleId, - workflowId, - blockId: block.id, - cronExpression: metadata.cronExpression, - nextRunAt: metadata.nextRunAt ?? null, - triggerType: 'schedule', - timezone: metadata.timezone, - status: 'active', - failedCount: 0, - createdAt: now, - updatedAt: now, - }) - - logger.info('Recreated missing schedule after workflow save', { - workflowId, - blockId: block.id, - scheduleId, - }) -} - interface BlockResourceSyncConfig { resourceName: string subBlockId: string @@ -573,27 +441,3 @@ async function syncBlockResources( } } } - -function stringifySubBlockValue(value: unknown): string { - if (value === undefined || value === null) { - return '' - } - - if (typeof value === 'string') { - return value - } - - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value) - } - - if (value instanceof Date) { - return value.toISOString() - } - - try { - return JSON.stringify(value) - } catch { - return String(value) - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 7ea3acfd59..31c7645c90 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -464,6 +464,8 @@ export function DeployModal({ setDeploymentInfo((prev) => (prev ? { ...prev, needsRedeployment: false } : prev)) } catch (error: unknown) { logger.error('Error redeploying workflow:', { error }) + const errorMessage = error instanceof Error ? error.message : 'Failed to redeploy workflow' + setApiDeployError(errorMessage) } finally { setIsSubmitting(false) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts index 8d2186546d..1851f77c97 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts @@ -1,6 +1,10 @@ import { useCallback, useState } from 'react' import { createLogger } from '@/lib/logs/console/logger' +import { useNotificationStore } from '@/stores/notifications/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { mergeSubblockState } from '@/stores/workflows/utils' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { runPreDeployChecks } from './use-predeploy-checks' const logger = createLogger('useDeployment') @@ -20,54 +24,94 @@ export function useDeployment({ }: UseDeploymentProps) { const [isDeploying, setIsDeploying] = useState(false) const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus) + const addNotification = useNotificationStore((state) => state.addNotification) + const blocks = useWorkflowStore((state) => state.blocks) + const edges = useWorkflowStore((state) => state.edges) + const loops = useWorkflowStore((state) => state.loops) + const parallels = useWorkflowStore((state) => state.parallels) /** - * Handle initial deployment and open modal + * Handle deploy button click + * First deploy: calls API to deploy, then opens modal on success + * Redeploy: validates client-side, then opens modal if valid */ const handleDeployClick = useCallback(async () => { if (!workflowId) return { success: false, shouldOpenModal: false } - // If undeployed, deploy first then open modal - if (!isDeployed) { - setIsDeploying(true) - try { - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - deployChatEnabled: false, - }), + if (isDeployed) { + const liveBlocks = mergeSubblockState(blocks, workflowId) + const checkResult = runPreDeployChecks({ + blocks: liveBlocks, + edges, + loops, + parallels, + workflowId, + }) + if (!checkResult.passed) { + addNotification({ + level: 'error', + message: checkResult.error || 'Pre-deploy validation failed', + workflowId, }) - - if (response.ok) { - const responseData = await response.json() - const isDeployedStatus = responseData.isDeployed ?? false - const deployedAtTime = responseData.deployedAt - ? new Date(responseData.deployedAt) - : undefined - setDeploymentStatus( - workflowId, - isDeployedStatus, - deployedAtTime, - responseData.apiKey || '' - ) - await refetchDeployedState() - return { success: true, shouldOpenModal: true } - } - return { success: false, shouldOpenModal: true } - } catch (error) { - logger.error('Error deploying workflow:', error) - return { success: false, shouldOpenModal: true } - } finally { - setIsDeploying(false) + return { success: false, shouldOpenModal: false } } + return { success: true, shouldOpenModal: true } } - // If already deployed, just signal to open modal - return { success: true, shouldOpenModal: true } - }, [workflowId, isDeployed, refetchDeployedState, setDeploymentStatus]) + setIsDeploying(true) + try { + const response = await fetch(`/api/workflows/${workflowId}/deploy`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + deployChatEnabled: false, + }), + }) + + if (response.ok) { + const responseData = await response.json() + const isDeployedStatus = responseData.isDeployed ?? false + const deployedAtTime = responseData.deployedAt + ? new Date(responseData.deployedAt) + : undefined + setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, responseData.apiKey || '') + await refetchDeployedState() + return { success: true, shouldOpenModal: true } + } + + const errorData = await response.json() + const errorMessage = errorData.error || 'Failed to deploy workflow' + addNotification({ + level: 'error', + message: errorMessage, + workflowId, + }) + return { success: false, shouldOpenModal: false } + } catch (error) { + logger.error('Error deploying workflow:', error) + const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow' + addNotification({ + level: 'error', + message: errorMessage, + workflowId, + }) + return { success: false, shouldOpenModal: false } + } finally { + setIsDeploying(false) + } + }, [ + workflowId, + isDeployed, + blocks, + edges, + loops, + parallels, + refetchDeployedState, + setDeploymentStatus, + addNotification, + ]) return { isDeploying, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks.ts new file mode 100644 index 0000000000..ef4d941226 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks.ts @@ -0,0 +1,65 @@ +import type { Edge } from 'reactflow' +import { validateWorkflowSchedules } from '@/lib/workflows/schedules/validation' +import { Serializer } from '@/serializer' +import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' + +export interface PreDeployCheckResult { + passed: boolean + error?: string +} + +export interface PreDeployContext { + blocks: Record + edges: Edge[] + loops: Record + parallels: Record + workflowId: string +} + +type PreDeployCheck = (context: PreDeployContext) => PreDeployCheckResult + +/** + * Validates schedule block configuration + */ +const scheduleValidationCheck: PreDeployCheck = ({ blocks }) => { + const result = validateWorkflowSchedules(blocks) + return { + passed: result.isValid, + error: result.error ? `Invalid schedule configuration: ${result.error}` : undefined, + } +} + +/** + * Validates required fields using the serializer's validation + */ +const requiredFieldsCheck: PreDeployCheck = ({ blocks, edges, loops, parallels }) => { + try { + const serializer = new Serializer() + serializer.serializeWorkflow(blocks, edges, loops, parallels, true) + return { passed: true } + } catch (error) { + return { + passed: false, + error: error instanceof Error ? error.message : 'Workflow validation failed', + } + } +} + +/** + * All pre-deploy checks in execution order + * Add new checks here as needed + */ +const preDeployChecks: PreDeployCheck[] = [scheduleValidationCheck, requiredFieldsCheck] + +/** + * Runs all pre-deploy checks and returns the first failure or success + */ +export function runPreDeployChecks(context: PreDeployContext): PreDeployCheckResult { + for (const check of preDeployChecks) { + const result = check(context) + if (!result.passed) { + return result + } + } + return { passed: true } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts index dc5ba115e0..4eaab626d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts @@ -21,7 +21,7 @@ export { McpToolSelector } from './mcp-server-modal/mcp-tool-selector' export { MessagesInput } from './messages-input/messages-input' export { ProjectSelectorInput } from './project-selector/project-selector-input' export { ResponseFormat } from './response/response-format' -export { ScheduleSave } from './schedule-save/schedule-save' +export { ScheduleInfo } from './schedule-info/schedule-info' export { ShortInput } from './short-input/short-input' export { SlackSelectorInput } from './slack-selector/slack-selector-input' export { SliderInput } from './slider-input/slider-input' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-info/schedule-info.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-info/schedule-info.tsx new file mode 100644 index 0000000000..5c2f5e487a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-info/schedule-info.tsx @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useState } from 'react' +import { AlertTriangle } from 'lucide-react' +import { useParams } from 'next/navigation' +import { createLogger } from '@/lib/logs/console/logger' +import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' + +const logger = createLogger('ScheduleStatus') + +interface ScheduleInfoProps { + blockId: string + isPreview?: boolean +} + +/** + * Schedule status display component. + * Shows the current schedule status, next run time, and last run time. + * Schedule creation/deletion is handled during workflow deploy/undeploy. + */ +export function ScheduleInfo({ blockId, isPreview = false }: ScheduleInfoProps) { + const params = useParams() + const workflowId = params.workflowId as string + const [scheduleStatus, setScheduleStatus] = useState<'active' | 'disabled' | null>(null) + const [nextRunAt, setNextRunAt] = useState(null) + const [lastRanAt, setLastRanAt] = useState(null) + const [failedCount, setFailedCount] = useState(0) + const [isLoadingStatus, setIsLoadingStatus] = useState(true) + const [savedCronExpression, setSavedCronExpression] = useState(null) + const [isRedeploying, setIsRedeploying] = useState(false) + const [hasSchedule, setHasSchedule] = useState(false) + + const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone')) + + const fetchScheduleStatus = useCallback(async () => { + if (isPreview) return + + setIsLoadingStatus(true) + try { + const response = await fetch(`/api/schedules?workflowId=${workflowId}&blockId=${blockId}`) + if (response.ok) { + const data = await response.json() + if (data.schedule) { + setHasSchedule(true) + setScheduleStatus(data.schedule.status) + setNextRunAt(data.schedule.nextRunAt ? new Date(data.schedule.nextRunAt) : null) + setLastRanAt(data.schedule.lastRanAt ? new Date(data.schedule.lastRanAt) : null) + setFailedCount(data.schedule.failedCount || 0) + setSavedCronExpression(data.schedule.cronExpression || null) + } else { + // No schedule exists (workflow not deployed or no schedule block) + setHasSchedule(false) + setScheduleStatus(null) + setNextRunAt(null) + setLastRanAt(null) + setFailedCount(0) + setSavedCronExpression(null) + } + } + } catch (error) { + logger.error('Error fetching schedule status', { error }) + } finally { + setIsLoadingStatus(false) + } + }, [workflowId, blockId, isPreview]) + + useEffect(() => { + if (!isPreview) { + fetchScheduleStatus() + } + }, [isPreview, fetchScheduleStatus]) + + /** + * Handles redeploying the workflow when schedule is disabled due to failures. + * Redeploying will recreate the schedule with reset failure count. + */ + const handleRedeploy = async () => { + if (isPreview || isRedeploying) return + + setIsRedeploying(true) + try { + const response = await fetch(`/api/workflows/${workflowId}/deploy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deployChatEnabled: false }), + }) + + if (response.ok) { + // Refresh schedule status after redeploy + await fetchScheduleStatus() + logger.info('Workflow redeployed successfully to reset schedule', { workflowId, blockId }) + } else { + const errorData = await response.json() + logger.error('Failed to redeploy workflow', { error: errorData.error }) + } + } catch (error) { + logger.error('Error redeploying workflow', { error }) + } finally { + setIsRedeploying(false) + } + } + + // Don't render anything if there's no deployed schedule + if (!hasSchedule && !isLoadingStatus) { + return null + } + + return ( +
+ {isLoadingStatus ? ( +
+
+ Loading schedule status... +
+ ) : ( +
+ {/* Failure badge with redeploy action */} + {failedCount >= 10 && scheduleStatus === 'disabled' && ( + + )} + + {/* Show warning for failed runs under threshold */} + {failedCount > 0 && failedCount < 10 && ( +
+ + ⚠️ {failedCount} failed run{failedCount !== 1 ? 's' : ''} + +
+ )} + + {/* Cron expression human-readable description */} + {savedCronExpression && ( +

+ Runs{' '} + {parseCronToHumanReadable( + savedCronExpression, + scheduleTimezone || 'UTC' + ).toLowerCase()} +

+ )} + + {/* Next run time */} + {nextRunAt && ( +

+ Next run:{' '} + {nextRunAt.toLocaleString('en-US', { + timeZone: scheduleTimezone || 'UTC', + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + })}{' '} + {scheduleTimezone || 'UTC'} +

+ )} + + {/* Last ran time */} + {lastRanAt && ( +

+ Last ran:{' '} + {lastRanAt.toLocaleString('en-US', { + timeZone: scheduleTimezone || 'UTC', + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + })}{' '} + {scheduleTimezone || 'UTC'} +

+ )} +
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-save/schedule-save.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-save/schedule-save.tsx deleted file mode 100644 index 94bf588c4a..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-save/schedule-save.tsx +++ /dev/null @@ -1,499 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useParams } from 'next/navigation' -import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn' -import { Trash } from '@/components/emcn/icons/trash' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { cn } from '@/lib/core/utils/cn' -import { createLogger } from '@/lib/logs/console/logger' -import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils' -import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' -import { useScheduleManagement } from '@/hooks/use-schedule-management' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' - -const logger = createLogger('ScheduleSave') - -interface ScheduleSaveProps { - blockId: string - isPreview?: boolean - disabled?: boolean -} - -type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' - -export function ScheduleSave({ blockId, isPreview = false, disabled = false }: ScheduleSaveProps) { - const params = useParams() - const workflowId = params.workflowId as string - const [saveStatus, setSaveStatus] = useState('idle') - const [errorMessage, setErrorMessage] = useState(null) - const [deleteStatus, setDeleteStatus] = useState<'idle' | 'deleting'>('idle') - const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [scheduleStatus, setScheduleStatus] = useState<'active' | 'disabled' | null>(null) - const [nextRunAt, setNextRunAt] = useState(null) - const [lastRanAt, setLastRanAt] = useState(null) - const [failedCount, setFailedCount] = useState(0) - const [isLoadingStatus, setIsLoadingStatus] = useState(false) - const [savedCronExpression, setSavedCronExpression] = useState(null) - - const { collaborativeSetSubblockValue } = useCollaborativeWorkflow() - - const { scheduleId, saveConfig, deleteConfig, isSaving } = useScheduleManagement({ - blockId, - isPreview, - }) - - const scheduleType = useSubBlockStore((state) => state.getValue(blockId, 'scheduleType')) - const scheduleMinutesInterval = useSubBlockStore((state) => - state.getValue(blockId, 'minutesInterval') - ) - const scheduleHourlyMinute = useSubBlockStore((state) => state.getValue(blockId, 'hourlyMinute')) - const scheduleDailyTime = useSubBlockStore((state) => state.getValue(blockId, 'dailyTime')) - const scheduleWeeklyDay = useSubBlockStore((state) => state.getValue(blockId, 'weeklyDay')) - const scheduleWeeklyTime = useSubBlockStore((state) => state.getValue(blockId, 'weeklyDayTime')) - const scheduleMonthlyDay = useSubBlockStore((state) => state.getValue(blockId, 'monthlyDay')) - const scheduleMonthlyTime = useSubBlockStore((state) => state.getValue(blockId, 'monthlyTime')) - const scheduleCronExpression = useSubBlockStore((state) => - state.getValue(blockId, 'cronExpression') - ) - const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone')) - - const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => { - const missingFields: string[] = [] - - if (!scheduleType) { - missingFields.push('Frequency') - return { valid: false, missingFields } - } - - switch (scheduleType) { - case 'minutes': { - const minutesNum = Number(scheduleMinutesInterval) - if ( - !scheduleMinutesInterval || - Number.isNaN(minutesNum) || - minutesNum < 1 || - minutesNum > 1440 - ) { - missingFields.push('Minutes Interval (must be 1-1440)') - } - break - } - case 'hourly': { - const hourlyNum = Number(scheduleHourlyMinute) - if ( - scheduleHourlyMinute === null || - scheduleHourlyMinute === undefined || - scheduleHourlyMinute === '' || - Number.isNaN(hourlyNum) || - hourlyNum < 0 || - hourlyNum > 59 - ) { - missingFields.push('Minute (must be 0-59)') - } - break - } - case 'daily': - if (!scheduleDailyTime) { - missingFields.push('Time') - } - break - case 'weekly': - if (!scheduleWeeklyDay) { - missingFields.push('Day of Week') - } - if (!scheduleWeeklyTime) { - missingFields.push('Time') - } - break - case 'monthly': { - const monthlyNum = Number(scheduleMonthlyDay) - if (!scheduleMonthlyDay || Number.isNaN(monthlyNum) || monthlyNum < 1 || monthlyNum > 31) { - missingFields.push('Day of Month (must be 1-31)') - } - if (!scheduleMonthlyTime) { - missingFields.push('Time') - } - break - } - case 'custom': - if (!scheduleCronExpression) { - missingFields.push('Cron Expression') - } - break - } - - if (!scheduleTimezone && scheduleType !== 'minutes' && scheduleType !== 'hourly') { - missingFields.push('Timezone') - } - - return { - valid: missingFields.length === 0, - missingFields, - } - }, [ - scheduleType, - scheduleMinutesInterval, - scheduleHourlyMinute, - scheduleDailyTime, - scheduleWeeklyDay, - scheduleWeeklyTime, - scheduleMonthlyDay, - scheduleMonthlyTime, - scheduleCronExpression, - scheduleTimezone, - ]) - - const requiredSubBlockIds = useMemo(() => { - return [ - 'scheduleType', - 'minutesInterval', - 'hourlyMinute', - 'dailyTime', - 'weeklyDay', - 'weeklyDayTime', - 'monthlyDay', - 'monthlyTime', - 'cronExpression', - 'timezone', - ] - }, []) - - const subscribedSubBlockValues = useSubBlockStore( - useCallback( - (state) => { - const values: Record = {} - requiredSubBlockIds.forEach((subBlockId) => { - const value = state.getValue(blockId, subBlockId) - if (value !== null && value !== undefined && value !== '') { - values[subBlockId] = value - } - }) - return values - }, - [blockId, requiredSubBlockIds] - ) - ) - - const previousValuesRef = useRef>({}) - const validationTimeoutRef = useRef(null) - - useEffect(() => { - if (saveStatus !== 'error') { - previousValuesRef.current = subscribedSubBlockValues - return - } - - const hasChanges = Object.keys(subscribedSubBlockValues).some( - (key) => - previousValuesRef.current[key] !== (subscribedSubBlockValues as Record)[key] - ) - - if (!hasChanges) { - return - } - - if (validationTimeoutRef.current) { - clearTimeout(validationTimeoutRef.current) - } - - validationTimeoutRef.current = setTimeout(() => { - const validation = validateRequiredFields() - - if (validation.valid) { - setErrorMessage(null) - setSaveStatus('idle') - logger.debug('Error cleared after validation passed', { blockId }) - } else { - setErrorMessage(`Missing required fields: ${validation.missingFields.join(', ')}`) - logger.debug('Error message updated', { - blockId, - missingFields: validation.missingFields, - }) - } - - previousValuesRef.current = subscribedSubBlockValues - }, 300) - - return () => { - if (validationTimeoutRef.current) { - clearTimeout(validationTimeoutRef.current) - } - } - }, [blockId, subscribedSubBlockValues, saveStatus, validateRequiredFields]) - - const fetchScheduleStatus = useCallback(async () => { - if (!scheduleId || isPreview) return - - setIsLoadingStatus(true) - try { - const response = await fetch( - `/api/schedules?workflowId=${workflowId}&blockId=${blockId}&mode=schedule` - ) - if (response.ok) { - const data = await response.json() - if (data.schedule) { - setScheduleStatus(data.schedule.status) - setNextRunAt(data.schedule.nextRunAt ? new Date(data.schedule.nextRunAt) : null) - setLastRanAt(data.schedule.lastRanAt ? new Date(data.schedule.lastRanAt) : null) - setFailedCount(data.schedule.failedCount || 0) - setSavedCronExpression(data.schedule.cronExpression || null) - } - } - } catch (error) { - logger.error('Error fetching schedule status', { error }) - } finally { - setIsLoadingStatus(false) - } - }, [workflowId, blockId, scheduleId, isPreview]) - - useEffect(() => { - if (scheduleId && !isPreview) { - fetchScheduleStatus() - } - }, [scheduleId, isPreview, fetchScheduleStatus]) - - const handleSave = async () => { - if (isPreview || disabled) return - - setSaveStatus('saving') - setErrorMessage(null) - - try { - const validation = validateRequiredFields() - if (!validation.valid) { - setErrorMessage(`Missing required fields: ${validation.missingFields.join(', ')}`) - setSaveStatus('error') - return - } - - const result = await saveConfig() - if (!result.success) { - throw new Error('Save config returned false') - } - - setSaveStatus('saved') - setErrorMessage(null) - - const scheduleIdValue = useSubBlockStore.getState().getValue(blockId, 'scheduleId') - collaborativeSetSubblockValue(blockId, 'scheduleId', scheduleIdValue) - - if (result.nextRunAt) { - setNextRunAt(new Date(result.nextRunAt)) - setScheduleStatus('active') - } - - // Fetch additional status info, then apply cron from save result to prevent stale data - await fetchScheduleStatus() - - if (result.cronExpression) { - setSavedCronExpression(result.cronExpression) - } - - setTimeout(() => { - setSaveStatus('idle') - }, 2000) - - logger.info('Schedule configuration saved successfully', { - blockId, - hasScheduleId: !!scheduleId, - }) - } catch (error: any) { - setSaveStatus('error') - setErrorMessage(error.message || 'An error occurred while saving.') - logger.error('Error saving schedule config', { error }) - } - } - - const handleDelete = async () => { - if (isPreview || disabled) return - - setShowDeleteDialog(false) - setDeleteStatus('deleting') - - try { - const success = await deleteConfig() - if (!success) { - throw new Error('Failed to delete schedule') - } - - setScheduleStatus(null) - setNextRunAt(null) - setLastRanAt(null) - setFailedCount(0) - - collaborativeSetSubblockValue(blockId, 'scheduleId', null) - - logger.info('Schedule deleted successfully', { blockId }) - } catch (error: any) { - setErrorMessage(error.message || 'An error occurred while deleting.') - logger.error('Error deleting schedule', { error }) - } finally { - setDeleteStatus('idle') - } - } - - const handleDeleteConfirm = () => { - handleDelete() - } - - const handleToggleStatus = async () => { - if (!scheduleId || isPreview || disabled) return - - try { - const action = scheduleStatus === 'active' ? 'disable' : 'reactivate' - const response = await fetch(`/api/schedules/${scheduleId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action }), - }) - - if (response.ok) { - await fetchScheduleStatus() - logger.info(`Schedule ${action}d successfully`, { scheduleId }) - } else { - throw new Error(`Failed to ${action} schedule`) - } - } catch (error: any) { - setErrorMessage( - error.message || - `An error occurred while ${scheduleStatus === 'active' ? 'disabling' : 'reactivating'} the schedule.` - ) - logger.error('Error toggling schedule status', { error }) - } - } - - return ( -
-
- - - {scheduleId && ( - - )} -
- - {errorMessage && ( - - {errorMessage} - - )} - - {scheduleId && (scheduleStatus || isLoadingStatus || nextRunAt) && ( -
- {isLoadingStatus ? ( -
-
- Loading schedule status... -
- ) : ( - <> - {failedCount > 0 && ( -
- - ⚠️ {failedCount} failed run{failedCount !== 1 ? 's' : ''} - -
- )} - - {savedCronExpression && ( -

- Runs{' '} - {parseCronToHumanReadable( - savedCronExpression, - scheduleTimezone || 'UTC' - ).toLowerCase()} -

- )} - - {nextRunAt && ( -

- Next run:{' '} - {nextRunAt.toLocaleString('en-US', { - timeZone: scheduleTimezone || 'UTC', - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true, - })}{' '} - {scheduleTimezone || 'UTC'} -

- )} - - {lastRanAt && ( -

- Last ran:{' '} - {lastRanAt.toLocaleString('en-US', { - timeZone: scheduleTimezone || 'UTC', - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true, - })}{' '} - {scheduleTimezone || 'UTC'} -

- )} - - )} -
- )} - - - - Delete Schedule - -

- Are you sure you want to delete this schedule configuration? This will stop the - workflow from running automatically.{' '} - This action cannot be undone. -

-
- - - - -
-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 0fb5dc6eda..c807666b7a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -29,7 +29,7 @@ import { MessagesInput, ProjectSelectorInput, ResponseFormat, - ScheduleSave, + ScheduleInfo, ShortInput, SlackSelectorInput, SliderInput, @@ -592,8 +592,8 @@ function SubBlockComponent({ /> ) - case 'schedule-save': - return + case 'schedule-info': + return case 'oauth-input': return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-schedule-info.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-schedule-info.ts index b970fb3cd0..0d740344d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-schedule-info.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-schedule-info.ts @@ -15,17 +15,15 @@ export interface UseScheduleInfoReturn { isLoading: boolean /** Function to reactivate a disabled schedule */ reactivateSchedule: (scheduleId: string) => Promise - /** Function to disable an active schedule */ - disableSchedule: (scheduleId: string) => Promise } /** - * Custom hook for managing schedule information + * Custom hook for fetching schedule information * * @param blockId - The ID of the block * @param blockType - The type of the block * @param workflowId - The current workflow ID - * @returns Schedule information state and operations + * @returns Schedule information state and reactivate function */ export function useScheduleInfo( blockId: string, @@ -44,7 +42,6 @@ export function useScheduleInfo( const params = new URLSearchParams({ workflowId: wfId, - mode: 'schedule', blockId, }) @@ -77,6 +74,7 @@ export function useScheduleInfo( timezone: scheduleTimezone, status: schedule.status, isDisabled: schedule.status === 'disabled', + failedCount: schedule.failedCount || 0, id: schedule.id, }) } catch (error) { @@ -94,14 +92,12 @@ export function useScheduleInfo( try { const response = await fetch(`/api/schedules/${scheduleId}`, { method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'reactivate' }), }) if (response.ok && workflowId) { - fetchScheduleInfo(workflowId) + await fetchScheduleInfo(workflowId) } else { logger.error('Failed to reactivate schedule') } @@ -112,29 +108,6 @@ export function useScheduleInfo( [workflowId, fetchScheduleInfo] ) - const disableSchedule = useCallback( - async (scheduleId: string) => { - try { - const response = await fetch(`/api/schedules/${scheduleId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ action: 'disable' }), - }) - - if (response.ok && workflowId) { - fetchScheduleInfo(workflowId) - } else { - logger.error('Failed to disable schedule') - } - } catch (error) { - logger.error('Error disabling schedule:', error) - } - }, - [workflowId, fetchScheduleInfo] - ) - useEffect(() => { if (blockType === 'schedule' && workflowId) { fetchScheduleInfo(workflowId) @@ -152,6 +125,5 @@ export function useScheduleInfo( scheduleInfo, isLoading, reactivateSchedule, - disableSchedule, } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts index 5ba34fb906..e3e3f0146f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts @@ -24,5 +24,6 @@ export interface ScheduleInfo { timezone: string status?: string isDisabled?: boolean + failedCount?: number id?: string } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index d548ee0be0..9b3339c159 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -564,7 +564,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({ scheduleInfo, isLoading: isLoadingScheduleInfo, reactivateSchedule, - disableSchedule, } = useScheduleInfo(id, type, currentWorkflowId) const { childWorkflowId, childIsDeployed, childNeedsRedeploy, refetchDeployment } = diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index 31ccf4db3b..2d8f618f50 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -565,6 +565,7 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { updatedAt: now, nextRunAt, failedCount: 0, + lastQueuedAt: null, }, requestId, `Error updating schedule ${payload.scheduleId} after success`, diff --git a/apps/sim/blocks/blocks/schedule.ts b/apps/sim/blocks/blocks/schedule.ts index 027ece7d8d..1e384f2282 100644 --- a/apps/sim/blocks/blocks/schedule.ts +++ b/apps/sim/blocks/blocks/schedule.ts @@ -155,18 +155,11 @@ export const ScheduleBlock: BlockConfig = { }, { - id: 'scheduleSave', - type: 'schedule-save', + id: 'scheduleInfo', + type: 'schedule-info', mode: 'trigger', hideFromPreview: true, }, - - { - id: 'scheduleId', - type: 'short-input', - hidden: true, - mode: 'trigger', - }, ], tools: { diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 64c1a89a24..40d4ad426b 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -55,7 +55,7 @@ export type SubBlockType = | 'time-input' // Time input | 'oauth-input' // OAuth credential selector | 'webhook-config' // Webhook configuration - | 'schedule-save' // Schedule save button with status display + | 'schedule-info' // Schedule status display (next run, last ran, failure badge) | 'file-selector' // File selector for Google Drive, etc. | 'project-selector' // Project selector for Jira, Discord, etc. | 'channel-selector' // Channel selector for Slack, Discord, etc. diff --git a/apps/sim/hooks/use-schedule-management.ts b/apps/sim/hooks/use-schedule-management.ts deleted file mode 100644 index c825f04b5b..0000000000 --- a/apps/sim/hooks/use-schedule-management.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { useParams } from 'next/navigation' -import { createLogger } from '@/lib/logs/console/logger' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' - -const logger = createLogger('useScheduleManagement') - -interface UseScheduleManagementProps { - blockId: string - isPreview?: boolean -} - -interface SaveConfigResult { - success: boolean - nextRunAt?: string - cronExpression?: string -} - -interface ScheduleManagementState { - scheduleId: string | null - isLoading: boolean - isSaving: boolean - saveConfig: () => Promise - deleteConfig: () => Promise -} - -/** - * Hook to manage schedule lifecycle for schedule blocks - * Handles: - * - Loading existing schedules from the API - * - Saving schedule configurations - * - Deleting schedule configurations - */ -export function useScheduleManagement({ - blockId, - isPreview = false, -}: UseScheduleManagementProps): ScheduleManagementState { - const params = useParams() - const workflowId = params.workflowId as string - - const scheduleId = useSubBlockStore( - useCallback((state) => state.getValue(blockId, 'scheduleId') as string | null, [blockId]) - ) - - const isLoading = useSubBlockStore((state) => state.loadingSchedules.has(blockId)) - const isChecked = useSubBlockStore((state) => state.checkedSchedules.has(blockId)) - - const [isSaving, setIsSaving] = useState(false) - - useEffect(() => { - if (isPreview) { - return - } - - const store = useSubBlockStore.getState() - const currentlyLoading = store.loadingSchedules.has(blockId) - const alreadyChecked = store.checkedSchedules.has(blockId) - const currentScheduleId = store.getValue(blockId, 'scheduleId') - - if (currentlyLoading || (alreadyChecked && currentScheduleId)) { - return - } - - const loadSchedule = async () => { - useSubBlockStore.setState((state) => ({ - loadingSchedules: new Set([...state.loadingSchedules, blockId]), - })) - - try { - const response = await fetch( - `/api/schedules?workflowId=${workflowId}&blockId=${blockId}&mode=schedule` - ) - - if (response.ok) { - const data = await response.json() - - if (data.schedule?.id) { - useSubBlockStore.getState().setValue(blockId, 'scheduleId', data.schedule.id) - logger.info('Schedule loaded from API', { - blockId, - scheduleId: data.schedule.id, - }) - } else { - useSubBlockStore.getState().setValue(blockId, 'scheduleId', null) - } - - useSubBlockStore.setState((state) => ({ - checkedSchedules: new Set([...state.checkedSchedules, blockId]), - })) - } else { - logger.warn('API response not OK', { - blockId, - workflowId, - status: response.status, - statusText: response.statusText, - }) - } - } catch (error) { - logger.error('Error loading schedule:', { error, blockId, workflowId }) - } finally { - useSubBlockStore.setState((state) => { - const newSet = new Set(state.loadingSchedules) - newSet.delete(blockId) - return { loadingSchedules: newSet } - }) - } - } - - loadSchedule() - }, [isPreview, workflowId, blockId]) - - const saveConfig = async (): Promise => { - if (isPreview || isSaving) { - return { success: false } - } - - try { - setIsSaving(true) - - const workflowStore = useWorkflowStore.getState() - const subBlockStore = useSubBlockStore.getState() - - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - const subBlockValues = activeWorkflowId - ? subBlockStore.workflowValues[activeWorkflowId] || {} - : {} - - const { mergeSubblockStateAsync } = await import('@/stores/workflows/server-utils') - const mergedBlocks = await mergeSubblockStateAsync(workflowStore.blocks, subBlockValues) - - const workflowState = { - blocks: mergedBlocks, - edges: workflowStore.edges, - loops: workflowStore.loops, - } - - const response = await fetch('/api/schedules', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workflowId, - blockId, - state: workflowState, - }), - }) - - if (!response.ok) { - let errorMessage = 'Failed to save schedule' - try { - const errorData = await response.json() - errorMessage = errorData.details || errorData.error || errorMessage - } catch { - // If response is not JSON, use default message - } - logger.error('Failed to save schedule', { errorMessage }) - throw new Error(errorMessage) - } - - const data = await response.json() - - if (data.schedule?.id) { - useSubBlockStore.getState().setValue(blockId, 'scheduleId', data.schedule.id) - useSubBlockStore.setState((state) => ({ - checkedSchedules: new Set([...state.checkedSchedules, blockId]), - })) - } - - logger.info('Schedule saved successfully', { - scheduleId: data.schedule?.id, - blockId, - nextRunAt: data.nextRunAt, - cronExpression: data.cronExpression, - }) - - return { success: true, nextRunAt: data.nextRunAt, cronExpression: data.cronExpression } - } catch (error) { - logger.error('Error saving schedule:', error) - throw error - } finally { - setIsSaving(false) - } - } - - const deleteConfig = async (): Promise => { - if (isPreview || !scheduleId) { - return false - } - - try { - setIsSaving(true) - - const response = await fetch(`/api/schedules/${scheduleId}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workspaceId: params.workspaceId as string, - }), - }) - - if (!response.ok) { - logger.error('Failed to delete schedule') - return false - } - - useSubBlockStore.getState().setValue(blockId, 'scheduleId', null) - useSubBlockStore.setState((state) => { - const newSet = new Set(state.checkedSchedules) - newSet.delete(blockId) - return { checkedSchedules: newSet } - }) - - logger.info('Schedule deleted successfully') - return true - } catch (error) { - logger.error('Error deleting schedule:', error) - return false - } finally { - setIsSaving(false) - } - } - - return { - scheduleId, - isLoading, - isSaving, - saveConfig, - deleteConfig, - } -} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts index d12c554a09..dc4f55a73b 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -1824,7 +1824,7 @@ function applyOperationsToWorkflowState( validationErrors.push(...validationResult.errors) Object.entries(validationResult.validInputs).forEach(([key, value]) => { - // Skip runtime subblock IDs (webhookId, triggerPath, testUrl, testUrlExpiresAt, scheduleId) + // Skip runtime subblock IDs (webhookId, triggerPath, testUrl, testUrlExpiresAt) if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) { return } diff --git a/apps/sim/lib/workflows/schedules/deploy.test.ts b/apps/sim/lib/workflows/schedules/deploy.test.ts new file mode 100644 index 0000000000..3fb7cca40a --- /dev/null +++ b/apps/sim/lib/workflows/schedules/deploy.test.ts @@ -0,0 +1,786 @@ +/** + * Tests for schedule deploy utilities + * + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockInsert, + mockDelete, + mockOnConflictDoUpdate, + mockValues, + mockWhere, + mockGenerateCronExpression, + mockCalculateNextRunTime, + mockValidateCronExpression, + mockGetScheduleTimeValues, + mockRandomUUID, +} = vi.hoisted(() => ({ + mockInsert: vi.fn(), + mockDelete: vi.fn(), + mockOnConflictDoUpdate: vi.fn(), + mockValues: vi.fn(), + mockWhere: vi.fn(), + mockGenerateCronExpression: vi.fn(), + mockCalculateNextRunTime: vi.fn(), + mockValidateCronExpression: vi.fn(), + mockGetScheduleTimeValues: vi.fn(), + mockRandomUUID: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: {}, + workflowSchedule: { + workflowId: 'workflow_id', + blockId: 'block_id', + }, +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((...args) => ({ type: 'eq', args })), +})) + +vi.mock('@/lib/logs/console/logger', () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})) + +vi.mock('./utils', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + generateCronExpression: mockGenerateCronExpression, + calculateNextRunTime: mockCalculateNextRunTime, + validateCronExpression: mockValidateCronExpression, + getScheduleTimeValues: mockGetScheduleTimeValues, + } +}) + +vi.stubGlobal('crypto', { + randomUUID: mockRandomUUID, +}) + +import { createSchedulesForDeploy, deleteSchedulesForWorkflow } from './deploy' +import type { BlockState } from './utils' +import { findScheduleBlocks, validateScheduleBlock, validateWorkflowSchedules } from './validation' + +describe('Schedule Deploy Utilities', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockRandomUUID.mockReturnValue('test-uuid') + mockGenerateCronExpression.mockReturnValue('0 9 * * *') + mockCalculateNextRunTime.mockReturnValue(new Date('2025-04-15T09:00:00Z')) + mockValidateCronExpression.mockReturnValue({ isValid: true, nextRun: new Date() }) + mockGetScheduleTimeValues.mockReturnValue({ + scheduleTime: '09:00', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0], + weeklyDay: 1, + weeklyTime: [9, 0], + monthlyDay: 1, + monthlyTime: [9, 0], + cronExpression: null, + }) + + // Setup mock chain for insert + mockOnConflictDoUpdate.mockResolvedValue({}) + mockValues.mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate }) + mockInsert.mockReturnValue({ values: mockValues }) + + // Setup mock chain for delete + mockWhere.mockResolvedValue({}) + mockDelete.mockReturnValue({ where: mockWhere }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('findScheduleBlocks', () => { + it('should find schedule blocks in a workflow', () => { + const blocks: Record = { + 'block-1': { id: 'block-1', type: 'schedule', subBlocks: {} } as BlockState, + 'block-2': { id: 'block-2', type: 'agent', subBlocks: {} } as BlockState, + 'block-3': { id: 'block-3', type: 'schedule', subBlocks: {} } as BlockState, + } + + const result = findScheduleBlocks(blocks) + + expect(result).toHaveLength(2) + expect(result.map((b) => b.id)).toEqual(['block-1', 'block-3']) + }) + + it('should return empty array when no schedule blocks exist', () => { + const blocks: Record = { + 'block-1': { id: 'block-1', type: 'agent', subBlocks: {} } as BlockState, + 'block-2': { id: 'block-2', type: 'starter', subBlocks: {} } as BlockState, + } + + const result = findScheduleBlocks(blocks) + + expect(result).toHaveLength(0) + }) + + it('should handle empty blocks object', () => { + const result = findScheduleBlocks({}) + expect(result).toHaveLength(0) + }) + }) + + describe('validateScheduleBlock', () => { + describe('schedule type validation', () => { + it('should fail when schedule type is missing', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: {}, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Schedule type is required') + }) + + it('should fail with empty schedule type', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: '' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Schedule type is required') + }) + }) + + describe('minutes schedule validation', () => { + it('should validate valid minutes interval', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'minutes' }, + minutesInterval: { value: '15' }, + timezone: { value: 'UTC' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(true) + expect(result.cronExpression).toBeDefined() + }) + + it('should fail with empty minutes interval', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'minutes' }, + minutesInterval: { value: '' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Minutes interval is required for minute-based schedules') + }) + + it('should fail with invalid minutes interval (out of range)', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'minutes' }, + minutesInterval: { value: '0' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Minutes interval is required for minute-based schedules') + }) + + it('should fail with minutes interval > 1440', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'minutes' }, + minutesInterval: { value: '1441' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Minutes interval is required for minute-based schedules') + }) + }) + + describe('hourly schedule validation', () => { + it('should validate valid hourly minute', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'hourly' }, + hourlyMinute: { value: '30' }, + timezone: { value: 'UTC' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(true) + }) + + it('should validate hourly minute of 0', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'hourly' }, + hourlyMinute: { value: '0' }, + timezone: { value: 'UTC' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(true) + }) + + it('should fail with empty hourly minute', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'hourly' }, + hourlyMinute: { value: '' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Minute value is required for hourly schedules') + }) + + it('should fail with hourly minute > 59', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'hourly' }, + hourlyMinute: { value: '60' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Minute value is required for hourly schedules') + }) + }) + + describe('daily schedule validation', () => { + it('should validate valid daily time', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'daily' }, + dailyTime: { value: '09:30' }, + timezone: { value: 'America/New_York' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(true) + expect(result.timezone).toBe('America/New_York') + }) + + it('should fail with empty daily time', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'daily' }, + dailyTime: { value: '' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Time is required for daily schedules') + }) + + it('should fail with invalid time format (no colon)', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'daily' }, + dailyTime: { value: '0930' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Time is required for daily schedules') + }) + }) + + describe('weekly schedule validation', () => { + it('should validate valid weekly configuration', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'weekly' }, + weeklyDay: { value: 'MON' }, + weeklyDayTime: { value: '10:00' }, + timezone: { value: 'UTC' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(true) + }) + + it('should fail with missing day', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'weekly' }, + weeklyDay: { value: '' }, + weeklyDayTime: { value: '10:00' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Day and time are required for weekly schedules') + }) + + it('should fail with missing time', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'weekly' }, + weeklyDay: { value: 'MON' }, + weeklyDayTime: { value: '' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Day and time are required for weekly schedules') + }) + }) + + describe('monthly schedule validation', () => { + it('should validate valid monthly configuration', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'monthly' }, + monthlyDay: { value: '15' }, + monthlyTime: { value: '14:30' }, + timezone: { value: 'UTC' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(true) + }) + + it('should fail with day out of range (0)', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'monthly' }, + monthlyDay: { value: '0' }, + monthlyTime: { value: '14:30' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Day and time are required for monthly schedules') + }) + + it('should fail with day out of range (32)', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'monthly' }, + monthlyDay: { value: '32' }, + monthlyTime: { value: '14:30' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Day and time are required for monthly schedules') + }) + + it('should fail with missing time', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'monthly' }, + monthlyDay: { value: '15' }, + monthlyTime: { value: '' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Day and time are required for monthly schedules') + }) + }) + + describe('custom cron schedule validation', () => { + it('should validate valid custom cron expression', () => { + mockGetScheduleTimeValues.mockReturnValue({ + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0], + weeklyDay: 1, + weeklyTime: [9, 0], + monthlyDay: 1, + monthlyTime: [9, 0], + cronExpression: '*/5 * * * *', + }) + + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'custom' }, + cronExpression: { value: '*/5 * * * *' }, + timezone: { value: 'UTC' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(true) + }) + + it('should fail with empty cron expression', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'custom' }, + cronExpression: { value: '' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Cron expression is required for custom schedules') + }) + }) + + describe('invalid cron expression handling', () => { + it('should fail when generated cron is invalid', () => { + mockValidateCronExpression.mockReturnValue({ + isValid: false, + error: 'Invalid minute value', + }) + + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'daily' }, + dailyTime: { value: '09:00' }, + timezone: { value: 'UTC' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toContain('Invalid cron expression') + }) + + it('should handle exceptions during cron generation', () => { + mockGenerateCronExpression.mockImplementation(() => { + throw new Error('Failed to parse schedule type') + }) + + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'daily' }, + dailyTime: { value: '09:00' }, + timezone: { value: 'UTC' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Failed to parse schedule type') + }) + }) + + describe('timezone handling', () => { + it('should use UTC as default timezone', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'daily' }, + dailyTime: { value: '09:00' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(true) + expect(result.timezone).toBe('UTC') + }) + + it('should use specified timezone', () => { + const block: BlockState = { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'daily' }, + dailyTime: { value: '09:00' }, + timezone: { value: 'Asia/Tokyo' }, + }, + } as BlockState + + const result = validateScheduleBlock(block) + + expect(result.isValid).toBe(true) + expect(result.timezone).toBe('Asia/Tokyo') + }) + }) + }) + + describe('validateWorkflowSchedules', () => { + it('should return valid for workflows without schedule blocks', () => { + const blocks: Record = { + 'block-1': { id: 'block-1', type: 'agent', subBlocks: {} } as BlockState, + } + + const result = validateWorkflowSchedules(blocks) + + expect(result.isValid).toBe(true) + }) + + it('should validate all schedule blocks', () => { + const blocks: Record = { + 'block-1': { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'daily' }, + dailyTime: { value: '09:00' }, + timezone: { value: 'UTC' }, + }, + } as BlockState, + 'block-2': { + id: 'block-2', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'hourly' }, + hourlyMinute: { value: '30' }, + timezone: { value: 'UTC' }, + }, + } as BlockState, + } + + const result = validateWorkflowSchedules(blocks) + + expect(result.isValid).toBe(true) + }) + + it('should return first validation error found', () => { + const blocks: Record = { + 'block-1': { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'daily' }, + dailyTime: { value: '09:00' }, + timezone: { value: 'UTC' }, + }, + } as BlockState, + 'block-2': { + id: 'block-2', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'daily' }, + dailyTime: { value: '' }, // Invalid - missing time + }, + } as BlockState, + } + + const result = validateWorkflowSchedules(blocks) + + expect(result.isValid).toBe(false) + expect(result.error).toBe('Time is required for daily schedules') + }) + }) + + describe('createSchedulesForDeploy', () => { + it('should return success with no schedule blocks', async () => { + const blocks: Record = { + 'block-1': { id: 'block-1', type: 'agent', subBlocks: {} } as BlockState, + } + + const mockTx = { + insert: mockInsert, + delete: mockDelete, + } + + const result = await createSchedulesForDeploy('workflow-1', blocks, mockTx as any) + + expect(result.success).toBe(true) + expect(mockInsert).not.toHaveBeenCalled() + }) + + it('should create schedule for valid schedule block', async () => { + const blocks: Record = { + 'block-1': { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'daily' }, + dailyTime: { value: '09:00' }, + timezone: { value: 'UTC' }, + }, + } as BlockState, + } + + const mockTx = { + insert: mockInsert, + delete: mockDelete, + } + + const result = await createSchedulesForDeploy('workflow-1', blocks, mockTx as any) + + expect(result.success).toBe(true) + expect(result.scheduleId).toBe('test-uuid') + expect(result.cronExpression).toBe('0 9 * * *') + expect(result.nextRunAt).toEqual(new Date('2025-04-15T09:00:00Z')) + expect(mockInsert).toHaveBeenCalled() + expect(mockOnConflictDoUpdate).toHaveBeenCalled() + }) + + it('should return error for invalid schedule block', async () => { + const blocks: Record = { + 'block-1': { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'daily' }, + dailyTime: { value: '' }, // Invalid + }, + } as BlockState, + } + + const mockTx = { + insert: mockInsert, + delete: mockDelete, + } + + const result = await createSchedulesForDeploy('workflow-1', blocks, mockTx as any) + + expect(result.success).toBe(false) + expect(result.error).toBe('Time is required for daily schedules') + expect(mockInsert).not.toHaveBeenCalled() + }) + + it('should use onConflictDoUpdate for existing schedules', async () => { + const blocks: Record = { + 'block-1': { + id: 'block-1', + type: 'schedule', + subBlocks: { + scheduleType: { value: 'minutes' }, + minutesInterval: { value: '30' }, + timezone: { value: 'UTC' }, + }, + } as BlockState, + } + + const mockTx = { + insert: mockInsert, + delete: mockDelete, + } + + await createSchedulesForDeploy('workflow-1', blocks, mockTx as any) + + expect(mockOnConflictDoUpdate).toHaveBeenCalledWith({ + target: expect.any(Array), + set: expect.objectContaining({ + blockId: 'block-1', + cronExpression: '0 9 * * *', + status: 'active', + failedCount: 0, + }), + }) + }) + }) + + describe('deleteSchedulesForWorkflow', () => { + it('should delete all schedules for a workflow', async () => { + const mockTx = { + insert: mockInsert, + delete: mockDelete, + } + + await deleteSchedulesForWorkflow('workflow-1', mockTx as any) + + expect(mockDelete).toHaveBeenCalled() + expect(mockWhere).toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/lib/workflows/schedules/deploy.ts b/apps/sim/lib/workflows/schedules/deploy.ts new file mode 100644 index 0000000000..8c6346bbb2 --- /dev/null +++ b/apps/sim/lib/workflows/schedules/deploy.ts @@ -0,0 +1,131 @@ +import { type db, workflowSchedule } from '@sim/db' +import type * as schema from '@sim/db/schema' +import type { ExtractTablesWithRelations } from 'drizzle-orm' +import { eq } from 'drizzle-orm' +import type { PgTransaction } from 'drizzle-orm/pg-core' +import type { PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js' +import { createLogger } from '@/lib/logs/console/logger' +import type { BlockState } from '@/lib/workflows/schedules/utils' +import { findScheduleBlocks, validateScheduleBlock } from '@/lib/workflows/schedules/validation' + +const logger = createLogger('ScheduleDeployUtils') + +/** + * Type for database or transaction context + * This allows the functions to work with either the db instance or a transaction + */ +type DbOrTx = + | typeof db + | PgTransaction< + PostgresJsQueryResultHKT, + typeof schema, + ExtractTablesWithRelations + > + +/** + * Result of schedule creation during deploy + */ +export interface ScheduleDeployResult { + success: boolean + error?: string + scheduleId?: string + cronExpression?: string + nextRunAt?: Date + timezone?: string +} + +/** + * Create or update schedule records for a workflow during deployment + * This should be called within a database transaction + */ +export async function createSchedulesForDeploy( + workflowId: string, + blocks: Record, + tx: DbOrTx +): Promise { + const scheduleBlocks = findScheduleBlocks(blocks) + + if (scheduleBlocks.length === 0) { + logger.info(`No schedule blocks found in workflow ${workflowId}`) + return { success: true } + } + + let lastScheduleInfo: { + scheduleId: string + cronExpression?: string + nextRunAt?: Date + timezone?: string + } | null = null + + for (const block of scheduleBlocks) { + const blockId = block.id as string + + const validation = validateScheduleBlock(block) + if (!validation.isValid) { + return { + success: false, + error: validation.error, + } + } + + const { cronExpression, nextRunAt, timezone } = validation + + const scheduleId = crypto.randomUUID() + const now = new Date() + + const values = { + id: scheduleId, + workflowId, + blockId, + cronExpression: cronExpression!, + triggerType: 'schedule', + createdAt: now, + updatedAt: now, + nextRunAt: nextRunAt!, + timezone: timezone!, + status: 'active', + failedCount: 0, + } + + const setValues = { + blockId, + cronExpression: cronExpression!, + updatedAt: now, + nextRunAt: nextRunAt!, + timezone: timezone!, + status: 'active', + failedCount: 0, + } + + await tx + .insert(workflowSchedule) + .values(values) + .onConflictDoUpdate({ + target: [workflowSchedule.workflowId, workflowSchedule.blockId], + set: setValues, + }) + + logger.info(`Schedule created/updated for workflow ${workflowId}, block ${blockId}`, { + scheduleId: values.id, + cronExpression, + nextRunAt: nextRunAt?.toISOString(), + }) + + lastScheduleInfo = { scheduleId: values.id, cronExpression, nextRunAt, timezone } + } + + return { + success: true, + ...lastScheduleInfo, + } +} + +/** + * Delete all schedules for a workflow + * This should be called within a database transaction during undeploy + */ +export async function deleteSchedulesForWorkflow(workflowId: string, tx: DbOrTx): Promise { + await tx.delete(workflowSchedule).where(eq(workflowSchedule.workflowId, workflowId)) + + logger.info(`Deleted all schedules for workflow ${workflowId}`) +} diff --git a/apps/sim/lib/workflows/schedules/index.ts b/apps/sim/lib/workflows/schedules/index.ts new file mode 100644 index 0000000000..0a5eb94bac --- /dev/null +++ b/apps/sim/lib/workflows/schedules/index.ts @@ -0,0 +1,23 @@ +export { + createSchedulesForDeploy, + deleteSchedulesForWorkflow, + type ScheduleDeployResult, +} from './deploy' +export { + type BlockState, + calculateNextRunTime, + DAY_MAP, + generateCronExpression, + getScheduleInfo, + getScheduleTimeValues, + getSubBlockValue, + parseCronToHumanReadable, + parseTimeString, + validateCronExpression, +} from './utils' +export { + findScheduleBlocks, + type ScheduleValidationResult, + validateScheduleBlock, + validateWorkflowSchedules, +} from './validation' diff --git a/apps/sim/lib/workflows/schedules/utils.test.ts b/apps/sim/lib/workflows/schedules/utils.test.ts index 949cb32311..834f189b7f 100644 --- a/apps/sim/lib/workflows/schedules/utils.test.ts +++ b/apps/sim/lib/workflows/schedules/utils.test.ts @@ -782,4 +782,397 @@ describe('Schedule Utilities', () => { expect(date.toISOString()).toBe('2025-10-14T14:00:00.000Z') }) }) + + describe('Edge Cases and DST Transitions', () => { + describe('DST Transition Edge Cases', () => { + it.concurrent('should handle DST spring forward transition (2:00 AM skipped)', () => { + // In US timezones, DST spring forward happens at 2:00 AM -> jumps to 3:00 AM + // March 9, 2025 is DST transition day in America/New_York + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'America/New_York', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [2, 30] as [number, number], // 2:30 AM (during the skipped hour) + weeklyDay: 1, + weeklyTime: [2, 30] as [number, number], + monthlyDay: 1, + monthlyTime: [2, 30] as [number, number], + cronExpression: null, + } + + // Should handle the skipped hour gracefully + const nextRun = calculateNextRunTime('daily', scheduleValues) + expect(nextRun instanceof Date).toBe(true) + expect(nextRun > new Date()).toBe(true) + }) + + it.concurrent('should handle DST fall back transition (1:00 AM repeated)', () => { + // In US timezones, DST fall back happens at 2:00 AM -> falls back to 1:00 AM + // November 2, 2025 is DST fall back day in America/Los_Angeles + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-11-01T12:00:00.000Z')) + + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'America/Los_Angeles', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [1, 30] as [number, number], // 1:30 AM (during the repeated hour) + weeklyDay: 1, + weeklyTime: [1, 30] as [number, number], + monthlyDay: 1, + monthlyTime: [1, 30] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('daily', scheduleValues) + expect(nextRun instanceof Date).toBe(true) + expect(nextRun > new Date()).toBe(true) + + vi.useRealTimers() + }) + }) + + describe('End of Month Edge Cases', () => { + it.concurrent('should handle February 29th in non-leap year', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-01-15T12:00:00.000Z')) // 2025 is not a leap year + + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 29, // Feb doesn't have 29 days in 2025 + monthlyTime: [9, 0] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('monthly', scheduleValues) + // Should skip February and schedule for next valid month (March 29) + expect(nextRun.getUTCMonth()).not.toBe(1) // Not February + + vi.useRealTimers() + }) + + it.concurrent('should handle February 29th in leap year', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-01-15T12:00:00.000Z')) // 2024 is a leap year + + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 29, // Feb has 29 days in 2024 + monthlyTime: [9, 0] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('monthly', scheduleValues) + expect(nextRun instanceof Date).toBe(true) + + vi.useRealTimers() + }) + + it.concurrent('should handle day 31 in months with only 30 days', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-04-05T12:00:00.000Z')) // April has 30 days + + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 31, // April only has 30 days + monthlyTime: [9, 0] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('monthly', scheduleValues) + // Should skip April and schedule for May 31 + expect(nextRun.getUTCDate()).toBe(31) + expect(nextRun.getUTCMonth()).toBe(4) // May (0-indexed) + + vi.useRealTimers() + }) + }) + + describe('Timezone-specific Edge Cases', () => { + it.concurrent('should handle Australia/Lord_Howe with 30-minute DST shift', () => { + // Lord Howe Island has a unique 30-minute DST shift + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'Australia/Lord_Howe', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [14, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [14, 0] as [number, number], + monthlyDay: 1, + monthlyTime: [14, 0] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('daily', scheduleValues) + expect(nextRun instanceof Date).toBe(true) + expect(nextRun > new Date()).toBe(true) + }) + + it.concurrent('should handle negative UTC offsets correctly', () => { + // America/Sao_Paulo (UTC-3) + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'America/Sao_Paulo', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [23, 30] as [number, number], // 11:30 PM local + weeklyDay: 1, + weeklyTime: [23, 30] as [number, number], + monthlyDay: 1, + monthlyTime: [23, 30] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('daily', scheduleValues) + expect(nextRun instanceof Date).toBe(true) + // 11:30 PM in UTC-3 should be 2:30 AM UTC next day + expect(nextRun.getUTCHours()).toBeGreaterThanOrEqual(2) + }) + }) + + describe('Complex Cron Pattern Edge Cases', () => { + it.concurrent('should handle cron with specific days of month and week', () => { + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 1, + monthlyTime: [9, 0] as [number, number], + cronExpression: '0 9 13 * 5', // Friday the 13th at 9:00 AM + } + + const result = validateCronExpression(scheduleValues.cronExpression, 'UTC') + expect(result.isValid).toBe(true) + expect(result.nextRun).toBeInstanceOf(Date) + }) + + it.concurrent('should handle cron with multiple specific hours', () => { + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'America/New_York', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 1, + monthlyTime: [9, 0] as [number, number], + cronExpression: '0 9,12,15,18 * * *', // At 9 AM, noon, 3 PM, 6 PM + } + + const result = validateCronExpression(scheduleValues.cronExpression, 'America/New_York') + expect(result.isValid).toBe(true) + expect(result.nextRun).toBeInstanceOf(Date) + }) + + it.concurrent('should handle cron with step values in multiple fields', () => { + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'Europe/Paris', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 1, + monthlyTime: [9, 0] as [number, number], + cronExpression: '*/15 */2 * * *', // Every 15 minutes, every 2 hours + } + + const result = validateCronExpression(scheduleValues.cronExpression, 'Europe/Paris') + expect(result.isValid).toBe(true) + expect(result.nextRun).toBeInstanceOf(Date) + }) + + it.concurrent('should handle cron with ranges', () => { + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'Asia/Singapore', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 1, + monthlyTime: [9, 0] as [number, number], + cronExpression: '0 9-17 * * 1-5', // Business hours (9 AM - 5 PM) on weekdays + } + + const result = validateCronExpression(scheduleValues.cronExpression, 'Asia/Singapore') + expect(result.isValid).toBe(true) + expect(result.nextRun).toBeInstanceOf(Date) + }) + }) + + describe('Validation Edge Cases', () => { + it.concurrent('should reject cron with invalid day of week', () => { + const result = validateCronExpression('0 9 * * 8', 'UTC') // Day 8 doesn't exist (0-7) + expect(result.isValid).toBe(false) + expect(result.error).toBeDefined() + }) + + it.concurrent('should reject cron with invalid month', () => { + const result = validateCronExpression('0 9 1 13 *', 'UTC') // Month 13 doesn't exist (1-12) + expect(result.isValid).toBe(false) + expect(result.error).toBeDefined() + }) + + it.concurrent('should reject cron with invalid hour', () => { + const result = validateCronExpression('0 25 * * *', 'UTC') // Hour 25 doesn't exist (0-23) + expect(result.isValid).toBe(false) + expect(result.error).toBeDefined() + }) + + it.concurrent('should reject cron with invalid minute', () => { + const result = validateCronExpression('60 9 * * *', 'UTC') // Minute 60 doesn't exist (0-59) + expect(result.isValid).toBe(false) + expect(result.error).toBeDefined() + }) + + it.concurrent('should handle standard cron expressions correctly', () => { + // Croner requires proper spacing, so test with standard spaces + const result = validateCronExpression('0 9 * * *', 'UTC') + expect(result.isValid).toBe(true) + expect(result.nextRun).toBeInstanceOf(Date) + }) + + it.concurrent('should reject cron with too few fields', () => { + const result = validateCronExpression('0 9 * *', 'UTC') // Missing day of week field + expect(result.isValid).toBe(false) + expect(result.error).toBeDefined() + }) + + it.concurrent('should reject cron with too many fields', () => { + const result = validateCronExpression('0 0 9 * * * *', 'UTC') // Too many fields (has seconds) + expect(result.isValid).toBe(false) + expect(result.error).toBeDefined() + }) + + it.concurrent('should handle timezone with invalid IANA name', () => { + const result = validateCronExpression('0 9 * * *', 'Invalid/Timezone') + // Croner might handle this differently, but it should either reject or fall back + expect(result).toBeDefined() + }) + }) + + describe('Boundary Conditions', () => { + it.concurrent('should handle midnight (00:00)', () => { + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [0, 0] as [number, number], // Midnight + weeklyDay: 1, + weeklyTime: [0, 0] as [number, number], + monthlyDay: 1, + monthlyTime: [0, 0] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('daily', scheduleValues) + expect(nextRun.getUTCHours()).toBe(0) + expect(nextRun.getUTCMinutes()).toBe(0) + }) + + it.concurrent('should handle end of day (23:59)', () => { + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [23, 59] as [number, number], // One minute before midnight + weeklyDay: 1, + weeklyTime: [23, 59] as [number, number], + monthlyDay: 1, + monthlyTime: [23, 59] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('daily', scheduleValues) + expect(nextRun.getUTCHours()).toBe(23) + expect(nextRun.getUTCMinutes()).toBe(59) + }) + + it.concurrent('should handle first day of month', () => { + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 15, + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 1, // First day of month + monthlyTime: [9, 0] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('monthly', scheduleValues) + expect(nextRun.getUTCDate()).toBe(1) + expect(nextRun.getUTCHours()).toBe(9) + }) + + it.concurrent('should handle minimum interval (every minute)', () => { + const scheduleValues = { + scheduleTime: '', + scheduleStartAt: '', + timezone: 'UTC', + minutesInterval: 1, // Every minute + hourlyMinute: 0, + dailyTime: [9, 0] as [number, number], + weeklyDay: 1, + weeklyTime: [9, 0] as [number, number], + monthlyDay: 1, + monthlyTime: [9, 0] as [number, number], + cronExpression: null, + } + + const nextRun = calculateNextRunTime('minutes', scheduleValues) + const now = Date.now() + // Should be within the next minute + expect(nextRun.getTime()).toBeGreaterThan(now) + expect(nextRun.getTime()).toBeLessThanOrEqual(now + 60 * 1000 + 1000) + }) + }) + }) }) diff --git a/apps/sim/lib/workflows/schedules/utils.ts b/apps/sim/lib/workflows/schedules/utils.ts index 233715c711..2658ddca7e 100644 --- a/apps/sim/lib/workflows/schedules/utils.ts +++ b/apps/sim/lib/workflows/schedules/utils.ts @@ -324,7 +324,7 @@ export function generateCronExpression( * Uses Croner library with timezone support for accurate scheduling across timezones and DST transitions * @param scheduleType - Type of schedule (minutes, hourly, daily, etc) * @param scheduleValues - Object with schedule configuration values - * @param lastRanAt - Optional last execution time + * @param lastRanAt - Optional last execution time (currently unused, Croner calculates from current time) * @returns Date object for next execution time */ export function calculateNextRunTime( diff --git a/apps/sim/lib/workflows/schedules/validation.ts b/apps/sim/lib/workflows/schedules/validation.ts new file mode 100644 index 0000000000..2ad436983e --- /dev/null +++ b/apps/sim/lib/workflows/schedules/validation.ts @@ -0,0 +1,184 @@ +/** + * Client-safe schedule validation functions + * These can be used in both client and server contexts + */ +import { + type BlockState, + calculateNextRunTime, + generateCronExpression, + getScheduleTimeValues, + getSubBlockValue, + validateCronExpression, +} from '@/lib/workflows/schedules/utils' + +/** + * Result of schedule validation + */ +export interface ScheduleValidationResult { + isValid: boolean + error?: string + scheduleType?: string + cronExpression?: string + nextRunAt?: Date + timezone?: string +} + +/** + * Check if a time value is valid (not empty/null/undefined) + */ +function isValidTimeValue(value: string | null | undefined): boolean { + return !!value && value.trim() !== '' && value.includes(':') +} + +/** + * Check if a schedule type has valid configuration + * Uses raw block values to avoid false positives from default parsing + */ +function hasValidScheduleConfig(scheduleType: string | undefined, block: BlockState): boolean { + switch (scheduleType) { + case 'minutes': { + const rawValue = getSubBlockValue(block, 'minutesInterval') + const numValue = Number(rawValue) + return !!rawValue && !Number.isNaN(numValue) && numValue >= 1 && numValue <= 1440 + } + case 'hourly': { + const rawValue = getSubBlockValue(block, 'hourlyMinute') + const numValue = Number(rawValue) + return rawValue !== '' && !Number.isNaN(numValue) && numValue >= 0 && numValue <= 59 + } + case 'daily': { + const rawTime = getSubBlockValue(block, 'dailyTime') + return isValidTimeValue(rawTime) + } + case 'weekly': { + const rawDay = getSubBlockValue(block, 'weeklyDay') + const rawTime = getSubBlockValue(block, 'weeklyDayTime') + return !!rawDay && isValidTimeValue(rawTime) + } + case 'monthly': { + const rawDay = getSubBlockValue(block, 'monthlyDay') + const rawTime = getSubBlockValue(block, 'monthlyTime') + const dayNum = Number(rawDay) + return ( + !!rawDay && + !Number.isNaN(dayNum) && + dayNum >= 1 && + dayNum <= 31 && + isValidTimeValue(rawTime) + ) + } + case 'custom': + return !!getSubBlockValue(block, 'cronExpression') + default: + return false + } +} + +/** + * Get human-readable error message for missing schedule configuration + */ +function getMissingConfigError(scheduleType: string): string { + switch (scheduleType) { + case 'minutes': + return 'Minutes interval is required for minute-based schedules' + case 'hourly': + return 'Minute value is required for hourly schedules' + case 'daily': + return 'Time is required for daily schedules' + case 'weekly': + return 'Day and time are required for weekly schedules' + case 'monthly': + return 'Day and time are required for monthly schedules' + case 'custom': + return 'Cron expression is required for custom schedules' + default: + return 'Schedule type is required' + } +} + +/** + * Find schedule blocks in a workflow's blocks + */ +export function findScheduleBlocks(blocks: Record): BlockState[] { + return Object.values(blocks).filter((block) => block.type === 'schedule') +} + +/** + * Validate a schedule block's configuration + * Returns validation result with error details if invalid + */ +export function validateScheduleBlock(block: BlockState): ScheduleValidationResult { + const scheduleType = getSubBlockValue(block, 'scheduleType') + + if (!scheduleType) { + return { + isValid: false, + error: 'Schedule type is required', + } + } + + const hasValidConfig = hasValidScheduleConfig(scheduleType, block) + + if (!hasValidConfig) { + return { + isValid: false, + error: getMissingConfigError(scheduleType), + scheduleType, + } + } + + const timezone = getSubBlockValue(block, 'timezone') || 'UTC' + + try { + // Get parsed schedule values (safe to use after validation passes) + const scheduleValues = getScheduleTimeValues(block) + const sanitizedScheduleValues = + scheduleType !== 'custom' ? { ...scheduleValues, cronExpression: null } : scheduleValues + + const cronExpression = generateCronExpression(scheduleType, sanitizedScheduleValues) + + const validation = validateCronExpression(cronExpression, timezone) + if (!validation.isValid) { + return { + isValid: false, + error: `Invalid cron expression: ${validation.error}`, + scheduleType, + } + } + + const nextRunAt = calculateNextRunTime(scheduleType, sanitizedScheduleValues) + + return { + isValid: true, + scheduleType, + cronExpression, + nextRunAt, + timezone, + } + } catch (error) { + return { + isValid: false, + error: error instanceof Error ? error.message : 'Failed to generate schedule', + scheduleType, + } + } +} + +/** + * Validate all schedule blocks in a workflow + * Returns the first validation error found, or success if all are valid + */ +export function validateWorkflowSchedules( + blocks: Record +): ScheduleValidationResult { + const scheduleBlocks = findScheduleBlocks(blocks) + + for (const block of scheduleBlocks) { + const result = validateScheduleBlock(block) + if (!result.isValid) { + return result + } + } + + return { isValid: true } +} diff --git a/apps/sim/stores/constants.ts b/apps/sim/stores/constants.ts index 8c7ef86707..5e82494708 100644 --- a/apps/sim/stores/constants.ts +++ b/apps/sim/stores/constants.ts @@ -1,6 +1,5 @@ export const API_ENDPOINTS = { ENVIRONMENT: '/api/environment', - SCHEDULE: '/api/schedules', SETTINGS: '/api/settings', WORKFLOWS: '/api/workflows', WORKSPACE_PERMISSIONS: (id: string) => `/api/workspaces/${id}/permissions`, diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index c3bdf63183..3f8e781362 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -3,7 +3,6 @@ import { devtools } from 'zustand/middleware' import { withOptimisticUpdate } from '@/lib/core/utils/optimistic-update' import { createLogger } from '@/lib/logs/console/logger' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' -import { API_ENDPOINTS } from '@/stores/constants' import { useVariablesStore } from '@/stores/panel/variables/store' import type { DeploymentStatus, @@ -673,21 +672,6 @@ export const useWorkflowRegistry = create()( } logger.info(`Successfully deleted workflow ${id} from database`) - - fetch(API_ENDPOINTS.SCHEDULE, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workflowId: id, - state: { - blocks: {}, - edges: [], - loops: {}, - }, - }), - }).catch((error) => { - logger.error(`Error cancelling schedule for deleted workflow ${id}:`, error) - }) }, rollback: (originalState) => { set({ diff --git a/apps/sim/stores/workflows/subblock/store.ts b/apps/sim/stores/workflows/subblock/store.ts index c68812c4c1..5b4a7ab37e 100644 --- a/apps/sim/stores/workflows/subblock/store.ts +++ b/apps/sim/stores/workflows/subblock/store.ts @@ -27,8 +27,6 @@ export const useSubBlockStore = create()( workflowValues: {}, loadingWebhooks: new Set(), checkedWebhooks: new Set(), - loadingSchedules: new Set(), - checkedSchedules: new Set(), setValue: (blockId: string, subBlockId: string, value: any) => { const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId diff --git a/apps/sim/stores/workflows/subblock/types.ts b/apps/sim/stores/workflows/subblock/types.ts index d6f13fd92b..243e12bf01 100644 --- a/apps/sim/stores/workflows/subblock/types.ts +++ b/apps/sim/stores/workflows/subblock/types.ts @@ -2,8 +2,6 @@ export interface SubBlockState { workflowValues: Record>> // Store values per workflow ID loadingWebhooks: Set // Track which blockIds are currently loading webhooks checkedWebhooks: Set // Track which blockIds have been checked for webhooks - loadingSchedules: Set // Track which blockIds are currently loading schedules - checkedSchedules: Set // Track which blockIds have been checked for schedules } export interface SubBlockStore extends SubBlockState { diff --git a/apps/sim/triggers/constants.ts b/apps/sim/triggers/constants.ts index ff511b2e92..3c47d671f5 100644 --- a/apps/sim/triggers/constants.ts +++ b/apps/sim/triggers/constants.ts @@ -31,7 +31,7 @@ export const TRIGGER_PERSISTED_SUBBLOCK_IDS: string[] = [ ] /** - * Trigger and schedule-related subblock IDs that represent runtime metadata. They should remain + * Trigger-related subblock IDs that represent runtime metadata. They should remain * in the workflow state but must not be modified or cleared by diff operations. */ export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [ @@ -39,5 +39,4 @@ export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [ 'triggerPath', 'testUrl', 'testUrlExpiresAt', - 'scheduleId', ] diff --git a/bun.lock b/bun.lock index a17a532417..8b813ec712 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ "drizzle-kit": "^0.31.4", "husky": "9.1.7", "lint-staged": "16.0.0", - "turbo": "2.7.1", + "turbo": "2.7.2", }, }, "apps/docs": { @@ -3304,19 +3304,19 @@ "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - "turbo": ["turbo@2.7.1", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.1", "turbo-darwin-arm64": "2.7.1", "turbo-linux-64": "2.7.1", "turbo-linux-arm64": "2.7.1", "turbo-windows-64": "2.7.1", "turbo-windows-arm64": "2.7.1" }, "bin": { "turbo": "bin/turbo" } }, "sha512-zAj9jGc7VDvuAo/5Jbos4QTtWz9uUpkMhMKGyTjDJkx//hdL2bM31qQoJSAbU+7JyK5vb0LPzpwf6DUt3zayqg=="], + "turbo": ["turbo@2.7.2", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.2", "turbo-darwin-arm64": "2.7.2", "turbo-linux-64": "2.7.2", "turbo-linux-arm64": "2.7.2", "turbo-windows-64": "2.7.2", "turbo-windows-arm64": "2.7.2" }, "bin": { "turbo": "bin/turbo" } }, "sha512-5JIA5aYBAJSAhrhbyag1ZuMSgUZnHtI+Sq3H8D3an4fL8PeF+L1yYvbEJg47akP1PFfATMf5ehkqFnxfkmuwZQ=="], - "turbo-darwin-64": ["turbo-darwin-64@2.7.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-EaA7UfYujbY9/Ku0WqPpvfctxm91h9LF7zo8vjielz+omfAPB54Si+ADmUoBczBDC6RoLgbURC3GmUW2alnjJg=="], + "turbo-darwin-64": ["turbo-darwin-64@2.7.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-dxY3X6ezcT5vm3coK6VGixbrhplbQMwgNsCsvZamS/+/6JiebqW9DKt4NwpgYXhDY2HdH00I7FWs3wkVuan4rA=="], - "turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/pWGSygtBugd7sKQOeMm+jKY3qN1vyB0RiHBM6bN/6qUOo2VHo8IQwBTIaSgINN4Ue6fzEU+WfePNvonSU9yXw=="], + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1bXmuwPLqNFt3mzrtYcVx1sdJ8UYb124Bf48nIgcpMCGZy3kDhgxNv1503kmuK/37OGOZbsWSQFU4I08feIuSg=="], - "turbo-linux-64": ["turbo-linux-64@2.7.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Y5H11mdhASw/dJuRFyGtTCDFX5/MPT73EKsVEiHbw5MkFc77lx3nMc5L/Q7bKEhef/vYJAsAb61QuHsB6qdP8Q=="], + "turbo-linux-64": ["turbo-linux-64@2.7.2", "", { "os": "linux", "cpu": "x64" }, "sha512-kP+TiiMaiPugbRlv57VGLfcjFNsFbo8H64wMBCPV2270Or2TpDCBULMzZrvEsvWFjT3pBFvToYbdp8/Kw0jAQg=="], - "turbo-linux-arm64": ["turbo-linux-arm64@2.7.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-L/r77jD7cqIEXoyu2LGBUrTY5GJSi/XcGLsQ2nZ/fefk6x3MpljTvwsXUVG1BUkiBPc4zaKRj6yGyWMo5MbLxQ=="], + "turbo-linux-arm64": ["turbo-linux-arm64@2.7.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-VDJwQ0+8zjAfbyY6boNaWfP6RIez4ypKHxwkuB6SrWbOSk+vxTyW5/hEjytTwK8w/TsbKVcMDyvpora8tEsRFw=="], - "turbo-windows-64": ["turbo-windows-64@2.7.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rkeuviXZ/1F7lCare7TNKvYtT/SH9dZR55FAMrxrFRh88b+ZKwlXEBfq5/1OctEzRUo/VLIm+s5LJMOEy+QshA=="], + "turbo-windows-64": ["turbo-windows-64@2.7.2", "", { "os": "win32", "cpu": "x64" }, "sha512-rPjqQXVnI6A6oxgzNEE8DNb6Vdj2Wwyhfv3oDc+YM3U9P7CAcBIlKv/868mKl4vsBtz4ouWpTQNXG8vljgJO+w=="], - "turbo-windows-arm64": ["turbo-windows-arm64@2.7.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-1rZk9htm3+iP/rWCf/h4/DFQey9sMs2TJPC4T5QQfwqAdMWsphgrxBuFqHdxczlbBCgbWNhVw0CH2bTxe1/GFg=="], + "turbo-windows-arm64": ["turbo-windows-arm64@2.7.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-tcnHvBhO515OheIFWdxA+qUvZzNqqcHbLVFc1+n+TJ1rrp8prYicQtbtmsiKgMvr/54jb9jOabU62URAobnB7g=="], "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], diff --git a/package.json b/package.json index 7d82e0de6b..ef795e12ed 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "drizzle-kit": "^0.31.4", "husky": "9.1.7", "lint-staged": "16.0.0", - "turbo": "2.7.1" + "turbo": "2.7.2" }, "lint-staged": { "*.{js,jsx,ts,tsx,json,css,scss}": [ From 3a50ce4d99bcb14c2c87edffa25503124e1a4e9c Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 23 Dec 2025 19:03:43 -0800 Subject: [PATCH 18/18] feat(i18n): update translations (#2568) Co-authored-by: waleedlatif1 --- .../content/docs/de/triggers/schedule.mdx | 60 ++++++++----------- .../content/docs/es/triggers/schedule.mdx | 56 +++++++---------- .../content/docs/fr/triggers/schedule.mdx | 60 ++++++++----------- .../content/docs/ja/triggers/schedule.mdx | 60 ++++++++----------- .../content/docs/zh/triggers/schedule.mdx | 60 ++++++++----------- apps/docs/i18n.lock | 22 +++---- 6 files changed, 135 insertions(+), 183 deletions(-) diff --git a/apps/docs/content/docs/de/triggers/schedule.mdx b/apps/docs/content/docs/de/triggers/schedule.mdx index 08211198da..4fdb02fc18 100644 --- a/apps/docs/content/docs/de/triggers/schedule.mdx +++ b/apps/docs/content/docs/de/triggers/schedule.mdx @@ -5,7 +5,6 @@ title: Zeitplan import { Callout } from 'fumadocs-ui/components/callout' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { Image } from '@/components/ui/image' -import { Video } from '@/components/ui/video' Der Zeitplan-Block löst Workflows automatisch nach einem wiederkehrenden Zeitplan zu bestimmten Intervallen oder Zeiten aus. @@ -21,54 +20,47 @@ Der Zeitplan-Block löst Workflows automatisch nach einem wiederkehrenden Zeitpl ## Zeitplan-Optionen -Konfigurieren Sie, wann Ihr Workflow ausgeführt wird, mit den Dropdown-Optionen: +Konfigurieren Sie, wann Ihr Workflow ausgeführt wird:
    -
  • Alle paar Minuten: 5, 15, 30 Minuten-Intervalle
  • -
  • Stündlich: Jede Stunde oder alle paar Stunden
  • -
  • Täglich: Einmal oder mehrmals pro Tag
  • -
  • Wöchentlich: Bestimmte Wochentage
  • -
  • Monatlich: Bestimmte Tage des Monats
  • +
  • Alle X Minuten: Ausführung in Minutenintervallen (1-1440)
  • +
  • Stündlich: Ausführung zu einer bestimmten Minute jeder Stunde
  • +
  • Täglich: Ausführung zu einer bestimmten Uhrzeit jeden Tag
  • +
  • Wöchentlich: Ausführung an einem bestimmten Tag und einer bestimmten Uhrzeit jede Woche
  • +
  • Monatlich: Ausführung an einem bestimmten Tag und einer bestimmten Uhrzeit jeden Monat
-

Verwenden Sie Cron-Ausdrücke für erweiterte Zeitplanung:

+

Verwenden Sie Cron-Ausdrücke für erweiterte Planung:

0 9 * * 1-5 - Jeden Wochentag um 9 Uhr
*/15 * * * * - Alle 15 Minuten
-
0 0 1 * * - Am ersten Tag jedes Monats
+
0 0 1 * * - Erster Tag jedes Monats
-## Zeitpläne konfigurieren +## Aktivierung -Wenn ein Workflow geplant ist: -- Der Zeitplan wird **aktiv** und zeigt die nächste Ausführungszeit an -- Klicken Sie auf die Schaltfläche **"Geplant"**, um den Zeitplan zu deaktivieren -- Zeitpläne werden nach **3 aufeinanderfolgenden Fehlern** automatisch deaktiviert +Zeitpläne sind an die Workflow-Bereitstellung gebunden: -
- Aktiver Zeitplan-Block -
+- **Workflow bereitstellen** → Zeitplan wird aktiv und beginnt mit der Ausführung +- **Workflow-Bereitstellung aufheben** → Zeitplan wird entfernt +- **Workflow erneut bereitstellen** → Zeitplan wird mit aktueller Konfiguration neu erstellt -
- Deaktivierter Zeitplan -
+ +Sie müssen Ihren Workflow bereitstellen, damit der Zeitplan mit der Ausführung beginnt. Konfigurieren Sie den Zeitplan-Block und stellen Sie ihn dann über die Symbolleiste bereit. + + +## Automatische Deaktivierung + +Zeitpläne werden nach **10 aufeinanderfolgenden Fehlschlägen** automatisch deaktiviert, um unkontrollierte Fehler zu verhindern. Bei Deaktivierung: + +- Erscheint ein Warnhinweis auf dem Zeitplan-Block +- Die Ausführung des Zeitplans wird gestoppt +- Klicken Sie auf den Hinweis, um den Zeitplan zu reaktivieren
-Deaktivierte Zeitpläne zeigen an, wann sie zuletzt aktiv waren. Klicken Sie auf das **"Deaktiviert"**-Badge, um den Zeitplan wieder zu aktivieren. - -Zeitplan-Blöcke können keine eingehenden Verbindungen empfangen und dienen ausschließlich als Workflow-Auslöser. +Zeitplan-Blöcke können keine eingehenden Verbindungen empfangen und dienen ausschließlich als Workflow-Einstiegspunkte. \ No newline at end of file diff --git a/apps/docs/content/docs/es/triggers/schedule.mdx b/apps/docs/content/docs/es/triggers/schedule.mdx index b06aec135c..636e87b19d 100644 --- a/apps/docs/content/docs/es/triggers/schedule.mdx +++ b/apps/docs/content/docs/es/triggers/schedule.mdx @@ -5,7 +5,6 @@ title: Programación import { Callout } from 'fumadocs-ui/components/callout' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { Image } from '@/components/ui/image' -import { Video } from '@/components/ui/video' El bloque de Programación activa automáticamente flujos de trabajo de forma recurrente en intervalos o momentos específicos. @@ -21,16 +20,16 @@ El bloque de Programación activa automáticamente flujos de trabajo de forma re ## Opciones de programación -Configura cuándo se ejecuta tu flujo de trabajo utilizando las opciones desplegables: +Configura cuándo se ejecuta tu flujo de trabajo:
    -
  • Cada pocos minutos: intervalos de 5, 15, 30 minutos
  • -
  • Por hora: Cada hora o cada pocas horas
  • -
  • Diariamente: Una o varias veces al día
  • -
  • Semanalmente: Días específicos de la semana
  • -
  • Mensualmente: Días específicos del mes
  • +
  • Cada X minutos: ejecutar en intervalos de minutos (1-1440)
  • +
  • Cada hora: ejecutar en un minuto específico cada hora
  • +
  • Diariamente: ejecutar a una hora específica cada día
  • +
  • Semanalmente: ejecutar en un día y hora específicos cada semana
  • +
  • Mensualmente: ejecutar en un día y hora específicos cada mes
@@ -43,32 +42,25 @@ Configura cuándo se ejecuta tu flujo de trabajo utilizando las opciones despleg
-## Configuración de programaciones +## Activación -Cuando un flujo de trabajo está programado: -- La programación se vuelve **activa** y muestra el próximo tiempo de ejecución -- Haz clic en el botón **"Programado"** para desactivar la programación -- Las programaciones se desactivan automáticamente después de **3 fallos consecutivos** +Las programaciones están vinculadas al despliegue del flujo de trabajo: -
- Bloque de programación activo -
+- **Desplegar flujo de trabajo** → la programación se activa y comienza a ejecutarse +- **Retirar flujo de trabajo** → la programación se elimina +- **Redesplegar flujo de trabajo** → la programación se recrea con la configuración actual -
- Programación desactivada -
+ +Debes desplegar tu flujo de trabajo para que la programación comience a ejecutarse. Configura el bloque de programación y luego despliega desde la barra de herramientas. + + +## Desactivación automática + +Las programaciones se desactivan automáticamente después de **10 fallos consecutivos** para evitar errores descontrolados. Cuando se desactiva: + +- Aparece una insignia de advertencia en el bloque de programación +- La programación deja de ejecutarse +- Haz clic en la insignia para reactivar la programación
-Las programaciones desactivadas muestran cuándo estuvieron activas por última vez. Haz clic en la insignia **"Desactivado"** para reactivar la programación. - -Los bloques de programación no pueden recibir conexiones entrantes y funcionan exclusivamente como disparadores de flujos de trabajo. +Los bloques de programación no pueden recibir conexiones entrantes y sirven únicamente como puntos de entrada del flujo de trabajo. \ No newline at end of file diff --git a/apps/docs/content/docs/fr/triggers/schedule.mdx b/apps/docs/content/docs/fr/triggers/schedule.mdx index 25446c3753..df9e112687 100644 --- a/apps/docs/content/docs/fr/triggers/schedule.mdx +++ b/apps/docs/content/docs/fr/triggers/schedule.mdx @@ -5,7 +5,6 @@ title: Planification import { Callout } from 'fumadocs-ui/components/callout' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { Image } from '@/components/ui/image' -import { Video } from '@/components/ui/video' Le bloc Planification déclenche automatiquement des workflows de manière récurrente à des intervalles ou moments spécifiés. @@ -21,54 +20,47 @@ Le bloc Planification déclenche automatiquement des workflows de manière récu ## Options de planification -Configurez quand votre workflow s'exécute en utilisant les options du menu déroulant : +Configurez quand votre workflow s'exécute :
    -
  • Toutes les quelques minutes : intervalles de 5, 15, 30 minutes
  • -
  • Toutes les heures : chaque heure ou toutes les quelques heures
  • -
  • Quotidien : une ou plusieurs fois par jour
  • -
  • Hebdomadaire : jours spécifiques de la semaine
  • -
  • Mensuel : jours spécifiques du mois
  • +
  • Toutes les X minutes : exécution à intervalles de minutes (1-1440)
  • +
  • Toutes les heures : exécution à une minute spécifique chaque heure
  • +
  • Quotidien : exécution à une heure spécifique chaque jour
  • +
  • Hebdomadaire : exécution un jour et une heure spécifiques chaque semaine
  • +
  • Mensuel : exécution un jour et une heure spécifiques chaque mois
-

Utilisez des expressions cron pour une planification avancée :

+

Utilisez les expressions cron pour une planification avancée :

-
0 9 * * 1-5 - Chaque jour de semaine à 9h
+
0 9 * * 1-5 - Chaque jour de semaine à 9 h
*/15 * * * * - Toutes les 15 minutes
0 0 1 * * - Premier jour de chaque mois
-## Configuration des planifications +## Activation -Lorsqu'un workflow est planifié : -- La planification devient **active** et affiche la prochaine heure d'exécution -- Cliquez sur le bouton **"Planifié"** pour désactiver la planification -- Les planifications se désactivent automatiquement après **3 échecs consécutifs** +Les planifications sont liées au déploiement du workflow : -
- Bloc de planification actif -
+- **Déployer le workflow** → la planification devient active et commence à s'exécuter +- **Annuler le déploiement du workflow** → la planification est supprimée +- **Redéployer le workflow** → la planification est recréée avec la configuration actuelle -
- Planification désactivée -
+ +Vous devez déployer votre workflow pour que la planification commence à s'exécuter. Configurez le bloc de planification, puis déployez depuis la barre d'outils. + + +## Désactivation automatique + +Les planifications se désactivent automatiquement après **10 échecs consécutifs** pour éviter les erreurs incontrôlées. Lorsqu'elle est désactivée : + +- Un badge d'avertissement apparaît sur le bloc de planification +- La planification cesse de s'exécuter +- Cliquez sur le badge pour réactiver la planification
-Les planifications désactivées indiquent quand elles ont été actives pour la dernière fois. Cliquez sur le badge **"Désactivé"** pour réactiver la planification. - -Les blocs de planification ne peuvent pas recevoir de connexions entrantes et servent uniquement de déclencheurs de workflow. +Les blocs de planification ne peuvent pas recevoir de connexions entrantes et servent uniquement de points d'entrée de workflow. \ No newline at end of file diff --git a/apps/docs/content/docs/ja/triggers/schedule.mdx b/apps/docs/content/docs/ja/triggers/schedule.mdx index 950096a689..f88d45d937 100644 --- a/apps/docs/content/docs/ja/triggers/schedule.mdx +++ b/apps/docs/content/docs/ja/triggers/schedule.mdx @@ -5,7 +5,6 @@ title: スケジュール import { Callout } from 'fumadocs-ui/components/callout' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { Image } from '@/components/ui/image' -import { Video } from '@/components/ui/video' スケジュールブロックは、指定された間隔または時間に定期的なスケジュールでワークフローを自動的にトリガーします。 @@ -21,20 +20,20 @@ import { Video } from '@/components/ui/video' ## スケジュールオプション -ドロップダウンオプションを使用してワークフローの実行タイミングを設定します: +ワークフローの実行タイミングを設定します: - +
    -
  • 数分ごと:5分、15分、30分間隔
  • -
  • 毎時:1時間ごとまたは数時間ごと
  • -
  • 毎日:1日に1回または複数回
  • -
  • 毎週:週の特定の曜日
  • -
  • 毎月:月の特定の日
  • +
  • X分ごと:分単位の間隔で実行(1〜1440)
  • +
  • 毎時:毎時指定した分に実行
  • +
  • 毎日:毎日指定した時刻に実行
  • +
  • 毎週:毎週指定した曜日と時刻に実行
  • +
  • 毎月:毎月指定した日時に実行
-

高度なスケジューリングにはCron式を使用します:

+

高度なスケジュール設定にはcron式を使用します:

0 9 * * 1-5 - 平日の午前9時
*/15 * * * * - 15分ごと
@@ -43,32 +42,25 @@ import { Video } from '@/components/ui/video' -## スケジュールの設定 +## アクティベーション -ワークフローがスケジュールされると: -- スケジュールが**有効**になり、次の実行時間が表示されます -- **「スケジュール済み」**ボタンをクリックするとスケジュールを無効にできます -- スケジュールは**3回連続で失敗すると**自動的に無効になります +スケジュールはワークフローのデプロイに連動します: -
- アクティブなスケジュールブロック -
+- **ワークフローをデプロイ** → スケジュールが有効になり実行を開始 +- **ワークフローをアンデプロイ** → スケジュールが削除 +- **ワークフローを再デプロイ** → 現在の設定でスケジュールが再作成 -
- 無効化されたスケジュール -
+ +スケジュールを開始するには、ワークフローをデプロイする必要があります。スケジュールブロックを設定してから、ツールバーからデプロイしてください。 + + +## 自動無効化 + +スケジュールは**10回連続で失敗**すると、エラーの連鎖を防ぐため自動的に無効化されます。無効化されると: + +- スケジュールブロックに警告バッジが表示されます +- スケジュールの実行が停止します +- バッジをクリックしてスケジュールを再有効化できます
-無効化されたスケジュールは、最後に有効だった時間を表示します。**「無効」**バッジをクリックすると、スケジュールを再度有効にできます。 - -スケジュールブロックは入力接続を受け取ることができず、純粋なワークフロートリガーとして機能します。 +スケジュールブロックは入力接続を受け取ることができず、ワークフローのエントリーポイントとしてのみ機能します。 \ No newline at end of file diff --git a/apps/docs/content/docs/zh/triggers/schedule.mdx b/apps/docs/content/docs/zh/triggers/schedule.mdx index 2bb2611a36..84d7d2f39e 100644 --- a/apps/docs/content/docs/zh/triggers/schedule.mdx +++ b/apps/docs/content/docs/zh/triggers/schedule.mdx @@ -5,7 +5,6 @@ title: 计划 import { Callout } from 'fumadocs-ui/components/callout' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { Image } from '@/components/ui/image' -import { Video } from '@/components/ui/video' 计划模块会在指定的时间间隔或时间点自动触发工作流。 @@ -21,67 +20,58 @@ import { Video } from '@/components/ui/video' ## 计划选项 -通过下拉选项配置工作流的运行时间: +配置工作流的运行时间:
    -
  • 每隔几分钟:5 分钟、15 分钟、30 分钟的间隔
  • -
  • 每小时:每小时或每隔几小时
  • -
  • 每天:每天一次或多次
  • -
  • 每周:一周中的特定日子
  • -
  • 每月:一个月中的特定日子
  • +
  • 每 X 分钟:按分钟间隔运行(1-1440)
  • +
  • 每小时:每小时在指定的分钟运行
  • +
  • 每天:每天在指定时间运行
  • +
  • 每周:每周在指定的星期和时间运行
  • +
  • 每月:每月在指定的日期和时间运行

使用 cron 表达式进行高级调度:

0 9 * * 1-5 - 每个工作日的上午 9 点
-
*/15 * * * * - 每 15 分钟
+
*/15 * * * * - 每 15 分钟一次
0 0 1 * * - 每月的第一天
-## 配置计划 +## 启用 -当工作流被计划时: -- 计划变为**激活**状态,并显示下次执行时间 -- 点击 **"已计划"** 按钮以停用计划 -- 计划在 **连续失败 3 次** 后会自动停用 +计划与工作流部署相关联: -
- 活动计划块 -
+- **部署工作流** → 计划激活并开始运行 +- **取消部署工作流** → 计划被移除 +- **重新部署工作流** → 计划会以当前配置重新创建 -
- 停用的计划 -
+ +必须先部署工作流,计划才会开始运行。请先配置计划块,然后在工具栏中部署。 + + +## 自动禁用 + +计划在连续 **10 次失败** 后会自动禁用,以防止错误持续发生。禁用后: + +- 计划块上会显示警告徽章 +- 计划将停止执行 +- 点击徽章可重新激活计划
禁用计划
-已禁用的计划会显示上次激活的时间。点击 **"已禁用"** 徽章以重新激活计划。 - -计划块无法接收传入连接,仅作为纯工作流触发器。 +计划块无法接收传入连接,只能作为工作流的入口点。 \ No newline at end of file diff --git a/apps/docs/i18n.lock b/apps/docs/i18n.lock index ea5b814ac3..06265e9b8b 100644 --- a/apps/docs/i18n.lock +++ b/apps/docs/i18n.lock @@ -217,19 +217,21 @@ checksums: content/9: cbca5d806da167603e38e7dc90344e57 fb53ce2c1fc28db4c6c09f5296ff59c6: meta/title: a75428cb811bc50150cecde090a3a0d5 - content/0: c0a142478cc5c515f87d368fa72da818 + content/0: e4684b7201c2aed215c82606e9eaa293 content/1: 3fcad3dff5044fbf0c734bab806c437e content/2: 7c82b7d111a2517b08861c4c0e71eff9 content/3: 1ed1a03c7f922f4b1437594f34ea2afb - content/4: ca43f34465308970910b39fa073e10ec - content/5: f67fd398c98884cf0829682dca6d5d91 - content/6: 72a5feaa2b80a1f22d224e311a0e4efe - content/7: 62261cedf5fff6a13220f3f0b6de661b - content/8: e58bf5c8afb239f2606ec5dfba30fc2f - content/9: 35840d3d91271d11c9449e7f316ff280 - content/10: 2ff1c8bf00c740f66bce8a4a7f768ca8 - content/11: 909f57e2475676b16d90b6605cd3ff43 - content/12: 8f5b5d43297c4ff114ca49395878292b + content/4: d34ebf41fb97810c8398b4064520bd7b + content/5: 5025a2d2e9eadc2b91f323b2862b0a1a + content/6: 913f67efd4923e0f70e29640405e34d2 + content/7: a706670c6362a1b723ccc3d6720ad6af + content/8: ab4fe131de634064f9a7744a11599434 + content/9: 2f6c9564a33ad9f752df55840b0c8e16 + content/10: fef34568e5bbd5a50e2a89412f85302c + content/11: b7ae0ecf6fbaa92b049c718720e4007e + content/12: bcd95e6bef30b6f480fee33800928b13 + content/13: 2ff1c8bf00c740f66bce8a4a7f768ca8 + content/14: 16eb64906b9e981ea3c11525ff5a1c2e 73129cc41f543288d67924faea3172db: meta/title: 8cbe02c3108a0dbe0586dbc18db04efe meta/description: 9540ac7731cebd594afa4ce2fb59ab9d