From 66bab935db0971c1b89f28e8944c59cf3219394d Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 2 May 2026 11:16:25 -0700 Subject: [PATCH 01/11] fix(chat): close SSO auth bypass via checkSSOAccess body flag (#4408) * fix(chat): close SSO auth bypass via checkSSOAccess body flag - Remove checkSSOAccess short-circuit; SSO branch always validates via getSession() - Skip chat_auth cookie issuance/validation for SSO deployments to prevent replay - Split eligibility pre-flight into dedicated POST /api/chat/[identifier]/sso route - Drop .passthrough() and checkSSOAccess from deployed chat contracts - Add SSO branch test coverage in chat utils * fix(chat): cast allowedEmails to string[] for SSO eligibility check * fix(chat): close SSO GET cookie replay and add eligibility rate limit - Skip chat_auth cookie validation for SSO in GET handler (replay vector for pre-fix cookies) - Route SSO GET through getSession() instead of always returning auth_required_sso so post-IdP config fetch works - Add per-IP rate limiting to /api/chat/[identifier]/sso to prevent allowlist enumeration --- apps/sim/app/api/chat/[identifier]/route.ts | 5 +- .../app/api/chat/[identifier]/sso/route.ts | 81 +++++++++++++++++++ apps/sim/app/api/chat/utils.test.ts | 69 ++++++++++++++++ apps/sim/app/api/chat/utils.ts | 36 ++------- apps/sim/ee/sso/components/sso-auth.tsx | 13 ++- apps/sim/lib/api/contracts/chats.ts | 32 ++++++-- scripts/check-api-validation-contracts.ts | 4 +- 7 files changed, 197 insertions(+), 43 deletions(-) create mode 100644 apps/sim/app/api/chat/[identifier]/sso/route.ts diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 24e6b709997..a6dff447355 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -149,7 +149,9 @@ export const POST = withRouteHandler( request ) - setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) + if (deployment.authType !== 'sso') { + setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) + } return response } @@ -358,6 +360,7 @@ export const GET = withRouteHandler( if ( deployment.authType !== 'public' && + deployment.authType !== 'sso' && authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password) ) { diff --git a/apps/sim/app/api/chat/[identifier]/sso/route.ts b/apps/sim/app/api/chat/[identifier]/sso/route.ts new file mode 100644 index 00000000000..812f27df5b3 --- /dev/null +++ b/apps/sim/app/api/chat/[identifier]/sso/route.ts @@ -0,0 +1,81 @@ +import { db } from '@sim/db' +import { chat } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, isNull } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { chatSSOContract } from '@/lib/api/contracts/chats' +import { parseRequest } from '@/lib/api/server' +import type { TokenBucketConfig } from '@/lib/core/rate-limiter' +import { RateLimiter } from '@/lib/core/rate-limiter' +import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment' +import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' + +const logger = createLogger('ChatSSOAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const rateLimiter = new RateLimiter() + +const SSO_IP_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 20, + refillRate: 20, + refillIntervalMs: 15 * 60_000, +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => { + const requestId = generateRequestId() + + const ip = getClientIp(request) + if (ip !== 'unknown') { + const ipRateLimit = await rateLimiter.checkRateLimitDirect( + `chat-sso:ip:${ip}`, + SSO_IP_RATE_LIMIT + ) + if (!ipRateLimit.allowed) { + logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`) + const retryAfter = Math.ceil( + (ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000 + ) + const response = createErrorResponse('Too many requests. Please try again later.', 429) + response.headers.set('Retry-After', String(retryAfter)) + return addCorsHeaders(response, request) + } + } + + const parsed = await parseRequest(chatSSOContract, request, context) + if (!parsed.success) return parsed.response + + const { identifier } = parsed.data.params + const { email } = parsed.data.body + + const [deployment] = await db + .select({ + authType: chat.authType, + allowedEmails: chat.allowedEmails, + isActive: chat.isActive, + }) + .from(chat) + .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) + .limit(1) + + if (!deployment || !deployment.isActive) { + logger.warn(`[${requestId}] SSO check on missing/inactive chat: ${identifier}`) + return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + } + + if (deployment.authType !== 'sso') { + return addCorsHeaders( + createErrorResponse('Chat is not configured for SSO authentication', 400), + request + ) + } + + const eligible = isEmailAllowed(email, (deployment.allowedEmails as string[]) || []) + + return addCorsHeaders(createSuccessResponse({ eligible }), request) + } +) diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 31401d6b5ec..60395c0bbd1 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -19,6 +19,7 @@ const { mockSetDeploymentAuthCookie, mockAddCorsHeaders, mockIsEmailAllowed, + mockGetSession, } = vi.hoisted(() => ({ mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}), mockMergeSubBlockValues: vi.fn().mockReturnValue({}), @@ -26,6 +27,12 @@ const { mockSetDeploymentAuthCookie: vi.fn(), mockAddCorsHeaders: vi.fn((response: unknown) => response), mockIsEmailAllowed: vi.fn(), + mockGetSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, })) const mockDecryptSecret = encryptionMockFns.mockDecryptSecret @@ -285,6 +292,68 @@ describe('Chat API Utils', () => { expect(result3.authorized).toBe(false) expect(result3.error).toBe('Email not authorized') }) + + describe('SSO auth', () => { + const ssoDeployment = { + id: 'chat-id', + authType: 'sso', + allowedEmails: ['user@example.com', '@company.com'], + } + + const postRequest = { + method: 'POST', + cookies: { get: vi.fn().mockReturnValue(null) }, + } as any + + it('rejects when no session is present', async () => { + mockGetSession.mockResolvedValue(null) + + const result = await validateChatAuth('request-id', ssoDeployment, postRequest, { + input: 'hello', + }) + + expect(result.authorized).toBe(false) + expect(result.error).toBe('auth_required_sso') + }) + + it('ignores body-supplied email and uses the session email', async () => { + mockGetSession.mockResolvedValue({ user: { email: 'session@example.com' } }) + mockIsEmailAllowed.mockReturnValue(true) + + await validateChatAuth('request-id', ssoDeployment, postRequest, { + email: 'attacker@evil.com', + input: 'hello', + }) + + expect(mockIsEmailAllowed).toHaveBeenCalledWith( + 'session@example.com', + ssoDeployment.allowedEmails + ) + }) + + it('authorizes execution when session email is allowlisted', async () => { + mockGetSession.mockResolvedValue({ user: { email: 'user@example.com' } }) + mockIsEmailAllowed.mockReturnValue(true) + + const result = await validateChatAuth('request-id', ssoDeployment, postRequest, { + input: 'hello', + }) + + expect(result.authorized).toBe(true) + }) + + it('rejects execution when session email is not allowlisted', async () => { + mockGetSession.mockResolvedValue({ user: { email: 'stranger@other.com' } }) + mockIsEmailAllowed.mockReturnValue(false) + + const result = await validateChatAuth('request-id', ssoDeployment, postRequest, { + input: 'hello', + }) + + expect(result.authorized).toBe(false) + expect(result.error).toBe('Your email is not authorized to access this chat') + }) + }) }) describe('Execution Result Processing', () => { diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 3909dd599fe..5a3d0750e8d 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -95,11 +95,13 @@ export async function validateChatAuth( return { authorized: true } } - const cookieName = `chat_auth_${deployment.id}` - const authCookie = request.cookies.get(cookieName) + if (authType !== 'sso') { + const cookieName = `chat_auth_${deployment.id}` + const authCookie = request.cookies.get(cookieName) - if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) { - return { authorized: true } + if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) { + return { authorized: true } + } } if (authType === 'password') { @@ -173,35 +175,11 @@ export async function validateChatAuth( } if (authType === 'sso') { - if (request.method === 'GET') { - return { authorized: false, error: 'auth_required_sso' } - } - try { - if (!parsedBody) { + if (request.method !== 'GET' && !parsedBody) { return { authorized: false, error: 'SSO authentication is required' } } - const { email, input, checkSSOAccess } = parsedBody - - if (input && !checkSSOAccess) { - return { authorized: false, error: 'auth_required_sso' } - } - - if (checkSSOAccess) { - if (!email) { - return { authorized: false, error: 'Email is required' } - } - - const allowedEmails = deployment.allowedEmails || [] - - if (isEmailAllowed(email, allowedEmails)) { - return { authorized: true } - } - - return { authorized: false, error: 'Email not authorized for SSO access' } - } - const { getSession } = await import('@/lib/auth') const session = await getSession() diff --git a/apps/sim/ee/sso/components/sso-auth.tsx b/apps/sim/ee/sso/components/sso-auth.tsx index 64affa51887..1dd99581741 100644 --- a/apps/sim/ee/sso/components/sso-auth.tsx +++ b/apps/sim/ee/sso/components/sso-auth.tsx @@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation' import { Input, Label, Loader } from '@/components/emcn' import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' -import { authenticateDeployedChatContract } from '@/lib/api/contracts/chats' +import { chatSSOContract } from '@/lib/api/contracts/chats' import { cn } from '@/lib/core/utils/cn' import { quickValidateEmail } from '@/lib/messaging/email/validation' import AuthBackground from '@/app/(auth)/components/auth-background' @@ -69,11 +69,18 @@ export default function SSOAuth({ identifier }: SSOAuthProps) { setIsLoading(true) try { - await requestJson(authenticateDeployedChatContract, { + const { eligible } = await requestJson(chatSSOContract, { params: { identifier }, - body: { email, checkSSOAccess: true }, + body: { email }, }) + if (!eligible) { + setEmailErrors(['Email not authorized for this chat']) + setShowEmailValidationError(true) + setIsLoading(false) + return + } + const callbackUrl = `/chat/${identifier}` const ssoUrl = `/sso?email=${encodeURIComponent(email)}&callbackUrl=${encodeURIComponent(callbackUrl)}` router.push(ssoUrl) diff --git a/apps/sim/lib/api/contracts/chats.ts b/apps/sim/lib/api/contracts/chats.ts index 3d3558adccf..c3e908121b6 100644 --- a/apps/sim/lib/api/contracts/chats.ts +++ b/apps/sim/lib/api/contracts/chats.ts @@ -104,13 +104,10 @@ export const deployedChatConfigSchema = z.object({ }) export type DeployedChatConfig = z.output -export const deployedChatAuthBodySchema = z - .object({ - password: z.string().optional(), - email: z.string().email('Invalid email format').optional().or(z.literal('')), - checkSSOAccess: z.boolean().optional(), - }) - .passthrough() +export const deployedChatAuthBodySchema = z.object({ + password: z.string().optional(), + email: z.string().email('Invalid email format').optional().or(z.literal('')), +}) export type DeployedChatAuthBody = z.input export const deployedChatFileSchema = z.object({ @@ -125,12 +122,20 @@ export const deployedChatPostBodySchema = z.object({ input: z.string().optional(), password: z.string().optional(), email: z.string().email('Invalid email format').optional().or(z.literal('')), - checkSSOAccess: z.boolean().optional(), conversationId: z.string().optional(), files: z.array(deployedChatFileSchema).optional().default([]), }) export type DeployedChatPostBody = z.input +export const chatSSOBodySchema = z.object({ + email: z.string().email('Invalid email address'), +}) + +export const chatSSOResponseSchema = z.object({ + eligible: z.boolean(), +}) +export type ChatSSOResponse = z.output + export const chatEmailOtpRequestBodySchema = z.object({ email: z.string().email('Invalid email address'), }) @@ -198,6 +203,17 @@ export const deployedChatPostContract = defineRouteContract({ }, }) +export const chatSSOContract = defineRouteContract({ + method: 'POST', + path: '/api/chat/[identifier]/sso', + params: chatIdentifierParamsSchema, + body: chatSSOBodySchema, + response: { + mode: 'json', + schema: chatSSOResponseSchema, + }, +}) + export const requestChatEmailOtpContract = defineRouteContract({ method: 'POST', path: '/api/chat/[identifier]/otp', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 751c1919f54..909e680e710 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 717, - zodRoutes: 717, + totalRoutes: 718, + zodRoutes: 718, nonZodRoutes: 0, } as const From 20ee07c6da3cb00ec814d8235fdd50ee5d378177 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 2 May 2026 11:29:50 -0700 Subject: [PATCH 02/11] improvement(blocks): depends on misalignments audit (#4409) * fix(blocks): depends on misalignment * fix overclearing finds --- apps/docs/content/docs/en/tools/stt.mdx | 2 +- apps/sim/app/api/tools/stt/route.ts | 14 +-- apps/sim/blocks/blocks/agent.ts | 3 + apps/sim/blocks/blocks/airtable.ts | 1 + apps/sim/blocks/blocks/asana.ts | 2 + apps/sim/blocks/blocks/elasticsearch.ts | 5 + apps/sim/blocks/blocks/gmail.ts | 4 + apps/sim/blocks/blocks/google_calendar.ts | 2 + apps/sim/blocks/blocks/google_maps.ts | 4 +- apps/sim/blocks/blocks/google_sheets.ts | 2 +- apps/sim/blocks/blocks/guardrails.ts | 9 ++ apps/sim/blocks/blocks/huggingface.ts | 1 + apps/sim/blocks/blocks/image_generator.ts | 16 +++- apps/sim/blocks/blocks/jira.ts | 2 +- .../blocks/blocks/jira_service_management.ts | 2 + apps/sim/blocks/blocks/knowledge.ts | 1 + apps/sim/blocks/blocks/linear.ts | 15 ++- apps/sim/blocks/blocks/mcp.ts | 1 + apps/sim/blocks/blocks/microsoft_teams.ts | 3 + apps/sim/blocks/blocks/monday.ts | 2 + apps/sim/blocks/blocks/notion.ts | 3 + apps/sim/blocks/blocks/outlook.ts | 3 + apps/sim/blocks/blocks/sftp.ts | 3 + apps/sim/blocks/blocks/slack.ts | 6 ++ apps/sim/blocks/blocks/ssh.ts | 3 + apps/sim/blocks/blocks/stagehand.ts | 36 +++++-- apps/sim/blocks/blocks/stt.ts | 14 +-- apps/sim/blocks/blocks/table.ts | 1 + apps/sim/blocks/blocks/trello.ts | 1 + apps/sim/blocks/blocks/tts.ts | 27 ++++++ apps/sim/blocks/blocks/video_generator.ts | 39 ++++++++ apps/sim/blocks/blocks/wealthbox.ts | 2 + apps/sim/blocks/blocks/zoom.ts | 1 + apps/sim/blocks/utils.test.ts | 93 +++++++++++++++++++ apps/sim/blocks/utils.ts | 34 +++++-- apps/sim/hooks/use-collaborative-workflow.ts | 5 +- apps/sim/tools/stt/elevenlabs.ts | 6 +- 37 files changed, 314 insertions(+), 54 deletions(-) diff --git a/apps/docs/content/docs/en/tools/stt.mdx b/apps/docs/content/docs/en/tools/stt.mdx index 6920544178b..eecd270fe1c 100644 --- a/apps/docs/content/docs/en/tools/stt.mdx +++ b/apps/docs/content/docs/en/tools/stt.mdx @@ -120,7 +120,7 @@ Transcribe audio and video files to text using leading AI providers. Supports mu | --------- | ---- | -------- | ----------- | | `provider` | string | Yes | STT provider \(elevenlabs\) | | `apiKey` | string | Yes | ElevenLabs API key | -| `model` | string | No | ElevenLabs model to use \(scribe_v1, scribe_v1_experimental\) | +| `model` | string | No | ElevenLabs model to use \(scribe_v2\) | | `audioFile` | file | No | Audio or video file to transcribe \(e.g., MP3, WAV, M4A, WEBM\) | | `audioFileReference` | file | No | Reference to audio/video file from previous blocks | | `audioUrl` | string | No | URL to audio or video file | diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index 4ff71c05cc0..3779a6b2982 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -20,6 +20,7 @@ import { import type { TranscriptSegment } from '@/tools/stt/types' const logger = createLogger('SttProxyAPI') +const ELEVENLABS_STT_MODEL = 'scribe_v2' export const dynamic = 'force-dynamic' export const maxDuration = 300 // 5 minutes for large files @@ -222,13 +223,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { duration = result.duration confidence = result.confidence } else if (provider === 'elevenlabs') { - const result = await transcribeWithElevenLabs( - audioBuffer, - apiKey, - language, - timestamps, - model - ) + const result = await transcribeWithElevenLabs(audioBuffer, apiKey, language, timestamps) transcript = result.transcript segments = result.segments detectedLanguage = result.language @@ -470,8 +465,7 @@ async function transcribeWithElevenLabs( audioBuffer: Buffer, apiKey: string, language?: string, - timestamps?: 'none' | 'sentence' | 'word', - model?: string + timestamps?: 'none' | 'sentence' | 'word' ): Promise<{ transcript: string segments?: TranscriptSegment[] @@ -481,7 +475,7 @@ async function transcribeWithElevenLabs( const formData = new FormData() const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/mpeg' }) formData.append('file', blob, 'audio.mp3') - formData.append('model_id', model || 'scribe_v1') + formData.append('model_id', ELEVENLABS_STT_MODEL) if (language && language !== 'auto') { formData.append('language_code', language) diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index 7f5a641576a..3a8e704859d 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -344,6 +344,7 @@ Return ONLY the JSON array.`, value: ['conversation', 'sliding_window', 'sliding_window_tokens'], and: { field: 'model', value: MODELS_WITHOUT_MEMORY, not: true }, }, + dependsOn: ['memoryType'], }, { id: 'slidingWindowSize', @@ -355,6 +356,7 @@ Return ONLY the JSON array.`, value: ['sliding_window'], and: { field: 'model', value: MODELS_WITHOUT_MEMORY, not: true }, }, + dependsOn: ['memoryType'], }, { id: 'slidingWindowTokens', @@ -366,6 +368,7 @@ Return ONLY the JSON array.`, value: ['sliding_window_tokens'], and: { field: 'model', value: MODELS_WITHOUT_MEMORY, not: true }, }, + dependsOn: ['memoryType'], }, { id: 'temperature', diff --git a/apps/sim/blocks/blocks/airtable.ts b/apps/sim/blocks/blocks/airtable.ts index ff0e3036040..dd1a4b61d97 100644 --- a/apps/sim/blocks/blocks/airtable.ts +++ b/apps/sim/blocks/blocks/airtable.ts @@ -74,6 +74,7 @@ export const AirtableBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'baseId', placeholder: 'Enter your base ID (e.g., appXXXXXXXXXXXXXX)', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', value: 'listBases', not: true }, required: { field: 'operation', value: 'listBases', not: true }, diff --git a/apps/sim/blocks/blocks/asana.ts b/apps/sim/blocks/blocks/asana.ts index da9bfe57e60..13e0e072725 100644 --- a/apps/sim/blocks/blocks/asana.ts +++ b/apps/sim/blocks/blocks/asana.ts @@ -74,6 +74,7 @@ export const AsanaBlock: BlockConfig = { canonicalParamId: 'workspace', required: true, placeholder: 'Enter Asana workspace GID', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', @@ -123,6 +124,7 @@ export const AsanaBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'getTasks_workspace', placeholder: 'Enter workspace GID', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', diff --git a/apps/sim/blocks/blocks/elasticsearch.ts b/apps/sim/blocks/blocks/elasticsearch.ts index e95a80eba8b..952b2884555 100644 --- a/apps/sim/blocks/blocks/elasticsearch.ts +++ b/apps/sim/blocks/blocks/elasticsearch.ts @@ -63,6 +63,7 @@ export const ElasticsearchBlock: BlockConfig = { placeholder: 'https://localhost:9200', required: true, condition: { field: 'deploymentType', value: 'self_hosted' }, + dependsOn: ['deploymentType'], }, // Cloud ID @@ -73,6 +74,7 @@ export const ElasticsearchBlock: BlockConfig = { placeholder: 'deployment-name:base64-encoded-data', required: true, condition: { field: 'deploymentType', value: 'cloud' }, + dependsOn: ['deploymentType'], }, // Authentication method @@ -96,6 +98,7 @@ export const ElasticsearchBlock: BlockConfig = { password: true, required: true, condition: { field: 'authMethod', value: 'api_key' }, + dependsOn: ['authMethod'], }, // Username @@ -106,6 +109,7 @@ export const ElasticsearchBlock: BlockConfig = { placeholder: 'Enter username', required: true, condition: { field: 'authMethod', value: 'basic_auth' }, + dependsOn: ['authMethod'], }, // Password @@ -117,6 +121,7 @@ export const ElasticsearchBlock: BlockConfig = { password: true, required: true, condition: { field: 'authMethod', value: 'basic_auth' }, + dependsOn: ['authMethod'], }, // Index name - for most operations diff --git a/apps/sim/blocks/blocks/gmail.ts b/apps/sim/blocks/blocks/gmail.ts index df5fa8f67e9..4385e909521 100644 --- a/apps/sim/blocks/blocks/gmail.ts +++ b/apps/sim/blocks/blocks/gmail.ts @@ -251,6 +251,7 @@ Return ONLY the email body - no explanations, no extra text.`, type: 'short-input', canonicalParamId: 'folder', placeholder: 'Enter Gmail label name (e.g., INBOX, SENT, or custom label)', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', value: 'read_gmail' }, }, @@ -333,6 +334,7 @@ Return ONLY the search query - no explanations, no extra text.`, type: 'short-input', canonicalParamId: 'addLabelIds', placeholder: 'Enter label ID (e.g., INBOX, Label_123)', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', value: 'move_gmail' }, required: true, @@ -359,6 +361,7 @@ Return ONLY the search query - no explanations, no extra text.`, type: 'short-input', canonicalParamId: 'removeLabelIds', placeholder: 'Enter label ID to remove (e.g., INBOX)', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', value: 'move_gmail' }, required: false, @@ -412,6 +415,7 @@ Return ONLY the search query - no explanations, no extra text.`, type: 'short-input', canonicalParamId: 'manageLabelId', placeholder: 'Enter label ID (e.g., INBOX, Label_123)', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', value: ['add_label_gmail', 'remove_label_gmail'] }, required: true, diff --git a/apps/sim/blocks/blocks/google_calendar.ts b/apps/sim/blocks/blocks/google_calendar.ts index 08ec3faa9d2..307d3fe21a6 100644 --- a/apps/sim/blocks/blocks/google_calendar.ts +++ b/apps/sim/blocks/blocks/google_calendar.ts @@ -81,6 +81,7 @@ export const GoogleCalendarBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'calendarId', placeholder: 'Enter calendar ID (e.g., primary or calendar@gmail.com)', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', value: 'list_calendars', not: true }, }, @@ -347,6 +348,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, type: 'short-input', canonicalParamId: 'destinationCalendarId', placeholder: 'destination@group.calendar.google.com', + dependsOn: ['credential'], condition: { field: 'operation', value: 'move' }, required: true, mode: 'advanced', diff --git a/apps/sim/blocks/blocks/google_maps.ts b/apps/sim/blocks/blocks/google_maps.ts index 24676cb10ff..7331d45f28d 100644 --- a/apps/sim/blocks/blocks/google_maps.ts +++ b/apps/sim/blocks/blocks/google_maps.ts @@ -411,7 +411,7 @@ export const GoogleMapsBlock: BlockConfig = { config: { tool: (params) => `google_maps_${params.operation}`, params: (params) => { - const { operation, locationBias, ...rest } = params + const { operation, locationBias, addressToValidate, ...rest } = params let location: { lat: number; lng: number } | undefined if (locationBias && typeof locationBias === 'string' && locationBias.includes(',')) { @@ -486,7 +486,7 @@ export const GoogleMapsBlock: BlockConfig = { } } - const address = params.addressToValidate || params.address + const address = operation === 'validate_address' ? addressToValidate : params.address // Parse boolean switches (can come as string or boolean from form) let interpolate: boolean | undefined diff --git a/apps/sim/blocks/blocks/google_sheets.ts b/apps/sim/blocks/blocks/google_sheets.ts index b2258b06c8b..ef61d864445 100644 --- a/apps/sim/blocks/blocks/google_sheets.ts +++ b/apps/sim/blocks/blocks/google_sheets.ts @@ -401,7 +401,7 @@ export const GoogleSheetsV2Block: BlockConfig = { canonicalParamId: 'sheetName', placeholder: 'Name of the sheet/tab (e.g., Sheet1)', required: true, - dependsOn: ['credential'], + dependsOn: { all: ['credential'], any: ['spreadsheetId', 'manualSpreadsheetId'] }, mode: 'advanced', condition: { field: 'operation', value: ['read', 'write', 'update', 'append', 'clear'] }, }, diff --git a/apps/sim/blocks/blocks/guardrails.ts b/apps/sim/blocks/blocks/guardrails.ts index 5eeacd9a638..7f1c08b831f 100644 --- a/apps/sim/blocks/blocks/guardrails.ts +++ b/apps/sim/blocks/blocks/guardrails.ts @@ -70,6 +70,7 @@ export const GuardrailsBlock: BlockConfig = { field: 'validationType', value: ['regex'], }, + dependsOn: ['validationType'], wandConfig: { enabled: true, prompt: `Generate a regular expression pattern based on the user's description. @@ -106,6 +107,7 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes, field: 'validationType', value: ['hallucination'], }, + dependsOn: ['validationType'], }, { id: 'model', @@ -118,6 +120,7 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes, field: 'validationType', value: ['hallucination'], }, + dependsOn: ['validationType'], }, { id: 'threshold', @@ -131,6 +134,7 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes, field: 'validationType', value: ['hallucination'], }, + dependsOn: ['validationType'], }, { id: 'topK', @@ -145,6 +149,7 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes, field: 'validationType', value: ['hallucination'], }, + dependsOn: ['validationType'], }, // Provider credential subblocks - only shown for hallucination validation ...getProviderCredentialSubBlocks().map((subBlock) => ({ @@ -158,6 +163,7 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes, typeof subBlock.condition === 'function' ? subBlock.condition() : subBlock.condition, } : { field: 'validationType' as const, value: ['hallucination'] }, + dependsOn: ['validationType'], })), { id: 'piiEntityTypes', @@ -227,6 +233,7 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes, field: 'validationType', value: ['pii'], }, + dependsOn: ['validationType'], }, { id: 'piiMode', @@ -242,6 +249,7 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes, field: 'validationType', value: ['pii'], }, + dependsOn: ['validationType'], }, { id: 'piiLanguage', @@ -259,6 +267,7 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes, field: 'validationType', value: ['pii'], }, + dependsOn: ['validationType'], }, ], tools: { diff --git a/apps/sim/blocks/blocks/huggingface.ts b/apps/sim/blocks/blocks/huggingface.ts index 9333d0d5902..2e072886a10 100644 --- a/apps/sim/blocks/blocks/huggingface.ts +++ b/apps/sim/blocks/blocks/huggingface.ts @@ -61,6 +61,7 @@ export const HuggingFaceBlock: BlockConfig = { placeholder: 'e.g., deepseek/deepseek-v3-0324, llama3.1-8b, meta-llama/Llama-3.2-3B-Instruct-Turbo', description: 'The model must be available for the selected provider.', + dependsOn: ['provider'], }, { id: 'temperature', diff --git a/apps/sim/blocks/blocks/image_generator.ts b/apps/sim/blocks/blocks/image_generator.ts index 517e25bf7a7..6963cd604fd 100644 --- a/apps/sim/blocks/blocks/image_generator.ts +++ b/apps/sim/blocks/blocks/image_generator.ts @@ -44,6 +44,7 @@ export const ImageGeneratorBlock: BlockConfig = { ], value: () => '1024x1024', condition: { field: 'model', value: 'dall-e-3' }, + dependsOn: ['model'], }, { id: 'size', @@ -57,6 +58,7 @@ export const ImageGeneratorBlock: BlockConfig = { ], value: () => 'auto', condition: { field: 'model', value: 'gpt-image-1' }, + dependsOn: ['model'], }, { id: 'quality', @@ -68,6 +70,7 @@ export const ImageGeneratorBlock: BlockConfig = { ], value: () => 'standard', condition: { field: 'model', value: 'dall-e-3' }, + dependsOn: ['model'], }, { id: 'style', @@ -79,6 +82,7 @@ export const ImageGeneratorBlock: BlockConfig = { ], value: () => 'vivid', condition: { field: 'model', value: 'dall-e-3' }, + dependsOn: ['model'], }, { id: 'background', @@ -91,6 +95,7 @@ export const ImageGeneratorBlock: BlockConfig = { ], value: () => 'auto', condition: { field: 'model', value: 'gpt-image-1' }, + dependsOn: ['model'], }, { id: 'apiKey', @@ -114,22 +119,23 @@ export const ImageGeneratorBlock: BlockConfig = { throw new Error('Prompt is required') } - // Base parameters for all models + const model = params.model || 'dall-e-3' + const size = params.size || (model === 'gpt-image-1' ? 'auto' : '1024x1024') const baseParams = { prompt: params.prompt, - model: params.model || 'dall-e-3', - size: params.size || '1024x1024', + model, + size, apiKey: params.apiKey, } - if (params.model === 'dall-e-3') { + if (model === 'dall-e-3') { return { ...baseParams, quality: params.quality || 'standard', style: params.style || 'vivid', } } - if (params.model === 'gpt-image-1') { + if (model === 'gpt-image-1') { return { ...baseParams, ...(params.background && { background: params.background }), diff --git a/apps/sim/blocks/blocks/jira.ts b/apps/sim/blocks/blocks/jira.ts index de5c671adbc..f5fd53cf39e 100644 --- a/apps/sim/blocks/blocks/jira.ts +++ b/apps/sim/blocks/blocks/jira.ts @@ -168,7 +168,7 @@ export const JiraBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'issueKey', placeholder: 'Enter Jira issue key', - dependsOn: ['credential', 'domain'], + dependsOn: ['credential', 'domain', 'projectId'], condition: { field: 'operation', value: [ diff --git a/apps/sim/blocks/blocks/jira_service_management.ts b/apps/sim/blocks/blocks/jira_service_management.ts index 342cccc0770..9e3952b7837 100644 --- a/apps/sim/blocks/blocks/jira_service_management.ts +++ b/apps/sim/blocks/blocks/jira_service_management.ts @@ -132,6 +132,7 @@ export const JiraServiceManagementBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'serviceDeskId', placeholder: 'Enter service desk ID', + dependsOn: ['credential', 'domain'], mode: 'advanced', required: { field: 'operation', @@ -181,6 +182,7 @@ export const JiraServiceManagementBlock: BlockConfig = { canonicalParamId: 'requestTypeId', required: true, placeholder: 'Enter request type ID', + dependsOn: ['credential', 'domain', 'serviceDeskId'], mode: 'advanced', condition: { field: 'operation', value: ['create_request', 'get_request_type_fields'] }, }, diff --git a/apps/sim/blocks/blocks/knowledge.ts b/apps/sim/blocks/blocks/knowledge.ts index 37c0d5b2914..3d17e9cb402 100644 --- a/apps/sim/blocks/blocks/knowledge.ts +++ b/apps/sim/blocks/blocks/knowledge.ts @@ -171,6 +171,7 @@ export const KnowledgeBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'documentId', placeholder: 'Enter document ID', + dependsOn: ['knowledgeBaseId'], required: true, mode: 'advanced', condition: { diff --git a/apps/sim/blocks/blocks/linear.ts b/apps/sim/blocks/blocks/linear.ts index d8aa7bb493d..4f06a9832ea 100644 --- a/apps/sim/blocks/blocks/linear.ts +++ b/apps/sim/blocks/blocks/linear.ts @@ -197,6 +197,7 @@ export const LinearBlock: BlockConfig = { canonicalParamId: 'teamId', placeholder: 'Enter Linear team ID', mode: 'advanced', + dependsOn: ['credential'], required: { field: 'operation', value: [ @@ -271,6 +272,7 @@ export const LinearBlock: BlockConfig = { canonicalParamId: 'projectId', placeholder: 'Enter Linear project ID', mode: 'advanced', + dependsOn: ['credential', 'teamId'], required: { field: 'operation', value: [ @@ -1090,7 +1092,7 @@ Return ONLY the description text - no explanations.`, }, // Customer request priority/urgency { - id: 'priority', + id: 'customerRequestPriority', title: 'Urgency', type: 'dropdown', options: [ @@ -2001,7 +2003,10 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n ...baseParams, customerId: params.customerId.trim(), body: params.requestBody?.trim(), - priority: params.priority !== undefined ? Number(params.priority) : 0, + priority: + params.customerRequestPriority !== undefined + ? Number(params.customerRequestPriority) + : 0, issueId: params.linkedIssueId?.trim(), projectId: effectiveProjectId || undefined, } @@ -2015,7 +2020,10 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n customerNeedId: params.customerNeedId.trim(), customerId: params.customerId?.trim(), body: params.requestBody?.trim(), - priority: params.priority !== undefined ? Number(params.priority) : undefined, + priority: + params.customerRequestPriority !== undefined + ? Number(params.customerRequestPriority) + : undefined, issueId: params.linkedIssueId?.trim(), projectId: effectiveProjectId || undefined, } @@ -2354,6 +2362,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n stateId: { type: 'string', description: 'Workflow state identifier' }, assigneeId: { type: 'string', description: 'Assignee user identifier' }, priority: { type: 'string', description: 'Priority level' }, + customerRequestPriority: { type: 'string', description: 'Customer request urgency level' }, estimate: { type: 'string', description: 'Estimate points' }, query: { type: 'string', description: 'Search query' }, includeArchived: { type: 'boolean', description: 'Include archived items' }, diff --git a/apps/sim/blocks/blocks/mcp.ts b/apps/sim/blocks/blocks/mcp.ts index 0b5d4da8db6..3a0e51caeae 100644 --- a/apps/sim/blocks/blocks/mcp.ts +++ b/apps/sim/blocks/blocks/mcp.ts @@ -53,6 +53,7 @@ export const McpBlock: BlockConfig = { value: '', not: true, // Show when tool is not empty }, + dependsOn: ['tool'], }, ], tools: { diff --git a/apps/sim/blocks/blocks/microsoft_teams.ts b/apps/sim/blocks/blocks/microsoft_teams.ts index b2a882e489c..0812169a24d 100644 --- a/apps/sim/blocks/blocks/microsoft_teams.ts +++ b/apps/sim/blocks/blocks/microsoft_teams.ts @@ -94,6 +94,7 @@ export const MicrosoftTeamsBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'teamId', placeholder: 'Enter team ID', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', @@ -132,6 +133,7 @@ export const MicrosoftTeamsBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'chatId', placeholder: 'Enter chat ID', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', @@ -169,6 +171,7 @@ export const MicrosoftTeamsBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'channelId', placeholder: 'Enter channel ID', + dependsOn: ['credential', 'teamId'], mode: 'advanced', condition: { field: 'operation', diff --git a/apps/sim/blocks/blocks/monday.ts b/apps/sim/blocks/blocks/monday.ts index 7f566c6181b..818e78a1449 100644 --- a/apps/sim/blocks/blocks/monday.ts +++ b/apps/sim/blocks/blocks/monday.ts @@ -128,6 +128,7 @@ export const MondayBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'boardId', placeholder: 'Enter board ID', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', value: BOARD_OPS }, required: { field: 'operation', value: BOARD_OPS }, @@ -180,6 +181,7 @@ export const MondayBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'groupId', placeholder: 'Enter group ID', + dependsOn: ['credential', 'boardId'], mode: 'advanced', condition: { field: 'operation', diff --git a/apps/sim/blocks/blocks/notion.ts b/apps/sim/blocks/blocks/notion.ts index 5afb6eeeda1..39dfb82b730 100644 --- a/apps/sim/blocks/blocks/notion.ts +++ b/apps/sim/blocks/blocks/notion.ts @@ -79,6 +79,7 @@ export const NotionBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'pageId', placeholder: 'Enter Notion page ID', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', @@ -108,6 +109,7 @@ export const NotionBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'databaseId', placeholder: 'Enter Notion database ID', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', @@ -134,6 +136,7 @@ export const NotionBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'parentId', placeholder: 'ID of parent page', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', value: ['notion_create_page', 'notion_create_database'] }, required: true, diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index 28b0fadf7e6..50d7179931d 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -186,6 +186,7 @@ export const OutlookBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'folder', placeholder: 'Enter Outlook folder name (e.g., INBOX, SENT, or custom folder)', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', value: 'read_outlook' }, }, @@ -233,6 +234,7 @@ export const OutlookBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'destinationId', placeholder: 'Enter folder ID', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', value: 'move_outlook' }, required: true, @@ -280,6 +282,7 @@ export const OutlookBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'copyDestinationId', placeholder: 'Enter folder ID', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', value: 'copy_outlook' }, required: true, diff --git a/apps/sim/blocks/blocks/sftp.ts b/apps/sim/blocks/blocks/sftp.ts index 18b3bd43f11..7a07bce06d9 100644 --- a/apps/sim/blocks/blocks/sftp.ts +++ b/apps/sim/blocks/blocks/sftp.ts @@ -74,6 +74,7 @@ export const SftpBlock: BlockConfig = { password: true, placeholder: 'Your SFTP password', condition: { field: 'authMethod', value: 'password' }, + dependsOn: ['authMethod'], }, { @@ -82,6 +83,7 @@ export const SftpBlock: BlockConfig = { type: 'code', placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', condition: { field: 'authMethod', value: 'privateKey' }, + dependsOn: ['authMethod'], }, { id: 'passphrase', @@ -90,6 +92,7 @@ export const SftpBlock: BlockConfig = { password: true, placeholder: 'Passphrase for encrypted key (optional)', condition: { field: 'authMethod', value: 'privateKey' }, + dependsOn: ['authMethod'], }, { diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index c303faa1c5a..bf503451921 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -179,6 +179,7 @@ export const SlackBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'channel', placeholder: 'Enter Slack channel ID (e.g., C1234567890)', + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, mode: 'advanced', condition: (values?: Record) => { const op = values?.operation as string @@ -238,6 +239,7 @@ export const SlackBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'dmUserId', placeholder: 'Enter Slack user ID (e.g., U1234567890)', + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, mode: 'advanced', condition: { field: 'destinationType', @@ -267,6 +269,7 @@ export const SlackBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'ephemeralUser', placeholder: 'Enter Slack user ID (e.g., U1234567890)', + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, mode: 'advanced', condition: { field: 'operation', @@ -529,6 +532,7 @@ Do not include any explanations, markdown formatting, or other text outside the type: 'short-input', canonicalParamId: 'userId', placeholder: 'Enter Slack user ID (e.g., U1234567890)', + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, mode: 'advanced', condition: { field: 'operation', @@ -748,6 +752,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, type: 'short-input', canonicalParamId: 'presenceUserId', placeholder: 'Enter Slack user ID (e.g., U1234567890)', + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, mode: 'advanced', condition: { field: 'operation', @@ -1108,6 +1113,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, type: 'short-input', canonicalParamId: 'publishUserId', placeholder: 'Enter Slack user ID (e.g., U0BPQUNTA)', + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, mode: 'advanced', condition: { field: 'operation', diff --git a/apps/sim/blocks/blocks/ssh.ts b/apps/sim/blocks/blocks/ssh.ts index 6e80f4a88ca..23a8753e08c 100644 --- a/apps/sim/blocks/blocks/ssh.ts +++ b/apps/sim/blocks/blocks/ssh.ts @@ -83,6 +83,7 @@ export const SSHBlock: BlockConfig = { password: true, placeholder: 'Your SSH password', condition: { field: 'authMethod', value: 'password' }, + dependsOn: ['authMethod'], }, // Private key authentication @@ -92,6 +93,7 @@ export const SSHBlock: BlockConfig = { type: 'code', placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', condition: { field: 'authMethod', value: 'privateKey' }, + dependsOn: ['authMethod'], }, { id: 'passphrase', @@ -100,6 +102,7 @@ export const SSHBlock: BlockConfig = { password: true, placeholder: 'Passphrase for encrypted key (optional)', condition: { field: 'authMethod', value: 'privateKey' }, + dependsOn: ['authMethod'], }, // EXECUTE COMMAND diff --git a/apps/sim/blocks/blocks/stagehand.ts b/apps/sim/blocks/blocks/stagehand.ts index 6c7b0c11e3a..998da6177ba 100644 --- a/apps/sim/blocks/blocks/stagehand.ts +++ b/apps/sim/blocks/blocks/stagehand.ts @@ -351,6 +351,7 @@ Example 3 (Data Collection): type: 'short-input', placeholder: 'Enter your API key for the selected provider', password: true, + dependsOn: ['provider'], required: true, }, ], @@ -361,17 +362,34 @@ Example 3 (Data Collection): return params.operation === 'agent' ? 'stagehand_agent' : 'stagehand_extract' }, params: (params) => { - const next: Record = { ...params } - if (typeof next.maxSteps === 'string') { - const trimmed = next.maxSteps.trim() - if (trimmed === '') { - next.maxSteps = undefined - } else { - const n = Number(trimmed) - next.maxSteps = Number.isFinite(n) ? n : undefined + const baseParams = { + operation: params.operation, + provider: params.provider, + apiKey: params.apiKey, + } + + if (params.operation !== 'agent') { + return { + ...baseParams, + url: params.url, + instruction: params.instruction, + schema: params.schema, } } - return next + + const maxStepsInput = + typeof params.maxSteps === 'string' ? params.maxSteps.trim() : params.maxSteps + const maxSteps = maxStepsInput === '' ? Number.NaN : Number(maxStepsInput) + + return { + ...baseParams, + startUrl: params.startUrl, + task: params.task, + variables: params.variables, + outputSchema: params.outputSchema, + mode: params.mode, + maxSteps: Number.isFinite(maxSteps) ? maxSteps : undefined, + } }, }, }, diff --git a/apps/sim/blocks/blocks/stt.ts b/apps/sim/blocks/blocks/stt.ts index 1f6a3820fcd..9ad540b1d3d 100644 --- a/apps/sim/blocks/blocks/stt.ts +++ b/apps/sim/blocks/blocks/stt.ts @@ -43,6 +43,7 @@ export const SttBlock: BlockConfig = { condition: { field: 'provider', value: 'whisper' }, options: [{ label: 'Whisper-1', id: 'whisper-1' }], value: () => 'whisper-1', + dependsOn: ['provider'], required: true, }, @@ -52,11 +53,9 @@ export const SttBlock: BlockConfig = { title: 'Model', type: 'dropdown', condition: { field: 'provider', value: 'elevenlabs' }, - options: [ - { label: 'Scribe v1', id: 'scribe_v1' }, - { label: 'Scribe v1 Experimental', id: 'scribe_v1_experimental' }, - ], - value: () => 'scribe_v1', + options: [{ label: 'Scribe v2', id: 'scribe_v2' }], + value: () => 'scribe_v2', + dependsOn: ['provider'], required: true, }, @@ -75,6 +74,7 @@ export const SttBlock: BlockConfig = { { label: 'Base', id: 'base' }, ], value: () => 'nova-3', + dependsOn: ['provider'], required: true, }, @@ -86,6 +86,7 @@ export const SttBlock: BlockConfig = { condition: { field: 'provider', value: 'assemblyai' }, options: [{ label: 'Best', id: 'best' }], value: () => 'best', + dependsOn: ['provider'], required: true, }, @@ -104,6 +105,7 @@ export const SttBlock: BlockConfig = { { label: 'Gemini 2.0 Flash', id: 'gemini-2.0-flash-exp' }, ], value: () => 'gemini-2.5-flash', + dependsOn: ['provider'], required: true, }, @@ -295,7 +297,7 @@ export const SttBlock: BlockConfig = { model: { type: 'string', description: - 'Provider-specific model (e.g., scribe_v1 for ElevenLabs, nova-3 for Deepgram, best for AssemblyAI, gemini-2.0-flash-exp for Gemini)', + 'Provider-specific model (e.g., scribe_v2 for ElevenLabs, nova-3 for Deepgram, best for AssemblyAI, gemini-2.0-flash-exp for Gemini)', }, audioFile: { type: 'json', description: 'Audio/video file (UserFile)' }, audioUrl: { type: 'string', description: 'Audio/video URL' }, diff --git a/apps/sim/blocks/blocks/table.ts b/apps/sim/blocks/blocks/table.ts index e75d1731675..a740bb5b872 100644 --- a/apps/sim/blocks/blocks/table.ts +++ b/apps/sim/blocks/blocks/table.ts @@ -230,6 +230,7 @@ export const TableBlock: BlockConfig = { title: 'Row ID', type: 'short-input', placeholder: 'row_xxxxx', + dependsOn: ['tableId'], condition: { field: 'operation', value: ['get_row', 'update_row', 'delete_row'] }, required: true, }, diff --git a/apps/sim/blocks/blocks/trello.ts b/apps/sim/blocks/blocks/trello.ts index 27465ac6ae7..3d1607d4230 100644 --- a/apps/sim/blocks/blocks/trello.ts +++ b/apps/sim/blocks/blocks/trello.ts @@ -125,6 +125,7 @@ export const TrelloBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'boardId', placeholder: 'Enter Trello board ID', + dependsOn: ['credential'], mode: 'advanced', condition: { field: 'operation', diff --git a/apps/sim/blocks/blocks/tts.ts b/apps/sim/blocks/blocks/tts.ts index c6fea970e79..62d050b0c91 100644 --- a/apps/sim/blocks/blocks/tts.ts +++ b/apps/sim/blocks/blocks/tts.ts @@ -56,6 +56,7 @@ export const TtsBlock: BlockConfig = { { label: 'GPT-4o Mini TTS', id: 'gpt-4o-mini-tts' }, ], value: () => 'tts-1', + dependsOn: ['provider'], required: false, }, @@ -78,6 +79,7 @@ export const TtsBlock: BlockConfig = { { label: 'Verse', id: 'verse' }, ], value: () => 'alloy', + dependsOn: ['provider'], required: false, }, @@ -95,6 +97,7 @@ export const TtsBlock: BlockConfig = { { label: 'WAV', id: 'wav' }, ], value: () => 'mp3', + dependsOn: ['provider'], required: false, }, @@ -108,6 +111,7 @@ export const TtsBlock: BlockConfig = { max: 4.0, step: 0.25, value: () => '1.0', + dependsOn: ['provider'], required: false, }, @@ -132,6 +136,7 @@ export const TtsBlock: BlockConfig = { { label: 'Zeus', id: 'aura-zeus-en' }, ], value: () => 'aura-asteria-en', + dependsOn: ['provider'], required: true, }, @@ -149,6 +154,7 @@ export const TtsBlock: BlockConfig = { { label: 'Linear16', id: 'linear16' }, ], value: () => 'mp3', + dependsOn: ['provider'], required: false, }, @@ -169,6 +175,7 @@ export const TtsBlock: BlockConfig = { { label: '48000 Hz', id: '48000' }, ], value: () => '24000', + dependsOn: ['encoding'], required: false, }, @@ -179,6 +186,7 @@ export const TtsBlock: BlockConfig = { type: 'short-input', condition: { field: 'provider', value: 'elevenlabs' }, placeholder: 'Enter ElevenLabs voice ID', + dependsOn: ['provider'], required: true, }, @@ -197,6 +205,7 @@ export const TtsBlock: BlockConfig = { { label: 'Multilingual v1', id: 'eleven_multilingual_v1' }, ], value: () => 'eleven_turbo_v2_5', + dependsOn: ['provider'], required: false, }, @@ -210,6 +219,7 @@ export const TtsBlock: BlockConfig = { max: 1.0, step: 0.05, value: () => '0.5', + dependsOn: ['provider'], required: false, }, @@ -223,6 +233,7 @@ export const TtsBlock: BlockConfig = { max: 1.0, step: 0.05, value: () => '0.8', + dependsOn: ['provider'], required: false, }, @@ -236,6 +247,7 @@ export const TtsBlock: BlockConfig = { max: 1.0, step: 0.05, value: () => '0.0', + dependsOn: ['provider'], required: false, }, @@ -253,6 +265,7 @@ export const TtsBlock: BlockConfig = { { label: 'Sonic Multilingual', id: 'sonic-multilingual' }, ], value: () => 'sonic-3', + dependsOn: ['provider'], required: false, }, @@ -263,6 +276,7 @@ export const TtsBlock: BlockConfig = { type: 'short-input', condition: { field: 'provider', value: 'cartesia' }, placeholder: 'Enter Cartesia voice ID', + dependsOn: ['provider'], required: true, }, @@ -276,6 +290,7 @@ export const TtsBlock: BlockConfig = { max: 2.0, step: 0.1, value: () => '1.0', + dependsOn: ['provider'], required: false, }, @@ -286,6 +301,7 @@ export const TtsBlock: BlockConfig = { type: 'short-input', condition: { field: 'provider', value: 'google' }, placeholder: 'e.g., en-US-Neural2-A', + dependsOn: ['provider'], required: false, }, @@ -297,6 +313,7 @@ export const TtsBlock: BlockConfig = { condition: { field: 'provider', value: 'google' }, placeholder: 'e.g., en-US, es-ES', value: () => 'en-US', + dependsOn: ['provider'], required: true, }, @@ -310,6 +327,7 @@ export const TtsBlock: BlockConfig = { max: 2.0, step: 0.25, value: () => '1.0', + dependsOn: ['provider'], required: false, }, @@ -323,6 +341,7 @@ export const TtsBlock: BlockConfig = { max: 20.0, step: 1.0, value: () => '0.0', + dependsOn: ['provider'], required: false, }, @@ -333,6 +352,7 @@ export const TtsBlock: BlockConfig = { type: 'short-input', condition: { field: 'provider', value: 'azure' }, placeholder: 'e.g., en-US-JennyNeural', + dependsOn: ['provider'], required: false, }, @@ -343,6 +363,7 @@ export const TtsBlock: BlockConfig = { type: 'short-input', condition: { field: 'provider', value: 'azure' }, placeholder: 'e.g., eastus, westus', + dependsOn: ['provider'], required: false, }, @@ -358,6 +379,7 @@ export const TtsBlock: BlockConfig = { { label: 'MP3 48kHz 96kbps', id: 'audio-48khz-96kbitrate-mono-mp3' }, ], value: () => 'audio-24khz-96kbitrate-mono-mp3', + dependsOn: ['provider'], required: false, }, @@ -368,6 +390,7 @@ export const TtsBlock: BlockConfig = { type: 'short-input', condition: { field: 'provider', value: 'azure' }, placeholder: 'e.g., cheerful, sad, angry', + dependsOn: ['provider'], required: false, }, @@ -379,6 +402,7 @@ export const TtsBlock: BlockConfig = { condition: { field: 'provider', value: 'playht' }, placeholder: 'Enter your PlayHT user ID', password: true, + dependsOn: ['provider'], required: true, }, @@ -389,6 +413,7 @@ export const TtsBlock: BlockConfig = { type: 'short-input', condition: { field: 'provider', value: 'playht' }, placeholder: 'Voice ID or manifest URL', + dependsOn: ['provider'], required: false, }, @@ -404,6 +429,7 @@ export const TtsBlock: BlockConfig = { { label: 'Premium', id: 'premium' }, ], value: () => 'standard', + dependsOn: ['provider'], required: false, }, @@ -417,6 +443,7 @@ export const TtsBlock: BlockConfig = { max: 2.0, step: 0.1, value: () => '1.0', + dependsOn: ['provider'], required: false, }, diff --git a/apps/sim/blocks/blocks/video_generator.ts b/apps/sim/blocks/blocks/video_generator.ts index cc7141f3371..eaccc6cde1f 100644 --- a/apps/sim/blocks/blocks/video_generator.ts +++ b/apps/sim/blocks/blocks/video_generator.ts @@ -49,6 +49,7 @@ export const VideoGeneratorBlock: BlockConfig = { { label: 'Veo 3.1', id: 'veo-3.1' }, ], value: () => 'veo-3', + dependsOn: ['provider'], required: false, }, @@ -60,6 +61,7 @@ export const VideoGeneratorBlock: BlockConfig = { condition: { field: 'provider', value: 'luma' }, options: [{ label: 'Ray 2', id: 'ray-2' }], value: () => 'ray-2', + dependsOn: ['provider'], required: false, }, @@ -71,6 +73,7 @@ export const VideoGeneratorBlock: BlockConfig = { condition: { field: 'provider', value: 'minimax' }, options: [{ label: 'Hailuo 2.3', id: 'hailuo-02' }], value: () => 'hailuo-02', + dependsOn: ['provider'], required: false, }, @@ -84,6 +87,7 @@ export const VideoGeneratorBlock: BlockConfig = { { label: 'Standard', id: 'standard' }, ], value: () => 'standard', + dependsOn: ['provider'], required: false, }, @@ -104,6 +108,7 @@ export const VideoGeneratorBlock: BlockConfig = { { label: 'LTXV 0.9.8', id: 'ltxv-0.9.8' }, ], value: () => 'veo-3.1', + dependsOn: ['provider'], required: true, }, @@ -127,6 +132,7 @@ export const VideoGeneratorBlock: BlockConfig = { { label: '10', id: '10' }, ], value: () => '5', + dependsOn: ['provider'], required: false, }, @@ -142,6 +148,7 @@ export const VideoGeneratorBlock: BlockConfig = { { label: '8', id: '8' }, ], value: () => '8', + dependsOn: ['provider'], required: false, }, @@ -156,6 +163,7 @@ export const VideoGeneratorBlock: BlockConfig = { { label: '9', id: '9' }, ], value: () => '5', + dependsOn: ['provider'], required: false, }, @@ -170,6 +178,7 @@ export const VideoGeneratorBlock: BlockConfig = { { label: '10', id: '10' }, ], value: () => '6', + dependsOn: ['provider'], required: false, }, @@ -193,6 +202,7 @@ export const VideoGeneratorBlock: BlockConfig = { { label: '10', id: '10' }, ], value: () => '5', + dependsOn: ['model'], required: false, }, @@ -207,6 +217,7 @@ export const VideoGeneratorBlock: BlockConfig = { { label: '9:16', id: '9:16' }, ], value: () => '16:9', + dependsOn: ['provider'], required: false, }, @@ -222,6 +233,7 @@ export const VideoGeneratorBlock: BlockConfig = { { label: '1:1', id: '1:1' }, ], value: () => '16:9', + dependsOn: ['provider'], required: false, }, @@ -237,6 +249,7 @@ export const VideoGeneratorBlock: BlockConfig = { { label: '1:1', id: '1:1' }, ], value: () => '16:9', + dependsOn: ['provider'], required: false, }, @@ -259,6 +272,7 @@ export const VideoGeneratorBlock: BlockConfig = { { label: '9:16', id: '9:16' }, ], value: () => '16:9', + dependsOn: ['model'], required: false, }, @@ -277,6 +291,7 @@ export const VideoGeneratorBlock: BlockConfig = { { label: '1080p', id: '1080p' }, ], value: () => '1080p', + dependsOn: ['provider'], required: false, }, @@ -292,6 +307,7 @@ export const VideoGeneratorBlock: BlockConfig = { { label: '1080p', id: '1080p' }, ], value: () => '1080p', + dependsOn: ['provider'], required: false, }, @@ -306,6 +322,7 @@ export const VideoGeneratorBlock: BlockConfig = { placeholder: 'Upload reference image', mode: 'basic', multiple: false, + dependsOn: ['provider'], required: true, acceptedTypes: '.jpg,.jpeg,.png,.webp', }, @@ -317,6 +334,7 @@ export const VideoGeneratorBlock: BlockConfig = { type: 'long-input', condition: { field: 'provider', value: 'luma' }, placeholder: 'JSON: [{ "key": "pan_right" }, { "key": "zoom_in" }]', + dependsOn: ['provider'], required: false, }, @@ -326,6 +344,7 @@ export const VideoGeneratorBlock: BlockConfig = { title: 'Prompt Optimizer', type: 'switch', condition: { field: 'provider', value: 'minimax' }, + dependsOn: ['provider'], }, // API Key @@ -463,6 +482,7 @@ export const VideoGeneratorV2Block: BlockConfig = { { label: 'Veo 3.1', id: 'veo-3.1' }, ], value: () => 'veo-3', + dependsOn: ['provider'], required: false, }, { @@ -472,6 +492,7 @@ export const VideoGeneratorV2Block: BlockConfig = { condition: { field: 'provider', value: 'luma' }, options: [{ label: 'Ray 2', id: 'ray-2' }], value: () => 'ray-2', + dependsOn: ['provider'], required: false, }, { @@ -481,6 +502,7 @@ export const VideoGeneratorV2Block: BlockConfig = { condition: { field: 'provider', value: 'minimax' }, options: [{ label: 'Hailuo 2.3', id: 'hailuo-02' }], value: () => 'hailuo-02', + dependsOn: ['provider'], required: false, }, { @@ -493,6 +515,7 @@ export const VideoGeneratorV2Block: BlockConfig = { { label: 'Standard', id: 'standard' }, ], value: () => 'standard', + dependsOn: ['provider'], required: false, }, { @@ -511,6 +534,7 @@ export const VideoGeneratorV2Block: BlockConfig = { { label: 'LTXV 0.9.8', id: 'ltxv-0.9.8' }, ], value: () => 'veo-3.1', + dependsOn: ['provider'], required: true, }, { @@ -530,6 +554,7 @@ export const VideoGeneratorV2Block: BlockConfig = { { label: '10', id: '10' }, ], value: () => '5', + dependsOn: ['provider'], required: false, }, { @@ -543,6 +568,7 @@ export const VideoGeneratorV2Block: BlockConfig = { { label: '8', id: '8' }, ], value: () => '8', + dependsOn: ['provider'], required: false, }, { @@ -555,6 +581,7 @@ export const VideoGeneratorV2Block: BlockConfig = { { label: '9', id: '9' }, ], value: () => '5', + dependsOn: ['provider'], required: false, }, { @@ -567,6 +594,7 @@ export const VideoGeneratorV2Block: BlockConfig = { { label: '10', id: '10' }, ], value: () => '6', + dependsOn: ['provider'], required: false, }, { @@ -588,6 +616,7 @@ export const VideoGeneratorV2Block: BlockConfig = { { label: '10', id: '10' }, ], value: () => '5', + dependsOn: ['model'], required: false, }, { @@ -600,6 +629,7 @@ export const VideoGeneratorV2Block: BlockConfig = { { label: '9:16', id: '9:16' }, ], value: () => '16:9', + dependsOn: ['provider'], required: false, }, { @@ -613,6 +643,7 @@ export const VideoGeneratorV2Block: BlockConfig = { { label: '1:1', id: '1:1' }, ], value: () => '16:9', + dependsOn: ['provider'], required: false, }, { @@ -626,6 +657,7 @@ export const VideoGeneratorV2Block: BlockConfig = { { label: '1:1', id: '1:1' }, ], value: () => '16:9', + dependsOn: ['provider'], required: false, }, { @@ -646,6 +678,7 @@ export const VideoGeneratorV2Block: BlockConfig = { { label: '9:16', id: '9:16' }, ], value: () => '16:9', + dependsOn: ['model'], required: false, }, { @@ -658,6 +691,7 @@ export const VideoGeneratorV2Block: BlockConfig = { { label: '1080p', id: '1080p' }, ], value: () => '1080p', + dependsOn: ['provider'], required: false, }, { @@ -671,6 +705,7 @@ export const VideoGeneratorV2Block: BlockConfig = { { label: '1080p', id: '1080p' }, ], value: () => '1080p', + dependsOn: ['provider'], required: false, }, { @@ -682,6 +717,7 @@ export const VideoGeneratorV2Block: BlockConfig = { placeholder: 'Upload reference image', mode: 'basic', multiple: false, + dependsOn: ['provider'], required: true, acceptedTypes: '.jpg,.jpeg,.png,.webp', }, @@ -693,6 +729,7 @@ export const VideoGeneratorV2Block: BlockConfig = { condition: { field: 'provider', value: 'runway' }, placeholder: 'Reference image from previous blocks', mode: 'advanced', + dependsOn: ['provider'], required: true, }, { @@ -701,6 +738,7 @@ export const VideoGeneratorV2Block: BlockConfig = { type: 'long-input', condition: { field: 'provider', value: 'luma' }, placeholder: 'JSON: [{ "key": "pan_right" }, { "key": "zoom_in" }]', + dependsOn: ['provider'], required: false, }, { @@ -708,6 +746,7 @@ export const VideoGeneratorV2Block: BlockConfig = { title: 'Prompt Optimizer', type: 'switch', condition: { field: 'provider', value: 'minimax' }, + dependsOn: ['provider'], }, { id: 'apiKey', diff --git a/apps/sim/blocks/blocks/wealthbox.ts b/apps/sim/blocks/blocks/wealthbox.ts index abdb86bca24..222d94b5938 100644 --- a/apps/sim/blocks/blocks/wealthbox.ts +++ b/apps/sim/blocks/blocks/wealthbox.ts @@ -69,6 +69,7 @@ export const WealthboxBlock: BlockConfig = { placeholder: 'Enter Contact ID', mode: 'basic', canonicalParamId: 'contactId', + dependsOn: ['credential'], condition: { field: 'operation', value: ['read_contact', 'write_task', 'write_note'] }, }, { @@ -78,6 +79,7 @@ export const WealthboxBlock: BlockConfig = { canonicalParamId: 'contactId', placeholder: 'Enter Contact ID', mode: 'advanced', + dependsOn: ['credential'], condition: { field: 'operation', value: ['read_contact', 'write_task', 'write_note'] }, }, { diff --git a/apps/sim/blocks/blocks/zoom.ts b/apps/sim/blocks/blocks/zoom.ts index c699431a643..93da307012b 100644 --- a/apps/sim/blocks/blocks/zoom.ts +++ b/apps/sim/blocks/blocks/zoom.ts @@ -112,6 +112,7 @@ export const ZoomBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'meetingId', placeholder: 'Enter meeting ID', + dependsOn: ['credential'], mode: 'advanced', required: true, condition: { diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index b59e0ebd17f..309f5990474 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -66,8 +66,11 @@ vi.mock('@/lib/oauth/utils', () => ({ getScopesForService: vi.fn(() => []), })) +import type { SubBlockConfig } from '@/blocks/types' import { getApiKeyCondition, + getDependsOnFields, + getSubBlocksDependingOnChange, parseOptionalBooleanInput, parseOptionalJsonInput, parseOptionalNumberInput, @@ -359,3 +362,93 @@ describe('parseOptionalBooleanInput', () => { expect(parseOptionalBooleanInput('no')).toBeUndefined() }) }) + +describe('getDependsOnFields', () => { + it('returns an empty array when dependsOn is unset', () => { + expect(getDependsOnFields(undefined)).toEqual([]) + }) + + it('returns array dependencies unchanged', () => { + expect(getDependsOnFields(['credential', 'projectId'])).toEqual(['credential', 'projectId']) + }) + + it('flattens all and any dependencies', () => { + expect(getDependsOnFields({ all: ['credential'], any: ['teamId', 'manualTeamId'] })).toEqual([ + 'credential', + 'teamId', + 'manualTeamId', + ]) + }) +}) + +describe('getSubBlocksDependingOnChange', () => { + it('finds direct dependents of a changed subblock', () => { + const subBlocks: SubBlockConfig[] = [ + { id: 'provider', title: 'Provider', type: 'dropdown' }, + { id: 'model', title: 'Model', type: 'dropdown', dependsOn: ['provider'] }, + { id: 'prompt', title: 'Prompt', type: 'long-input' }, + ] + + expect( + getSubBlocksDependingOnChange(subBlocks, 'provider').map((subBlock) => subBlock.id) + ).toEqual(['model']) + }) + + it('matches dependents through canonical basic and advanced siblings', () => { + const subBlocks: SubBlockConfig[] = [ + { + id: 'channel', + title: 'Channel', + type: 'channel-selector', + canonicalParamId: 'channelId', + mode: 'basic', + }, + { + id: 'manualChannel', + title: 'Channel ID', + type: 'short-input', + canonicalParamId: 'channelId', + mode: 'advanced', + }, + { + id: 'messageId', + title: 'Message ID', + type: 'short-input', + dependsOn: ['channelId'], + }, + { + id: 'threadTs', + title: 'Thread Timestamp', + type: 'short-input', + dependsOn: ['otherField'], + }, + ] + + expect( + getSubBlocksDependingOnChange(subBlocks, 'manualChannel').map((subBlock) => subBlock.id) + ).toEqual(['messageId']) + expect( + getSubBlocksDependingOnChange(subBlocks, 'channel').map((subBlock) => subBlock.id) + ).toEqual(['messageId']) + }) + + it('matches object-form dependencies when any listed dependency changes', () => { + const subBlocks: SubBlockConfig[] = [ + { id: 'credential', title: 'Credential', type: 'oauth-input' }, + { id: 'teamId', title: 'Team', type: 'short-input' }, + { + id: 'projectId', + title: 'Project', + type: 'short-input', + dependsOn: { all: ['credential'], any: ['teamId'] }, + }, + ] + + expect( + getSubBlocksDependingOnChange(subBlocks, 'credential').map((subBlock) => subBlock.id) + ).toEqual(['projectId']) + expect( + getSubBlocksDependingOnChange(subBlocks, 'teamId').map((subBlock) => subBlock.id) + ).toEqual(['projectId']) + }) +}) diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index fd5c15bdc0a..b70ca7af504 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -1,6 +1,7 @@ import { toError } from '@sim/utils/errors' import { isAzureConfigured, isHosted, isOllamaConfigured } from '@/lib/core/config/feature-flags' import { getScopesForService } from '@/lib/oauth/utils' +import { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility' import type { BlockOutput, OutputFieldDefinition, SubBlockConfig } from '@/blocks/types' import { getBaseModelProviders, @@ -63,16 +64,6 @@ export function getModelOptions() { }) } -/** - * Checks if a field is included in the dependsOn config. - * Handles both simple array format and object format with all/any fields. - */ -export function isDependency(dependsOn: SubBlockConfig['dependsOn'], field: string): boolean { - if (!dependsOn) return false - if (Array.isArray(dependsOn)) return dependsOn.includes(field) - return dependsOn.all?.includes(field) || dependsOn.any?.includes(field) || false -} - /** * Gets all dependency fields as a flat array. * Handles both simple array format and object format with all/any fields. @@ -83,6 +74,29 @@ export function getDependsOnFields(dependsOn: SubBlockConfig['dependsOn']): stri return [...(dependsOn.all || []), ...(dependsOn.any || [])] } +/** + * Finds subblocks that depend on a changed field, accounting for canonical pairs. + */ +export function getSubBlocksDependingOnChange( + allSubBlocks: SubBlockConfig[], + changedSubBlockId: string +): SubBlockConfig[] { + const canonicalIndex = buildCanonicalIndex(allSubBlocks) + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[changedSubBlockId] + const group = canonicalId ? canonicalIndex.groupsById[canonicalId] : undefined + const changedFields = new Set([changedSubBlockId]) + + if (canonicalId) changedFields.add(canonicalId) + if (group?.basicId) changedFields.add(group.basicId) + for (const advancedId of group?.advancedIds || []) { + changedFields.add(advancedId) + } + + return allSubBlocks.filter((subBlock) => + getDependsOnFields(subBlock.dependsOn).some((field) => changedFields.has(field)) + ) +} + export function resolveOutputType( outputs: Record ): Record { diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index b7eeceb6b18..5f3b16e56ee 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -19,6 +19,7 @@ import { getWorkflowStateContract } from '@/lib/api/contracts' import { useSession } from '@/lib/auth/auth-client' import { useSocket } from '@/app/workspace/providers/socket-provider' import { getBlock } from '@/blocks' +import { getSubBlocksDependingOnChange } from '@/blocks/utils' import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants' import { invalidateDeploymentQueries } from '@/hooks/queries/deployments' import { useUndoRedo } from '@/hooks/use-undo-redo' @@ -1322,9 +1323,7 @@ export function useCollaborativeWorkflow() { const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type const blockConfig = blockType ? getBlock(blockType) : null if (blockConfig?.subBlocks && Array.isArray(blockConfig.subBlocks)) { - const dependents = blockConfig.subBlocks.filter( - (sb: any) => Array.isArray(sb.dependsOn) && sb.dependsOn.includes(subblockId) - ) + const dependents = getSubBlocksDependingOnChange(blockConfig.subBlocks, subblockId) for (const dep of dependents) { if (!dep?.id || dep.id === subblockId) continue const currentDepValue = useSubBlockStore.getState().getValue(blockId, dep.id) diff --git a/apps/sim/tools/stt/elevenlabs.ts b/apps/sim/tools/stt/elevenlabs.ts index 26e074109d5..ee19379d502 100644 --- a/apps/sim/tools/stt/elevenlabs.ts +++ b/apps/sim/tools/stt/elevenlabs.ts @@ -24,7 +24,7 @@ export const elevenLabsSttTool: ToolConfig = { type: 'string', required: false, visibility: 'user-or-llm', - description: 'ElevenLabs model to use (scribe_v1, scribe_v1_experimental)', + description: 'ElevenLabs model to use (scribe_v2)', }, audioFile: { type: 'file', @@ -71,7 +71,7 @@ export const elevenLabsSttTool: ToolConfig = { ) => ({ provider: 'elevenlabs', apiKey: params.apiKey, - model: params.model, + model: 'scribe_v2', audioFile: params.audioFile, audioFileReference: params.audioFileReference, audioUrl: params.audioUrl, @@ -141,7 +141,7 @@ export const elevenLabsSttV2Tool: ToolConfig = { ) => ({ provider: 'elevenlabs', apiKey: params.apiKey, - model: params.model, + model: 'scribe_v2', audioFile: params.audioFile, audioFileReference: params.audioFileReference, language: params.language || 'auto', From 879dab9f196c4d6608c94c558cb7096bd224c397 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 2 May 2026 11:43:06 -0700 Subject: [PATCH 03/11] feat(table): make plan table limits configurable via env vars (#4406) * feat(table): make plan table limits configurable via env vars * fix(table): coerce env table limits to number for skipValidation env * improvement(env): extract envNumber helper for numeric env coercion * improvement(knowledge): use envNumber helper for KB_CONFIG_* env reads * fix(testing): add envNumber to env mock factory * fix(env): allow zero in envNumber for max-throughput configs * fix(env): add min option to envNumber for strict-positive configs --- apps/sim/background/knowledge-processing.ts | 14 ++--- apps/sim/lib/billing/subscriptions/utils.ts | 10 ++-- apps/sim/lib/billing/threshold-billing.ts | 4 +- .../sim/lib/copilot/request/session/buffer.ts | 14 ++--- apps/sim/lib/core/config/env.ts | 34 ++++++++++++ .../knowledge/documents/document-processor.ts | 4 +- apps/sim/lib/knowledge/documents/service.ts | 15 +++--- apps/sim/lib/knowledge/embeddings.ts | 4 +- apps/sim/lib/table/billing.ts | 10 ++-- apps/sim/lib/table/constants.ts | 53 +++++++++++++++++-- helm/sim/values.yaml | 11 ++++ packages/testing/src/mocks/env.mock.ts | 11 ++++ 12 files changed, 141 insertions(+), 43 deletions(-) diff --git a/apps/sim/background/knowledge-processing.ts b/apps/sim/background/knowledge-processing.ts index 5f20d5af285..8441fad1e05 100644 --- a/apps/sim/background/knowledge-processing.ts +++ b/apps/sim/background/knowledge-processing.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { task } from '@trigger.dev/sdk' -import { env } from '@/lib/core/config/env' +import { env, envNumber } from '@/lib/core/config/env' import { processDocumentAsync } from '@/lib/knowledge/documents/service' const logger = createLogger('TriggerKnowledgeProcessing') @@ -23,16 +23,16 @@ export type DocumentProcessingPayload = { export const processDocument = task({ id: 'knowledge-process-document', - maxDuration: env.KB_CONFIG_MAX_DURATION || 600, + maxDuration: envNumber(env.KB_CONFIG_MAX_DURATION, 600), machine: 'large-1x', // 2 vCPU, 2GB RAM - needed for large PDF processing retry: { - maxAttempts: env.KB_CONFIG_MAX_ATTEMPTS || 3, - factor: env.KB_CONFIG_RETRY_FACTOR || 2, - minTimeoutInMs: env.KB_CONFIG_MIN_TIMEOUT || 1000, - maxTimeoutInMs: env.KB_CONFIG_MAX_TIMEOUT || 10000, + maxAttempts: envNumber(env.KB_CONFIG_MAX_ATTEMPTS, 3), + factor: envNumber(env.KB_CONFIG_RETRY_FACTOR, 2), + minTimeoutInMs: envNumber(env.KB_CONFIG_MIN_TIMEOUT, 1000), + maxTimeoutInMs: envNumber(env.KB_CONFIG_MAX_TIMEOUT, 10000), }, queue: { - concurrencyLimit: env.KB_CONFIG_CONCURRENCY_LIMIT || 20, + concurrencyLimit: envNumber(env.KB_CONFIG_CONCURRENCY_LIMIT, 20), name: 'document-processing-queue', }, run: async (payload: DocumentProcessingPayload) => { diff --git a/apps/sim/lib/billing/subscriptions/utils.ts b/apps/sim/lib/billing/subscriptions/utils.ts index 03cbd953017..e4d226f96c2 100644 --- a/apps/sim/lib/billing/subscriptions/utils.ts +++ b/apps/sim/lib/billing/subscriptions/utils.ts @@ -14,7 +14,7 @@ import { isTeam, } from '@/lib/billing/plan-helpers' import { parseEnterpriseSubscriptionMetadata } from '@/lib/billing/types' -import { env } from '@/lib/core/config/env' +import { env, envNumber } from '@/lib/core/config/env' export const ENTITLED_SUBSCRIPTION_STATUSES = ['active', 'past_due'] as const @@ -52,28 +52,28 @@ export function hasUsableSubscriptionAccess( * Get the free tier limit from env or fallback to default */ export function getFreeTierLimit(): number { - return env.FREE_TIER_COST_LIMIT || DEFAULT_FREE_CREDITS + return envNumber(env.FREE_TIER_COST_LIMIT, DEFAULT_FREE_CREDITS) } /** * Get the pro tier limit from env or fallback to default */ export function getProTierLimit(): number { - return env.PRO_TIER_COST_LIMIT || DEFAULT_PRO_TIER_COST_LIMIT + return envNumber(env.PRO_TIER_COST_LIMIT, DEFAULT_PRO_TIER_COST_LIMIT) } /** * Get the team tier limit per seat from env or fallback to default */ export function getTeamTierLimitPerSeat(): number { - return env.TEAM_TIER_COST_LIMIT || DEFAULT_TEAM_TIER_COST_LIMIT + return envNumber(env.TEAM_TIER_COST_LIMIT, DEFAULT_TEAM_TIER_COST_LIMIT) } /** * Get the enterprise tier limit per seat from env or fallback to default */ export function getEnterpriseTierLimitPerSeat(): number { - return env.ENTERPRISE_TIER_COST_LIMIT || DEFAULT_ENTERPRISE_TIER_COST_LIMIT + return envNumber(env.ENTERPRISE_TIER_COST_LIMIT, DEFAULT_ENTERPRISE_TIER_COST_LIMIT) } export function checkEnterprisePlan(subscription: any): boolean { diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index 13b0b700919..f79aae2638a 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -16,12 +16,12 @@ import { } from '@/lib/billing/subscriptions/utils' import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' -import { env } from '@/lib/core/config/env' +import { env, envNumber } from '@/lib/core/config/env' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' const logger = createLogger('ThresholdBilling') -const OVERAGE_THRESHOLD = env.OVERAGE_THRESHOLD_DOLLARS || DEFAULT_OVERAGE_THRESHOLD +const OVERAGE_THRESHOLD = envNumber(env.OVERAGE_THRESHOLD_DOLLARS, DEFAULT_OVERAGE_THRESHOLD) export async function checkAndBillOverageThreshold(userId: string): Promise { try { diff --git a/apps/sim/lib/copilot/request/session/buffer.ts b/apps/sim/lib/copilot/request/session/buffer.ts index 352946b1a66..6ee42bedc97 100644 --- a/apps/sim/lib/copilot/request/session/buffer.ts +++ b/apps/sim/lib/copilot/request/session/buffer.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' -import { env } from '@/lib/core/config/env' +import { env, envNumber } from '@/lib/core/config/env' import { getRedisClient } from '@/lib/core/config/redis' import { type PersistedStreamEventEnvelope, @@ -40,19 +40,11 @@ export type StreamConfig = { export function getStreamConfig(): StreamConfig { return { - ttlSeconds: parsePositiveNumber(env.COPILOT_STREAM_TTL_SECONDS, DEFAULT_TTL_SECONDS), - eventLimit: parsePositiveNumber(env.COPILOT_STREAM_EVENT_LIMIT, DEFAULT_EVENT_LIMIT), + ttlSeconds: envNumber(env.COPILOT_STREAM_TTL_SECONDS, DEFAULT_TTL_SECONDS, { min: 1 }), + eventLimit: envNumber(env.COPILOT_STREAM_EVENT_LIMIT, DEFAULT_EVENT_LIMIT, { min: 1 }), } } -function parsePositiveNumber(value: number | string | undefined, fallback: number) { - if (typeof value === 'number' && Number.isFinite(value) && value > 0) { - return value - } - const parsed = Number(value) - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback -} - async function withRedisRetry( metadata: RedisOperationMetadata, operation: (redis: NonNullable>) => Promise diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 084523e11dc..969324591b0 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -60,6 +60,16 @@ export const env = createEnv({ ENTERPRISE_STORAGE_LIMIT_GB: z.number().optional().default(500), // Default storage limit in GB for enterprise tier (can be overridden per org) BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking + // Table feature limits (per plan). Apply when billing is disabled (free tier defaults) or for billed plans. + FREE_TABLES_LIMIT: z.number().optional(), // Max user tables per workspace on free tier (default: 3) + FREE_TABLE_ROWS_LIMIT: z.number().optional(), // Max rows per table on free tier (default: 1000) + PRO_TABLES_LIMIT: z.number().optional(), // Max user tables per workspace on pro tier (default: 25) + PRO_TABLE_ROWS_LIMIT: z.number().optional(), // Max rows per table on pro tier (default: 5000) + TEAM_TABLES_LIMIT: z.number().optional(), // Max user tables per workspace on team tier (default: 100) + TEAM_TABLE_ROWS_LIMIT: z.number().optional(), // Max rows per table on team tier (default: 10000) + ENTERPRISE_TABLES_LIMIT: z.number().optional(), // Max user tables per workspace on enterprise tier (default: 10000) + ENTERPRISE_TABLE_ROWS_LIMIT: z.number().optional(), // Max rows per table on enterprise tier (default: 1000000) + // Credit-tier Stripe prices (monthly) STRIPE_PRICE_TIER_25_MO: z.string().min(1).optional(), // Pro: $25/mo (6,000 credits) STRIPE_PRICE_TIER_100_MO: z.string().min(1).optional(), // Max: $100/mo (25,000 credits) @@ -504,3 +514,27 @@ export const isFalsy = (value: string | boolean | number | undefined) => typeof value === 'string' ? value.toLowerCase() === 'false' || value === '0' : value === false export { getEnv } + +/** + * Coerce an env-derived value to a finite number ≥ `min`, falling back to the + * provided default when the value is unset, empty, non-finite, or below `min`. + * `min` defaults to `0` so configs like `KB_CONFIG_DELAY_BETWEEN_BATCHES=0` + * (meaning "no delay / max throughput") are honored. Pass `min: 1` for configs + * where zero is invalid (e.g. Redis TTLs, capacity limits). + * + * `createEnv` is configured with `skipValidation: true`, so values declared as + * `z.number()` arrive as raw strings when sourced from `process.env` or Helm. + * Use this helper anywhere a numeric env override is consumed to normalize the + * type at the boundary instead of relying on JS implicit coercion. + */ +export function envNumber( + value: number | string | undefined | null, + fallback: number, + options: { min?: number } = {} +): number { + const min = options.min ?? 0 + if (typeof value === 'number' && Number.isFinite(value) && value >= min) return value + if (value === undefined || value === null || value === '') return fallback + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= min ? parsed : fallback +} diff --git a/apps/sim/lib/knowledge/documents/document-processor.ts b/apps/sim/lib/knowledge/documents/document-processor.ts index 6f3a7d9e7b6..4c0ac6377fd 100644 --- a/apps/sim/lib/knowledge/documents/document-processor.ts +++ b/apps/sim/lib/knowledge/documents/document-processor.ts @@ -13,7 +13,7 @@ import { TokenChunker, } from '@/lib/chunkers' import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' -import { env } from '@/lib/core/config/env' +import { env, envNumber } from '@/lib/core/config/env' import { parseBuffer, parseFile } from '@/lib/file-parsers' import type { FileParseMetadata } from '@/lib/file-parsers/types' import { resolveParserExtension } from '@/lib/knowledge/documents/parser-extension' @@ -30,7 +30,7 @@ const TIMEOUTS = { MISTRAL_OCR_API: 120000, } as const -const MAX_CONCURRENT_CHUNKS = env.KB_CONFIG_CHUNK_CONCURRENCY +const MAX_CONCURRENT_CHUNKS = envNumber(env.KB_CONFIG_CHUNK_CONCURRENCY, 10) type OCRResult = { success: boolean diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index e27a19de25e..2f108797d19 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -30,7 +30,7 @@ import { import { recordUsage } from '@/lib/billing/core/usage-log' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' -import { env } from '@/lib/core/config/env' +import { env, envNumber } from '@/lib/core/config/env' import { getCostMultiplier, isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { processDocument } from '@/lib/knowledge/documents/document-processor' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' @@ -54,12 +54,12 @@ import { calculateCost } from '@/providers/utils' const logger = createLogger('DocumentService') const TIMEOUTS = { - OVERALL_PROCESSING: (env.KB_CONFIG_MAX_DURATION || 600) * 1000, + OVERALL_PROCESSING: envNumber(env.KB_CONFIG_MAX_DURATION, 600) * 1000, } as const const LARGE_DOC_CONFIG = { MAX_CHUNKS_PER_BATCH: 500, - MAX_EMBEDDING_BATCH: env.KB_CONFIG_BATCH_SIZE || 2000, + MAX_EMBEDDING_BATCH: envNumber(env.KB_CONFIG_BATCH_SIZE, 2000), MAX_FILE_SIZE: 100 * 1024 * 1024, MAX_CHUNKS_PER_DOCUMENT: 100000, } @@ -78,10 +78,11 @@ function withTimeout( } const PROCESSING_CONFIG = { - maxConcurrentDocuments: Math.max(1, Math.floor((env.KB_CONFIG_CONCURRENCY_LIMIT || 20) / 5)) || 4, - batchSize: Math.max(1, Math.floor((env.KB_CONFIG_BATCH_SIZE || 20) / 2)) || 10, - delayBetweenBatches: (env.KB_CONFIG_DELAY_BETWEEN_BATCHES || 100) * 2, - delayBetweenDocuments: (env.KB_CONFIG_DELAY_BETWEEN_DOCUMENTS || 50) * 2, + maxConcurrentDocuments: + Math.max(1, Math.floor(envNumber(env.KB_CONFIG_CONCURRENCY_LIMIT, 20) / 5)) || 4, + batchSize: Math.max(1, Math.floor(envNumber(env.KB_CONFIG_BATCH_SIZE, 20) / 2)) || 10, + delayBetweenBatches: envNumber(env.KB_CONFIG_DELAY_BETWEEN_BATCHES, 100) * 2, + delayBetweenDocuments: envNumber(env.KB_CONFIG_DELAY_BETWEEN_DOCUMENTS, 50) * 2, } export function getProcessingConfig() { diff --git a/apps/sim/lib/knowledge/embeddings.ts b/apps/sim/lib/knowledge/embeddings.ts index 1791cc08499..8b0d62da1e9 100644 --- a/apps/sim/lib/knowledge/embeddings.ts +++ b/apps/sim/lib/knowledge/embeddings.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { getBYOKKey } from '@/lib/api-key/byok' import { getRotatingApiKey } from '@/lib/core/config/api-keys' -import { env } from '@/lib/core/config/env' +import { env, envNumber } from '@/lib/core/config/env' import { isRetryableError, retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils' import { DEFAULT_EMBEDDING_MODEL, @@ -15,7 +15,7 @@ import { batchByTokenLimit, estimateTokenCount } from '@/lib/tokenization' const logger = createLogger('EmbeddingUtils') const MAX_TOKENS_PER_REQUEST = 8000 -const MAX_CONCURRENT_BATCHES = env.KB_CONFIG_CONCURRENCY_LIMIT || 50 +const MAX_CONCURRENT_BATCHES = envNumber(env.KB_CONFIG_CONCURRENCY_LIMIT, 50) const EMBEDDING_REQUEST_TIMEOUT_MS = 60_000 export type { EmbeddingModelInfo } from '@/lib/knowledge/embedding-models' diff --git a/apps/sim/lib/table/billing.ts b/apps/sim/lib/table/billing.ts index a517d094c58..dbe0bde215a 100644 --- a/apps/sim/lib/table/billing.ts +++ b/apps/sim/lib/table/billing.ts @@ -8,7 +8,7 @@ import { createLogger } from '@sim/logger' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' -import { type PlanName, TABLE_PLAN_LIMITS, type TablePlanLimits } from './constants' +import { getTablePlanLimits, type PlanName, type TablePlanLimits } from './constants' const logger = createLogger('TableBilling') @@ -22,18 +22,20 @@ const logger = createLogger('TableBilling') * @returns Table limits based on the workspace's billing plan */ export async function getWorkspaceTableLimits(workspaceId: string): Promise { + const planLimits = getTablePlanLimits() + try { const billedAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId) if (!billedAccountUserId) { logger.warn('No billed account found for workspace, using free tier limits', { workspaceId }) - return TABLE_PLAN_LIMITS.free + return planLimits.free } const subscription = await getHighestPrioritySubscription(billedAccountUserId) const planName = getPlanTypeForLimits(subscription?.plan) as PlanName - const limits = TABLE_PLAN_LIMITS[planName] ?? TABLE_PLAN_LIMITS.free + const limits = planLimits[planName] ?? planLimits.free logger.info('Retrieved workspace table limits', { workspaceId, @@ -48,7 +50,7 @@ export async function getWorkspaceTableLimits(workspaceId: string): Promise + +/** + * Returns plan-based table limits, applying env var overrides on top of the + * defaults. When no override is set the value falls back to the hosted-default + * constant so behavior is unchanged for the hosted product. + */ +export function getTablePlanLimits(): TablePlanLimitsByPlan { + return { + free: { + maxTables: envNumber(env.FREE_TABLES_LIMIT, DEFAULT_TABLE_PLAN_LIMITS.free.maxTables), + maxRowsPerTable: envNumber( + env.FREE_TABLE_ROWS_LIMIT, + DEFAULT_TABLE_PLAN_LIMITS.free.maxRowsPerTable + ), + }, + pro: { + maxTables: envNumber(env.PRO_TABLES_LIMIT, DEFAULT_TABLE_PLAN_LIMITS.pro.maxTables), + maxRowsPerTable: envNumber( + env.PRO_TABLE_ROWS_LIMIT, + DEFAULT_TABLE_PLAN_LIMITS.pro.maxRowsPerTable + ), + }, + team: { + maxTables: envNumber(env.TEAM_TABLES_LIMIT, DEFAULT_TABLE_PLAN_LIMITS.team.maxTables), + maxRowsPerTable: envNumber( + env.TEAM_TABLE_ROWS_LIMIT, + DEFAULT_TABLE_PLAN_LIMITS.team.maxRowsPerTable + ), + }, + enterprise: { + maxTables: envNumber( + env.ENTERPRISE_TABLES_LIMIT, + DEFAULT_TABLE_PLAN_LIMITS.enterprise.maxTables + ), + maxRowsPerTable: envNumber( + env.ENTERPRISE_TABLE_ROWS_LIMIT, + DEFAULT_TABLE_PLAN_LIMITS.enterprise.maxRowsPerTable + ), + }, + } +} + export const COLUMN_TYPES = ['string', 'number', 'boolean', 'date', 'json'] as const export const NAME_PATTERN = /^[a-z_][a-z0-9_]*$/i diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 477122f218c..97fbeba5761 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -166,6 +166,17 @@ app: EXECUTION_TIMEOUT_ASYNC_TEAM: "5400" # Team tier async timeout (90 minutes) EXECUTION_TIMEOUT_ASYNC_ENTERPRISE: "5400" # Enterprise tier async timeout (90 minutes) + # Table Feature Limits (per workspace, per plan) + # Apply when billing is disabled (free tier defaults) or for billed plans + FREE_TABLES_LIMIT: "3" # Max user tables per workspace on free tier + FREE_TABLE_ROWS_LIMIT: "1000" # Max rows per table on free tier + PRO_TABLES_LIMIT: "25" # Max user tables per workspace on pro tier + PRO_TABLE_ROWS_LIMIT: "5000" # Max rows per table on pro tier + TEAM_TABLES_LIMIT: "100" # Max user tables per workspace on team tier + TEAM_TABLE_ROWS_LIMIT: "10000" # Max rows per table on team tier + ENTERPRISE_TABLES_LIMIT: "10000" # Max user tables per workspace on enterprise tier + ENTERPRISE_TABLE_ROWS_LIMIT: "1000000" # Max rows per table on enterprise tier + # Isolated-VM Worker Pool Configuration IVM_POOL_SIZE: "4" # Max worker processes in pool IVM_MAX_CONCURRENT: "10000" # Max concurrent executions globally diff --git a/packages/testing/src/mocks/env.mock.ts b/packages/testing/src/mocks/env.mock.ts index f216721afa2..61f733c1ec2 100644 --- a/packages/testing/src/mocks/env.mock.ts +++ b/packages/testing/src/mocks/env.mock.ts @@ -53,6 +53,17 @@ export function createEnvMock(overrides: Record = {} typeof value === 'string' ? value.toLowerCase() === 'false' || value === '0' : value === false, + envNumber: ( + value: number | string | undefined | null, + fallback: number, + options: { min?: number } = {} + ): number => { + const min = options.min ?? 0 + if (typeof value === 'number' && Number.isFinite(value) && value >= min) return value + if (value === undefined || value === null || value === '') return fallback + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= min ? parsed : fallback + }, } } From 31cfb74dc26fcbdb36ed5eee758f8f7e038f4933 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 2 May 2026 12:48:12 -0700 Subject: [PATCH 04/11] feat(table): add workflow execution column type (#4338) * Add table triggers for columns and row added * Add async batching job for running column * Add ui improvements, stop mechanism * Use trigger dev for workflow runs * Use unified column sidebar for table * Add socket waits for tables, multi column workflow support * change back to cell based trigger jobs * Reuse code, add view log inline in table view * reorganize db to treat each column separately * adjust column naming strategy * Column ui improvements * fix live update on table * Change new column behavior * fixed errored workfows not showing as stopped * Column sidebar improvements * fix table column swapping behavior * fix bugs * add prompting, fix lint * fix ui stuff * flip feature flag * Add zod contracts fix initial auto-run of columns * ui improvements * Use db filter to query unran rows * Use live workflow run * Change wording for deleting workflow column * Update tools * Add tool to run selected rows * Add mothership tools * adjust col width * Restore ff * fix drizzle migration * fix test --- apps/realtime/src/handlers/index.ts | 2 + apps/realtime/src/handlers/tables.ts | 73 + apps/realtime/src/middleware/permissions.ts | 48 + apps/realtime/src/rooms/memory-manager.ts | 28 +- apps/realtime/src/rooms/redis-manager.ts | 27 +- apps/realtime/src/rooms/types.ts | 41 + apps/realtime/src/routes/http.ts | 46 + .../api/table/[tableId]/cancel-runs/route.ts | 57 + .../[tableId]/groups/[groupId]/run/route.ts | 67 + .../app/api/table/[tableId]/groups/route.ts | 159 + apps/sim/app/api/table/[tableId]/route.ts | 1 + .../api/table/[tableId]/rows/[rowId]/route.ts | 3 + .../rows/[rowId]/run-workflow-group/route.ts | 97 + .../sim/app/api/table/[tableId]/rows/route.ts | 2 + apps/sim/app/api/table/utils.ts | 1 + .../v1/tables/[tableId]/rows/[rowId]/route.ts | 5 + .../resource-header/resource-header.tsx | 34 +- .../components/log-details/log-details.tsx | 5 +- .../column-sidebar/column-sidebar.tsx | 1307 ++ .../components/column-sidebar/column-types.ts | 32 + .../components/context-menu/context-menu.tsx | 16 +- .../components/table/cells/cell-content.tsx | 171 + .../table/cells/expanded-cell-popover.tsx | 209 + .../components/table/cells/inline-editors.tsx | 217 + .../[tableId]/components/table/constants.ts | 6 + .../table/headers/column-header-menu.tsx | 346 + .../table/headers/column-type-icon.tsx | 59 + .../headers/workflow-group-meta-cell.tsx | 293 + .../[tableId]/components/table/table.tsx | 2109 ++- .../[tableId]/components/table/types.ts | 34 + .../[tableId]/components/table/utils.ts | 192 + .../tables/[tableId]/hooks/index.ts | 3 +- .../[tableId]/hooks/use-row-execution.ts | 95 + .../tables/[tableId]/hooks/use-table-data.ts | 83 - .../tables/[tableId]/hooks/use-table.ts | 278 + .../[workspaceId]/tables/[tableId]/types.ts | 6 +- .../output-select/output-select.tsx | 106 +- .../workspace/providers/socket-provider.tsx | 122 + .../background/workflow-column-execution.ts | 323 + apps/sim/blocks/blocks/table.ts | 6 + apps/sim/components/emcn/icons/eye-off.tsx | 33 + apps/sim/components/emcn/icons/index.ts | 1 + apps/sim/hooks/queries/logs.ts | 38 + apps/sim/hooks/queries/tables.ts | 465 +- apps/sim/hooks/queries/workflows.ts | 26 + apps/sim/lib/api/contracts/tables.ts | 184 + .../lib/copilot/generated/tool-catalog-v1.ts | 114 +- .../lib/copilot/generated/tool-schemas-v1.ts | 162 +- .../copilot/tools/server/table/user-table.ts | 416 +- .../lib/core/async-jobs/backends/database.ts | 22 + .../core/async-jobs/backends/trigger-dev.ts | 23 +- apps/sim/lib/core/async-jobs/inline-abort.ts | 35 + apps/sim/lib/core/async-jobs/types.ts | 13 + apps/sim/lib/table/cell-write.ts | 134 + apps/sim/lib/table/column-naming.ts | 56 + apps/sim/lib/table/pluck.ts | 26 + apps/sim/lib/table/service.ts | 1218 +- apps/sim/lib/table/trigger.ts | 165 + apps/sim/lib/table/types.ts | 129 +- apps/sim/lib/table/workflow-columns.ts | 629 + apps/sim/lib/webhooks/processor.ts | 3 +- apps/sim/lib/webhooks/providers/registry.ts | 2 + apps/sim/lib/webhooks/providers/table.ts | 13 + .../lib/workflows/blocks/flatten-outputs.ts | 194 + .../workflows/executor/execute-workflow.ts | 24 +- .../sim/lib/workflows/orchestration/deploy.ts | 21 + apps/sim/stores/logs/utils.ts | 21 +- apps/sim/triggers/registry.ts | 2 + apps/sim/triggers/table/index.ts | 1 + apps/sim/triggers/table/poller.ts | 166 + .../db/migrations/0201_shiny_skullbuster.sql | 1 + .../db/migrations/meta/0201_snapshot.json | 15260 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 7 + scripts/check-api-validation-contracts.ts | 4 +- 75 files changed, 24986 insertions(+), 1338 deletions(-) create mode 100644 apps/realtime/src/handlers/tables.ts create mode 100644 apps/sim/app/api/table/[tableId]/cancel-runs/route.ts create mode 100644 apps/sim/app/api/table/[tableId]/groups/[groupId]/run/route.ts create mode 100644 apps/sim/app/api/table/[tableId]/groups/route.ts create mode 100644 apps/sim/app/api/table/[tableId]/rows/[rowId]/run-workflow-group/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-types.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/expanded-cell-popover.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/inline-editors.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/constants.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-type-icon.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/types.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/utils.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-row-execution.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-data.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts create mode 100644 apps/sim/background/workflow-column-execution.ts create mode 100644 apps/sim/components/emcn/icons/eye-off.tsx create mode 100644 apps/sim/lib/core/async-jobs/inline-abort.ts create mode 100644 apps/sim/lib/table/cell-write.ts create mode 100644 apps/sim/lib/table/column-naming.ts create mode 100644 apps/sim/lib/table/pluck.ts create mode 100644 apps/sim/lib/table/trigger.ts create mode 100644 apps/sim/lib/table/workflow-columns.ts create mode 100644 apps/sim/lib/webhooks/providers/table.ts create mode 100644 apps/sim/lib/workflows/blocks/flatten-outputs.ts create mode 100644 apps/sim/triggers/table/index.ts create mode 100644 apps/sim/triggers/table/poller.ts create mode 100644 packages/db/migrations/0201_shiny_skullbuster.sql create mode 100644 packages/db/migrations/meta/0201_snapshot.json diff --git a/apps/realtime/src/handlers/index.ts b/apps/realtime/src/handlers/index.ts index 6ded2e54741..8977eea550a 100644 --- a/apps/realtime/src/handlers/index.ts +++ b/apps/realtime/src/handlers/index.ts @@ -2,6 +2,7 @@ import { setupConnectionHandlers } from '@/handlers/connection' import { setupOperationsHandlers } from '@/handlers/operations' import { setupPresenceHandlers } from '@/handlers/presence' import { setupSubblocksHandlers } from '@/handlers/subblocks' +import { setupTableHandlers } from '@/handlers/tables' import { setupVariablesHandlers } from '@/handlers/variables' import { setupWorkflowHandlers } from '@/handlers/workflow' import type { AuthenticatedSocket } from '@/middleware/auth' @@ -13,5 +14,6 @@ export function setupAllHandlers(socket: AuthenticatedSocket, roomManager: IRoom setupSubblocksHandlers(socket, roomManager) setupVariablesHandlers(socket, roomManager) setupPresenceHandlers(socket, roomManager) + setupTableHandlers(socket, roomManager) setupConnectionHandlers(socket, roomManager) } diff --git a/apps/realtime/src/handlers/tables.ts b/apps/realtime/src/handlers/tables.ts new file mode 100644 index 00000000000..ae9a7c6f003 --- /dev/null +++ b/apps/realtime/src/handlers/tables.ts @@ -0,0 +1,73 @@ +import { createLogger } from '@sim/logger' +import type { AuthenticatedSocket } from '@/middleware/auth' +import { verifyTableAccess } from '@/middleware/permissions' +import { type IRoomManager, tableRoomName } from '@/rooms/types' + +const logger = createLogger('TableHandlers') + +/** + * Wires `join-table` / `leave-table` socket events. Tables don't track presence + * or last-modified state — joining is a thin wrapper around `socket.join` so the + * Sim API → Realtime HTTP bridge can broadcast row updates back to subscribed clients. + */ +export function setupTableHandlers(socket: AuthenticatedSocket, _roomManager: IRoomManager) { + socket.on('join-table', async ({ tableId }: { tableId?: string }) => { + try { + if (!tableId || typeof tableId !== 'string') { + socket.emit('join-table-error', { + tableId: tableId ?? null, + error: 'tableId required', + code: 'INVALID_TABLE_ID', + retryable: false, + }) + return + } + + const userId = socket.userId + if (!userId) { + socket.emit('join-table-error', { + tableId, + error: 'Authentication required', + code: 'AUTHENTICATION_REQUIRED', + retryable: false, + }) + return + } + + const { hasAccess } = await verifyTableAccess(userId, tableId) + if (!hasAccess) { + socket.emit('join-table-error', { + tableId, + error: 'Access denied to table', + code: 'ACCESS_DENIED', + retryable: false, + }) + return + } + + const room = tableRoomName(tableId) + socket.join(room) + socket.emit('join-table-success', { tableId, socketId: socket.id }) + logger.debug(`Socket ${socket.id} (user ${userId}) joined ${room}`) + } catch (error) { + logger.error(`Error joining table room:`, error) + socket.emit('join-table-error', { + tableId: null, + error: 'Failed to join table', + code: 'JOIN_TABLE_FAILED', + retryable: true, + }) + } + }) + + socket.on('leave-table', async ({ tableId }: { tableId?: string }) => { + try { + if (!tableId || typeof tableId !== 'string') return + const room = tableRoomName(tableId) + socket.leave(room) + logger.debug(`Socket ${socket.id} left ${room}`) + } catch (error) { + logger.error(`Error leaving table room:`, error) + } + }) +} diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index dcc893b1478..db97b16f8a2 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -131,3 +131,51 @@ export async function verifyWorkflowAccess( return { hasAccess: false } } } + +/** + * Verify a user has read access to a table by virtue of workspace permission. + * Mirrors `verifyWorkflowAccess` for the table-room socket join check. + */ +export async function verifyTableAccess( + userId: string, + tableId: string +): Promise<{ hasAccess: boolean; workspaceId?: string }> { + try { + const { userTableDefinitions, permissions } = await import('@sim/db') + const tableData = await db + .select({ workspaceId: userTableDefinitions.workspaceId }) + .from(userTableDefinitions) + .where(and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt))) + .limit(1) + + if (!tableData.length) { + logger.warn(`Table ${tableId} not found`) + return { hasAccess: false } + } + const { workspaceId } = tableData[0] + if (!workspaceId) return { hasAccess: false } + + const [permissionRow] = await db + .select({ permissionType: permissions.permissionType }) + .from(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + .limit(1) + + if (!permissionRow?.permissionType) { + logger.warn( + `User ${userId} has no permission for workspace ${workspaceId} (table ${tableId})` + ) + return { hasAccess: false } + } + return { hasAccess: true, workspaceId } + } catch (error) { + logger.error(`Error verifying table access for user ${userId}, table ${tableId}:`, error) + return { hasAccess: false } + } +} diff --git a/apps/realtime/src/rooms/memory-manager.ts b/apps/realtime/src/rooms/memory-manager.ts index a032e785bb5..0cd37daf493 100644 --- a/apps/realtime/src/rooms/memory-manager.ts +++ b/apps/realtime/src/rooms/memory-manager.ts @@ -1,6 +1,13 @@ import { createLogger } from '@sim/logger' import type { Server } from 'socket.io' -import type { IRoomManager, UserPresence, UserSession, WorkflowRoom } from '@/rooms/types' +import { + type IRoomManager, + type TableRowUpdatedPayload, + tableRoomName, + type UserPresence, + type UserSession, + type WorkflowRoom, +} from '@/rooms/types' const logger = createLogger('MemoryRoomManager') @@ -255,4 +262,23 @@ export class MemoryRoomManager implements IRoomManager { logger.info(`Notified ${room.users.size} users about workflow deployment change: ${workflowId}`) } + + emitToTable(tableId: string, event: string, payload: T): void { + this._io.to(tableRoomName(tableId)).emit(event, payload) + } + + async handleTableRowUpdated(tableId: string, payload: TableRowUpdatedPayload): Promise { + this.emitToTable(tableId, 'table-row-updated', { tableId, ...payload }) + } + + async handleTableRowDeleted(tableId: string, rowId: string): Promise { + this.emitToTable(tableId, 'table-row-deleted', { tableId, rowId }) + } + + async handleTableDeleted(tableId: string): Promise { + logger.info(`Handling table deletion notification for ${tableId}`) + this.emitToTable(tableId, 'table-deleted', { tableId, timestamp: Date.now() }) + // Eject sockets so they don't hold a stale room. Cross-pod safe via socket.io. + await this._io.in(tableRoomName(tableId)).socketsLeave(tableRoomName(tableId)) + } } diff --git a/apps/realtime/src/rooms/redis-manager.ts b/apps/realtime/src/rooms/redis-manager.ts index 0e6b3eadf2b..0fb41417906 100644 --- a/apps/realtime/src/rooms/redis-manager.ts +++ b/apps/realtime/src/rooms/redis-manager.ts @@ -1,7 +1,13 @@ import { createLogger } from '@sim/logger' import { createClient, type RedisClientType } from 'redis' import type { Server } from 'socket.io' -import type { IRoomManager, UserPresence, UserSession } from '@/rooms/types' +import { + type IRoomManager, + type TableRowUpdatedPayload, + tableRoomName, + type UserPresence, + type UserSession, +} from '@/rooms/types' const logger = createLogger('RedisRoomManager') @@ -457,4 +463,23 @@ export class RedisRoomManager implements IRoomManager { const userCount = await this.getUniqueUserCount(workflowId) logger.info(`Notified ${userCount} users about workflow deployment change: ${workflowId}`) } + + emitToTable(tableId: string, event: string, payload: T): void { + this._io.to(tableRoomName(tableId)).emit(event, payload) + } + + async handleTableRowUpdated(tableId: string, payload: TableRowUpdatedPayload): Promise { + this.emitToTable(tableId, 'table-row-updated', { tableId, ...payload }) + } + + async handleTableRowDeleted(tableId: string, rowId: string): Promise { + this.emitToTable(tableId, 'table-row-deleted', { tableId, rowId }) + } + + async handleTableDeleted(tableId: string): Promise { + logger.info(`Handling table deletion notification for ${tableId}`) + this.emitToTable(tableId, 'table-deleted', { tableId, timestamp: Date.now() }) + // Eject sockets across all pods via socket.io's Redis adapter. + await this._io.in(tableRoomName(tableId)).socketsLeave(tableRoomName(tableId)) + } } diff --git a/apps/realtime/src/rooms/types.ts b/apps/realtime/src/rooms/types.ts index 9553a427e1e..9c15c967d54 100644 --- a/apps/realtime/src/rooms/types.ts +++ b/apps/realtime/src/rooms/types.ts @@ -143,4 +143,45 @@ export interface IRoomManager { * Handle workflow deployment change - notify users to refresh deployment state */ handleWorkflowDeployed(workflowId: string): Promise + + /** + * Emit an event to all clients in a table room (`table:${tableId}`). + * Tables don't track presence/last-modified state — just pub/sub. + */ + emitToTable(tableId: string, event: string, payload: T): void + + /** + * Notify all clients in a table room of a row write (insert/update/cell-state-change). + * Sim API calls this via the `/api/table-row-updated` HTTP bridge after every successful + * row commit; the client merges the delta into its React Query cache. + */ + handleTableRowUpdated(tableId: string, payload: TableRowUpdatedPayload): Promise + + /** + * Notify all clients in a table room that a row has been deleted. + */ + handleTableRowDeleted(tableId: string, rowId: string): Promise + + /** + * Notify all clients in a table room that the table has been deleted; eject sockets. + */ + handleTableDeleted(tableId: string): Promise +} + +/** + * Payload broadcast on `table-row-updated`. Mirrors the shape of `TableRow.data` so + * the client can merge directly into its React Query rows cache. `position` and + * `updatedAt` are included for cache reconciliation; `data` is the full row data + * (not a per-cell delta) — see plan Notes. + */ +export interface TableRowUpdatedPayload { + rowId: string + data: Record + /** Per-workflow-group execution state. Keyed by `WorkflowGroup.id`. */ + executions?: Record + position: number + updatedAt: string | number } + +/** Socket.IO room name for a table. Namespaced from workflow rooms. */ +export const tableRoomName = (tableId: string): string => `table:${tableId}` diff --git a/apps/realtime/src/routes/http.ts b/apps/realtime/src/routes/http.ts index 0f8ed73cc52..78cd89e63d9 100644 --- a/apps/realtime/src/routes/http.ts +++ b/apps/realtime/src/routes/http.ts @@ -150,6 +150,52 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { return } + // Handle table row write notifications from the Sim API + if (req.method === 'POST' && req.url === '/api/table-row-updated') { + try { + const body = await readRequestBody(req) + const { tableId, rowId, data, executions, position, updatedAt } = JSON.parse(body) + await roomManager.handleTableRowUpdated(tableId, { + rowId, + data, + executions, + position, + updatedAt, + }) + sendSuccess(res) + } catch (error) { + logger.error('Error handling table row update notification:', error) + sendError(res, 'Failed to process table row update') + } + return + } + + if (req.method === 'POST' && req.url === '/api/table-row-deleted') { + try { + const body = await readRequestBody(req) + const { tableId, rowId } = JSON.parse(body) + await roomManager.handleTableRowDeleted(tableId, rowId) + sendSuccess(res) + } catch (error) { + logger.error('Error handling table row deletion notification:', error) + sendError(res, 'Failed to process table row deletion') + } + return + } + + if (req.method === 'POST' && req.url === '/api/table-deleted') { + try { + const body = await readRequestBody(req) + const { tableId } = JSON.parse(body) + await roomManager.handleTableDeleted(tableId) + sendSuccess(res) + } catch (error) { + logger.error('Error handling table deletion notification:', error) + sendError(res, 'Failed to process table deletion') + } + return + } + res.writeHead(404, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Not found' })) } diff --git a/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts new file mode 100644 index 00000000000..be89633d7e9 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts @@ -0,0 +1,57 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { cancelTableRunsContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableCancelRunsAPI') + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/table/[tableId]/cancel-runs + * + * Cancels in-flight and pending workflow-column runs for this table. Scopes: + * `all` (every cell) or `row` (every cell for `rowId`). + */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(cancelTableRunsContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, scope, rowId } = parsed.data.body + + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + const { table } = result + + if (table.workspaceId !== workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined) + logger.info( + `[${requestId}] cancel-runs: tableId=${tableId} scope=${scope}${ + rowId ? ` rowId=${rowId}` : '' + } cancelled=${cancelled}` + ) + + return NextResponse.json({ success: true, data: { cancelled } }) + } catch (error) { + logger.error(`[${requestId}] cancel-runs failed:`, error) + return NextResponse.json({ error: 'Failed to cancel runs' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/table/[tableId]/groups/[groupId]/run/route.ts b/apps/sim/app/api/table/[tableId]/groups/[groupId]/run/route.ts new file mode 100644 index 00000000000..80f80bb7945 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/groups/[groupId]/run/route.ts @@ -0,0 +1,67 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { runWorkflowGroupContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { triggerWorkflowGroupRun } from '@/lib/table/workflow-columns' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableRunGroupAPI') + +interface RouteParams { + params: Promise<{ tableId: string; groupId: string }> +} + +/** + * POST /api/table/[tableId]/groups/[groupId]/run + * + * Manually triggers the workflow group for every eligible row in the table. + * Each eligible row's `executions[groupId]` is reset to `pending` so the + * scheduler picks it up and enqueues a per-cell trigger.dev job. Rows whose + * deps aren't satisfied or whose group is already running are skipped. + */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(runWorkflowGroupContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId, groupId } = parsed.data.params + const { workspaceId, runMode, rowIds } = parsed.data.body + + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + const { table } = result + + if (table.workspaceId !== workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const { triggered } = await triggerWorkflowGroupRun({ + tableId, + groupId, + workspaceId, + mode: runMode, + requestId, + rowIds, + }) + + return NextResponse.json({ success: true, data: { triggered } }) + } catch (error) { + if (error instanceof Error && error.message === 'Workflow group not found') { + return NextResponse.json({ error: 'Workflow group not found' }, { status: 404 }) + } + if (error instanceof Error && error.message === 'Invalid workspace ID') { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + logger.error(`run-group failed:`, error) + return NextResponse.json({ error: 'Failed to run group' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts new file mode 100644 index 00000000000..847647fc397 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -0,0 +1,159 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { + addWorkflowGroupContract, + deleteWorkflowGroupContract, + updateWorkflowGroupContract, +} from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { addWorkflowGroup, deleteWorkflowGroup, updateWorkflowGroup } from '@/lib/table/service' +import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' + +const logger = createLogger('TableWorkflowGroupsAPI') + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** + * Maps known service-layer error messages onto HTTP responses; falls through + * to a 500 with a generic message for anything unrecognized. The three + * group-route handlers all surface the same error shapes from + * `addWorkflowGroup` / `updateWorkflowGroup` / `deleteWorkflowGroup`, so they + * share this mapper instead of repeating the if-chain three times. + */ +function mapWorkflowGroupError(error: unknown, fallbackMessage: string): NextResponse { + if (error instanceof Error) { + const msg = error.message + if (msg === 'Table not found' || msg.includes('not found')) { + return NextResponse.json({ error: msg }, { status: 404 }) + } + if ( + msg.includes('Schema validation') || + msg.includes('Missing column definition') || + msg.includes('already exists') || + msg.includes('exceed') + ) { + return NextResponse.json({ error: msg }, { status: 400 }) + } + } + logger.error(fallbackMessage, error) + return NextResponse.json({ error: fallbackMessage }, { status: 500 }) +} + +/** POST /api/table/[tableId]/groups — create a workflow group + its output columns. */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(addWorkflowGroupContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const validated = parsed.data.body + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + if (result.table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + const updatedTable = await addWorkflowGroup( + { + tableId, + group: validated.group, + outputColumns: validated.outputColumns, + autoRun: validated.autoRun, + }, + requestId + ) + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + workflowGroups: updatedTable.schema.workflowGroups ?? [], + }, + }) + } catch (error) { + return mapWorkflowGroupError(error, 'Failed to add workflow group') + } +}) + +/** PATCH /api/table/[tableId]/groups — update a workflow group (deps / outputs). */ +export const PATCH = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(updateWorkflowGroupContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const validated = parsed.data.body + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + if (result.table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + const updatedTable = await updateWorkflowGroup( + { + tableId, + groupId: validated.groupId, + ...(validated.workflowId !== undefined ? { workflowId: validated.workflowId } : {}), + ...(validated.name !== undefined ? { name: validated.name } : {}), + ...(validated.dependencies !== undefined ? { dependencies: validated.dependencies } : {}), + ...(validated.outputs !== undefined ? { outputs: validated.outputs } : {}), + ...(validated.newOutputColumns !== undefined + ? { newOutputColumns: validated.newOutputColumns } + : {}), + }, + requestId + ) + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + workflowGroups: updatedTable.schema.workflowGroups ?? [], + }, + }) + } catch (error) { + return mapWorkflowGroupError(error, 'Failed to update workflow group') + } +}) + +/** DELETE /api/table/[tableId]/groups — remove a workflow group + its columns. */ +export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(deleteWorkflowGroupContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const validated = parsed.data.body + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + if (result.table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + const updatedTable = await deleteWorkflowGroup( + { tableId, groupId: validated.groupId }, + requestId + ) + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + workflowGroups: updatedTable.schema.workflowGroups ?? [], + }, + }) + } catch (error) { + return mapWorkflowGroupError(error, 'Failed to delete workflow group') + } +}) diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 2d87ed39553..64e2600bb94 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -54,6 +54,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab description: table.description, schema: { columns: schemaData.columns.map(normalizeColumn), + ...(schemaData.workflowGroups ? { workflowGroups: schemaData.workflowGroups } : {}), }, metadata: table.metadata ?? null, rowCount: table.rowCount, diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts index 6d9e319f868..9a4a988bc25 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -133,6 +133,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR table, requestId ) + // Only `null` when a `cancellationGuard` is supplied and the SQL guard + // rejects the write — this route doesn't pass one, so reaching null is a bug. + if (!updatedRow) throw new Error('updateRow returned null without a cancellationGuard') return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/run-workflow-group/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/run-workflow-group/route.ts new file mode 100644 index 00000000000..aee786d226d --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/run-workflow-group/route.ts @@ -0,0 +1,97 @@ +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { runRowWorkflowGroupContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { RowExecutionMetadata } from '@/lib/table' +import { updateRow } from '@/lib/table' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableRunWorkflowGroupAPI') + +interface RouteParams { + params: Promise<{ tableId: string; rowId: string }> +} + +/** + * POST /api/table/[tableId]/rows/[rowId]/run-workflow-group + * + * Manually (re-)runs a workflow group for a single row by force-resetting + * `executions[groupId]` to `pending`. The `updateRow` call fires the + * scheduler which enqueues the cell job. + */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(runRowWorkflowGroupContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId, rowId } = parsed.data.params + const { workspaceId, groupId } = parsed.data.body + + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + const { table } = result + + if (table.workspaceId !== workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const group = (table.schema.workflowGroups ?? []).find((g) => g.id === groupId) + if (!group) { + return NextResponse.json({ error: 'Workflow group not found' }, { status: 404 }) + } + + const executionId = generateId() + const pendingExec: RowExecutionMetadata = { + status: 'pending', + executionId, + jobId: null, + workflowId: group.workflowId, + error: null, + } + /** + * Clear the group's output cells so the rerun starts visually fresh — + * otherwise stale values from the previous run linger in the UI until the + * new run writes new ones (or doesn't, on error/router-skip). + */ + const clearedData = Object.fromEntries(group.outputs.map((o) => [o.columnName, null])) + const updated = await updateRow( + { + tableId, + rowId, + data: clearedData, + workspaceId, + executionsPatch: { [groupId]: pendingExec }, + }, + table, + requestId + ) + if (updated === null) { + // The cell-task cancellation guard rejected the write — typically a + // racing stop click that already wrote `cancelled` for this run. + // Surface 409 so the caller doesn't poll indefinitely for a run that + // was never enqueued. + return NextResponse.json( + { error: 'Run was cancelled before it could be scheduled' }, + { status: 409 } + ) + } + + return NextResponse.json({ success: true, data: { executionId } }) + } catch (error) { + if (error instanceof Error && error.message === 'Row not found') { + return NextResponse.json({ error: 'Row not found' }, { status: 404 }) + } + logger.error(`run-workflow-group failed:`, error) + return NextResponse.json({ error: 'Failed to run workflow group' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index 4e107e82ea6..8c69ef55a38 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -277,6 +277,7 @@ export const GET = withRouteHandler( .select({ id: userTableRows.id, data: userTableRows.data, + executions: userTableRows.executions, position: userTableRows.position, createdAt: userTableRows.createdAt, updatedAt: userTableRows.updatedAt, @@ -317,6 +318,7 @@ export const GET = withRouteHandler( rows: rows.map((r) => ({ id: r.id, data: r.data, + executions: r.executions ?? {}, position: r.position, createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index e80c9dbf0be..7db4ec31732 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -173,5 +173,6 @@ export function normalizeColumn(col: ColumnDefinition): ColumnDefinition { type: col.type, required: col.required ?? false, unique: col.unique ?? false, + ...(col.workflowGroupId ? { workflowGroupId: col.workflowGroupId } : {}), } } diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 65625720c31..810bb0dfc65 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -139,6 +139,11 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR table, requestId ) + // No `cancellationGuard` is passed here, so `updateRow` can't return null + // from this caller. Defensive narrowing for TypeScript. + if (!updatedRow) { + return NextResponse.json({ error: 'Row not found' }, { status: 404 }) + } return NextResponse.json({ success: true, diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 68baec3f2b6..22686115782 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -55,6 +55,14 @@ interface ResourceHeaderProps { breadcrumbs?: BreadcrumbItem[] create?: CreateAction actions?: HeaderAction[] + /** Arbitrary content rendered in the right-aligned actions row, before the Create button. */ + trailingActions?: React.ReactNode + /** + * Replaces the default Create button entirely — supply your own trigger (for + * example a dropdown) when the create action needs richer UI. When provided, + * `create` is ignored. + */ + createTrigger?: React.ReactNode } export const ResourceHeader = memo(function ResourceHeader({ @@ -63,6 +71,8 @@ export const ResourceHeader = memo(function ResourceHeader({ breadcrumbs, create, actions, + trailingActions, + createTrigger, }: ResourceHeaderProps) { const hasBreadcrumbs = breadcrumbs && breadcrumbs.length > 0 @@ -124,17 +134,19 @@ export const ResourceHeader = memo(function ResourceHeader({ ) })} - {create && ( - - )} + {trailingActions} + {createTrigger ?? + (create && ( + + ))} 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 ee33779f6dd..c1cd8c78b91 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 @@ -664,7 +664,10 @@ export const LogDetails = memo(function LogDetails({ const { handleMouseDown } = useLogDetailsResize() const maxVw = `${MAX_LOG_DETAILS_WIDTH_RATIO * 100}vw` - const effectiveWidth = `clamp(${MIN_LOG_DETAILS_WIDTH}px, ${panelWidth}px, ${maxVw})` + // CSS-side clamp matching `clampPanelWidth` in stores/logs/utils.ts: the + // floor is itself capped at the max-vw ratio so a narrow viewport doesn't + // let the min outpace the cap and cover the table behind the panel. + const effectiveWidth = `clamp(min(${MIN_LOG_DETAILS_WIDTH}px, ${maxVw}), ${panelWidth}px, ${maxVw})` useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx new file mode 100644 index 00000000000..cd72fe26d57 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx @@ -0,0 +1,1307 @@ +'use client' + +import type React from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { + ChevronDown, + ChevronRight, + ExternalLink, + Loader2, + Plus, + RepeatIcon, + SplitIcon, + X, +} from 'lucide-react' +import { + Button, + Checkbox, + Combobox, + Expandable, + ExpandableContent, + Input, + Label, + Switch, + Tooltip, + toast, +} from '@/components/emcn' +import { requestJson } from '@/lib/api/client/request' +import type { + AddWorkflowGroupBodyInput, + UpdateWorkflowGroupBodyInput, +} from '@/lib/api/contracts/tables' +import { + putWorkflowNormalizedStateContract, + type WorkflowStateContractInput, +} from '@/lib/api/contracts/workflows' +import { cn } from '@/lib/core/utils/cn' +import type { + ColumnDefinition, + WorkflowGroup, + WorkflowGroupDependencies, + WorkflowGroupOutput, +} from '@/lib/table' +import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' +import { + type FlattenOutputsBlockInput, + type FlattenOutputsEdgeInput, + flattenWorkflowOutputs, + getBlockExecutionOrder, +} from '@/lib/workflows/blocks/flatten-outputs' +import { normalizeInputFormatValue } from '@/lib/workflows/input-format' +import { TriggerUtils } from '@/lib/workflows/triggers/triggers' +import type { InputFormatField } from '@/lib/workflows/types' +import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' +import { getBlock } from '@/blocks' +import { + useAddTableColumn, + useAddWorkflowGroup, + useUpdateColumn, + useUpdateWorkflowGroup, +} from '@/hooks/queries/tables' +import { useWorkflowState, workflowKeys } from '@/hooks/queries/workflows' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' +import { COLUMN_TYPE_OPTIONS, type SidebarColumnType } from './column-types' + +export type ColumnConfigState = + | { mode: 'edit'; columnName: string } + | { mode: 'new'; columnName: string; workflowId: string; proposedName: string } + | { + mode: 'create' + columnName: string + proposedName: string + /** When present, the sidebar opens with the workflow type pre-selected. */ + workflowId?: string + } + | null + +interface ColumnSidebarProps { + configState: ColumnConfigState + onClose: () => void + /** The current column record for edit mode. Null for new mode or closed. */ + existingColumn: ColumnDefinition | null + allColumns: ColumnDefinition[] + workflowGroups: WorkflowGroup[] + workflows: WorkflowMetadata[] | undefined + workspaceId: string + tableId: string +} + +const OUTPUT_VALUE_SEPARATOR = '::' + +/** Shared dashed-divider style — mirrors the workflow editor's subblock divider. */ +const DASHED_DIVIDER_STYLE = { + backgroundImage: + 'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)', +} as const + +/** Encodes blockId + path so duplicate field names across blocks stay distinct in the picker UI. */ +const encodeOutputValue = (blockId: string, path: string) => + `${blockId}${OUTPUT_VALUE_SEPARATOR}${path}` + +/** Splits an encoded `${blockId}::${path}` into its components for persistence. */ +const decodeOutputValue = (value: string): { blockId: string; path: string } => { + const idx = value.indexOf(OUTPUT_VALUE_SEPARATOR) + if (idx === -1) return { blockId: '', path: value } + return { blockId: value.slice(0, idx), path: value.slice(idx + OUTPUT_VALUE_SEPARATOR.length) } +} + +interface BlockOutputGroup { + blockId: string + blockName: string + blockType: string + blockIcon: string | React.ComponentType<{ className?: string }> + blockColor: string + paths: string[] +} + +/** + * Loose shape of `useWorkflowState` data — we only need the fields we round-trip + * through PUT /state. Typed locally to avoid pulling the heavy `WorkflowState` + * generic from `@/stores/workflows/workflow/types`. + */ +interface WorkflowStatePayload { + blocks: Record< + string, + { + type: string + subBlocks?: Record + } & Record + > + edges: unknown[] + loops: unknown + parallels: unknown + lastSaved?: number + isDeployed?: boolean +} + +function tableColumnTypeToInputType(colType: ColumnDefinition['type'] | undefined): string { + switch (colType) { + case 'number': + return 'number' + case 'boolean': + return 'boolean' + case 'json': + return 'object' + default: + return 'string' + } +} + +const TagIcon: React.FC<{ + icon: string | React.ComponentType<{ className?: string }> + color: string +}> = ({ icon, color }) => ( +
+ {typeof icon === 'string' ? ( + {icon} + ) : ( + (() => { + const IconComponent = icon + return + })() + )} +
+) + +function FieldDivider() { + return ( +
+
+
+ ) +} + +/** Mirrors the workflow editor's required-field label: title + asterisk. */ +function FieldLabel({ + htmlFor, + required, + children, +}: { + htmlFor?: string + required?: boolean + children: React.ReactNode +}) { + return ( + + ) +} + +/** Inline validation message styled like the workflow editor's destructive text. */ +function FieldError({ message }: { message: string }) { + return

{message}

+} + +/** + * Tinted inline warning row with a message on the left and an action button + * on the right. Stacks naturally — render multiple in sequence and they line + * up. Color mirrors the group-header deploy badge: `red` for blocking states, + * `amber` for soft warnings. + */ +function WarningRow({ + tone, + message, + action, +}: { + tone: 'red' | 'amber' + message: string + action: React.ReactNode +}) { + return ( +
+ + {message} + +
{action}
+
+ ) +} + +/** + * Collapsible "Run settings" section. Collapsed by default since outputs are + * the primary focus of the workflow flow — most users never need to touch + * the trigger conditions. The header shows a one-line summary of when the + * group will fire so the current state is visible without expanding. + */ +function RunSettingsSection({ + open, + onOpenChange, + summary, + scalarDepColumns, + groupDepOptions, + deps, + groupDeps, + workflows, + onToggleDep, + onToggleGroupDep, +}: { + open: boolean + onOpenChange: (open: boolean) => void + summary: string + scalarDepColumns: ColumnDefinition[] + groupDepOptions: WorkflowGroup[] + deps: string[] + groupDeps: string[] + workflows: WorkflowMetadata[] | undefined + onToggleDep: (name: string) => void + onToggleGroupDep: (groupId: string) => void +}) { + return ( +
+ + + +
+ {scalarDepColumns.length === 0 && groupDepOptions.length === 0 ? ( +
+ No upstream columns or groups. +
+ ) : ( + <> + {scalarDepColumns.map((c, idx) => { + const checked = deps.includes(c.name) + const isLast = idx === scalarDepColumns.length - 1 && groupDepOptions.length === 0 + return ( +
onToggleDep(c.name)} + onKeyDown={(e) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault() + onToggleDep(c.name) + } + }} + className={cn( + 'flex h-[36px] flex-shrink-0 cursor-pointer items-center gap-2.5 px-2.5 hover:bg-[var(--surface-2)]', + !isLast && 'border-[var(--border)] border-b' + )} + > + + + {c.name} + + + {c.type} + +
+ ) + })} + {groupDepOptions.map((g, idx) => { + const checked = groupDeps.includes(g.id) + const isLast = idx === groupDepOptions.length - 1 + const wf = workflows?.find((w) => w.id === g.workflowId) + const color = wf?.color ?? 'var(--text-muted)' + const label = g.name ?? wf?.name ?? 'Workflow' + return ( +
onToggleGroupDep(g.id)} + onKeyDown={(e) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault() + onToggleGroupDep(g.id) + } + }} + className={cn( + 'flex h-[36px] flex-shrink-0 cursor-pointer items-center gap-2.5 px-2.5 hover:bg-[var(--surface-2)]', + !isLast && 'border-[var(--border)] border-b' + )} + > + +
+ ) + })} + + )} +
+
+
+
+ ) +} + +/** + * Right-edge configuration panel for any column. + * + * Shows name / type / unique for every column, plus workflow-specific fields + * (workflow picker, output field, dependencies, run concurrency) when the + * selected type is `'workflow'`. + * + * Three modes: + * - 'edit': modify an existing column. PATCH sends a unified updates payload. + * - 'new': user picked a workflow via Change type → Workflow → [pick]. Nothing + * is persisted yet. Save writes type + workflowConfig + renames in one PATCH. + * - 'create': user picked a workflow from "Add column"; the column doesn't exist yet + * and Save creates it. + * + * Visual styling mirrors the workflow editor's subblock panel (label above + * control, dashed dividers between fields). + */ +export function ColumnSidebar({ + configState, + onClose, + existingColumn, + allColumns, + workflowGroups, + workflows, + workspaceId, + tableId, +}: ColumnSidebarProps) { + const updateColumn = useUpdateColumn({ workspaceId, tableId }) + const addColumn = useAddTableColumn({ workspaceId, tableId }) + const addWorkflowGroup = useAddWorkflowGroup({ workspaceId, tableId }) + const updateWorkflowGroup = useUpdateWorkflowGroup({ workspaceId, tableId }) + const open = configState !== null + + const columnName = configState ? configState.columnName : '' + + /** + * If the column being edited is a workflow output, resolve its parent group + * so we can populate workflow / outputs / dependencies state from it. + */ + const existingGroup = useMemo(() => { + if (!existingColumn?.workflowGroupId) return undefined + return workflowGroups.find((g) => g.id === existingColumn.workflowGroupId) + }, [existingColumn, workflowGroups]) + + const [nameInput, setNameInput] = useState('') + const [typeInput, setTypeInput] = useState('string') + + const isWorkflow = !!existingGroup || configState?.mode === 'new' || typeInput === 'workflow' + + /** + * Show the Column name field whenever a *specific* column is open: scalar + * columns (create or edit) and per-output workflow columns (edit only). Hide + * it when the surface is the workflow-group as a whole — i.e. creating a + * brand-new workflow column where individual output names are auto-derived. + */ + const showColumnNameField = + !isWorkflow || configState?.mode === 'edit' || configState?.mode === 'new' + + /** + * Columns to the left of the current column — these are the only valid trigger + * dependencies, since a workflow column can't depend on values that haven't been + * filled yet. For 'create' mode the column doesn't exist yet, so every existing + * column counts as left of it. + */ + const otherColumns = useMemo(() => { + if (!configState) return [] + if (configState.mode === 'create') return allColumns + const idx = allColumns.findIndex((c) => c.name === configState.columnName) + if (idx === -1) return allColumns.filter((c) => c.name !== configState.columnName) + return allColumns.slice(0, idx) + }, [configState, allColumns]) + + /** + * Split `otherColumns` into the two dep buckets: + * - `scalarDepColumns` — plain columns; tickable into `dependencies.columns`. + * - `groupDepOptions` — producing workflow groups whose outputs land left of the + * current column; tickable into `dependencies.workflowGroups`. A group only + * shows up here when at least one of its output columns is left-of-current. + * The current group itself is excluded so we never depend on ourselves. + */ + const scalarDepColumns = useMemo( + () => otherColumns.filter((c) => !c.workflowGroupId), + [otherColumns] + ) + const groupDepOptions = useMemo(() => { + const seen = new Set() + const result: WorkflowGroup[] = [] + for (const c of otherColumns) { + if (!c.workflowGroupId) continue + if (seen.has(c.workflowGroupId)) continue + if (existingGroup && c.workflowGroupId === existingGroup.id) continue + const g = workflowGroups.find((gg) => gg.id === c.workflowGroupId) + if (!g) continue + seen.add(c.workflowGroupId) + result.push(g) + } + return result + }, [otherColumns, workflowGroups, existingGroup]) + + const [uniqueInput, setUniqueInput] = useState(false) + const [selectedWorkflowId, setSelectedWorkflowId] = useState('') + /** Plain (non-workflow-output) column names this group waits on. */ + const [deps, setDeps] = useState([]) + /** Producing workflow group ids this group waits on. Workflow-output columns are + * represented by their parent group, since the schema validator forbids depending + * on a workflow-output column directly (`workflow-columns.ts` enforces this). */ + const [groupDeps, setGroupDeps] = useState([]) + /** Encoded `${blockId}::${path}` values — disambiguates duplicate paths in the picker. */ + const [selectedOutputs, setSelectedOutputs] = useState([]) + /** Surfaces required-field errors only after a save attempt, matching the workflow editor's deploy flow. */ + const [showValidation, setShowValidation] = useState(false) + /** Save-time error (network/validation thrown by the mutation). Rendered inline next to the footer + * buttons so it isn't covered by the toaster, which sits over the bottom-right of the panel. */ + const [saveError, setSaveError] = useState(null) + /** Run settings (the trigger-deps picker) starts collapsed — outputs are the + * primary task; configuring run timing is rare. */ + const [runSettingsOpen, setRunSettingsOpen] = useState(false) + + const existingColumnRef = useRef(existingColumn) + existingColumnRef.current = existingColumn + const allColumnsRef = useRef(allColumns) + allColumnsRef.current = allColumns + + useEffect(() => { + if (!open || !configState) return + setShowValidation(false) + setSaveError(null) + setRunSettingsOpen(false) + const existing = existingColumnRef.current + const cols = allColumnsRef.current + const leftOfCurrent = (() => { + if (configState.mode === 'create') return cols + const idx = cols.findIndex((c) => c.name === configState.columnName) + if (idx === -1) return cols.filter((c) => c.name !== configState.columnName) + return cols.slice(0, idx) + })() + // Default deps when there's no persisted group yet: tick every left-of-current + // scalar column + every left-of-current producing group. + const defaultScalarDeps = leftOfCurrent.filter((c) => !c.workflowGroupId).map((c) => c.name) + const defaultGroupDeps = (() => { + const seen = new Set() + for (const c of leftOfCurrent) { + if (c.workflowGroupId) seen.add(c.workflowGroupId) + } + return Array.from(seen) + })() + if (configState.mode === 'edit') { + const group = existing?.workflowGroupId + ? workflowGroups.find((g) => g.id === existing.workflowGroupId) + : undefined + // Surface workflow-typed columns as `'workflow'` in the combobox even + // though they're stored as scalar columns under the hood. + setTypeInput(group ? 'workflow' : (existing?.type ?? 'string')) + setUniqueInput(!!existing?.unique) + setNameInput(existing?.name ?? configState.columnName) + if (group) { + setSelectedWorkflowId(group.workflowId) + // Sanitize legacy persisted deps: any workflow-output column names that + // sneaked into `dependencies.columns` (writes from before the schema + // validator forbade them) are lifted into `workflowGroups` here so the + // sidebar surfaces a re-saveable state. + const persistedCols = group.dependencies?.columns + const persistedGroups = group.dependencies?.workflowGroups + if (persistedCols !== undefined || persistedGroups !== undefined) { + const liftedGroupIds = new Set(persistedGroups ?? []) + const cleanCols: string[] = [] + for (const colName of persistedCols ?? []) { + const c = cols.find((cc) => cc.name === colName) + if (c?.workflowGroupId) liftedGroupIds.add(c.workflowGroupId) + else cleanCols.push(colName) + } + setDeps(cleanCols) + setGroupDeps(Array.from(liftedGroupIds)) + } else { + setDeps(defaultScalarDeps) + setGroupDeps(defaultGroupDeps) + } + setSelectedOutputs([]) // re-encoded against current workflow blocks below + } else { + setSelectedWorkflowId('') + setDeps([]) + setGroupDeps([]) + setSelectedOutputs([]) + } + } else { + const workflowId = + 'workflowId' in configState && configState.workflowId ? configState.workflowId : '' + setTypeInput(workflowId ? 'workflow' : 'string') + setUniqueInput(false) + setNameInput(configState.proposedName) + setSelectedWorkflowId(workflowId) + setDeps(defaultScalarDeps) + setGroupDeps(defaultGroupDeps) + setSelectedOutputs([]) + } + }, [open, configState, workflowGroups]) + + const workflowState = useWorkflowState( + open && isWorkflow && selectedWorkflowId ? selectedWorkflowId : undefined + ) + + /** + * Resolves the unified Start block id and its current `inputFormat` field + * names. The "Add inputs" mutation only adds rows for table columns that + * aren't already represented in the start block — clicking the button when + * everything's covered does nothing, so we hide it in that case. + */ + const startBlockInputs = useMemo<{ + blockId: string | null + existingNames: Set + existing: InputFormatField[] + }>(() => { + const blocks = (workflowState.data as { blocks?: Record } | null) + ?.blocks + if (!blocks) return { blockId: null, existingNames: new Set(), existing: [] } + const candidate = TriggerUtils.findStartBlock(blocks, 'manual') + if (!candidate) return { blockId: null, existingNames: new Set(), existing: [] } + const block = blocks[candidate.blockId] as + | { subBlocks?: Record } + | undefined + const existing = normalizeInputFormatValue(block?.subBlocks?.inputFormat?.value) + return { + blockId: candidate.blockId, + existingNames: new Set(existing.map((f) => f.name).filter((n): n is string => !!n)), + existing, + } + }, [workflowState.data]) + + const missingInputColumnNames = useMemo(() => { + if (!startBlockInputs.blockId) return [] + return allColumns + .filter( + (c) => + c.name !== columnName && !c.workflowGroupId && !startBlockInputs.existingNames.has(c.name) + ) + .map((c) => c.name) + }, [allColumns, columnName, startBlockInputs]) + + const queryClient = useQueryClient() + const addInputsMutation = useMutation({ + mutationFn: async () => { + const wfId = selectedWorkflowId + const startBlockId = startBlockInputs.blockId + const state = workflowState.data as WorkflowStatePayload | null | undefined + if (!wfId || !startBlockId || !state || missingInputColumnNames.length === 0) { + throw new Error('Nothing to add') + } + const startBlock = state.blocks[startBlockId] + if (!startBlock) throw new Error('Start block missing from workflow') + + const newFields: InputFormatField[] = missingInputColumnNames.map((name) => { + const col = allColumns.find((c) => c.name === name) + return { + id: generateId(), + name, + type: tableColumnTypeToInputType(col?.type), + value: '', + collapsed: false, + } as InputFormatField & { id: string; collapsed: boolean } + }) + + const updatedSubBlock = { + ...(startBlock.subBlocks?.inputFormat ?? { id: 'inputFormat', type: 'input-format' }), + value: [...startBlockInputs.existing, ...newFields], + } + const updatedBlocks = { + ...state.blocks, + [startBlockId]: { + ...startBlock, + subBlocks: { ...startBlock.subBlocks, inputFormat: updatedSubBlock }, + }, + } + + const rawBody = { + blocks: updatedBlocks, + edges: state.edges, + loops: state.loops, + parallels: state.parallels, + lastSaved: state.lastSaved ?? Date.now(), + isDeployed: state.isDeployed ?? false, + } + // double-cast-allowed: WorkflowStatePayload is the loose local view of + // useWorkflowState; we round-trip it back to the strict PUT body shape. + const body = rawBody as unknown as WorkflowStateContractInput + await requestJson(putWorkflowNormalizedStateContract, { + params: { id: wfId }, + body, + }) + return missingInputColumnNames.length + }, + onSuccess: (added) => { + queryClient.invalidateQueries({ queryKey: workflowKeys.state(selectedWorkflowId) }) + toast.success(`Added ${added} input${added === 1 ? '' : 's'} to start block`) + }, + onError: (err) => { + toast.error(toError(err).message) + }, + }) + + const blockOutputGroups = useMemo(() => { + const state = workflowState.data as + | { + blocks?: Record + edges?: FlattenOutputsEdgeInput[] + } + | null + | undefined + if (!state?.blocks) return [] + + const blocks = Object.values(state.blocks) + const edges = state.edges ?? [] + const flat = flattenWorkflowOutputs(blocks, edges) + if (flat.length === 0) return [] + + const groupsByBlockId = new Map() + for (const f of flat) { + let group = groupsByBlockId.get(f.blockId) + if (!group) { + const blockConfig = getBlock(f.blockType) + const blockColor = blockConfig?.bgColor || '#2F55FF' + let blockIcon: string | React.ComponentType<{ className?: string }> = f.blockName + .charAt(0) + .toUpperCase() + if (blockConfig?.icon) blockIcon = blockConfig.icon + else if (f.blockType === 'loop') blockIcon = RepeatIcon + else if (f.blockType === 'parallel') blockIcon = SplitIcon + group = { + blockId: f.blockId, + blockName: f.blockName, + blockType: f.blockType, + blockIcon, + blockColor, + paths: [], + } + groupsByBlockId.set(f.blockId, group) + } + group.paths.push(f.path) + } + // Sort the picker by execution order (start block first) so it matches the + // saved-column ordering. Unreachable blocks sink to the end. + const distances = getBlockExecutionOrder(blocks, edges) + return Array.from(groupsByBlockId.values()).sort((a, b) => { + const da = distances[a.blockId] + const db = distances[b.blockId] + const sa = da === undefined || da < 0 ? Number.POSITIVE_INFINITY : da + const sb = db === undefined || db < 0 ? Number.POSITIVE_INFINITY : db + return sa - sb + }) + }, [workflowState.data]) + + /** + * Re-encode persisted `{blockId, path}` entries into the picker's encoded form + * once the workflow's blocks are loaded. Stale entries (block deleted or path + * removed) are dropped silently — the user can re-pick on save. + */ + useEffect(() => { + if (!existingGroup?.outputs.length) return + if (selectedOutputs.length > 0) return + if (blockOutputGroups.length === 0) return + const encoded: string[] = [] + for (const entry of existingGroup.outputs) { + const match = blockOutputGroups.find( + (g) => g.blockId === entry.blockId && g.paths.includes(entry.path) + ) + if (match) encoded.push(encodeOutputValue(entry.blockId, entry.path)) + } + if (encoded.length > 0) setSelectedOutputs(encoded) + }, [blockOutputGroups, selectedOutputs.length, existingGroup]) + + const toggleDep = (name: string) => { + setDeps((prev) => (prev.includes(name) ? prev.filter((d) => d !== name) : [...prev, name])) + } + + const toggleGroupDep = (groupId: string) => { + setGroupDeps((prev) => + prev.includes(groupId) ? prev.filter((d) => d !== groupId) : [...prev, groupId] + ) + } + + const toggleOutput = (encoded: string) => { + setSelectedOutputs((prev) => + prev.includes(encoded) ? prev.filter((v) => v !== encoded) : [...prev, encoded] + ) + } + + const typeOptions = useMemo( + () => COLUMN_TYPE_OPTIONS.map((o) => ({ label: o.label, value: o.type, icon: o.icon })), + [] + ) + + /** + * One-line summary of the trigger picker shown when Run settings is collapsed. + * Lists the dep names ("Run when X, Y, are filled") so the user can see at a + * glance whether anything's gating the group without expanding the section. + */ + const runSettingsSummary = useMemo(() => { + const names: string[] = [...deps] + for (const gid of groupDeps) { + const g = workflowGroups.find((gg) => gg.id === gid) + const wf = workflows?.find((w) => w.id === g?.workflowId) + const label = g?.name ?? wf?.name ?? 'workflow' + names.push(label) + } + if (names.length === 0) return 'Runs as soon as the group is added' + return `Runs when ${names.join(', ')} ${names.length === 1 ? 'is' : 'are'} filled` + }, [deps, groupDeps, workflowGroups, workflows]) + + /** + * Builds the ordered, deduplicated `(blockId, path)` list from the picker + * state, sorted by execution order. Empty array if the user hasn't picked + * anything. + */ + const buildOrderedPickedOutputs = (): Array<{ + blockId: string + path: string + leafType?: string + }> => { + const seen = new Set() + const outputs: Array<{ blockId: string; path: string; leafType?: string }> = [] + for (const encoded of selectedOutputs) { + if (seen.has(encoded)) continue + seen.add(encoded) + outputs.push(decodeOutputValue(encoded)) + } + const wfState = workflowState.data as + | { + blocks?: Record + edges?: FlattenOutputsEdgeInput[] + } + | null + | undefined + if (wfState?.blocks) { + const blocks = Object.values(wfState.blocks) + const edges = wfState.edges ?? [] + const distances = getBlockExecutionOrder(blocks, edges) + const flat = flattenWorkflowOutputs(blocks, edges) + const indexInFlat = new Map( + flat.map((f, i) => [`${f.blockId}${OUTPUT_VALUE_SEPARATOR}${f.path}`, i]) + ) + const leafTypeByKey = new Map( + flat.map((f) => [`${f.blockId}${OUTPUT_VALUE_SEPARATOR}${f.path}`, f.leafType]) + ) + for (const o of outputs) { + o.leafType = leafTypeByKey.get(`${o.blockId}${OUTPUT_VALUE_SEPARATOR}${o.path}`) + } + outputs.sort((a, b) => { + const da = distances[a.blockId] + const db = distances[b.blockId] + const sa = da === undefined || da < 0 ? Number.POSITIVE_INFINITY : da + const sb = db === undefined || db < 0 ? Number.POSITIVE_INFINITY : db + if (sa !== sb) return sa - sb + const ia = + indexInFlat.get(`${a.blockId}${OUTPUT_VALUE_SEPARATOR}${a.path}`) ?? + Number.POSITIVE_INFINITY + const ib = + indexInFlat.get(`${b.blockId}${OUTPUT_VALUE_SEPARATOR}${b.path}`) ?? + Number.POSITIVE_INFINITY + return ia - ib + }) + } + return outputs + } + + const handleSave = async () => { + if (!configState) return + setSaveError(null) + const trimmedName = nameInput.trim() + // Name is required iff the field is shown — when configuring a whole + // workflow group at creation time, per-output column names are auto-derived + // and the field is hidden, so don't gate save on it. + const missing: string[] = [] + if (showColumnNameField && !trimmedName) missing.push('a column name') + if (isWorkflow && !selectedWorkflowId) missing.push('a workflow') + if (isWorkflow && selectedWorkflowId && selectedOutputs.length === 0) { + missing.push('at least one output column') + } + if (missing.length > 0) { + setShowValidation(true) + // Surface a short summary near the Save button too — the inline FieldError + // can be scrolled out of view when the panel content is tall. + setSaveError(`Add ${missing.join(' and ')} before saving.`) + return + } + + try { + if (isWorkflow) { + const orderedOutputs = buildOrderedPickedOutputs() + const dependencies: WorkflowGroupDependencies = { + columns: deps, + ...(groupDeps.length > 0 ? { workflowGroups: groupDeps } : {}), + } + + if (existingGroup) { + // Update path: diff outputs, derive new column names for added entries, + // call updateWorkflowGroup so service handles add/remove transactionally. + // If the sidebar was opened on a *specific* workflow-output column and + // the user renamed it, propagate that into the group's `outputs` ref + // (the column rename itself goes through `updateColumn` below, which + // server-side cascades into outputs/deps — but our outgoing payload + // also has to use the new name so the group update doesn't undo it). + const editedColumnName = configState.mode === 'edit' ? configState.columnName : null + const renamedColumn = + editedColumnName && trimmedName && trimmedName !== editedColumnName + ? { from: editedColumnName, to: trimmedName } + : null + const oldKeys = new Set(existingGroup.outputs.map((o) => `${o.blockId}::${o.path}`)) + const taken = new Set( + allColumns.map((c) => + renamedColumn && c.name === renamedColumn.from ? renamedColumn.to : c.name + ) + ) + const fullOutputs: WorkflowGroupOutput[] = [] + const newOutputColumns: NonNullable = [] + for (const o of orderedOutputs) { + const key = `${o.blockId}::${o.path}` + const existing = existingGroup.outputs.find( + (e) => e.blockId === o.blockId && e.path === o.path + ) + if (existing) { + fullOutputs.push( + renamedColumn && existing.columnName === renamedColumn.from + ? { ...existing, columnName: renamedColumn.to } + : existing + ) + } else { + const colName = deriveOutputColumnName(o.path, taken) + taken.add(colName) + fullOutputs.push({ blockId: o.blockId, path: o.path, columnName: colName }) + newOutputColumns.push({ + name: colName, + type: columnTypeForLeaf(o.leafType), + required: false, + unique: false, + workflowGroupId: existingGroup.id, + }) + } + oldKeys.delete(key) + } + if (renamedColumn) { + await updateColumn.mutateAsync({ + columnName: renamedColumn.from, + updates: { name: renamedColumn.to }, + }) + } + await updateWorkflowGroup.mutateAsync({ + groupId: existingGroup.id, + workflowId: selectedWorkflowId, + name: existingGroup.name, + dependencies, + outputs: fullOutputs, + ...(newOutputColumns.length > 0 ? { newOutputColumns } : {}), + }) + toast.success(`Saved "${existingGroup.name ?? 'Workflow'}"`) + } else { + // Create path: build a fresh group with auto-derived column names. + const groupId = generateId() + const taken = new Set(allColumns.map((c) => c.name)) + const newOutputColumns: AddWorkflowGroupBodyInput['outputColumns'] = [] + const groupOutputs: WorkflowGroupOutput[] = [] + for (const o of orderedOutputs) { + const colName = deriveOutputColumnName(o.path, taken) + taken.add(colName) + newOutputColumns.push({ + name: colName, + type: columnTypeForLeaf(o.leafType), + required: false, + unique: false, + workflowGroupId: groupId, + }) + groupOutputs.push({ blockId: o.blockId, path: o.path, columnName: colName }) + } + const workflowName = + workflows?.find((w) => w.id === selectedWorkflowId)?.name ?? 'Workflow' + const group: WorkflowGroup = { + id: groupId, + workflowId: selectedWorkflowId, + name: workflowName, + dependencies, + outputs: groupOutputs, + } + await addWorkflowGroup.mutateAsync({ group, outputColumns: newOutputColumns }) + toast.success(`Added "${workflowName}"`) + } + } else if (configState.mode === 'create') { + // `isWorkflow` is false here, so `typeInput` is a real ColumnDefinition type. + const scalarType = typeInput as ColumnDefinition['type'] + await addColumn.mutateAsync({ + name: trimmedName, + type: scalarType, + }) + toast.success(`Added "${trimmedName}"`) + } else { + const existing = existingColumnRef.current + const scalarType = typeInput as ColumnDefinition['type'] + const renamed = trimmedName !== configState.columnName + const typeChanged = !!existing && existing.type !== scalarType + const uniqueChanged = !!existing && !!existing.unique !== uniqueInput + + const updates: { + name?: string + type?: ColumnDefinition['type'] + unique?: boolean + } = { + ...(renamed ? { name: trimmedName } : {}), + ...(typeChanged ? { type: scalarType } : {}), + ...(uniqueChanged ? { unique: uniqueInput } : {}), + } + + if (Object.keys(updates).length === 0) { + onClose() + return + } + + await updateColumn.mutateAsync({ + columnName: configState.columnName, + updates, + }) + toast.success(`Saved "${trimmedName}"`) + } + + onClose() + } catch (err) { + setSaveError(toError(err).message) + } + } + + const saveDisabled = updateColumn.isPending || addColumn.isPending + + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-types.ts new file mode 100644 index 00000000000..10e392e82a1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-types.ts @@ -0,0 +1,32 @@ +import type React from 'react' +import { + Calendar as CalendarIcon, + PlayOutline, + TypeBoolean, + TypeJson, + TypeNumber, + TypeText, +} from '@/components/emcn/icons' +import type { ColumnDefinition } from '@/lib/table' + +/** + * UI-only column type. `'workflow'` is a virtual selection that lets the user + * configure a workflow group from the sidebar; on save, it expands into N real + * scalar columns + one workflow group, none of which carry a `'workflow'` type. + */ +export type SidebarColumnType = ColumnDefinition['type'] | 'workflow' + +export interface ColumnTypeOption { + type: SidebarColumnType + label: string + icon: React.ComponentType<{ className?: string }> +} + +export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [ + { type: 'string', label: 'Text', icon: TypeText }, + { type: 'number', label: 'Number', icon: TypeNumber }, + { type: 'boolean', label: 'Boolean', icon: TypeBoolean }, + { type: 'date', label: 'Date', icon: CalendarIcon }, + { type: 'json', label: 'JSON', icon: TypeJson }, + { type: 'workflow', label: 'Workflow', icon: PlayOutline }, +] diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index 9939e3cd2f4..dfe0523ba8d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -5,7 +5,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/emcn' -import { ArrowDown, ArrowUp, Duplicate, Pencil, Trash } from '@/components/emcn/icons' +import { ArrowDown, ArrowUp, Duplicate, Eye, Pencil, Trash } from '@/components/emcn/icons' import type { ContextMenuState } from '../../types' interface ContextMenuProps { @@ -16,6 +16,9 @@ interface ContextMenuProps { onInsertAbove: () => void onInsertBelow: () => void onDuplicate: () => void + onViewExecution?: () => void + canViewExecution?: boolean + canEditCell?: boolean selectedRowCount?: number disableEdit?: boolean disableInsert?: boolean @@ -30,6 +33,9 @@ export function ContextMenu({ onInsertAbove, onInsertBelow, onDuplicate, + onViewExecution, + canViewExecution = false, + canEditCell = true, selectedRowCount = 1, disableEdit = false, disableInsert = false, @@ -63,12 +69,18 @@ export function ContextMenu({ sideOffset={4} onCloseAutoFocus={(e) => e.preventDefault()} > - {contextMenu.columnName && ( + {contextMenu.columnName && canEditCell && ( Edit cell )} + {canViewExecution && onViewExecution && ( + + + View execution + + )} Insert row above diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx new file mode 100644 index 00000000000..57457056af0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx @@ -0,0 +1,171 @@ +'use client' + +import type React from 'react' +import { Circle } from 'lucide-react' +import { Checkbox } from '@/components/emcn' +import { Loader } from '@/components/emcn/icons/loader' +import { cn } from '@/lib/core/utils/cn' +import type { RowExecutionMetadata } from '@/lib/table' +import type { SaveReason } from '../../../types' +import { storageToDisplay } from '../../../utils' +import type { DisplayColumn } from '../types' +import { InlineEditor } from './inline-editors' + +interface CellContentProps { + value: unknown + exec?: RowExecutionMetadata + column: DisplayColumn + isEditing: boolean + initialCharacter?: string | null + onSave: (value: unknown, reason: SaveReason) => void + onCancel: () => void + workflowNameById?: Record +} + +/** + * Renders the visible content of a single cell. Workflow-output cells follow + * a status-state-machine (block error / value / running / waiting / cancelled + * / dash); plain cells render the typed value. When `isEditing` is true the + * `InlineEditor` overlay sits on top of the static content. + */ +export function CellContent({ + value, + exec, + column, + isEditing, + initialCharacter, + onSave, + onCancel, +}: CellContentProps) { + const isNull = value === null || value === undefined + + let displayContent: React.ReactNode = null + if (column.workflowGroupId) { + const blockId = column.outputBlockId + const blockError = blockId ? exec?.blockErrors?.[blockId] : undefined + const blockRunning = blockId ? (exec?.runningBlockIds?.includes(blockId) ?? false) : false + const hasValue = !isNull + const valueText = + typeof value === 'string' + ? value + : value === null || value === undefined + ? '' + : JSON.stringify(value) + + // Once any block in the group has reported an error, downstream cells + // that haven't started won't run on this attempt — collapse them to dash + // instead of leaving a stale "Waiting" spinner if the cell task didn't + // reach a clean terminal state. + const groupHasBlockErrors = !!(exec?.blockErrors && Object.keys(exec.blockErrors).length > 0) + if (blockError) { + displayContent = ( + + Error + + ) + } else if (hasValue) { + displayContent = ( + + {valueText} + + ) + } else if ( + (exec?.status === 'running' || exec?.status === 'pending') && + !(groupHasBlockErrors && !blockRunning) + ) { + // Motion only when this cell's own block is in flight. Pending and + // upstream-blocked Waiting render as static dots — the moving spinner + // is reserved for "right now, actually running". + if (blockRunning) { + displayContent = ( +
+ + + Running + +
+ ) + } else { + const label = exec.status === 'pending' ? 'Pending' : 'Waiting' + displayContent = ( +
+ + + {label} + +
+ ) + } + } else if (exec?.status === 'cancelled') { + displayContent = ( + + Cancelled + + ) + } else { + displayContent = — + } + // Workflow-output cells are hand-editable: hide the status content under + // the InlineEditor when the user opts to edit, then fall through to the + // common return that renders the editor overlay. + if (isEditing) { + displayContent =
{displayContent}
+ } + } else if (column.type === 'boolean') { + displayContent = ( +
+ +
+ ) + } else if (!isNull && column.type === 'json') { + displayContent = ( + + {JSON.stringify(value)} + + ) + } else if (!isNull && column.type === 'date') { + displayContent = ( + + {storageToDisplay(String(value))} + + ) + } else if (!isNull) { + displayContent = ( + + {String(value)} + + ) + } + + return ( + <> + {isEditing && ( +
+ +
+ )} + {displayContent} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/expanded-cell-popover.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/expanded-cell-popover.tsx new file mode 100644 index 00000000000..388622c82b9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/expanded-cell-popover.tsx @@ -0,0 +1,209 @@ +'use client' + +import type React from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Button } from '@/components/emcn' +import type { TableRow as TableRowType } from '@/lib/table' +import type { EditingCell, SaveReason } from '../../../types' +import { cleanCellValue, displayToStorage, formatValueForInput } from '../../../utils' +import type { DisplayColumn } from '../types' + +interface ExpandedCellPopoverProps { + expandedCell: EditingCell | null + onClose: () => void + rows: TableRowType[] + columns: DisplayColumn[] + onSave: (rowId: string, columnName: string, value: unknown, reason: SaveReason) => void + canEdit: boolean + scrollContainer: HTMLElement | null +} + +const EXPANDED_CELL_MIN_WIDTH = 420 +const EXPANDED_CELL_HEIGHT = 280 + +/** + * Supabase-style anchored cell expander. Floats over the clicked cell at the cell's + * top-left, minimum width {@link EXPANDED_CELL_MIN_WIDTH}, fixed height, internally + * scrollable. Triggered by cell double-click so long values are readable/editable + * without widening the column. Inline edit via Enter/F2/typing is unaffected. + * + * Workflow and boolean cells are read-only in this view — workflow cells are driven + * by the scheduler, booleans use a checkbox cell inline. + */ +export function ExpandedCellPopover({ + expandedCell, + onClose, + rows, + columns, + onSave, + canEdit, + scrollContainer, +}: ExpandedCellPopoverProps) { + const rootRef = useRef(null) + const textareaRef = useRef(null) + const [rect, setRect] = useState<{ top: number; left: number; width: number } | null>(null) + const [draftValue, setDraftValue] = useState('') + + const target = useMemo(() => { + if (!expandedCell) return null + const row = rows.find((r) => r.id === expandedCell.rowId) + // Match the specific visual column the user double-clicked on. Fanned-out + // workflow columns share `name` across siblings, so prefer `key` when set. + const matchByKey = expandedCell.columnKey + ? (c: DisplayColumn) => c.key === expandedCell.columnKey + : (c: DisplayColumn) => c.name === expandedCell.columnName + const column = columns.find(matchByKey) + if (!row || !column) return null + const colIndex = columns.findIndex(matchByKey) + return { row, column, colIndex, value: row.data[column.name] } + }, [expandedCell, rows, columns]) + + const isBooleanCell = target?.column.type === 'boolean' + // Workflow-output cells are editable in the expanded view too — the user + // can override the workflow's value. Booleans toggle inline; the expanded + // popover only handles text-shaped inputs. + const isEditable = Boolean(target) && canEdit && !isBooleanCell + + const displayText = useMemo(() => { + if (!target) return '' + const { value } = target + if (value == null) return '' + if (typeof value === 'string') return value + return JSON.stringify(value, null, 2) + }, [target]) + + useLayoutEffect(() => { + if (!expandedCell || !target) { + setRect(null) + return + } + setDraftValue(isEditable ? formatValueForInput(target.value, target.column.type) : '') + const selector = `[data-table-scroll] [data-row="${target.row.position}"][data-col="${target.colIndex}"]` + const el = document.querySelector(selector) + if (!el) { + setRect(null) + return + } + const r = el.getBoundingClientRect() + setRect({ top: r.top, left: r.left, width: r.width }) + // Focus textarea on open so typing works immediately. + requestAnimationFrame(() => textareaRef.current?.focus()) + }, [expandedCell, target, isEditable]) + + useEffect(() => { + if (!expandedCell) return + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + onClose() + } + } + const handleMouseDown = (e: MouseEvent) => { + if (!rootRef.current) return + if (rootRef.current.contains(e.target as Node)) return + onClose() + } + window.addEventListener('keydown', handleKey) + window.addEventListener('mousedown', handleMouseDown) + return () => { + window.removeEventListener('keydown', handleKey) + window.removeEventListener('mousedown', handleMouseDown) + } + }, [expandedCell, onClose]) + + // Close on table scroll — re-anchoring mid-scroll is more jarring than dismissing. + useEffect(() => { + if (!expandedCell || !scrollContainer) return + const handler = () => onClose() + scrollContainer.addEventListener('scroll', handler, { passive: true }) + return () => scrollContainer.removeEventListener('scroll', handler) + }, [expandedCell, scrollContainer, onClose]) + + if (!expandedCell || !target || !rect) return null + + const width = Math.max(rect.width, EXPANDED_CELL_MIN_WIDTH) + // Clamp to viewport. Prefer anchoring at the cell's left edge; if the popover + // would overflow right, align its right edge with the cell's right edge + // (mirroring Radix/menu flip behavior). Same idea for bottom-of-viewport. + const VIEWPORT_PAD = 8 + const cellRight = rect.left + rect.width + const overflowsRight = rect.left + width > window.innerWidth - VIEWPORT_PAD + const left = overflowsRight + ? Math.max(VIEWPORT_PAD, cellRight - width) + : Math.max(VIEWPORT_PAD, rect.left) + const overflowsBottom = rect.top + EXPANDED_CELL_HEIGHT > window.innerHeight - VIEWPORT_PAD + const top = overflowsBottom + ? Math.max(VIEWPORT_PAD, window.innerHeight - EXPANDED_CELL_HEIGHT - VIEWPORT_PAD) + : rect.top + + const handleSave = () => { + if (!isEditable) return + // `displayToStorage` only normalizes dates — it returns null for anything else. + // Fall back to the raw draft for non-date columns, matching the inline editor. + const raw = displayToStorage(draftValue) ?? draftValue + const cleaned = cleanCellValue(raw, target.column) + onSave(target.row.id, target.column.name, cleaned, 'blur') + onClose() + } + + const handleTextareaKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSave() + } + } + + return ( +
+ {isEditable ? ( + <> +