From 01577a18b42e7894c92590c85362f545a655a87e Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 11 Feb 2026 17:24:17 -0800 Subject: [PATCH 01/13] fix(change-detection): resolve false positive trigger block change detection (#3204) --- .../deploy/hooks/use-change-detection.ts | 15 ++ .../lib/workflows/comparison/compare.test.ts | 255 ++++++++++++++++++ apps/sim/lib/workflows/comparison/compare.ts | 11 +- .../workflows/comparison/normalize.test.ts | 212 +++++++++++++++ .../sim/lib/workflows/comparison/normalize.ts | 40 ++- apps/sim/triggers/constants.ts | 7 +- 6 files changed, 535 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts index ed0bb66f6..529a4e2f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts @@ -57,6 +57,21 @@ export function useChangeDetection({ } } + if (block.triggerMode) { + const triggerConfigValue = blockSubValues?.triggerConfig + if ( + triggerConfigValue && + typeof triggerConfigValue === 'object' && + !subBlocks.triggerConfig + ) { + subBlocks.triggerConfig = { + id: 'triggerConfig', + type: 'short-input', + value: triggerConfigValue, + } + } + } + blocksWithSubBlocks[blockId] = { ...block, subBlocks, diff --git a/apps/sim/lib/workflows/comparison/compare.test.ts b/apps/sim/lib/workflows/comparison/compare.test.ts index be7b6e9c5..5fe6e5923 100644 --- a/apps/sim/lib/workflows/comparison/compare.test.ts +++ b/apps/sim/lib/workflows/comparison/compare.test.ts @@ -2364,6 +2364,261 @@ describe('hasWorkflowChanged', () => { }) }) + describe('Trigger Config Normalization (False Positive Prevention)', () => { + it.concurrent( + 'should not detect change when deployed has null fields but current has values from triggerConfig', + () => { + // Core scenario: deployed state has null individual fields, current state has + // values populated from triggerConfig at runtime by populateTriggerFieldsFromConfig + const deployedState = createWorkflowState({ + blocks: { + block1: createBlock('block1', { + type: 'starter', + subBlocks: { + signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, + botToken: { id: 'botToken', type: 'short-input', value: null }, + triggerConfig: { + id: 'triggerConfig', + type: 'short-input', + value: { signingSecret: 'secret123', botToken: 'token456' }, + }, + }, + }), + }, + }) + + const currentState = createWorkflowState({ + blocks: { + block1: createBlock('block1', { + type: 'starter', + subBlocks: { + signingSecret: { id: 'signingSecret', type: 'short-input', value: 'secret123' }, + botToken: { id: 'botToken', type: 'short-input', value: 'token456' }, + triggerConfig: { + id: 'triggerConfig', + type: 'short-input', + value: { signingSecret: 'secret123', botToken: 'token456' }, + }, + }, + }), + }, + }) + + expect(hasWorkflowChanged(currentState, deployedState)).toBe(false) + } + ) + + it.concurrent( + 'should detect change when user edits a trigger field to a different value', + () => { + const deployedState = createWorkflowState({ + blocks: { + block1: createBlock('block1', { + type: 'starter', + subBlocks: { + signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, + triggerConfig: { + id: 'triggerConfig', + type: 'short-input', + value: { signingSecret: 'old-secret' }, + }, + }, + }), + }, + }) + + const currentState = createWorkflowState({ + blocks: { + block1: createBlock('block1', { + type: 'starter', + subBlocks: { + signingSecret: { id: 'signingSecret', type: 'short-input', value: 'new-secret' }, + triggerConfig: { + id: 'triggerConfig', + type: 'short-input', + value: { signingSecret: 'old-secret' }, + }, + }, + }), + }, + }) + + expect(hasWorkflowChanged(currentState, deployedState)).toBe(true) + } + ) + + it.concurrent('should not detect change when both sides have no triggerConfig', () => { + const deployedState = createWorkflowState({ + blocks: { + block1: createBlock('block1', { + type: 'starter', + subBlocks: { + signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, + }, + }), + }, + }) + + const currentState = createWorkflowState({ + blocks: { + block1: createBlock('block1', { + type: 'starter', + subBlocks: { + signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, + }, + }), + }, + }) + + expect(hasWorkflowChanged(currentState, deployedState)).toBe(false) + }) + + it.concurrent( + 'should not detect change when deployed has empty fields and triggerConfig populates them', + () => { + // Empty string is also treated as "empty" by normalizeTriggerConfigValues + const deployedState = createWorkflowState({ + blocks: { + block1: createBlock('block1', { + type: 'starter', + subBlocks: { + signingSecret: { id: 'signingSecret', type: 'short-input', value: '' }, + triggerConfig: { + id: 'triggerConfig', + type: 'short-input', + value: { signingSecret: 'secret123' }, + }, + }, + }), + }, + }) + + const currentState = createWorkflowState({ + blocks: { + block1: createBlock('block1', { + type: 'starter', + subBlocks: { + signingSecret: { id: 'signingSecret', type: 'short-input', value: 'secret123' }, + triggerConfig: { + id: 'triggerConfig', + type: 'short-input', + value: { signingSecret: 'secret123' }, + }, + }, + }), + }, + }) + + expect(hasWorkflowChanged(currentState, deployedState)).toBe(false) + } + ) + + it.concurrent('should not detect change when triggerId differs', () => { + const deployedState = createWorkflowState({ + blocks: { + block1: createBlock('block1', { + type: 'starter', + subBlocks: { + model: { value: 'gpt-4' }, + triggerId: { value: null }, + }, + }), + }, + }) + + const currentState = createWorkflowState({ + blocks: { + block1: createBlock('block1', { + type: 'starter', + subBlocks: { + model: { value: 'gpt-4' }, + triggerId: { value: 'slack_webhook' }, + }, + }), + }, + }) + + expect(hasWorkflowChanged(currentState, deployedState)).toBe(false) + }) + + it.concurrent( + 'should not detect change for namespaced system subBlock IDs like samplePayload_slack_webhook', + () => { + const deployedState = createWorkflowState({ + blocks: { + block1: createBlock('block1', { + type: 'starter', + subBlocks: { + model: { value: 'gpt-4' }, + samplePayload_slack_webhook: { value: 'old payload' }, + triggerInstructions_slack_webhook: { value: 'old instructions' }, + }, + }), + }, + }) + + const currentState = createWorkflowState({ + blocks: { + block1: createBlock('block1', { + type: 'starter', + subBlocks: { + model: { value: 'gpt-4' }, + samplePayload_slack_webhook: { value: 'new payload' }, + triggerInstructions_slack_webhook: { value: 'new instructions' }, + }, + }), + }, + }) + + expect(hasWorkflowChanged(currentState, deployedState)).toBe(false) + } + ) + + it.concurrent( + 'should handle mixed scenario: some fields from triggerConfig, some user-edited', + () => { + const deployedState = createWorkflowState({ + blocks: { + block1: createBlock('block1', { + type: 'starter', + subBlocks: { + signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, + botToken: { id: 'botToken', type: 'short-input', value: null }, + includeFiles: { id: 'includeFiles', type: 'switch', value: false }, + triggerConfig: { + id: 'triggerConfig', + type: 'short-input', + value: { signingSecret: 'secret123', botToken: 'token456' }, + }, + }, + }), + }, + }) + + const currentState = createWorkflowState({ + blocks: { + block1: createBlock('block1', { + type: 'starter', + subBlocks: { + signingSecret: { id: 'signingSecret', type: 'short-input', value: 'secret123' }, + botToken: { id: 'botToken', type: 'short-input', value: 'token456' }, + includeFiles: { id: 'includeFiles', type: 'switch', value: true }, + triggerConfig: { + id: 'triggerConfig', + type: 'short-input', + value: { signingSecret: 'secret123', botToken: 'token456' }, + }, + }, + }), + }, + }) + + // includeFiles changed from false to true — this IS a real change + expect(hasWorkflowChanged(currentState, deployedState)).toBe(true) + } + ) + }) + describe('Trigger Runtime Metadata (Should Not Trigger Change)', () => { it.concurrent('should not detect change when webhookId differs', () => { const deployedState = createWorkflowState({ diff --git a/apps/sim/lib/workflows/comparison/compare.ts b/apps/sim/lib/workflows/comparison/compare.ts index ce37dd86a..f739f3f20 100644 --- a/apps/sim/lib/workflows/comparison/compare.ts +++ b/apps/sim/lib/workflows/comparison/compare.ts @@ -9,6 +9,7 @@ import { normalizeLoop, normalizeParallel, normalizeSubBlockValue, + normalizeTriggerConfigValues, normalizeValue, normalizeVariables, sanitizeVariable, @@ -172,14 +173,18 @@ export function generateWorkflowDiffSummary( } } + // Normalize trigger config values for both states before comparison + const normalizedCurrentSubs = normalizeTriggerConfigValues(currentSubBlocks) + const normalizedPreviousSubs = normalizeTriggerConfigValues(previousSubBlocks) + // Compare subBlocks using shared helper for filtering (single source of truth) const allSubBlockIds = filterSubBlockIds([ - ...new Set([...Object.keys(currentSubBlocks), ...Object.keys(previousSubBlocks)]), + ...new Set([...Object.keys(normalizedCurrentSubs), ...Object.keys(normalizedPreviousSubs)]), ]) for (const subId of allSubBlockIds) { - const currentSub = currentSubBlocks[subId] as Record | undefined - const previousSub = previousSubBlocks[subId] as Record | undefined + const currentSub = normalizedCurrentSubs[subId] as Record | undefined + const previousSub = normalizedPreviousSubs[subId] as Record | undefined if (!currentSub || !previousSub) { changes.push({ diff --git a/apps/sim/lib/workflows/comparison/normalize.test.ts b/apps/sim/lib/workflows/comparison/normalize.test.ts index 6c2cc5eb1..2cf9b925a 100644 --- a/apps/sim/lib/workflows/comparison/normalize.test.ts +++ b/apps/sim/lib/workflows/comparison/normalize.test.ts @@ -4,10 +4,12 @@ import { describe, expect, it } from 'vitest' import type { Loop, Parallel } from '@/stores/workflows/workflow/types' import { + filterSubBlockIds, normalizedStringify, normalizeEdge, normalizeLoop, normalizeParallel, + normalizeTriggerConfigValues, normalizeValue, sanitizeInputFormat, sanitizeTools, @@ -584,4 +586,214 @@ describe('Workflow Normalization Utilities', () => { expect(result2).toBe(result3) }) }) + + describe('filterSubBlockIds', () => { + it.concurrent('should exclude exact SYSTEM_SUBBLOCK_IDS', () => { + const ids = ['signingSecret', 'samplePayload', 'triggerInstructions', 'botToken'] + const result = filterSubBlockIds(ids) + expect(result).toEqual(['botToken', 'signingSecret']) + }) + + it.concurrent('should exclude namespaced SYSTEM_SUBBLOCK_IDS (prefix matching)', () => { + const ids = [ + 'signingSecret', + 'samplePayload_slack_webhook', + 'triggerInstructions_slack_webhook', + 'webhookUrlDisplay_slack_webhook', + 'botToken', + ] + const result = filterSubBlockIds(ids) + expect(result).toEqual(['botToken', 'signingSecret']) + }) + + it.concurrent('should exclude exact TRIGGER_RUNTIME_SUBBLOCK_IDS', () => { + const ids = ['webhookId', 'triggerPath', 'triggerConfig', 'triggerId', 'signingSecret'] + const result = filterSubBlockIds(ids) + expect(result).toEqual(['signingSecret']) + }) + + it.concurrent('should not exclude IDs that merely contain a system ID substring', () => { + const ids = ['mySamplePayload', 'notSamplePayload'] + const result = filterSubBlockIds(ids) + expect(result).toEqual(['mySamplePayload', 'notSamplePayload']) + }) + + it.concurrent('should return sorted results', () => { + const ids = ['zebra', 'alpha', 'middle'] + const result = filterSubBlockIds(ids) + expect(result).toEqual(['alpha', 'middle', 'zebra']) + }) + + it.concurrent('should handle empty array', () => { + expect(filterSubBlockIds([])).toEqual([]) + }) + + it.concurrent('should handle all IDs being excluded', () => { + const ids = ['webhookId', 'triggerPath', 'samplePayload', 'triggerConfig'] + const result = filterSubBlockIds(ids) + expect(result).toEqual([]) + }) + + it.concurrent('should exclude setupScript and scheduleInfo namespaced variants', () => { + const ids = ['setupScript_google_sheets_row', 'scheduleInfo_cron_trigger', 'realField'] + const result = filterSubBlockIds(ids) + expect(result).toEqual(['realField']) + }) + + it.concurrent('should exclude triggerCredentials namespaced variants', () => { + const ids = ['triggerCredentials_slack_webhook', 'signingSecret'] + const result = filterSubBlockIds(ids) + expect(result).toEqual(['signingSecret']) + }) + }) + + describe('normalizeTriggerConfigValues', () => { + it.concurrent('should return subBlocks unchanged when no triggerConfig exists', () => { + const subBlocks = { + signingSecret: { id: 'signingSecret', type: 'short-input', value: 'secret123' }, + botToken: { id: 'botToken', type: 'short-input', value: 'token456' }, + } + const result = normalizeTriggerConfigValues(subBlocks) + expect(result).toEqual(subBlocks) + }) + + it.concurrent('should return subBlocks unchanged when triggerConfig value is null', () => { + const subBlocks = { + triggerConfig: { id: 'triggerConfig', type: 'short-input', value: null }, + signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, + } + const result = normalizeTriggerConfigValues(subBlocks) + expect(result).toEqual(subBlocks) + }) + + it.concurrent( + 'should return subBlocks unchanged when triggerConfig value is not an object', + () => { + const subBlocks = { + triggerConfig: { id: 'triggerConfig', type: 'short-input', value: 'string-value' }, + signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, + } + const result = normalizeTriggerConfigValues(subBlocks) + expect(result).toEqual(subBlocks) + } + ) + + it.concurrent('should populate null individual fields from triggerConfig', () => { + const subBlocks = { + triggerConfig: { + id: 'triggerConfig', + type: 'short-input', + value: { signingSecret: 'secret123', botToken: 'token456' }, + }, + signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, + botToken: { id: 'botToken', type: 'short-input', value: null }, + } + const result = normalizeTriggerConfigValues(subBlocks) + expect((result.signingSecret as Record).value).toBe('secret123') + expect((result.botToken as Record).value).toBe('token456') + }) + + it.concurrent('should populate undefined individual fields from triggerConfig', () => { + const subBlocks = { + triggerConfig: { + id: 'triggerConfig', + type: 'short-input', + value: { signingSecret: 'secret123' }, + }, + signingSecret: { id: 'signingSecret', type: 'short-input', value: undefined }, + } + const result = normalizeTriggerConfigValues(subBlocks) + expect((result.signingSecret as Record).value).toBe('secret123') + }) + + it.concurrent('should populate empty string individual fields from triggerConfig', () => { + const subBlocks = { + triggerConfig: { + id: 'triggerConfig', + type: 'short-input', + value: { signingSecret: 'secret123' }, + }, + signingSecret: { id: 'signingSecret', type: 'short-input', value: '' }, + } + const result = normalizeTriggerConfigValues(subBlocks) + expect((result.signingSecret as Record).value).toBe('secret123') + }) + + it.concurrent('should NOT overwrite existing non-empty individual field values', () => { + const subBlocks = { + triggerConfig: { + id: 'triggerConfig', + type: 'short-input', + value: { signingSecret: 'old-secret' }, + }, + signingSecret: { id: 'signingSecret', type: 'short-input', value: 'user-edited-secret' }, + } + const result = normalizeTriggerConfigValues(subBlocks) + expect((result.signingSecret as Record).value).toBe('user-edited-secret') + }) + + it.concurrent('should skip triggerConfig fields that are null/undefined', () => { + const subBlocks = { + triggerConfig: { + id: 'triggerConfig', + type: 'short-input', + value: { signingSecret: null, botToken: undefined }, + }, + signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, + botToken: { id: 'botToken', type: 'short-input', value: null }, + } + const result = normalizeTriggerConfigValues(subBlocks) + expect((result.signingSecret as Record).value).toBe(null) + expect((result.botToken as Record).value).toBe(null) + }) + + it.concurrent('should skip fields from triggerConfig that have no matching subBlock', () => { + const subBlocks = { + triggerConfig: { + id: 'triggerConfig', + type: 'short-input', + value: { nonExistentField: 'value123' }, + }, + signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, + } + const result = normalizeTriggerConfigValues(subBlocks) + expect(result.nonExistentField).toBeUndefined() + expect((result.signingSecret as Record).value).toBe(null) + }) + + it.concurrent('should not mutate the original subBlocks object', () => { + const original = { + triggerConfig: { + id: 'triggerConfig', + type: 'short-input', + value: { signingSecret: 'secret123' }, + }, + signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, + } + normalizeTriggerConfigValues(original) + expect((original.signingSecret as Record).value).toBe(null) + }) + + it.concurrent('should preserve other subBlock properties when populating value', () => { + const subBlocks = { + triggerConfig: { + id: 'triggerConfig', + type: 'short-input', + value: { signingSecret: 'secret123' }, + }, + signingSecret: { + id: 'signingSecret', + type: 'short-input', + value: null, + placeholder: 'Enter signing secret', + }, + } + const result = normalizeTriggerConfigValues(subBlocks) + const normalized = result.signingSecret as Record + expect(normalized.value).toBe('secret123') + expect(normalized.id).toBe('signingSecret') + expect(normalized.type).toBe('short-input') + expect(normalized.placeholder).toBe('Enter signing secret') + }) + }) }) diff --git a/apps/sim/lib/workflows/comparison/normalize.ts b/apps/sim/lib/workflows/comparison/normalize.ts index dc414e25b..4a8ce18a2 100644 --- a/apps/sim/lib/workflows/comparison/normalize.ts +++ b/apps/sim/lib/workflows/comparison/normalize.ts @@ -418,10 +418,48 @@ export function extractBlockFieldsForComparison(block: BlockState): ExtractedBlo */ export function filterSubBlockIds(subBlockIds: string[]): string[] { return subBlockIds - .filter((id) => !SYSTEM_SUBBLOCK_IDS.includes(id) && !TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id)) + .filter((id) => { + if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id)) return false + if (SYSTEM_SUBBLOCK_IDS.some((sysId) => id === sysId || id.startsWith(`${sysId}_`))) + return false + return true + }) .sort() } +/** + * Normalizes trigger block subBlocks by populating null/empty individual fields + * from the triggerConfig aggregate subBlock. This compensates for the runtime + * population done by populateTriggerFieldsFromConfig, ensuring consistent + * comparison between client state (with populated values) and deployed state + * (with null values from DB). + */ +export function normalizeTriggerConfigValues( + subBlocks: Record +): Record { + const triggerConfigSub = subBlocks.triggerConfig as Record | undefined + const triggerConfigValue = triggerConfigSub?.value + if (!triggerConfigValue || typeof triggerConfigValue !== 'object') { + return subBlocks + } + + const result = { ...subBlocks } + for (const [fieldId, configValue] of Object.entries( + triggerConfigValue as Record + )) { + if (configValue === null || configValue === undefined) continue + const existingSub = result[fieldId] as Record | undefined + if ( + existingSub && + (existingSub.value === null || existingSub.value === undefined || existingSub.value === '') + ) { + result[fieldId] = { ...existingSub, value: configValue } + } + } + + return result +} + /** * Normalizes a subBlock value with sanitization for specific subBlock types. * Sanitizes: tools (removes isExpanded), inputFormat (removes collapsed) diff --git a/apps/sim/triggers/constants.ts b/apps/sim/triggers/constants.ts index 4cceb5439..d7fcdc997 100644 --- a/apps/sim/triggers/constants.ts +++ b/apps/sim/triggers/constants.ts @@ -23,7 +23,12 @@ export const SYSTEM_SUBBLOCK_IDS: string[] = [ * with default values from the trigger definition on load, which aren't present in * the deployed state, causing false positive change detection. */ -export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = ['webhookId', 'triggerPath', 'triggerConfig'] +export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [ + 'webhookId', + 'triggerPath', + 'triggerConfig', + 'triggerId', +] /** * Maximum number of consecutive failures before a trigger (schedule/webhook) is auto-disabled. From 81dfeb0bb0af1c1e6df371504892fd74f537a7e4 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 11 Feb 2026 19:31:29 -0800 Subject: [PATCH 02/13] fix(terminal): reconnect to running executions after page refresh (#3200) * fix(terminal): reconnect to running executions after page refresh * fix(terminal): use ExecutionEvent type instead of any in reconnection stream * fix(execution): type event buffer with ExecutionEvent instead of Record Co-Authored-By: Claude Opus 4.6 * fix(execution): validate fromEventId query param in reconnection endpoint Co-Authored-By: Claude Opus 4.6 * Fix some bugs * fix(variables): fix tag dropdown and cursor alignment in variables block (#3199) * feat(confluence): added list space labels, delete label, delete page prop (#3201) * updated route * ack comments * fix(execution): reset execution state in reconnection cleanup to unblock re-entry Co-Authored-By: Claude Opus 4.6 * fix(execution): restore running entries when reconnection is interrupted by navigation Co-Authored-By: Claude Opus 4.6 * done * remove cast in ioredis types * ack PR comments --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Siddharth Ganesan --- .../[id]/deployments/[version]/route.ts | 2 +- .../app/api/workflows/[id]/execute/route.ts | 47 +- .../executions/[executionId]/stream/route.ts | 170 +++++++ .../components/version-description-modal.tsx | 4 +- .../hooks/use-workflow-execution.ts | 431 ++++++++++++++---- apps/sim/hooks/queries/deployments.ts | 2 +- apps/sim/hooks/use-execution-stream.ts | 167 ++++--- apps/sim/lib/execution/event-buffer.ts | 246 ++++++++++ apps/sim/stores/execution/store.ts | 12 + apps/sim/stores/execution/types.ts | 7 + apps/sim/stores/terminal/console/store.ts | 25 +- apps/sim/stores/terminal/console/types.ts | 1 + 12 files changed, 944 insertions(+), 170 deletions(-) create mode 100644 apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts create mode 100644 apps/sim/lib/execution/event-buffer.ts diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index 74194eba6..3af21e758 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -29,7 +29,7 @@ const patchBodySchema = z description: z .string() .trim() - .max(500, 'Description must be 500 characters or less') + .max(2000, 'Description must be 2000 characters or less') .nullable() .optional(), isActive: z.literal(true).optional(), // Set to true to activate this version diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 13fc0ff41..b6ed6bd8b 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -12,7 +12,7 @@ import { import { generateRequestId } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getBaseUrl } from '@/lib/core/utils/urls' -import { markExecutionCancelled } from '@/lib/execution/cancellation' +import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer' import { processInputFileFields } from '@/lib/execution/files' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' @@ -700,15 +700,27 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync) let isStreamClosed = false + const eventWriter = createExecutionEventWriter(executionId) + setExecutionMeta(executionId, { + status: 'active', + userId: actorUserId, + workflowId, + }).catch(() => {}) + const stream = new ReadableStream({ async start(controller) { - const sendEvent = (event: ExecutionEvent) => { - if (isStreamClosed) return + let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null - try { - controller.enqueue(encodeSSEEvent(event)) - } catch { - isStreamClosed = true + const sendEvent = (event: ExecutionEvent) => { + if (!isStreamClosed) { + try { + controller.enqueue(encodeSSEEvent(event)) + } catch { + isStreamClosed = true + } + } + if (event.type !== 'stream:chunk' && event.type !== 'stream:done') { + eventWriter.write(event).catch(() => {}) } } @@ -829,14 +841,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const reader = streamingExec.stream.getReader() const decoder = new TextDecoder() - let chunkCount = 0 try { while (true) { const { done, value } = await reader.read() if (done) break - chunkCount++ const chunk = decoder.decode(value, { stream: true }) sendEvent({ type: 'stream:chunk', @@ -951,6 +961,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: duration: result.metadata?.duration || 0, }, }) + finalMetaStatus = 'error' } else { logger.info(`[${requestId}] Workflow execution was cancelled`) @@ -963,6 +974,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: duration: result.metadata?.duration || 0, }, }) + finalMetaStatus = 'cancelled' } return } @@ -986,6 +998,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: endTime: result.metadata?.endTime || new Date().toISOString(), }, }) + finalMetaStatus = 'complete' } catch (error: unknown) { const isTimeout = isTimeoutError(error) || timeoutController.isTimedOut() const errorMessage = isTimeout @@ -1017,7 +1030,18 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: duration: executionResult?.metadata?.duration || 0, }, }) + finalMetaStatus = 'error' } finally { + try { + await eventWriter.close() + } catch (closeError) { + logger.warn(`[${requestId}] Failed to close event writer`, { + error: closeError instanceof Error ? closeError.message : String(closeError), + }) + } + if (finalMetaStatus) { + setExecutionMeta(executionId, { status: finalMetaStatus }).catch(() => {}) + } timeoutController.cleanup() if (executionId) { await cleanupExecutionBase64Cache(executionId) @@ -1032,10 +1056,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: }, cancel() { isStreamClosed = true - timeoutController.cleanup() - logger.info(`[${requestId}] Client aborted SSE stream, signalling cancellation`) - timeoutController.abort() - markExecutionCancelled(executionId).catch(() => {}) + logger.info(`[${requestId}] Client disconnected from SSE stream`) }, }) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts new file mode 100644 index 000000000..1f77ff391 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts @@ -0,0 +1,170 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { SSE_HEADERS } from '@/lib/core/utils/sse' +import { + type ExecutionStreamStatus, + getExecutionMeta, + readExecutionEvents, +} from '@/lib/execution/event-buffer' +import { formatSSEEvent } from '@/lib/workflows/executor/execution-events' +import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' + +const logger = createLogger('ExecutionStreamReconnectAPI') + +const POLL_INTERVAL_MS = 500 +const MAX_POLL_DURATION_MS = 10 * 60 * 1000 // 10 minutes + +function isTerminalStatus(status: ExecutionStreamStatus): boolean { + return status === 'complete' || status === 'error' || status === 'cancelled' +} + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string; executionId: string }> } +) { + const { id: workflowId, executionId } = await params + + try { + const auth = await checkHybridAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId: auth.userId, + action: 'read', + }) + if (!workflowAuthorization.allowed) { + return NextResponse.json( + { error: workflowAuthorization.message || 'Access denied' }, + { status: workflowAuthorization.status } + ) + } + + const meta = await getExecutionMeta(executionId) + if (!meta) { + return NextResponse.json({ error: 'Execution buffer not found or expired' }, { status: 404 }) + } + + if (meta.workflowId && meta.workflowId !== workflowId) { + return NextResponse.json( + { error: 'Execution does not belong to this workflow' }, + { status: 403 } + ) + } + + const fromParam = req.nextUrl.searchParams.get('from') + const parsed = fromParam ? Number.parseInt(fromParam, 10) : 0 + const fromEventId = Number.isFinite(parsed) && parsed >= 0 ? parsed : 0 + + logger.info('Reconnection stream requested', { + workflowId, + executionId, + fromEventId, + metaStatus: meta.status, + }) + + const encoder = new TextEncoder() + + let closed = false + + const stream = new ReadableStream({ + async start(controller) { + let lastEventId = fromEventId + const pollDeadline = Date.now() + MAX_POLL_DURATION_MS + + const enqueue = (text: string) => { + if (closed) return + try { + controller.enqueue(encoder.encode(text)) + } catch { + closed = true + } + } + + try { + const events = await readExecutionEvents(executionId, lastEventId) + for (const entry of events) { + if (closed) return + enqueue(formatSSEEvent(entry.event)) + lastEventId = entry.eventId + } + + const currentMeta = await getExecutionMeta(executionId) + if (!currentMeta || isTerminalStatus(currentMeta.status)) { + enqueue('data: [DONE]\n\n') + if (!closed) controller.close() + return + } + + while (!closed && Date.now() < pollDeadline) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + if (closed) return + + const newEvents = await readExecutionEvents(executionId, lastEventId) + for (const entry of newEvents) { + if (closed) return + enqueue(formatSSEEvent(entry.event)) + lastEventId = entry.eventId + } + + const polledMeta = await getExecutionMeta(executionId) + if (!polledMeta || isTerminalStatus(polledMeta.status)) { + const finalEvents = await readExecutionEvents(executionId, lastEventId) + for (const entry of finalEvents) { + if (closed) return + enqueue(formatSSEEvent(entry.event)) + lastEventId = entry.eventId + } + enqueue('data: [DONE]\n\n') + if (!closed) controller.close() + return + } + } + + if (!closed) { + logger.warn('Reconnection stream poll deadline reached', { executionId }) + enqueue('data: [DONE]\n\n') + controller.close() + } + } catch (error) { + logger.error('Error in reconnection stream', { + executionId, + error: error instanceof Error ? error.message : String(error), + }) + if (!closed) { + try { + controller.close() + } catch {} + } + } + }, + cancel() { + closed = true + logger.info('Client disconnected from reconnection stream', { executionId }) + }, + }) + + return new NextResponse(stream, { + headers: { + ...SSE_HEADERS, + 'X-Execution-Id': executionId, + }, + }) + } catch (error: any) { + logger.error('Failed to start reconnection stream', { + workflowId, + executionId, + error: error.message, + }) + return NextResponse.json( + { error: error.message || 'Failed to start reconnection stream' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx index 3cf5106ea..63606c56a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx @@ -113,7 +113,7 @@ export function VersionDescriptionModal({ className='min-h-[120px] resize-none' value={description} onChange={(e) => setDescription(e.target.value)} - maxLength={500} + maxLength={2000} disabled={isGenerating} />
@@ -123,7 +123,7 @@ export function VersionDescriptionModal({

)} {!updateMutation.error && !generateMutation.error &&
} -

{description.length}/500

+

{description.length}/2000

diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 16c0e81f1..1088f8c87 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { v4 as uuidv4 } from 'uuid' @@ -46,7 +46,13 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('useWorkflowExecution') -// Debug state validation result +/** + * Module-level Set tracking which workflows have an active reconnection effect. + * Prevents multiple hook instances (from different components) from starting + * concurrent reconnection streams for the same workflow during the same mount cycle. + */ +const activeReconnections = new Set() + interface DebugValidationResult { isValid: boolean error?: string @@ -54,7 +60,7 @@ interface DebugValidationResult { interface BlockEventHandlerConfig { workflowId?: string - executionId?: string + executionIdRef: { current: string } workflowEdges: Array<{ id: string; target: string; sourceHandle?: string | null }> activeBlocksSet: Set accumulatedBlockLogs: BlockLog[] @@ -108,12 +114,15 @@ export function useWorkflowExecution() { const queryClient = useQueryClient() const currentWorkflow = useCurrentWorkflow() const { activeWorkflowId, workflows } = useWorkflowRegistry() - const { toggleConsole, addConsole, updateConsole, cancelRunningEntries } = + const { toggleConsole, addConsole, updateConsole, cancelRunningEntries, clearExecutionEntries } = useTerminalConsoleStore() + const hasHydrated = useTerminalConsoleStore((s) => s._hasHydrated) const { getAllVariables } = useEnvironmentStore() const { getVariablesByWorkflowId, variables } = useVariablesStore() const { isExecuting, isDebugging, pendingBlocks, executor, debugContext } = useCurrentWorkflowExecution() + const setCurrentExecutionId = useExecutionStore((s) => s.setCurrentExecutionId) + const getCurrentExecutionId = useExecutionStore((s) => s.getCurrentExecutionId) const setIsExecuting = useExecutionStore((s) => s.setIsExecuting) const setIsDebugging = useExecutionStore((s) => s.setIsDebugging) const setPendingBlocks = useExecutionStore((s) => s.setPendingBlocks) @@ -297,7 +306,7 @@ export function useWorkflowExecution() { (config: BlockEventHandlerConfig) => { const { workflowId, - executionId, + executionIdRef, workflowEdges, activeBlocksSet, accumulatedBlockLogs, @@ -308,6 +317,14 @@ export function useWorkflowExecution() { onBlockCompleteCallback, } = config + /** Returns true if this execution was cancelled or superseded by another run. */ + const isStaleExecution = () => + !!( + workflowId && + executionIdRef.current && + useExecutionStore.getState().getCurrentExecutionId(workflowId) !== executionIdRef.current + ) + const updateActiveBlocks = (blockId: string, isActive: boolean) => { if (!workflowId) return if (isActive) { @@ -360,7 +377,7 @@ export function useWorkflowExecution() { endedAt: data.endedAt, workflowId, blockId: data.blockId, - executionId, + executionId: executionIdRef.current, blockName: data.blockName || 'Unknown Block', blockType: data.blockType || 'unknown', iterationCurrent: data.iterationCurrent, @@ -383,7 +400,7 @@ export function useWorkflowExecution() { endedAt: data.endedAt, workflowId, blockId: data.blockId, - executionId, + executionId: executionIdRef.current, blockName: data.blockName || 'Unknown Block', blockType: data.blockType || 'unknown', iterationCurrent: data.iterationCurrent, @@ -410,7 +427,7 @@ export function useWorkflowExecution() { iterationType: data.iterationType, iterationContainerId: data.iterationContainerId, }, - executionId + executionIdRef.current ) } @@ -432,11 +449,12 @@ export function useWorkflowExecution() { iterationType: data.iterationType, iterationContainerId: data.iterationContainerId, }, - executionId + executionIdRef.current ) } const onBlockStarted = (data: BlockStartedData) => { + if (isStaleExecution()) return updateActiveBlocks(data.blockId, true) markIncomingEdges(data.blockId) @@ -453,7 +471,7 @@ export function useWorkflowExecution() { endedAt: undefined, workflowId, blockId: data.blockId, - executionId, + executionId: executionIdRef.current, blockName: data.blockName || 'Unknown Block', blockType: data.blockType || 'unknown', isRunning: true, @@ -465,6 +483,7 @@ export function useWorkflowExecution() { } const onBlockCompleted = (data: BlockCompletedData) => { + if (isStaleExecution()) return updateActiveBlocks(data.blockId, false) if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'success') @@ -495,6 +514,7 @@ export function useWorkflowExecution() { } const onBlockError = (data: BlockErrorData) => { + if (isStaleExecution()) return updateActiveBlocks(data.blockId, false) if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'error') @@ -902,10 +922,6 @@ export function useWorkflowExecution() { // Update block logs with actual stream completion times if (result.logs && streamCompletionTimes.size > 0) { - const streamCompletionEndTime = new Date( - Math.max(...Array.from(streamCompletionTimes.values())) - ).toISOString() - result.logs.forEach((log: BlockLog) => { if (streamCompletionTimes.has(log.blockId)) { const completionTime = streamCompletionTimes.get(log.blockId)! @@ -987,7 +1003,6 @@ export function useWorkflowExecution() { return { success: true, stream } } - // For manual (non-chat) execution const manualExecutionId = uuidv4() try { const result = await executeWorkflow( @@ -1002,29 +1017,10 @@ export function useWorkflowExecution() { if (result.metadata.pendingBlocks) { setPendingBlocks(activeWorkflowId, result.metadata.pendingBlocks) } - } else if (result && 'success' in result) { - setExecutionResult(result) - // Reset execution state after successful non-debug execution - setIsExecuting(activeWorkflowId, false) - setIsDebugging(activeWorkflowId, false) - setActiveBlocks(activeWorkflowId, new Set()) - - if (isChatExecution) { - if (!result.metadata) { - result.metadata = { duration: 0, startTime: new Date().toISOString() } - } - ;(result.metadata as any).source = 'chat' - } - - // Invalidate subscription queries to update usage - setTimeout(() => { - queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }) - }, 1000) } return result } catch (error: any) { const errorResult = handleExecutionError(error, { executionId: manualExecutionId }) - // Note: Error logs are already persisted server-side via execution-core.ts return errorResult } }, @@ -1275,7 +1271,7 @@ export function useWorkflowExecution() { if (activeWorkflowId) { logger.info('Using server-side executor') - const executionId = uuidv4() + const executionIdRef = { current: '' } let executionResult: ExecutionResult = { success: false, @@ -1293,7 +1289,7 @@ export function useWorkflowExecution() { try { const blockHandlers = buildBlockEventHandlers({ workflowId: activeWorkflowId, - executionId, + executionIdRef, workflowEdges, activeBlocksSet, accumulatedBlockLogs, @@ -1326,6 +1322,10 @@ export function useWorkflowExecution() { loops: clientWorkflowState.loops, parallels: clientWorkflowState.parallels, }, + onExecutionId: (id) => { + executionIdRef.current = id + setCurrentExecutionId(activeWorkflowId, id) + }, callbacks: { onExecutionStarted: (data) => { logger.info('Server execution started:', data) @@ -1368,6 +1368,18 @@ export function useWorkflowExecution() { }, onExecutionCompleted: (data) => { + if ( + activeWorkflowId && + executionIdRef.current && + useExecutionStore.getState().getCurrentExecutionId(activeWorkflowId) !== + executionIdRef.current + ) + return + + if (activeWorkflowId) { + setCurrentExecutionId(activeWorkflowId, null) + } + executionResult = { success: data.success, output: data.output, @@ -1425,9 +1437,33 @@ export function useWorkflowExecution() { }) } } + + const workflowExecState = activeWorkflowId + ? useExecutionStore.getState().getWorkflowExecution(activeWorkflowId) + : null + if (activeWorkflowId && !workflowExecState?.isDebugging) { + setExecutionResult(executionResult) + setIsExecuting(activeWorkflowId, false) + setActiveBlocks(activeWorkflowId, new Set()) + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }) + }, 1000) + } }, onExecutionError: (data) => { + if ( + activeWorkflowId && + executionIdRef.current && + useExecutionStore.getState().getCurrentExecutionId(activeWorkflowId) !== + executionIdRef.current + ) + return + + if (activeWorkflowId) { + setCurrentExecutionId(activeWorkflowId, null) + } + executionResult = { success: false, output: {}, @@ -1441,43 +1477,53 @@ export function useWorkflowExecution() { const isPreExecutionError = accumulatedBlockLogs.length === 0 handleExecutionErrorConsole({ workflowId: activeWorkflowId, - executionId, + executionId: executionIdRef.current, error: data.error, durationMs: data.duration, blockLogs: accumulatedBlockLogs, isPreExecutionError, }) + + if (activeWorkflowId) { + setIsExecuting(activeWorkflowId, false) + setIsDebugging(activeWorkflowId, false) + setActiveBlocks(activeWorkflowId, new Set()) + } }, onExecutionCancelled: (data) => { + if ( + activeWorkflowId && + executionIdRef.current && + useExecutionStore.getState().getCurrentExecutionId(activeWorkflowId) !== + executionIdRef.current + ) + return + + if (activeWorkflowId) { + setCurrentExecutionId(activeWorkflowId, null) + } + handleExecutionCancelledConsole({ workflowId: activeWorkflowId, - executionId, + executionId: executionIdRef.current, durationMs: data?.duration, }) + + if (activeWorkflowId) { + setIsExecuting(activeWorkflowId, false) + setIsDebugging(activeWorkflowId, false) + setActiveBlocks(activeWorkflowId, new Set()) + } }, }, }) return executionResult } catch (error: any) { - // Don't log abort errors - they're intentional user actions if (error.name === 'AbortError' || error.message?.includes('aborted')) { logger.info('Execution aborted by user') - - // Reset execution state - if (activeWorkflowId) { - setIsExecuting(activeWorkflowId, false) - setActiveBlocks(activeWorkflowId, new Set()) - } - - // Return gracefully without error - return { - success: false, - output: {}, - metadata: { duration: 0 }, - logs: [], - } + return executionResult } logger.error('Server-side execution failed:', error) @@ -1485,7 +1531,6 @@ export function useWorkflowExecution() { } } - // Fallback: should never reach here throw new Error('Server-side execution is required') } @@ -1717,25 +1762,28 @@ export function useWorkflowExecution() { * Handles cancelling the current workflow execution */ const handleCancelExecution = useCallback(() => { + if (!activeWorkflowId) return logger.info('Workflow execution cancellation requested') - // Cancel the execution stream for this workflow (server-side) - executionStream.cancel(activeWorkflowId ?? undefined) + const storedExecutionId = getCurrentExecutionId(activeWorkflowId) - // Mark current chat execution as superseded so its cleanup won't affect new executions - currentChatExecutionIdRef.current = null - - // Mark all running entries as canceled in the terminal - if (activeWorkflowId) { - cancelRunningEntries(activeWorkflowId) - - // Reset execution state - this triggers chat stream cleanup via useEffect in chat.tsx - setIsExecuting(activeWorkflowId, false) - setIsDebugging(activeWorkflowId, false) - setActiveBlocks(activeWorkflowId, new Set()) + if (storedExecutionId) { + setCurrentExecutionId(activeWorkflowId, null) + fetch(`/api/workflows/${activeWorkflowId}/executions/${storedExecutionId}/cancel`, { + method: 'POST', + }).catch(() => {}) + handleExecutionCancelledConsole({ + workflowId: activeWorkflowId, + executionId: storedExecutionId, + }) } - // If in debug mode, also reset debug state + executionStream.cancel(activeWorkflowId) + currentChatExecutionIdRef.current = null + setIsExecuting(activeWorkflowId, false) + setIsDebugging(activeWorkflowId, false) + setActiveBlocks(activeWorkflowId, new Set()) + if (isDebugging) { resetDebugState() } @@ -1747,7 +1795,9 @@ export function useWorkflowExecution() { setIsDebugging, setActiveBlocks, activeWorkflowId, - cancelRunningEntries, + getCurrentExecutionId, + setCurrentExecutionId, + handleExecutionCancelledConsole, ]) /** @@ -1847,7 +1897,7 @@ export function useWorkflowExecution() { } setIsExecuting(workflowId, true) - const executionId = uuidv4() + const executionIdRef = { current: '' } const accumulatedBlockLogs: BlockLog[] = [] const accumulatedBlockStates = new Map() const executedBlockIds = new Set() @@ -1856,7 +1906,7 @@ export function useWorkflowExecution() { try { const blockHandlers = buildBlockEventHandlers({ workflowId, - executionId, + executionIdRef, workflowEdges, activeBlocksSet, accumulatedBlockLogs, @@ -1871,6 +1921,10 @@ export function useWorkflowExecution() { startBlockId: blockId, sourceSnapshot: effectiveSnapshot, input: workflowInput, + onExecutionId: (id) => { + executionIdRef.current = id + setCurrentExecutionId(workflowId, id) + }, callbacks: { onBlockStarted: blockHandlers.onBlockStarted, onBlockCompleted: blockHandlers.onBlockCompleted, @@ -1878,7 +1932,6 @@ export function useWorkflowExecution() { onExecutionCompleted: (data) => { if (data.success) { - // Add the start block (trigger) to executed blocks executedBlockIds.add(blockId) const mergedBlockStates: Record = { @@ -1902,6 +1955,10 @@ export function useWorkflowExecution() { } setLastExecutionSnapshot(workflowId, updatedSnapshot) } + + setCurrentExecutionId(workflowId, null) + setIsExecuting(workflowId, false) + setActiveBlocks(workflowId, new Set()) }, onExecutionError: (data) => { @@ -1921,19 +1978,27 @@ export function useWorkflowExecution() { handleExecutionErrorConsole({ workflowId, - executionId, + executionId: executionIdRef.current, error: data.error, durationMs: data.duration, blockLogs: accumulatedBlockLogs, }) + + setCurrentExecutionId(workflowId, null) + setIsExecuting(workflowId, false) + setActiveBlocks(workflowId, new Set()) }, onExecutionCancelled: (data) => { handleExecutionCancelledConsole({ workflowId, - executionId, + executionId: executionIdRef.current, durationMs: data?.duration, }) + + setCurrentExecutionId(workflowId, null) + setIsExecuting(workflowId, false) + setActiveBlocks(workflowId, new Set()) }, }, }) @@ -1942,14 +2007,20 @@ export function useWorkflowExecution() { logger.error('Run-from-block failed:', error) } } finally { - setIsExecuting(workflowId, false) - setActiveBlocks(workflowId, new Set()) + const currentId = getCurrentExecutionId(workflowId) + if (currentId === null || currentId === executionIdRef.current) { + setCurrentExecutionId(workflowId, null) + setIsExecuting(workflowId, false) + setActiveBlocks(workflowId, new Set()) + } } }, [ getLastExecutionSnapshot, setLastExecutionSnapshot, clearLastExecutionSnapshot, + getCurrentExecutionId, + setCurrentExecutionId, setIsExecuting, setActiveBlocks, setBlockRunStatus, @@ -1979,29 +2050,213 @@ export function useWorkflowExecution() { const executionId = uuidv4() try { - const result = await executeWorkflow( - undefined, - undefined, - executionId, - undefined, - 'manual', - blockId - ) - if (result && 'success' in result) { - setExecutionResult(result) - } + await executeWorkflow(undefined, undefined, executionId, undefined, 'manual', blockId) } catch (error) { const errorResult = handleExecutionError(error, { executionId }) return errorResult } finally { + setCurrentExecutionId(workflowId, null) setIsExecuting(workflowId, false) setIsDebugging(workflowId, false) setActiveBlocks(workflowId, new Set()) } }, - [activeWorkflowId, setExecutionResult, setIsExecuting, setIsDebugging, setActiveBlocks] + [ + activeWorkflowId, + setCurrentExecutionId, + setExecutionResult, + setIsExecuting, + setIsDebugging, + setActiveBlocks, + ] ) + useEffect(() => { + if (!activeWorkflowId || !hasHydrated) return + + const entries = useTerminalConsoleStore.getState().entries + const runningEntries = entries.filter( + (e) => e.isRunning && e.workflowId === activeWorkflowId && e.executionId + ) + if (runningEntries.length === 0) return + + if (activeReconnections.has(activeWorkflowId)) return + activeReconnections.add(activeWorkflowId) + + executionStream.cancel(activeWorkflowId) + + const sorted = [...runningEntries].sort((a, b) => { + const aTime = a.startedAt ? new Date(a.startedAt).getTime() : 0 + const bTime = b.startedAt ? new Date(b.startedAt).getTime() : 0 + return bTime - aTime + }) + const executionId = sorted[0].executionId! + + const otherExecutionIds = new Set( + sorted.filter((e) => e.executionId !== executionId).map((e) => e.executionId!) + ) + if (otherExecutionIds.size > 0) { + cancelRunningEntries(activeWorkflowId) + } + + setCurrentExecutionId(activeWorkflowId, executionId) + setIsExecuting(activeWorkflowId, true) + + const workflowEdges = useWorkflowStore.getState().edges + const activeBlocksSet = new Set() + const accumulatedBlockLogs: BlockLog[] = [] + const accumulatedBlockStates = new Map() + const executedBlockIds = new Set() + + const executionIdRef = { current: executionId } + + const handlers = buildBlockEventHandlers({ + workflowId: activeWorkflowId, + executionIdRef, + workflowEdges, + activeBlocksSet, + accumulatedBlockLogs, + accumulatedBlockStates, + executedBlockIds, + consoleMode: 'update', + includeStartConsoleEntry: true, + }) + + const originalEntries = entries + .filter((e) => e.executionId === executionId) + .map((e) => ({ ...e })) + + let cleared = false + let reconnectionComplete = false + let cleanupRan = false + const clearOnce = () => { + if (!cleared) { + cleared = true + clearExecutionEntries(executionId) + } + } + + const reconnectWorkflowId = activeWorkflowId + + executionStream + .reconnect({ + workflowId: reconnectWorkflowId, + executionId, + callbacks: { + onBlockStarted: (data) => { + clearOnce() + handlers.onBlockStarted(data) + }, + onBlockCompleted: (data) => { + clearOnce() + handlers.onBlockCompleted(data) + }, + onBlockError: (data) => { + clearOnce() + handlers.onBlockError(data) + }, + onExecutionCompleted: () => { + const currentId = useExecutionStore + .getState() + .getCurrentExecutionId(reconnectWorkflowId) + if (currentId !== executionId) { + reconnectionComplete = true + activeReconnections.delete(reconnectWorkflowId) + return + } + clearOnce() + reconnectionComplete = true + activeReconnections.delete(reconnectWorkflowId) + setCurrentExecutionId(reconnectWorkflowId, null) + setIsExecuting(reconnectWorkflowId, false) + setActiveBlocks(reconnectWorkflowId, new Set()) + }, + onExecutionError: (data) => { + const currentId = useExecutionStore + .getState() + .getCurrentExecutionId(reconnectWorkflowId) + if (currentId !== executionId) { + reconnectionComplete = true + activeReconnections.delete(reconnectWorkflowId) + return + } + clearOnce() + reconnectionComplete = true + activeReconnections.delete(reconnectWorkflowId) + setCurrentExecutionId(reconnectWorkflowId, null) + setIsExecuting(reconnectWorkflowId, false) + setActiveBlocks(reconnectWorkflowId, new Set()) + handleExecutionErrorConsole({ + workflowId: reconnectWorkflowId, + executionId, + error: data.error, + blockLogs: accumulatedBlockLogs, + }) + }, + onExecutionCancelled: () => { + const currentId = useExecutionStore + .getState() + .getCurrentExecutionId(reconnectWorkflowId) + if (currentId !== executionId) { + reconnectionComplete = true + activeReconnections.delete(reconnectWorkflowId) + return + } + clearOnce() + reconnectionComplete = true + activeReconnections.delete(reconnectWorkflowId) + setCurrentExecutionId(reconnectWorkflowId, null) + setIsExecuting(reconnectWorkflowId, false) + setActiveBlocks(reconnectWorkflowId, new Set()) + handleExecutionCancelledConsole({ + workflowId: reconnectWorkflowId, + executionId, + }) + }, + }, + }) + .catch((error) => { + logger.warn('Execution reconnection failed', { executionId, error }) + }) + .finally(() => { + if (reconnectionComplete || cleanupRan) return + const currentId = useExecutionStore.getState().getCurrentExecutionId(reconnectWorkflowId) + if (currentId !== executionId) return + reconnectionComplete = true + activeReconnections.delete(reconnectWorkflowId) + clearExecutionEntries(executionId) + for (const entry of originalEntries) { + addConsole({ + workflowId: entry.workflowId, + blockId: entry.blockId, + blockName: entry.blockName, + blockType: entry.blockType, + executionId: entry.executionId, + executionOrder: entry.executionOrder, + isRunning: false, + warning: 'Execution result unavailable — check the logs page', + }) + } + setCurrentExecutionId(reconnectWorkflowId, null) + setIsExecuting(reconnectWorkflowId, false) + setActiveBlocks(reconnectWorkflowId, new Set()) + }) + + return () => { + cleanupRan = true + executionStream.cancel(reconnectWorkflowId) + activeReconnections.delete(reconnectWorkflowId) + + if (cleared && !reconnectionComplete) { + clearExecutionEntries(executionId) + for (const entry of originalEntries) { + addConsole(entry) + } + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeWorkflowId, hasHydrated]) + return { isExecuting, isDebugging, diff --git a/apps/sim/hooks/queries/deployments.ts b/apps/sim/hooks/queries/deployments.ts index 894e1152c..e2f5b5ffe 100644 --- a/apps/sim/hooks/queries/deployments.ts +++ b/apps/sim/hooks/queries/deployments.ts @@ -423,7 +423,7 @@ interface GenerateVersionDescriptionVariables { const VERSION_DESCRIPTION_SYSTEM_PROMPT = `You are writing deployment version descriptions for a workflow automation platform. -Write a brief, factual description (1-3 sentences, under 400 characters) that states what changed between versions. +Write a brief, factual description (1-3 sentences, under 2000 characters) that states what changed between versions. Guidelines: - Use the specific values provided (credential names, channel names, model names) diff --git a/apps/sim/hooks/use-execution-stream.ts b/apps/sim/hooks/use-execution-stream.ts index e664788b5..2ab98059f 100644 --- a/apps/sim/hooks/use-execution-stream.ts +++ b/apps/sim/hooks/use-execution-stream.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react' +import { useCallback } from 'react' import { createLogger } from '@sim/logger' import type { BlockCompletedData, @@ -16,6 +16,18 @@ import type { SerializableExecutionState } from '@/executor/execution/types' const logger = createLogger('useExecutionStream') +/** + * Detects errors caused by the browser killing a fetch (page refresh, navigation, tab close). + * These should be treated as clean disconnects, not execution errors. + */ +function isClientDisconnectError(error: any): boolean { + if (error.name === 'AbortError') return true + const msg = (error.message ?? '').toLowerCase() + return ( + msg.includes('network error') || msg.includes('failed to fetch') || msg.includes('load failed') + ) +} + /** * Processes SSE events from a response body and invokes appropriate callbacks. */ @@ -121,6 +133,7 @@ export interface ExecuteStreamOptions { parallels?: Record } stopAfterBlockId?: string + onExecutionId?: (executionId: string) => void callbacks?: ExecutionStreamCallbacks } @@ -129,30 +142,40 @@ export interface ExecuteFromBlockOptions { startBlockId: string sourceSnapshot: SerializableExecutionState input?: any + onExecutionId?: (executionId: string) => void callbacks?: ExecutionStreamCallbacks } +export interface ReconnectStreamOptions { + workflowId: string + executionId: string + fromEventId?: number + callbacks?: ExecutionStreamCallbacks +} + +/** + * Module-level map shared across all hook instances. + * Ensures ANY instance can cancel streams started by ANY other instance, + * which is critical for SPA navigation where the original hook instance unmounts + * but the SSE stream must be cancellable from the new instance. + */ +const sharedAbortControllers = new Map() + /** * Hook for executing workflows via server-side SSE streaming. * Supports concurrent executions via per-workflow AbortController maps. */ export function useExecutionStream() { - const abortControllersRef = useRef>(new Map()) - const currentExecutionsRef = useRef>( - new Map() - ) - const execute = useCallback(async (options: ExecuteStreamOptions) => { - const { workflowId, callbacks = {}, ...payload } = options + const { workflowId, callbacks = {}, onExecutionId, ...payload } = options - const existing = abortControllersRef.current.get(workflowId) + const existing = sharedAbortControllers.get(workflowId) if (existing) { existing.abort() } const abortController = new AbortController() - abortControllersRef.current.set(workflowId, abortController) - currentExecutionsRef.current.delete(workflowId) + sharedAbortControllers.set(workflowId, abortController) try { const response = await fetch(`/api/workflows/${workflowId}/execute`, { @@ -177,42 +200,48 @@ export function useExecutionStream() { throw new Error('No response body') } - const executionId = response.headers.get('X-Execution-Id') - if (executionId) { - currentExecutionsRef.current.set(workflowId, { workflowId, executionId }) + const serverExecutionId = response.headers.get('X-Execution-Id') + if (serverExecutionId) { + onExecutionId?.(serverExecutionId) } const reader = response.body.getReader() await processSSEStream(reader, callbacks, 'Execution') } catch (error: any) { - if (error.name === 'AbortError') { - logger.info('Execution stream cancelled') - callbacks.onExecutionCancelled?.({ duration: 0 }) - } else { - logger.error('Execution stream error:', error) - callbacks.onExecutionError?.({ - error: error.message || 'Unknown error', - duration: 0, - }) + if (isClientDisconnectError(error)) { + logger.info('Execution stream disconnected (page unload or abort)') + return } + logger.error('Execution stream error:', error) + callbacks.onExecutionError?.({ + error: error.message || 'Unknown error', + duration: 0, + }) throw error } finally { - abortControllersRef.current.delete(workflowId) - currentExecutionsRef.current.delete(workflowId) + if (sharedAbortControllers.get(workflowId) === abortController) { + sharedAbortControllers.delete(workflowId) + } } }, []) const executeFromBlock = useCallback(async (options: ExecuteFromBlockOptions) => { - const { workflowId, startBlockId, sourceSnapshot, input, callbacks = {} } = options + const { + workflowId, + startBlockId, + sourceSnapshot, + input, + onExecutionId, + callbacks = {}, + } = options - const existing = abortControllersRef.current.get(workflowId) + const existing = sharedAbortControllers.get(workflowId) if (existing) { existing.abort() } const abortController = new AbortController() - abortControllersRef.current.set(workflowId, abortController) - currentExecutionsRef.current.delete(workflowId) + sharedAbortControllers.set(workflowId, abortController) try { const response = await fetch(`/api/workflows/${workflowId}/execute`, { @@ -246,64 +275,80 @@ export function useExecutionStream() { throw new Error('No response body') } - const executionId = response.headers.get('X-Execution-Id') - if (executionId) { - currentExecutionsRef.current.set(workflowId, { workflowId, executionId }) + const serverExecutionId = response.headers.get('X-Execution-Id') + if (serverExecutionId) { + onExecutionId?.(serverExecutionId) } const reader = response.body.getReader() await processSSEStream(reader, callbacks, 'Run-from-block') } catch (error: any) { - if (error.name === 'AbortError') { - logger.info('Run-from-block execution cancelled') - callbacks.onExecutionCancelled?.({ duration: 0 }) - } else { - logger.error('Run-from-block execution error:', error) - callbacks.onExecutionError?.({ - error: error.message || 'Unknown error', - duration: 0, - }) + if (isClientDisconnectError(error)) { + logger.info('Run-from-block stream disconnected (page unload or abort)') + return } + logger.error('Run-from-block execution error:', error) + callbacks.onExecutionError?.({ + error: error.message || 'Unknown error', + duration: 0, + }) throw error } finally { - abortControllersRef.current.delete(workflowId) - currentExecutionsRef.current.delete(workflowId) + if (sharedAbortControllers.get(workflowId) === abortController) { + sharedAbortControllers.delete(workflowId) + } + } + }, []) + + const reconnect = useCallback(async (options: ReconnectStreamOptions) => { + const { workflowId, executionId, fromEventId = 0, callbacks = {} } = options + + const existing = sharedAbortControllers.get(workflowId) + if (existing) { + existing.abort() + } + + const abortController = new AbortController() + sharedAbortControllers.set(workflowId, abortController) + try { + const response = await fetch( + `/api/workflows/${workflowId}/executions/${executionId}/stream?from=${fromEventId}`, + { signal: abortController.signal } + ) + if (!response.ok) throw new Error(`Reconnect failed (${response.status})`) + if (!response.body) throw new Error('No response body') + + await processSSEStream(response.body.getReader(), callbacks, 'Reconnect') + } catch (error: any) { + if (isClientDisconnectError(error)) return + logger.error('Reconnection stream error:', error) + throw error + } finally { + if (sharedAbortControllers.get(workflowId) === abortController) { + sharedAbortControllers.delete(workflowId) + } } }, []) const cancel = useCallback((workflowId?: string) => { if (workflowId) { - const execution = currentExecutionsRef.current.get(workflowId) - if (execution) { - fetch(`/api/workflows/${execution.workflowId}/executions/${execution.executionId}/cancel`, { - method: 'POST', - }).catch(() => {}) - } - - const controller = abortControllersRef.current.get(workflowId) + const controller = sharedAbortControllers.get(workflowId) if (controller) { controller.abort() - abortControllersRef.current.delete(workflowId) + sharedAbortControllers.delete(workflowId) } - currentExecutionsRef.current.delete(workflowId) } else { - for (const [, execution] of currentExecutionsRef.current) { - fetch(`/api/workflows/${execution.workflowId}/executions/${execution.executionId}/cancel`, { - method: 'POST', - }).catch(() => {}) - } - - for (const [, controller] of abortControllersRef.current) { + for (const [, controller] of sharedAbortControllers) { controller.abort() } - abortControllersRef.current.clear() - currentExecutionsRef.current.clear() + sharedAbortControllers.clear() } }, []) return { execute, executeFromBlock, + reconnect, cancel, } } diff --git a/apps/sim/lib/execution/event-buffer.ts b/apps/sim/lib/execution/event-buffer.ts new file mode 100644 index 000000000..4473a922f --- /dev/null +++ b/apps/sim/lib/execution/event-buffer.ts @@ -0,0 +1,246 @@ +import { createLogger } from '@sim/logger' +import { getRedisClient } from '@/lib/core/config/redis' +import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events' + +const logger = createLogger('ExecutionEventBuffer') + +const REDIS_PREFIX = 'execution:stream:' +const TTL_SECONDS = 60 * 60 // 1 hour +const EVENT_LIMIT = 1000 +const RESERVE_BATCH = 100 +const FLUSH_INTERVAL_MS = 15 +const FLUSH_MAX_BATCH = 200 + +function getEventsKey(executionId: string) { + return `${REDIS_PREFIX}${executionId}:events` +} + +function getSeqKey(executionId: string) { + return `${REDIS_PREFIX}${executionId}:seq` +} + +function getMetaKey(executionId: string) { + return `${REDIS_PREFIX}${executionId}:meta` +} + +export type ExecutionStreamStatus = 'active' | 'complete' | 'error' | 'cancelled' + +export interface ExecutionStreamMeta { + status: ExecutionStreamStatus + userId?: string + workflowId?: string + updatedAt?: string +} + +export interface ExecutionEventEntry { + eventId: number + executionId: string + event: ExecutionEvent +} + +export interface ExecutionEventWriter { + write: (event: ExecutionEvent) => Promise + flush: () => Promise + close: () => Promise +} + +export async function setExecutionMeta( + executionId: string, + meta: Partial +): Promise { + const redis = getRedisClient() + if (!redis) { + logger.warn('setExecutionMeta: Redis client unavailable', { executionId }) + return + } + try { + const key = getMetaKey(executionId) + const payload: Record = { + updatedAt: new Date().toISOString(), + } + if (meta.status) payload.status = meta.status + if (meta.userId) payload.userId = meta.userId + if (meta.workflowId) payload.workflowId = meta.workflowId + await redis.hset(key, payload) + await redis.expire(key, TTL_SECONDS) + } catch (error) { + logger.warn('Failed to update execution meta', { + executionId, + error: error instanceof Error ? error.message : String(error), + }) + } +} + +export async function getExecutionMeta(executionId: string): Promise { + const redis = getRedisClient() + if (!redis) { + logger.warn('getExecutionMeta: Redis client unavailable', { executionId }) + return null + } + try { + const key = getMetaKey(executionId) + const meta = await redis.hgetall(key) + if (!meta || Object.keys(meta).length === 0) return null + return meta as unknown as ExecutionStreamMeta + } catch (error) { + logger.warn('Failed to read execution meta', { + executionId, + error: error instanceof Error ? error.message : String(error), + }) + return null + } +} + +export async function readExecutionEvents( + executionId: string, + afterEventId: number +): Promise { + const redis = getRedisClient() + if (!redis) return [] + try { + const raw = await redis.zrangebyscore(getEventsKey(executionId), afterEventId + 1, '+inf') + return raw + .map((entry) => { + try { + return JSON.parse(entry) as ExecutionEventEntry + } catch { + return null + } + }) + .filter((entry): entry is ExecutionEventEntry => Boolean(entry)) + } catch (error) { + logger.warn('Failed to read execution events', { + executionId, + error: error instanceof Error ? error.message : String(error), + }) + return [] + } +} + +export function createExecutionEventWriter(executionId: string): ExecutionEventWriter { + const redis = getRedisClient() + if (!redis) { + logger.warn( + 'createExecutionEventWriter: Redis client unavailable, events will not be buffered', + { + executionId, + } + ) + return { + write: async (event) => ({ eventId: 0, executionId, event }), + flush: async () => {}, + close: async () => {}, + } + } + + let pending: ExecutionEventEntry[] = [] + let nextEventId = 0 + let maxReservedId = 0 + let flushTimer: ReturnType | null = null + + const scheduleFlush = () => { + if (flushTimer) return + flushTimer = setTimeout(() => { + flushTimer = null + void flush() + }, FLUSH_INTERVAL_MS) + } + + const reserveIds = async (minCount: number) => { + const reserveCount = Math.max(RESERVE_BATCH, minCount) + const newMax = await redis.incrby(getSeqKey(executionId), reserveCount) + const startId = newMax - reserveCount + 1 + if (nextEventId === 0 || nextEventId > maxReservedId) { + nextEventId = startId + maxReservedId = newMax + } + } + + let flushPromise: Promise | null = null + let closed = false + const inflightWrites = new Set>() + + const doFlush = async () => { + if (pending.length === 0) return + const batch = pending + pending = [] + try { + const key = getEventsKey(executionId) + const zaddArgs: (string | number)[] = [] + for (const entry of batch) { + zaddArgs.push(entry.eventId, JSON.stringify(entry)) + } + const pipeline = redis.pipeline() + pipeline.zadd(key, ...zaddArgs) + pipeline.expire(key, TTL_SECONDS) + pipeline.expire(getSeqKey(executionId), TTL_SECONDS) + pipeline.zremrangebyrank(key, 0, -EVENT_LIMIT - 1) + await pipeline.exec() + } catch (error) { + logger.warn('Failed to flush execution events', { + executionId, + batchSize: batch.length, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) + pending = batch.concat(pending) + } + } + + const flush = async () => { + if (flushPromise) { + await flushPromise + return + } + flushPromise = doFlush() + try { + await flushPromise + } finally { + flushPromise = null + if (pending.length > 0) scheduleFlush() + } + } + + const writeCore = async (event: ExecutionEvent): Promise => { + if (closed) return { eventId: 0, executionId, event } + if (nextEventId === 0 || nextEventId > maxReservedId) { + await reserveIds(1) + } + const eventId = nextEventId++ + const entry: ExecutionEventEntry = { eventId, executionId, event } + pending.push(entry) + if (pending.length >= FLUSH_MAX_BATCH) { + await flush() + } else { + scheduleFlush() + } + return entry + } + + const write = (event: ExecutionEvent): Promise => { + const p = writeCore(event) + inflightWrites.add(p) + const remove = () => inflightWrites.delete(p) + p.then(remove, remove) + return p + } + + const close = async () => { + closed = true + if (flushTimer) { + clearTimeout(flushTimer) + flushTimer = null + } + if (inflightWrites.size > 0) { + await Promise.allSettled(inflightWrites) + } + if (flushPromise) { + await flushPromise + } + if (pending.length > 0) { + await doFlush() + } + } + + return { write, flush, close } +} diff --git a/apps/sim/stores/execution/store.ts b/apps/sim/stores/execution/store.ts index 6983ddcda..b82d4a3c5 100644 --- a/apps/sim/stores/execution/store.ts +++ b/apps/sim/stores/execution/store.ts @@ -129,6 +129,18 @@ export const useExecutionStore = create()((se }) }, + setCurrentExecutionId: (workflowId, executionId) => { + set({ + workflowExecutions: updatedMap(get().workflowExecutions, workflowId, { + currentExecutionId: executionId, + }), + }) + }, + + getCurrentExecutionId: (workflowId) => { + return getOrCreate(get().workflowExecutions, workflowId).currentExecutionId + }, + clearRunPath: (workflowId) => { set({ workflowExecutions: updatedMap(get().workflowExecutions, workflowId, { diff --git a/apps/sim/stores/execution/types.ts b/apps/sim/stores/execution/types.ts index 55d873b49..b36ea43a1 100644 --- a/apps/sim/stores/execution/types.ts +++ b/apps/sim/stores/execution/types.ts @@ -35,6 +35,8 @@ export interface WorkflowExecutionState { lastRunPath: Map /** Maps edge IDs to their run result from the last execution */ lastRunEdges: Map + /** The execution ID of the currently running execution */ + currentExecutionId: string | null } /** @@ -54,6 +56,7 @@ export const defaultWorkflowExecutionState: WorkflowExecutionState = { debugContext: null, lastRunPath: new Map(), lastRunEdges: new Map(), + currentExecutionId: null, } /** @@ -96,6 +99,10 @@ export interface ExecutionActions { setEdgeRunStatus: (workflowId: string, edgeId: string, status: EdgeRunStatus) => void /** Clears the run path and run edges for a workflow */ clearRunPath: (workflowId: string) => void + /** Stores the current execution ID for a workflow */ + setCurrentExecutionId: (workflowId: string, executionId: string | null) => void + /** Returns the current execution ID for a workflow */ + getCurrentExecutionId: (workflowId: string) => string | null /** Resets the entire store to its initial empty state */ reset: () => void /** Stores a serializable execution snapshot for a workflow */ diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index 55b59b135..9fddbf3ef 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -224,7 +224,7 @@ export const useTerminalConsoleStore = create()( const newEntry = get().entries[0] - if (newEntry?.error) { + if (newEntry?.error && newEntry.blockType !== 'cancelled') { notifyBlockError({ error: newEntry.error, blockName: newEntry.blockName || 'Unknown Block', @@ -243,6 +243,11 @@ export const useTerminalConsoleStore = create()( useExecutionStore.getState().clearRunPath(workflowId) }, + clearExecutionEntries: (executionId: string) => + set((state) => ({ + entries: state.entries.filter((e) => e.executionId !== executionId), + })), + exportConsoleCSV: (workflowId: string) => { const entries = get().entries.filter((entry) => entry.workflowId === workflowId) @@ -470,12 +475,24 @@ export const useTerminalConsoleStore = create()( }, merge: (persistedState, currentState) => { const persisted = persistedState as Partial | undefined - const entries = (persisted?.entries ?? currentState.entries).map((entry, index) => { + const rawEntries = persisted?.entries ?? currentState.entries + const oneHourAgo = Date.now() - 60 * 60 * 1000 + + const entries = rawEntries.map((entry, index) => { + let updated = entry if (entry.executionOrder === undefined) { - return { ...entry, executionOrder: index + 1 } + updated = { ...updated, executionOrder: index + 1 } } - return entry + if ( + entry.isRunning && + entry.startedAt && + new Date(entry.startedAt).getTime() < oneHourAgo + ) { + updated = { ...updated, isRunning: false } + } + return updated }) + return { ...currentState, entries, diff --git a/apps/sim/stores/terminal/console/types.ts b/apps/sim/stores/terminal/console/types.ts index f15f36377..e057854d8 100644 --- a/apps/sim/stores/terminal/console/types.ts +++ b/apps/sim/stores/terminal/console/types.ts @@ -51,6 +51,7 @@ export interface ConsoleStore { isOpen: boolean addConsole: (entry: Omit) => ConsoleEntry clearWorkflowConsole: (workflowId: string) => void + clearExecutionEntries: (executionId: string) => void exportConsoleCSV: (workflowId: string) => void getWorkflowEntries: (workflowId: string) => ConsoleEntry[] toggleConsole: () => void From 2944579d21a75eeb1d480bbfe8c8d37e28a1d6cc Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 12 Feb 2026 10:59:22 -0800 Subject: [PATCH 03/13] fix(s3): support get-object region override and robust S3 URL parsing (#3206) * fix(s3): support get-object region override and robust S3 URL parsing * ack pr comments --- apps/sim/blocks/blocks/s3.ts | 44 +++++++++--------------- apps/sim/tools/s3/get_object.ts | 15 ++++++--- apps/sim/tools/s3/utils.ts | 60 +++++++++++++++++++++++++++++---- 3 files changed, 81 insertions(+), 38 deletions(-) diff --git a/apps/sim/blocks/blocks/s3.ts b/apps/sim/blocks/blocks/s3.ts index 10491a078..30fabd9d3 100644 --- a/apps/sim/blocks/blocks/s3.ts +++ b/apps/sim/blocks/blocks/s3.ts @@ -58,6 +58,16 @@ export const S3Block: BlockConfig = { }, required: true, }, + { + id: 'getObjectRegion', + title: 'AWS Region', + type: 'short-input', + placeholder: 'Used when S3 URL does not include region', + condition: { + field: 'operation', + value: ['get_object'], + }, + }, { id: 'bucketName', title: 'Bucket Name', @@ -291,34 +301,11 @@ export const S3Block: BlockConfig = { if (!params.s3Uri) { throw new Error('S3 Object URL is required') } - - // Parse S3 URI for get_object - try { - const url = new URL(params.s3Uri) - const hostname = url.hostname - const bucketName = hostname.split('.')[0] - const regionMatch = hostname.match(/s3[.-]([^.]+)\.amazonaws\.com/) - const region = regionMatch ? regionMatch[1] : params.region - const objectKey = url.pathname.startsWith('/') - ? url.pathname.substring(1) - : url.pathname - - if (!bucketName || !objectKey) { - throw new Error('Could not parse S3 URL') - } - - return { - accessKeyId: params.accessKeyId, - secretAccessKey: params.secretAccessKey, - region, - bucketName, - objectKey, - s3Uri: params.s3Uri, - } - } catch (_error) { - throw new Error( - 'Invalid S3 Object URL format. Expected: https://bucket-name.s3.region.amazonaws.com/path/to/file' - ) + return { + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + region: params.getObjectRegion || params.region, + s3Uri: params.s3Uri, } } @@ -401,6 +388,7 @@ export const S3Block: BlockConfig = { acl: { type: 'string', description: 'Access control list' }, // Download inputs s3Uri: { type: 'string', description: 'S3 object URL' }, + getObjectRegion: { type: 'string', description: 'Optional AWS region override for downloads' }, // List inputs prefix: { type: 'string', description: 'Prefix filter' }, maxKeys: { type: 'number', description: 'Maximum results' }, diff --git a/apps/sim/tools/s3/get_object.ts b/apps/sim/tools/s3/get_object.ts index 585604265..1e83ecc8b 100644 --- a/apps/sim/tools/s3/get_object.ts +++ b/apps/sim/tools/s3/get_object.ts @@ -26,6 +26,13 @@ export const s3GetObjectTool: ToolConfig = { visibility: 'user-only', description: 'Your AWS Secret Access Key', }, + region: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'Optional region override when URL does not include region (e.g., us-east-1, eu-west-1)', + }, s3Uri: { type: 'string', required: true, @@ -37,7 +44,7 @@ export const s3GetObjectTool: ToolConfig = { request: { url: (params) => { try { - const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri) + const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri, params.region) params.bucketName = bucketName params.region = region @@ -46,7 +53,7 @@ export const s3GetObjectTool: ToolConfig = { return `https://${bucketName}.s3.${region}.amazonaws.com/${encodeS3PathComponent(objectKey)}` } catch (_error) { throw new Error( - 'Invalid S3 Object URL format. Expected format: https://bucket-name.s3.region.amazonaws.com/path/to/file' + 'Invalid S3 Object URL. Use a valid S3 URL and optionally provide region if the URL omits it.' ) } }, @@ -55,7 +62,7 @@ export const s3GetObjectTool: ToolConfig = { try { // Parse S3 URI if not already parsed if (!params.bucketName || !params.region || !params.objectKey) { - const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri) + const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri, params.region) params.bucketName = bucketName params.region = region params.objectKey = objectKey @@ -102,7 +109,7 @@ export const s3GetObjectTool: ToolConfig = { transformResponse: async (response: Response, params) => { // Parse S3 URI if not already parsed if (!params.bucketName || !params.region || !params.objectKey) { - const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri) + const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri, params.region) params.bucketName = bucketName params.region = region params.objectKey = objectKey diff --git a/apps/sim/tools/s3/utils.ts b/apps/sim/tools/s3/utils.ts index a0815a878..8d5f5ad65 100644 --- a/apps/sim/tools/s3/utils.ts +++ b/apps/sim/tools/s3/utils.ts @@ -20,7 +20,10 @@ export function getSignatureKey( return kSigning } -export function parseS3Uri(s3Uri: string): { +export function parseS3Uri( + s3Uri: string, + fallbackRegion?: string +): { bucketName: string region: string objectKey: string @@ -28,10 +31,55 @@ export function parseS3Uri(s3Uri: string): { try { const url = new URL(s3Uri) const hostname = url.hostname - const bucketName = hostname.split('.')[0] - const regionMatch = hostname.match(/s3[.-]([^.]+)\.amazonaws\.com/) - const region = regionMatch ? regionMatch[1] : 'us-east-1' - const objectKey = url.pathname.startsWith('/') ? url.pathname.substring(1) : url.pathname + const normalizedPath = url.pathname.startsWith('/') ? url.pathname.slice(1) : url.pathname + + const virtualHostedDualstackMatch = hostname.match( + /^(.+)\.s3\.dualstack\.([^.]+)\.amazonaws\.com(?:\.cn)?$/ + ) + const virtualHostedRegionalMatch = hostname.match( + /^(.+)\.s3[.-]([^.]+)\.amazonaws\.com(?:\.cn)?$/ + ) + const virtualHostedGlobalMatch = hostname.match(/^(.+)\.s3\.amazonaws\.com(?:\.cn)?$/) + + const pathStyleDualstackMatch = hostname.match( + /^s3\.dualstack\.([^.]+)\.amazonaws\.com(?:\.cn)?$/ + ) + const pathStyleRegionalMatch = hostname.match(/^s3[.-]([^.]+)\.amazonaws\.com(?:\.cn)?$/) + const pathStyleGlobalMatch = hostname.match(/^s3\.amazonaws\.com(?:\.cn)?$/) + + const isPathStyleHost = Boolean( + pathStyleDualstackMatch || pathStyleRegionalMatch || pathStyleGlobalMatch + ) + + const firstSlashIndex = normalizedPath.indexOf('/') + const pathStyleBucketName = + firstSlashIndex === -1 ? normalizedPath : normalizedPath.slice(0, firstSlashIndex) + const pathStyleObjectKey = + firstSlashIndex === -1 ? '' : normalizedPath.slice(firstSlashIndex + 1) + + const bucketName = isPathStyleHost + ? pathStyleBucketName + : (virtualHostedDualstackMatch?.[1] ?? + virtualHostedRegionalMatch?.[1] ?? + virtualHostedGlobalMatch?.[1] ?? + '') + + const rawObjectKey = isPathStyleHost ? pathStyleObjectKey : normalizedPath + const objectKey = (() => { + try { + return decodeURIComponent(rawObjectKey) + } catch { + return rawObjectKey + } + })() + + const normalizedFallbackRegion = fallbackRegion?.trim() + const regionFromHost = + virtualHostedDualstackMatch?.[2] ?? + virtualHostedRegionalMatch?.[2] ?? + pathStyleDualstackMatch?.[1] ?? + pathStyleRegionalMatch?.[1] + const region = regionFromHost || normalizedFallbackRegion || 'us-east-1' if (!bucketName || !objectKey) { throw new Error('Invalid S3 URI format') @@ -40,7 +88,7 @@ export function parseS3Uri(s3Uri: string): { return { bucketName, region, objectKey } } catch (_error) { throw new Error( - 'Invalid S3 Object URL format. Expected format: https://bucket-name.s3.region.amazonaws.com/path/to/file' + 'Invalid S3 Object URL format. Expected S3 virtual-hosted or path-style URL with object key.' ) } } From c380e59cb3a5218e01e22cebd18c6582f58e855a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:17:45 -0800 Subject: [PATCH 04/13] fix(copilot): make default model opus 4.5 (#3209) * Fix default model * Fix --- apps/sim/app/api/copilot/chat/route.ts | 4 ++-- apps/sim/app/api/mcp/copilot/route.ts | 2 +- apps/sim/app/api/v1/copilot/chat/route.ts | 2 +- apps/sim/stores/panel/copilot/store.ts | 14 +++++++------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 25349e914..500b7e1a6 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -85,7 +85,7 @@ const ChatMessageSchema = z.object({ chatId: z.string().optional(), workflowId: z.string().optional(), workflowName: z.string().optional(), - model: z.string().optional().default('claude-opus-4-6'), + model: z.string().optional().default('claude-opus-4-5'), mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'), prefetch: z.boolean().optional(), createNewChat: z.boolean().optional().default(false), @@ -238,7 +238,7 @@ export async function POST(req: NextRequest) { let currentChat: any = null let conversationHistory: any[] = [] let actualChatId = chatId - const selectedModel = model || 'claude-opus-4-6' + const selectedModel = model || 'claude-opus-4-5' if (chatId || createNewChat) { const chatResult = await resolveOrCreateChat({ diff --git a/apps/sim/app/api/mcp/copilot/route.ts b/apps/sim/app/api/mcp/copilot/route.ts index 5fcce8563..643b339f9 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -38,7 +38,7 @@ import { const logger = createLogger('CopilotMcpAPI') const mcpRateLimiter = new RateLimiter() -const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6' +const DEFAULT_COPILOT_MODEL = 'claude-opus-4-5' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' diff --git a/apps/sim/app/api/v1/copilot/chat/route.ts b/apps/sim/app/api/v1/copilot/chat/route.ts index 9a71ee54b..6a3817385 100644 --- a/apps/sim/app/api/v1/copilot/chat/route.ts +++ b/apps/sim/app/api/v1/copilot/chat/route.ts @@ -8,7 +8,7 @@ import { resolveWorkflowIdForUser } from '@/lib/workflows/utils' import { authenticateV1Request } from '@/app/api/v1/auth' const logger = createLogger('CopilotHeadlessAPI') -const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6' +const DEFAULT_COPILOT_MODEL = 'claude-opus-4-5' const RequestSchema = z.object({ message: z.string().min(1, 'message is required'), diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 44f17df10..bd4dd76e2 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -1042,7 +1042,7 @@ const cachedAutoAllowedTools = readAutoAllowedToolsFromStorage() // Initial state (subset required for UI/streaming) const initialState = { mode: 'build' as const, - selectedModel: 'anthropic/claude-opus-4-6' as CopilotStore['selectedModel'], + selectedModel: 'anthropic/claude-opus-4-5' as CopilotStore['selectedModel'], agentPrefetch: false, availableModels: [] as AvailableModel[], isLoadingModels: false, @@ -2381,17 +2381,17 @@ export const useCopilotStore = create()( (model) => model.id === normalizedSelectedModel ) - // Pick the best default: prefer claude-opus-4-6 with provider priority: + // Pick the best default: prefer claude-opus-4-5 with provider priority: // direct anthropic > bedrock > azure-anthropic > any other. let nextSelectedModel = normalizedSelectedModel if (!selectedModelExists && normalizedModels.length > 0) { - let opus46: AvailableModel | undefined + let opus45: AvailableModel | undefined for (const prov of MODEL_PROVIDER_PRIORITY) { - opus46 = normalizedModels.find((m) => m.id === `${prov}/claude-opus-4-6`) - if (opus46) break + opus45 = normalizedModels.find((m) => m.id === `${prov}/claude-opus-4-5`) + if (opus45) break } - if (!opus46) opus46 = normalizedModels.find((m) => m.id.endsWith('/claude-opus-4-6')) - nextSelectedModel = opus46 ? opus46.id : normalizedModels[0].id + if (!opus45) opus45 = normalizedModels.find((m) => m.id.endsWith('/claude-opus-4-5')) + nextSelectedModel = opus45 ? opus45.id : normalizedModels[0].id } set({ From 6c006cdfec2d20c91c27e00a4df974f5dbefa2cd Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Feb 2026 13:03:54 -0800 Subject: [PATCH 05/13] feat(google books): Add google books integration --- apps/docs/components/icons.tsx | 15 ++ apps/docs/components/ui/icon-mapping.ts | 2 + .../content/docs/en/tools/google_books.mdx | 96 +++++++++ apps/docs/content/docs/en/tools/meta.json | 1 + apps/sim/blocks/blocks/google_books.ts | 198 +++++++++++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 15 ++ apps/sim/tools/google_books/index.ts | 3 + apps/sim/tools/google_books/types.ts | 64 ++++++ apps/sim/tools/google_books/volume_details.ts | 198 +++++++++++++++++ apps/sim/tools/google_books/volume_search.ts | 202 ++++++++++++++++++ apps/sim/tools/registry.ts | 3 + 12 files changed, 799 insertions(+) create mode 100644 apps/docs/content/docs/en/tools/google_books.mdx create mode 100644 apps/sim/blocks/blocks/google_books.ts create mode 100644 apps/sim/tools/google_books/index.ts create mode 100644 apps/sim/tools/google_books/types.ts create mode 100644 apps/sim/tools/google_books/volume_details.ts create mode 100644 apps/sim/tools/google_books/volume_search.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index f13fc8aa8..dfb95dab2 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1157,6 +1157,21 @@ export function AirweaveIcon(props: SVGProps) { ) } +export function GoogleBooksIcon(props: SVGProps) { + return ( + + + + + ) +} + export function GoogleDocsIcon(props: SVGProps) { return ( = { github_v2: GithubIcon, gitlab: GitLabIcon, gmail_v2: GmailIcon, + google_books: GoogleBooksIcon, google_calendar_v2: GoogleCalendarIcon, google_docs: GoogleDocsIcon, google_drive: GoogleDriveIcon, diff --git a/apps/docs/content/docs/en/tools/google_books.mdx b/apps/docs/content/docs/en/tools/google_books.mdx new file mode 100644 index 000000000..9baec6846 --- /dev/null +++ b/apps/docs/content/docs/en/tools/google_books.mdx @@ -0,0 +1,96 @@ +--- +title: Google Books +description: Search and retrieve book information +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Search for books using the Google Books API. Find volumes by title, author, ISBN, or keywords, and retrieve detailed information about specific books including descriptions, ratings, and publication details. + + + +## Tools + +### `google_books_volume_search` + +Search for books using the Google Books API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Google Books API key | +| `query` | string | Yes | Search query. Supports special keywords: intitle:, inauthor:, inpublisher:, subject:, isbn: | +| `filter` | string | No | Filter results by availability \(partial, full, free-ebooks, paid-ebooks, ebooks\) | +| `printType` | string | No | Restrict to print type \(all, books, magazines\) | +| `orderBy` | string | No | Sort order \(relevance, newest\) | +| `startIndex` | number | No | Index of the first result to return \(for pagination\) | +| `maxResults` | number | No | Maximum number of results to return \(1-40\) | +| `langRestrict` | string | No | Restrict results to a specific language \(ISO 639-1 code\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totalItems` | number | Total number of matching results | +| `volumes` | array | List of matching volumes | +| ↳ `id` | string | Volume ID | +| ↳ `title` | string | Book title | +| ↳ `subtitle` | string | Book subtitle | +| ↳ `authors` | array | List of authors | +| ↳ `publisher` | string | Publisher name | +| ↳ `publishedDate` | string | Publication date | +| ↳ `description` | string | Book description | +| ↳ `pageCount` | number | Number of pages | +| ↳ `categories` | array | Book categories | +| ↳ `averageRating` | number | Average rating \(1-5\) | +| ↳ `ratingsCount` | number | Number of ratings | +| ↳ `language` | string | Language code | +| ↳ `previewLink` | string | Link to preview on Google Books | +| ↳ `infoLink` | string | Link to info page | +| ↳ `thumbnailUrl` | string | Book cover thumbnail URL | +| ↳ `isbn10` | string | ISBN-10 identifier | +| ↳ `isbn13` | string | ISBN-13 identifier | + +### `google_books_volume_details` + +Get detailed information about a specific book volume + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Google Books API key | +| `volumeId` | string | Yes | The ID of the volume to retrieve | +| `projection` | string | No | Projection level \(full, lite\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Volume ID | +| `title` | string | Book title | +| `subtitle` | string | Book subtitle | +| `authors` | array | List of authors | +| `publisher` | string | Publisher name | +| `publishedDate` | string | Publication date | +| `description` | string | Book description | +| `pageCount` | number | Number of pages | +| `categories` | array | Book categories | +| `averageRating` | number | Average rating \(1-5\) | +| `ratingsCount` | number | Number of ratings | +| `language` | string | Language code | +| `previewLink` | string | Link to preview on Google Books | +| `infoLink` | string | Link to info page | +| `thumbnailUrl` | string | Book cover thumbnail URL | +| `isbn10` | string | ISBN-10 identifier | +| `isbn13` | string | ISBN-13 identifier | + + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index f9bd3ca1f..c10640a96 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -33,6 +33,7 @@ "github", "gitlab", "gmail", + "google_books", "google_calendar", "google_docs", "google_drive", diff --git a/apps/sim/blocks/blocks/google_books.ts b/apps/sim/blocks/blocks/google_books.ts new file mode 100644 index 000000000..5b7e31aae --- /dev/null +++ b/apps/sim/blocks/blocks/google_books.ts @@ -0,0 +1,198 @@ +import { GoogleBooksIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' + +export const GoogleBooksBlock: BlockConfig = { + type: 'google_books', + name: 'Google Books', + description: 'Search and retrieve book information', + longDescription: + 'Search for books using the Google Books API. Find volumes by title, author, ISBN, or keywords, and retrieve detailed information about specific books including descriptions, ratings, and publication details.', + docsLink: 'https://docs.sim.ai/tools/google_books', + category: 'tools', + bgColor: '#E0E0E0', + icon: GoogleBooksIcon, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Search Volumes', id: 'volume_search' }, + { label: 'Get Volume Details', id: 'volume_details' }, + ], + value: () => 'volume_search', + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + password: true, + placeholder: 'Enter your Google Books API key', + required: true, + }, + { + id: 'query', + title: 'Search Query', + type: 'short-input', + placeholder: 'e.g., intitle:harry potter inauthor:rowling', + condition: { field: 'operation', value: 'volume_search' }, + required: { field: 'operation', value: 'volume_search' }, + }, + { + id: 'filter', + title: 'Filter', + type: 'dropdown', + options: [ + { label: 'None', id: '' }, + { label: 'Partial Preview', id: 'partial' }, + { label: 'Full Preview', id: 'full' }, + { label: 'Free eBooks', id: 'free-ebooks' }, + { label: 'Paid eBooks', id: 'paid-ebooks' }, + { label: 'All eBooks', id: 'ebooks' }, + ], + condition: { field: 'operation', value: 'volume_search' }, + mode: 'advanced', + }, + { + id: 'printType', + title: 'Print Type', + type: 'dropdown', + options: [ + { label: 'All', id: 'all' }, + { label: 'Books', id: 'books' }, + { label: 'Magazines', id: 'magazines' }, + ], + value: () => 'all', + condition: { field: 'operation', value: 'volume_search' }, + mode: 'advanced', + }, + { + id: 'orderBy', + title: 'Order By', + type: 'dropdown', + options: [ + { label: 'Relevance', id: 'relevance' }, + { label: 'Newest', id: 'newest' }, + ], + value: () => 'relevance', + condition: { field: 'operation', value: 'volume_search' }, + mode: 'advanced', + }, + { + id: 'maxResults', + title: 'Max Results', + type: 'short-input', + placeholder: 'Number of results (1-40)', + condition: { field: 'operation', value: 'volume_search' }, + mode: 'advanced', + }, + { + id: 'startIndex', + title: 'Start Index', + type: 'short-input', + placeholder: 'Starting index for pagination', + condition: { field: 'operation', value: 'volume_search' }, + mode: 'advanced', + }, + { + id: 'langRestrict', + title: 'Language', + type: 'short-input', + placeholder: 'ISO 639-1 code (e.g., en, es, fr)', + condition: { field: 'operation', value: 'volume_search' }, + mode: 'advanced', + }, + { + id: 'volumeId', + title: 'Volume ID', + type: 'short-input', + placeholder: 'Google Books volume ID', + condition: { field: 'operation', value: 'volume_details' }, + required: { field: 'operation', value: 'volume_details' }, + }, + { + id: 'projection', + title: 'Projection', + type: 'dropdown', + options: [ + { label: 'Full', id: 'full' }, + { label: 'Lite', id: 'lite' }, + ], + value: () => 'full', + mode: 'advanced', + }, + ], + + tools: { + access: ['google_books_volume_search', 'google_books_volume_details'], + config: { + tool: (params) => `google_books_${params.operation}`, + params: (params) => { + const { operation, ...rest } = params + + let maxResults: number | undefined + if (params.maxResults) { + maxResults = Number.parseInt(params.maxResults, 10) + if (Number.isNaN(maxResults)) { + maxResults = undefined + } + } + + let startIndex: number | undefined + if (params.startIndex) { + startIndex = Number.parseInt(params.startIndex, 10) + if (Number.isNaN(startIndex)) { + startIndex = undefined + } + } + + return { + ...rest, + maxResults, + startIndex, + filter: params.filter || undefined, + printType: params.printType || undefined, + orderBy: params.orderBy || undefined, + projection: params.projection || undefined, + } + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + apiKey: { type: 'string', description: 'Google Books API key' }, + query: { type: 'string', description: 'Search query' }, + filter: { type: 'string', description: 'Filter by availability' }, + printType: { type: 'string', description: 'Print type filter' }, + orderBy: { type: 'string', description: 'Sort order' }, + maxResults: { type: 'string', description: 'Maximum number of results' }, + startIndex: { type: 'string', description: 'Starting index for pagination' }, + langRestrict: { type: 'string', description: 'Language restriction' }, + volumeId: { type: 'string', description: 'Volume ID for details' }, + projection: { type: 'string', description: 'Projection level' }, + }, + + outputs: { + totalItems: { type: 'number', description: 'Total number of matching results' }, + volumes: { type: 'json', description: 'List of matching volumes' }, + id: { type: 'string', description: 'Volume ID' }, + title: { type: 'string', description: 'Book title' }, + subtitle: { type: 'string', description: 'Book subtitle' }, + authors: { type: 'json', description: 'List of authors' }, + publisher: { type: 'string', description: 'Publisher name' }, + publishedDate: { type: 'string', description: 'Publication date' }, + description: { type: 'string', description: 'Book description' }, + pageCount: { type: 'number', description: 'Number of pages' }, + categories: { type: 'json', description: 'Book categories' }, + averageRating: { type: 'number', description: 'Average rating (1-5)' }, + ratingsCount: { type: 'number', description: 'Number of ratings' }, + language: { type: 'string', description: 'Language code' }, + previewLink: { type: 'string', description: 'Link to preview on Google Books' }, + infoLink: { type: 'string', description: 'Link to info page' }, + thumbnailUrl: { type: 'string', description: 'Book cover thumbnail URL' }, + isbn10: { type: 'string', description: 'ISBN-10 identifier' }, + isbn13: { type: 'string', description: 'ISBN-13 identifier' }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 301b7b350..f51019da2 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -39,6 +39,7 @@ import { GitHubBlock, GitHubV2Block } from '@/blocks/blocks/github' import { GitLabBlock } from '@/blocks/blocks/gitlab' import { GmailBlock, GmailV2Block } from '@/blocks/blocks/gmail' import { GoogleSearchBlock } from '@/blocks/blocks/google' +import { GoogleBooksBlock } from '@/blocks/blocks/google_books' import { GoogleCalendarBlock, GoogleCalendarV2Block } from '@/blocks/blocks/google_calendar' import { GoogleDocsBlock } from '@/blocks/blocks/google_docs' import { GoogleDriveBlock } from '@/blocks/blocks/google_drive' @@ -214,6 +215,7 @@ export const registry: Record = { gmail_v2: GmailV2Block, google_calendar: GoogleCalendarBlock, google_calendar_v2: GoogleCalendarV2Block, + google_books: GoogleBooksBlock, google_docs: GoogleDocsBlock, google_drive: GoogleDriveBlock, google_forms: GoogleFormsBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index f13fc8aa8..dfb95dab2 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1157,6 +1157,21 @@ export function AirweaveIcon(props: SVGProps) { ) } +export function GoogleBooksIcon(props: SVGProps) { + return ( + + + + + ) +} + export function GoogleDocsIcon(props: SVGProps) { return ( + } +} + +export const googleBooksVolumeDetailsTool: ToolConfig< + GoogleBooksVolumeDetailsParams, + GoogleBooksVolumeDetailsResponse +> = { + id: 'google_books_volume_details', + name: 'Google Books Volume Details', + description: 'Get detailed information about a specific book volume', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Google Books API key', + }, + volumeId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the volume to retrieve', + }, + projection: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Projection level (full, lite)', + }, + }, + + request: { + url: (params) => { + const url = new URL(`https://www.googleapis.com/books/v1/volumes/${params.volumeId.trim()}`) + url.searchParams.set('key', params.apiKey.trim()) + + if (params.projection) { + url.searchParams.set('projection', params.projection) + } + + return url.toString() + }, + method: 'GET', + headers: () => ({ + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data: GoogleBooksVolumeResponse = await response.json() + + if (!data.volumeInfo) { + throw new Error('Volume not found') + } + + const info = data.volumeInfo + const identifiers = info.industryIdentifiers ?? [] + + return { + success: true, + output: { + id: data.id, + title: info.title ?? '', + subtitle: info.subtitle ?? null, + authors: info.authors ?? [], + publisher: info.publisher ?? null, + publishedDate: info.publishedDate ?? null, + description: info.description ?? null, + pageCount: info.pageCount ?? null, + categories: info.categories ?? [], + averageRating: info.averageRating ?? null, + ratingsCount: info.ratingsCount ?? null, + language: info.language ?? null, + previewLink: info.previewLink ?? null, + infoLink: info.infoLink ?? null, + thumbnailUrl: info.imageLinks?.thumbnail ?? info.imageLinks?.smallThumbnail ?? null, + isbn10: identifiers.find((id) => id.type === 'ISBN_10')?.identifier ?? null, + isbn13: identifiers.find((id) => id.type === 'ISBN_13')?.identifier ?? null, + }, + } + }, + + outputs: { + id: { + type: 'string', + description: 'Volume ID', + }, + title: { + type: 'string', + description: 'Book title', + }, + subtitle: { + type: 'string', + description: 'Book subtitle', + optional: true, + }, + authors: { + type: 'array', + description: 'List of authors', + }, + publisher: { + type: 'string', + description: 'Publisher name', + optional: true, + }, + publishedDate: { + type: 'string', + description: 'Publication date', + optional: true, + }, + description: { + type: 'string', + description: 'Book description', + optional: true, + }, + pageCount: { + type: 'number', + description: 'Number of pages', + optional: true, + }, + categories: { + type: 'array', + description: 'Book categories', + }, + averageRating: { + type: 'number', + description: 'Average rating (1-5)', + optional: true, + }, + ratingsCount: { + type: 'number', + description: 'Number of ratings', + optional: true, + }, + language: { + type: 'string', + description: 'Language code', + optional: true, + }, + previewLink: { + type: 'string', + description: 'Link to preview on Google Books', + optional: true, + }, + infoLink: { + type: 'string', + description: 'Link to info page', + optional: true, + }, + thumbnailUrl: { + type: 'string', + description: 'Book cover thumbnail URL', + optional: true, + }, + isbn10: { + type: 'string', + description: 'ISBN-10 identifier', + optional: true, + }, + isbn13: { + type: 'string', + description: 'ISBN-13 identifier', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/google_books/volume_search.ts b/apps/sim/tools/google_books/volume_search.ts new file mode 100644 index 000000000..1f643c599 --- /dev/null +++ b/apps/sim/tools/google_books/volume_search.ts @@ -0,0 +1,202 @@ +import type { + GoogleBooksVolumeSearchParams, + GoogleBooksVolumeSearchResponse, + VolumeInfo, +} from '@/tools/google_books/types' +import type { ToolConfig } from '@/tools/types' + +interface GoogleBooksVolumeItem { + id: string + volumeInfo: { + title?: string + subtitle?: string + authors?: string[] + publisher?: string + publishedDate?: string + description?: string + pageCount?: number + categories?: string[] + averageRating?: number + ratingsCount?: number + language?: string + previewLink?: string + infoLink?: string + imageLinks?: { + thumbnail?: string + smallThumbnail?: string + } + industryIdentifiers?: Array<{ + type: string + identifier: string + }> + } +} + +function extractVolumeInfo(item: GoogleBooksVolumeItem): VolumeInfo { + const info = item.volumeInfo + const identifiers = info.industryIdentifiers ?? [] + + return { + id: item.id, + title: info.title ?? '', + subtitle: info.subtitle ?? null, + authors: info.authors ?? [], + publisher: info.publisher ?? null, + publishedDate: info.publishedDate ?? null, + description: info.description ?? null, + pageCount: info.pageCount ?? null, + categories: info.categories ?? [], + averageRating: info.averageRating ?? null, + ratingsCount: info.ratingsCount ?? null, + language: info.language ?? null, + previewLink: info.previewLink ?? null, + infoLink: info.infoLink ?? null, + thumbnailUrl: info.imageLinks?.thumbnail ?? info.imageLinks?.smallThumbnail ?? null, + isbn10: identifiers.find((id) => id.type === 'ISBN_10')?.identifier ?? null, + isbn13: identifiers.find((id) => id.type === 'ISBN_13')?.identifier ?? null, + } +} + +export const googleBooksVolumeSearchTool: ToolConfig< + GoogleBooksVolumeSearchParams, + GoogleBooksVolumeSearchResponse +> = { + id: 'google_books_volume_search', + name: 'Google Books Volume Search', + description: 'Search for books using the Google Books API', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Google Books API key', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Search query. Supports special keywords: intitle:, inauthor:, inpublisher:, subject:, isbn:', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Filter results by availability (partial, full, free-ebooks, paid-ebooks, ebooks)', + }, + printType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Restrict to print type (all, books, magazines)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order (relevance, newest)', + }, + startIndex: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Index of the first result to return (for pagination)', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results to return (1-40)', + }, + langRestrict: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Restrict results to a specific language (ISO 639-1 code)', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://www.googleapis.com/books/v1/volumes') + url.searchParams.set('q', params.query.trim()) + url.searchParams.set('key', params.apiKey.trim()) + + if (params.filter) { + url.searchParams.set('filter', params.filter) + } + if (params.printType) { + url.searchParams.set('printType', params.printType) + } + if (params.orderBy) { + url.searchParams.set('orderBy', params.orderBy) + } + if (params.startIndex !== undefined) { + url.searchParams.set('startIndex', String(params.startIndex)) + } + if (params.maxResults !== undefined) { + url.searchParams.set('maxResults', String(params.maxResults)) + } + if (params.langRestrict) { + url.searchParams.set('langRestrict', params.langRestrict) + } + + return url.toString() + }, + method: 'GET', + headers: () => ({ + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + const items: GoogleBooksVolumeItem[] = data.items ?? [] + const volumes = items.map(extractVolumeInfo) + + return { + success: true, + output: { + totalItems: data.totalItems ?? 0, + volumes, + }, + } + }, + + outputs: { + totalItems: { + type: 'number', + description: 'Total number of matching results', + }, + volumes: { + type: 'array', + description: 'List of matching volumes', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Volume ID' }, + title: { type: 'string', description: 'Book title' }, + subtitle: { type: 'string', description: 'Book subtitle' }, + authors: { type: 'array', description: 'List of authors' }, + publisher: { type: 'string', description: 'Publisher name' }, + publishedDate: { type: 'string', description: 'Publication date' }, + description: { type: 'string', description: 'Book description' }, + pageCount: { type: 'number', description: 'Number of pages' }, + categories: { type: 'array', description: 'Book categories' }, + averageRating: { type: 'number', description: 'Average rating (1-5)' }, + ratingsCount: { type: 'number', description: 'Number of ratings' }, + language: { type: 'string', description: 'Language code' }, + previewLink: { type: 'string', description: 'Link to preview on Google Books' }, + infoLink: { type: 'string', description: 'Link to info page' }, + thumbnailUrl: { type: 'string', description: 'Book cover thumbnail URL' }, + isbn10: { type: 'string', description: 'ISBN-10 identifier' }, + isbn13: { type: 'string', description: 'ISBN-13 identifier' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 52506d744..991c7e140 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -526,6 +526,7 @@ import { gmailUnarchiveV2Tool, } from '@/tools/gmail' import { googleSearchTool } from '@/tools/google' +import { googleBooksVolumeDetailsTool, googleBooksVolumeSearchTool } from '@/tools/google_books' import { googleCalendarCreateTool, googleCalendarCreateV2Tool, @@ -2556,6 +2557,8 @@ export const tools: Record = { google_docs_read: googleDocsReadTool, google_docs_write: googleDocsWriteTool, google_docs_create: googleDocsCreateTool, + google_books_volume_search: googleBooksVolumeSearchTool, + google_books_volume_details: googleBooksVolumeDetailsTool, google_maps_air_quality: googleMapsAirQualityTool, google_maps_directions: googleMapsDirectionsTool, google_maps_distance_matrix: googleMapsDistanceMatrixTool, From fc97ce007db4f4c0b5dc2c08cafe4ec427c4729d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Feb 2026 15:26:13 -0800 Subject: [PATCH 06/13] Correct error handling, specify auth mode as api key --- apps/sim/blocks/blocks/google_books.ts | 3 +++ apps/sim/tools/google_books/volume_details.ts | 7 +++++++ apps/sim/tools/google_books/volume_search.ts | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/apps/sim/blocks/blocks/google_books.ts b/apps/sim/blocks/blocks/google_books.ts index 5b7e31aae..764ee0290 100644 --- a/apps/sim/blocks/blocks/google_books.ts +++ b/apps/sim/blocks/blocks/google_books.ts @@ -1,10 +1,12 @@ import { GoogleBooksIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' export const GoogleBooksBlock: BlockConfig = { type: 'google_books', name: 'Google Books', description: 'Search and retrieve book information', + authMode: AuthMode.ApiKey, longDescription: 'Search for books using the Google Books API. Find volumes by title, author, ISBN, or keywords, and retrieve detailed information about specific books including descriptions, ratings, and publication details.', docsLink: 'https://docs.sim.ai/tools/google_books', @@ -120,6 +122,7 @@ export const GoogleBooksBlock: BlockConfig = { { label: 'Lite', id: 'lite' }, ], value: () => 'full', + condition: { field: 'operation', value: 'volume_details' }, mode: 'advanced', }, ], diff --git a/apps/sim/tools/google_books/volume_details.ts b/apps/sim/tools/google_books/volume_details.ts index f50e23986..114a78361 100644 --- a/apps/sim/tools/google_books/volume_details.ts +++ b/apps/sim/tools/google_books/volume_details.ts @@ -29,6 +29,9 @@ interface GoogleBooksVolumeResponse { identifier: string }> } + error?: { + message?: string + } } export const googleBooksVolumeDetailsTool: ToolConfig< @@ -81,6 +84,10 @@ export const googleBooksVolumeDetailsTool: ToolConfig< transformResponse: async (response: Response) => { const data: GoogleBooksVolumeResponse = await response.json() + if (data.error) { + throw new Error(`Google Books API error: ${data.error.message || 'Unknown error'}`) + } + if (!data.volumeInfo) { throw new Error('Volume not found') } diff --git a/apps/sim/tools/google_books/volume_search.ts b/apps/sim/tools/google_books/volume_search.ts index 1f643c599..72ca1a783 100644 --- a/apps/sim/tools/google_books/volume_search.ts +++ b/apps/sim/tools/google_books/volume_search.ts @@ -155,6 +155,10 @@ export const googleBooksVolumeSearchTool: ToolConfig< transformResponse: async (response: Response) => { const data = await response.json() + if (data.error) { + throw new Error(`Google Books API error: ${data.error.message || 'Unknown error'}`) + } + const items: GoogleBooksVolumeItem[] = data.items ?? [] const volumes = items.map(extractVolumeInfo) From ebc2ffa1c569ad7f809d450b42f000edd070958a Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 12 Feb 2026 15:31:11 -0800 Subject: [PATCH 07/13] fix(agent): always fetch latest custom tool from DB when customToolId is present (#3208) * fix(agent): always fetch latest custom tool from DB when customToolId is present * test(agent): use generic test data for customToolId resolution tests * fix(agent): mock buildAuthHeaders in tests for CI compatibility * remove inline mocks in favor of sim/testing ones --- apps/sim/app/api/auth/oauth/utils.test.ts | 70 ++-- .../app/api/knowledge/search/utils.test.ts | 8 +- apps/sim/app/api/schedules/[id]/route.test.ts | 23 +- apps/sim/app/api/schedules/route.test.ts | 21 +- apps/sim/app/api/workflows/[id]/route.test.ts | 10 +- .../handlers/agent/agent-handler.test.ts | 323 +++++++++++++++++- .../executor/handlers/agent/agent-handler.ts | 11 +- .../workflow/workflow-handler.test.ts | 3 +- apps/sim/lib/messaging/email/mailer.test.ts | 6 +- .../lib/messaging/email/unsubscribe.test.ts | 18 +- .../lib/workflows/diff/diff-engine.test.ts | 11 +- apps/sim/lib/workflows/utils.test.ts | 92 ++--- .../lib/workspaces/permissions/utils.test.ts | 14 +- 13 files changed, 452 insertions(+), 158 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index ca1d2c8eb..352ba5e78 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -4,20 +4,10 @@ * @vitest-environment node */ -import { loggerMock } from '@sim/testing' +import { databaseMock, loggerMock } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnValue([]), - update: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockReturnThis(), - }, -})) +vi.mock('@sim/db', () => databaseMock) vi.mock('@/lib/oauth/oauth', () => ({ refreshOAuthToken: vi.fn(), @@ -34,13 +24,36 @@ import { refreshTokenIfNeeded, } from '@/app/api/auth/oauth/utils' -const mockDbTyped = db as any +const mockDb = db as any const mockRefreshOAuthToken = refreshOAuthToken as any +/** + * Creates a chainable mock for db.select() calls. + * Returns a nested chain: select() -> from() -> where() -> limit() / orderBy() + */ +function mockSelectChain(limitResult: unknown[]) { + const mockLimit = vi.fn().mockReturnValue(limitResult) + const mockOrderBy = vi.fn().mockReturnValue(limitResult) + const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit, orderBy: mockOrderBy }) + const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }) + mockDb.select.mockReturnValueOnce({ from: mockFrom }) + return { mockFrom, mockWhere, mockLimit } +} + +/** + * Creates a chainable mock for db.update() calls. + * Returns a nested chain: update() -> set() -> where() + */ +function mockUpdateChain() { + const mockWhere = vi.fn().mockResolvedValue({}) + const mockSet = vi.fn().mockReturnValue({ where: mockWhere }) + mockDb.update.mockReturnValueOnce({ set: mockSet }) + return { mockSet, mockWhere } +} + describe('OAuth Utils', () => { beforeEach(() => { vi.clearAllMocks() - mockDbTyped.limit.mockReturnValue([]) }) afterEach(() => { @@ -50,20 +63,20 @@ describe('OAuth Utils', () => { describe('getCredential', () => { it('should return credential when found', async () => { const mockCredential = { id: 'credential-id', userId: 'test-user-id' } - mockDbTyped.limit.mockReturnValueOnce([mockCredential]) + const { mockFrom, mockWhere, mockLimit } = mockSelectChain([mockCredential]) const credential = await getCredential('request-id', 'credential-id', 'test-user-id') - expect(mockDbTyped.select).toHaveBeenCalled() - expect(mockDbTyped.from).toHaveBeenCalled() - expect(mockDbTyped.where).toHaveBeenCalled() - expect(mockDbTyped.limit).toHaveBeenCalledWith(1) + expect(mockDb.select).toHaveBeenCalled() + expect(mockFrom).toHaveBeenCalled() + expect(mockWhere).toHaveBeenCalled() + expect(mockLimit).toHaveBeenCalledWith(1) expect(credential).toEqual(mockCredential) }) it('should return undefined when credential is not found', async () => { - mockDbTyped.limit.mockReturnValueOnce([]) + mockSelectChain([]) const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id') @@ -102,11 +115,12 @@ describe('OAuth Utils', () => { refreshToken: 'new-refresh-token', }) + mockUpdateChain() + const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id') expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token') - expect(mockDbTyped.update).toHaveBeenCalled() - expect(mockDbTyped.set).toHaveBeenCalled() + expect(mockDb.update).toHaveBeenCalled() expect(result).toEqual({ accessToken: 'new-token', refreshed: true }) }) @@ -152,7 +166,7 @@ describe('OAuth Utils', () => { providerId: 'google', userId: 'test-user-id', } - mockDbTyped.limit.mockReturnValueOnce([mockCredential]) + mockSelectChain([mockCredential]) const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id') @@ -169,7 +183,8 @@ describe('OAuth Utils', () => { providerId: 'google', userId: 'test-user-id', } - mockDbTyped.limit.mockReturnValueOnce([mockCredential]) + mockSelectChain([mockCredential]) + mockUpdateChain() mockRefreshOAuthToken.mockResolvedValueOnce({ accessToken: 'new-token', @@ -180,13 +195,12 @@ describe('OAuth Utils', () => { const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id') expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token') - expect(mockDbTyped.update).toHaveBeenCalled() - expect(mockDbTyped.set).toHaveBeenCalled() + expect(mockDb.update).toHaveBeenCalled() expect(token).toBe('new-token') }) it('should return null if credential not found', async () => { - mockDbTyped.limit.mockReturnValueOnce([]) + mockSelectChain([]) const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id') @@ -202,7 +216,7 @@ describe('OAuth Utils', () => { providerId: 'google', userId: 'test-user-id', } - mockDbTyped.limit.mockReturnValueOnce([mockCredential]) + mockSelectChain([mockCredential]) mockRefreshOAuthToken.mockResolvedValueOnce(null) diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 6224e046e..a3d6b3856 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -4,16 +4,12 @@ * * @vitest-environment node */ -import { createEnvMock, createMockLogger } from '@sim/testing' +import { createEnvMock, databaseMock, loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const loggerMock = vi.hoisted(() => ({ - createLogger: () => createMockLogger(), -})) - vi.mock('drizzle-orm') vi.mock('@sim/logger', () => loggerMock) -vi.mock('@sim/db') +vi.mock('@sim/db', () => databaseMock) vi.mock('@/lib/knowledge/documents/utils', () => ({ retryWithExponentialBackoff: (fn: any) => fn(), })) diff --git a/apps/sim/app/api/schedules/[id]/route.test.ts b/apps/sim/app/api/schedules/[id]/route.test.ts index 012f327d1..f33ed5a24 100644 --- a/apps/sim/app/api/schedules/[id]/route.test.ts +++ b/apps/sim/app/api/schedules/[id]/route.test.ts @@ -3,17 +3,14 @@ * * @vitest-environment node */ -import { loggerMock } from '@sim/testing' +import { databaseMock, loggerMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockAuthorizeWorkflowByWorkspacePermission, mockDbSelect, mockDbUpdate } = - vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), - mockDbSelect: vi.fn(), - mockDbUpdate: vi.fn(), - })) +const { mockGetSession, mockAuthorizeWorkflowByWorkspacePermission } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), +})) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, @@ -23,12 +20,7 @@ vi.mock('@/lib/workflows/utils', () => ({ authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, })) -vi.mock('@sim/db', () => ({ - db: { - select: mockDbSelect, - update: mockDbUpdate, - }, -})) +vi.mock('@sim/db', () => databaseMock) vi.mock('@sim/db/schema', () => ({ workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' }, @@ -59,6 +51,9 @@ function createParams(id: string): { params: Promise<{ id: string }> } { return { params: Promise.resolve({ id }) } } +const mockDbSelect = databaseMock.db.select as ReturnType +const mockDbUpdate = databaseMock.db.update as ReturnType + function mockDbChain(selectResults: unknown[][]) { let selectCallIndex = 0 mockDbSelect.mockImplementation(() => ({ diff --git a/apps/sim/app/api/schedules/route.test.ts b/apps/sim/app/api/schedules/route.test.ts index e6320b2b6..9d1530d50 100644 --- a/apps/sim/app/api/schedules/route.test.ts +++ b/apps/sim/app/api/schedules/route.test.ts @@ -3,17 +3,14 @@ * * @vitest-environment node */ -import { loggerMock } from '@sim/testing' +import { databaseMock, loggerMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockAuthorizeWorkflowByWorkspacePermission, mockDbSelect } = vi.hoisted( - () => ({ - mockGetSession: vi.fn(), - mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), - mockDbSelect: vi.fn(), - }) -) +const { mockGetSession, mockAuthorizeWorkflowByWorkspacePermission } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), +})) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, @@ -23,11 +20,7 @@ vi.mock('@/lib/workflows/utils', () => ({ authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, })) -vi.mock('@sim/db', () => ({ - db: { - select: mockDbSelect, - }, -})) +vi.mock('@sim/db', () => databaseMock) vi.mock('@sim/db/schema', () => ({ workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' }, @@ -62,6 +55,8 @@ function createRequest(url: string): NextRequest { return new NextRequest(new URL(url), { method: 'GET' }) } +const mockDbSelect = databaseMock.db.select as ReturnType + function mockDbChain(results: any[]) { let callIndex = 0 mockDbSelect.mockImplementation(() => ({ diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 7012453b1..62b3d0437 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -5,7 +5,7 @@ * @vitest-environment node */ -import { loggerMock } from '@sim/testing' +import { loggerMock, setupGlobalFetchMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -284,9 +284,7 @@ describe('Workflow By ID API Route', () => { where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]), }) - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - }) + setupGlobalFetchMock({ ok: true }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'DELETE', @@ -331,9 +329,7 @@ describe('Workflow By ID API Route', () => { where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]), }) - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - }) + setupGlobalFetchMock({ ok: true }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'DELETE', diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index c583555a2..217971b9e 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -1,3 +1,4 @@ +import { setupGlobalFetchMock } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { getAllBlocks } from '@/blocks' import { BlockType, isMcpTool } from '@/executor/constants' @@ -61,6 +62,30 @@ vi.mock('@/providers', () => ({ }), })) +vi.mock('@/executor/utils/http', () => ({ + buildAuthHeaders: vi.fn().mockResolvedValue({ 'Content-Type': 'application/json' }), + buildAPIUrl: vi.fn((path: string, params?: Record) => { + const url = new URL(path, 'http://localhost:3000') + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, value) + } + } + } + return url + }), + extractAPIErrorMessage: vi.fn(async (response: Response) => { + const defaultMessage = `API request failed with status ${response.status}` + try { + const errorData = await response.json() + return errorData.error || defaultMessage + } catch { + return defaultMessage + } + }), +})) + vi.mock('@sim/db', () => ({ db: { select: vi.fn().mockReturnValue({ @@ -84,7 +109,7 @@ vi.mock('@sim/db/schema', () => ({ }, })) -global.fetch = Object.assign(vi.fn(), { preconnect: vi.fn() }) as typeof fetch +setupGlobalFetchMock() const mockGetAllBlocks = getAllBlocks as Mock const mockExecuteTool = executeTool as Mock @@ -1901,5 +1926,301 @@ describe('AgentBlockHandler', () => { expect(discoveryCalls[0].url).toContain('serverId=mcp-legacy-server') }) + + describe('customToolId resolution - DB as source of truth', () => { + const staleInlineSchema = { + function: { + name: 'formatReport', + description: 'Formats a report', + parameters: { + type: 'object', + properties: { + title: { type: 'string', description: 'Report title' }, + content: { type: 'string', description: 'Report content' }, + }, + required: ['title', 'content'], + }, + }, + } + + const dbSchema = { + function: { + name: 'formatReport', + description: 'Formats a report', + parameters: { + type: 'object', + properties: { + title: { type: 'string', description: 'Report title' }, + content: { type: 'string', description: 'Report content' }, + format: { type: 'string', description: 'Output format' }, + }, + required: ['title', 'content', 'format'], + }, + }, + } + + const staleInlineCode = 'return { title, content };' + const dbCode = 'return { title, content, format };' + + function mockFetchForCustomTool(toolId: string) { + mockFetch.mockImplementation((url: string) => { + if (typeof url === 'string' && url.includes('/api/tools/custom')) { + return Promise.resolve({ + ok: true, + headers: { get: () => null }, + json: () => + Promise.resolve({ + data: [ + { + id: toolId, + title: 'formatReport', + schema: dbSchema, + code: dbCode, + }, + ], + }), + }) + } + return Promise.resolve({ + ok: true, + headers: { get: () => null }, + json: () => Promise.resolve({}), + }) + }) + } + + function mockFetchFailure() { + mockFetch.mockImplementation((url: string) => { + if (typeof url === 'string' && url.includes('/api/tools/custom')) { + return Promise.resolve({ + ok: false, + status: 500, + headers: { get: () => null }, + json: () => Promise.resolve({}), + }) + } + return Promise.resolve({ + ok: true, + headers: { get: () => null }, + json: () => Promise.resolve({}), + }) + }) + } + + beforeEach(() => { + Object.defineProperty(global, 'window', { + value: undefined, + writable: true, + configurable: true, + }) + }) + + it('should always fetch latest schema from DB when customToolId is present', async () => { + const toolId = 'custom-tool-123' + mockFetchForCustomTool(toolId) + + const inputs = { + model: 'gpt-4o', + userPrompt: 'Format a report', + apiKey: 'test-api-key', + tools: [ + { + type: 'custom-tool', + customToolId: toolId, + title: 'formatReport', + schema: staleInlineSchema, + code: staleInlineCode, + usageControl: 'auto' as const, + }, + ], + } + + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(mockContext, mockBlock, inputs) + + expect(mockExecuteProviderRequest).toHaveBeenCalled() + const providerCall = mockExecuteProviderRequest.mock.calls[0] + const tools = providerCall[1].tools + + expect(tools.length).toBe(1) + // DB schema wins over stale inline — includes format param + expect(tools[0].parameters.required).toContain('format') + expect(tools[0].parameters.properties).toHaveProperty('format') + }) + + it('should fetch from DB when customToolId has no inline schema', async () => { + const toolId = 'custom-tool-123' + mockFetchForCustomTool(toolId) + + const inputs = { + model: 'gpt-4o', + userPrompt: 'Format a report', + apiKey: 'test-api-key', + tools: [ + { + type: 'custom-tool', + customToolId: toolId, + usageControl: 'auto' as const, + }, + ], + } + + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(mockContext, mockBlock, inputs) + + expect(mockExecuteProviderRequest).toHaveBeenCalled() + const providerCall = mockExecuteProviderRequest.mock.calls[0] + const tools = providerCall[1].tools + + expect(tools.length).toBe(1) + expect(tools[0].name).toBe('formatReport') + expect(tools[0].parameters.required).toContain('format') + }) + + it('should fall back to inline schema when DB fetch fails and inline exists', async () => { + mockFetchFailure() + + const inputs = { + model: 'gpt-4o', + userPrompt: 'Format a report', + apiKey: 'test-api-key', + tools: [ + { + type: 'custom-tool', + customToolId: 'custom-tool-123', + title: 'formatReport', + schema: staleInlineSchema, + code: staleInlineCode, + usageControl: 'auto' as const, + }, + ], + } + + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(mockContext, mockBlock, inputs) + + expect(mockExecuteProviderRequest).toHaveBeenCalled() + const providerCall = mockExecuteProviderRequest.mock.calls[0] + const tools = providerCall[1].tools + + expect(tools.length).toBe(1) + expect(tools[0].name).toBe('formatReport') + expect(tools[0].parameters.required).not.toContain('format') + }) + + it('should return null when DB fetch fails and no inline schema exists', async () => { + mockFetchFailure() + + const inputs = { + model: 'gpt-4o', + userPrompt: 'Format a report', + apiKey: 'test-api-key', + tools: [ + { + type: 'custom-tool', + customToolId: 'custom-tool-123', + usageControl: 'auto' as const, + }, + ], + } + + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(mockContext, mockBlock, inputs) + + expect(mockExecuteProviderRequest).toHaveBeenCalled() + const providerCall = mockExecuteProviderRequest.mock.calls[0] + const tools = providerCall[1].tools + + expect(tools.length).toBe(0) + }) + + it('should use DB code for executeFunction when customToolId resolves', async () => { + const toolId = 'custom-tool-123' + mockFetchForCustomTool(toolId) + + let capturedTools: any[] = [] + Promise.all = vi.fn().mockImplementation((promises: Promise[]) => { + const result = originalPromiseAll.call(Promise, promises) + result.then((tools: any[]) => { + if (tools?.length) { + capturedTools = tools.filter((t) => t !== null) + } + }) + return result + }) + + const inputs = { + model: 'gpt-4o', + userPrompt: 'Format a report', + apiKey: 'test-api-key', + tools: [ + { + type: 'custom-tool', + customToolId: toolId, + title: 'formatReport', + schema: staleInlineSchema, + code: staleInlineCode, + usageControl: 'auto' as const, + }, + ], + } + + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(mockContext, mockBlock, inputs) + + expect(capturedTools.length).toBe(1) + expect(typeof capturedTools[0].executeFunction).toBe('function') + + await capturedTools[0].executeFunction({ title: 'Q1', format: 'pdf' }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ + code: dbCode, + }), + false, + expect.any(Object) + ) + }) + + it('should not fetch from DB when no customToolId is present', async () => { + const inputs = { + model: 'gpt-4o', + userPrompt: 'Use the tool', + apiKey: 'test-api-key', + tools: [ + { + type: 'custom-tool', + title: 'formatReport', + schema: staleInlineSchema, + code: staleInlineCode, + usageControl: 'auto' as const, + }, + ], + } + + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(mockContext, mockBlock, inputs) + + const customToolFetches = mockFetch.mock.calls.filter( + (call: any[]) => typeof call[0] === 'string' && call[0].includes('/api/tools/custom') + ) + expect(customToolFetches.length).toBe(0) + + expect(mockExecuteProviderRequest).toHaveBeenCalled() + const providerCall = mockExecuteProviderRequest.mock.calls[0] + const tools = providerCall[1].tools + + expect(tools.length).toBe(1) + expect(tools[0].name).toBe('formatReport') + expect(tools[0].parameters.required).not.toContain('format') + }) + }) }) }) diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 7cba8deb7..2b370471a 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -272,15 +272,16 @@ export class AgentBlockHandler implements BlockHandler { let code = tool.code let title = tool.title - if (tool.customToolId && !schema) { + if (tool.customToolId) { const resolved = await this.fetchCustomToolById(ctx, tool.customToolId) - if (!resolved) { + if (resolved) { + schema = resolved.schema + code = resolved.code + title = resolved.title + } else if (!schema) { logger.error(`Custom tool not found: ${tool.customToolId}`) return null } - schema = resolved.schema - code = resolved.code - title = resolved.title } if (!schema?.function) { diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index 5218dbc05..661796db9 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -1,3 +1,4 @@ +import { setupGlobalFetchMock } from '@sim/testing' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { BlockType } from '@/executor/constants' import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler' @@ -9,7 +10,7 @@ vi.mock('@/lib/auth/internal', () => ({ })) // Mock fetch globally -global.fetch = vi.fn() +setupGlobalFetchMock() describe('WorkflowBlockHandler', () => { let handler: WorkflowBlockHandler diff --git a/apps/sim/lib/messaging/email/mailer.test.ts b/apps/sim/lib/messaging/email/mailer.test.ts index c78855e6e..327c8f496 100644 --- a/apps/sim/lib/messaging/email/mailer.test.ts +++ b/apps/sim/lib/messaging/email/mailer.test.ts @@ -1,4 +1,4 @@ -import { createEnvMock, createMockLogger } from '@sim/testing' +import { createEnvMock, loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' /** @@ -10,10 +10,6 @@ import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' * mock functions can intercept. */ -const loggerMock = vi.hoisted(() => ({ - createLogger: () => createMockLogger(), -})) - const mockSend = vi.fn() const mockBatchSend = vi.fn() const mockAzureBeginSend = vi.fn() diff --git a/apps/sim/lib/messaging/email/unsubscribe.test.ts b/apps/sim/lib/messaging/email/unsubscribe.test.ts index 43f2cd581..5cfdce661 100644 --- a/apps/sim/lib/messaging/email/unsubscribe.test.ts +++ b/apps/sim/lib/messaging/email/unsubscribe.test.ts @@ -1,20 +1,8 @@ -import { createEnvMock, createMockLogger } from '@sim/testing' +import { createEnvMock, databaseMock, loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { EmailType } from '@/lib/messaging/email/mailer' -const loggerMock = vi.hoisted(() => ({ - createLogger: () => createMockLogger(), -})) - -const mockDb = vi.hoisted(() => ({ - select: vi.fn(), - insert: vi.fn(), - update: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ - db: mockDb, -})) +vi.mock('@sim/db', () => databaseMock) vi.mock('@sim/db/schema', () => ({ user: { id: 'id', email: 'email' }, @@ -30,6 +18,8 @@ vi.mock('drizzle-orm', () => ({ eq: vi.fn((a, b) => ({ type: 'eq', left: a, right: b })), })) +const mockDb = databaseMock.db as Record> + vi.mock('@/lib/core/config/env', () => createEnvMock({ BETTER_AUTH_SECRET: 'test-secret-key' })) vi.mock('@sim/logger', () => loggerMock) diff --git a/apps/sim/lib/workflows/diff/diff-engine.test.ts b/apps/sim/lib/workflows/diff/diff-engine.test.ts index aecbd801e..0f7103a10 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.test.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.test.ts @@ -1,18 +1,11 @@ /** * @vitest-environment node */ +import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' -// Mock all external dependencies before imports -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), -})) +vi.mock('@sim/logger', () => loggerMock) vi.mock('@/stores/workflows/workflow/store', () => ({ useWorkflowStore: { diff --git a/apps/sim/lib/workflows/utils.test.ts b/apps/sim/lib/workflows/utils.test.ts index e1787e229..da1dd8b26 100644 --- a/apps/sim/lib/workflows/utils.test.ts +++ b/apps/sim/lib/workflows/utils.test.ts @@ -14,22 +14,15 @@ import { databaseMock, expectWorkflowAccessDenied, expectWorkflowAccessGranted, + mockAuth, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => databaseMock) - -// Mock the auth module -vi.mock('@/lib/auth', () => ({ - getSession: vi.fn(), -})) - -import { db } from '@sim/db' -import { getSession } from '@/lib/auth' -// Import after mocks are set up -import { validateWorkflowPermissions } from '@/lib/workflows/utils' +const mockDb = databaseMock.db describe('validateWorkflowPermissions', () => { + const auth = mockAuth() + const mockSession = createSession({ userId: 'user-1', email: 'user1@test.com' }) const mockWorkflow = createWorkflowRecord({ id: 'wf-1', @@ -42,13 +35,17 @@ describe('validateWorkflowPermissions', () => { }) beforeEach(() => { + vi.resetModules() vi.clearAllMocks() + + vi.doMock('@sim/db', () => databaseMock) }) describe('authentication', () => { it('should return 401 when no session exists', async () => { - vi.mocked(getSession).mockResolvedValue(null) + auth.setUnauthenticated() + const { validateWorkflowPermissions } = await import('@/lib/workflows/utils') const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read') expectWorkflowAccessDenied(result, 401) @@ -56,8 +53,9 @@ describe('validateWorkflowPermissions', () => { }) it('should return 401 when session has no user id', async () => { - vi.mocked(getSession).mockResolvedValue({ user: {} } as any) + auth.mockGetSession.mockResolvedValue({ user: {} } as any) + const { validateWorkflowPermissions } = await import('@/lib/workflows/utils') const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read') expectWorkflowAccessDenied(result, 401) @@ -66,14 +64,14 @@ describe('validateWorkflowPermissions', () => { describe('workflow not found', () => { it('should return 404 when workflow does not exist', async () => { - vi.mocked(getSession).mockResolvedValue(mockSession as any) + auth.mockGetSession.mockResolvedValue(mockSession as any) - // Mock workflow query to return empty const mockLimit = vi.fn().mockResolvedValue([]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any) + vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + const { validateWorkflowPermissions } = await import('@/lib/workflows/utils') const result = await validateWorkflowPermissions('non-existent', 'req-1', 'read') expectWorkflowAccessDenied(result, 404) @@ -83,43 +81,42 @@ describe('validateWorkflowPermissions', () => { describe('owner access', () => { it('should deny access to workflow owner without workspace permissions for read action', async () => { - const ownerSession = createSession({ userId: 'owner-1' }) - vi.mocked(getSession).mockResolvedValue(ownerSession as any) + auth.setAuthenticated({ id: 'owner-1', email: 'owner-1@test.com' }) - // Mock workflow query const mockLimit = vi.fn().mockResolvedValue([mockWorkflow]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any) + vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + const { validateWorkflowPermissions } = await import('@/lib/workflows/utils') const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read') expectWorkflowAccessDenied(result, 403) }) it('should deny access to workflow owner without workspace permissions for write action', async () => { - const ownerSession = createSession({ userId: 'owner-1' }) - vi.mocked(getSession).mockResolvedValue(ownerSession as any) + auth.setAuthenticated({ id: 'owner-1', email: 'owner-1@test.com' }) const mockLimit = vi.fn().mockResolvedValue([mockWorkflow]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any) + vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + const { validateWorkflowPermissions } = await import('@/lib/workflows/utils') const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write') expectWorkflowAccessDenied(result, 403) }) it('should deny access to workflow owner without workspace permissions for admin action', async () => { - const ownerSession = createSession({ userId: 'owner-1' }) - vi.mocked(getSession).mockResolvedValue(ownerSession as any) + auth.setAuthenticated({ id: 'owner-1', email: 'owner-1@test.com' }) const mockLimit = vi.fn().mockResolvedValue([mockWorkflow]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any) + vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + const { validateWorkflowPermissions } = await import('@/lib/workflows/utils') const result = await validateWorkflowPermissions('wf-1', 'req-1', 'admin') expectWorkflowAccessDenied(result, 403) @@ -128,11 +125,10 @@ describe('validateWorkflowPermissions', () => { describe('workspace member access with permissions', () => { beforeEach(() => { - vi.mocked(getSession).mockResolvedValue(mockSession as any) + auth.mockGetSession.mockResolvedValue(mockSession as any) }) it('should grant read access to user with read permission', async () => { - // First call: workflow query, second call: workspace owner, third call: permission let callCount = 0 const mockLimit = vi.fn().mockImplementation(() => { callCount++ @@ -141,8 +137,9 @@ describe('validateWorkflowPermissions', () => { }) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any) + vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + const { validateWorkflowPermissions } = await import('@/lib/workflows/utils') const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read') expectWorkflowAccessGranted(result) @@ -157,8 +154,9 @@ describe('validateWorkflowPermissions', () => { }) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any) + vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + const { validateWorkflowPermissions } = await import('@/lib/workflows/utils') const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write') expectWorkflowAccessDenied(result, 403) @@ -174,8 +172,9 @@ describe('validateWorkflowPermissions', () => { }) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any) + vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + const { validateWorkflowPermissions } = await import('@/lib/workflows/utils') const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write') expectWorkflowAccessGranted(result) @@ -190,8 +189,9 @@ describe('validateWorkflowPermissions', () => { }) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any) + vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + const { validateWorkflowPermissions } = await import('@/lib/workflows/utils') const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write') expectWorkflowAccessGranted(result) @@ -206,8 +206,9 @@ describe('validateWorkflowPermissions', () => { }) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any) + vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + const { validateWorkflowPermissions } = await import('@/lib/workflows/utils') const result = await validateWorkflowPermissions('wf-1', 'req-1', 'admin') expectWorkflowAccessDenied(result, 403) @@ -223,8 +224,9 @@ describe('validateWorkflowPermissions', () => { }) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any) + vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + const { validateWorkflowPermissions } = await import('@/lib/workflows/utils') const result = await validateWorkflowPermissions('wf-1', 'req-1', 'admin') expectWorkflowAccessGranted(result) @@ -233,18 +235,19 @@ describe('validateWorkflowPermissions', () => { describe('no workspace permission', () => { it('should deny access to user without any workspace permission', async () => { - vi.mocked(getSession).mockResolvedValue(mockSession as any) + auth.mockGetSession.mockResolvedValue(mockSession as any) let callCount = 0 const mockLimit = vi.fn().mockImplementation(() => { callCount++ if (callCount === 1) return Promise.resolve([mockWorkflow]) - return Promise.resolve([]) // No permission record + return Promise.resolve([]) }) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any) + vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + const { validateWorkflowPermissions } = await import('@/lib/workflows/utils') const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read') expectWorkflowAccessDenied(result, 403) @@ -259,13 +262,14 @@ describe('validateWorkflowPermissions', () => { workspaceId: null, }) - vi.mocked(getSession).mockResolvedValue(mockSession as any) + auth.mockGetSession.mockResolvedValue(mockSession as any) const mockLimit = vi.fn().mockResolvedValue([workflowWithoutWorkspace]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any) + vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + const { validateWorkflowPermissions } = await import('@/lib/workflows/utils') const result = await validateWorkflowPermissions('wf-2', 'req-1', 'read') expectWorkflowAccessDenied(result, 403) @@ -278,13 +282,14 @@ describe('validateWorkflowPermissions', () => { workspaceId: null, }) - vi.mocked(getSession).mockResolvedValue(mockSession as any) + auth.mockGetSession.mockResolvedValue(mockSession as any) const mockLimit = vi.fn().mockResolvedValue([workflowWithoutWorkspace]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any) + vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + const { validateWorkflowPermissions } = await import('@/lib/workflows/utils') const result = await validateWorkflowPermissions('wf-2', 'req-1', 'read') expectWorkflowAccessDenied(result, 403) @@ -293,7 +298,7 @@ describe('validateWorkflowPermissions', () => { describe('default action', () => { it('should default to read action when not specified', async () => { - vi.mocked(getSession).mockResolvedValue(mockSession as any) + auth.mockGetSession.mockResolvedValue(mockSession as any) let callCount = 0 const mockLimit = vi.fn().mockImplementation(() => { @@ -303,8 +308,9 @@ describe('validateWorkflowPermissions', () => { }) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any) + vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + const { validateWorkflowPermissions } = await import('@/lib/workflows/utils') const result = await validateWorkflowPermissions('wf-1', 'req-1') expectWorkflowAccessGranted(result) diff --git a/apps/sim/lib/workspaces/permissions/utils.test.ts b/apps/sim/lib/workspaces/permissions/utils.test.ts index 938937d22..04d863323 100644 --- a/apps/sim/lib/workspaces/permissions/utils.test.ts +++ b/apps/sim/lib/workspaces/permissions/utils.test.ts @@ -1,17 +1,7 @@ -import { drizzleOrmMock } from '@sim/testing/mocks' +import { databaseMock, drizzleOrmMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(), - from: vi.fn(), - where: vi.fn(), - limit: vi.fn(), - innerJoin: vi.fn(), - leftJoin: vi.fn(), - orderBy: vi.fn(), - }, -})) +vi.mock('@sim/db', () => databaseMock) vi.mock('@sim/db/schema', () => ({ permissions: { From 1130f8ddb216b64121157c17996046c74deca07c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Feb 2026 15:31:12 -0800 Subject: [PATCH 08/13] Remove redundant error handling, move volume item to types file --- apps/sim/tools/google_books/types.ts | 30 +++++++++++++++++ apps/sim/tools/google_books/volume_details.ts | 7 ---- apps/sim/tools/google_books/volume_search.ts | 32 +------------------ 3 files changed, 31 insertions(+), 38 deletions(-) diff --git a/apps/sim/tools/google_books/types.ts b/apps/sim/tools/google_books/types.ts index f56966095..3484d576a 100644 --- a/apps/sim/tools/google_books/types.ts +++ b/apps/sim/tools/google_books/types.ts @@ -1,5 +1,35 @@ import type { ToolResponse } from '@/tools/types' +/** + * Raw volume item from Google Books API response + */ +export interface GoogleBooksVolumeItem { + id: string + volumeInfo: { + title?: string + subtitle?: string + authors?: string[] + publisher?: string + publishedDate?: string + description?: string + pageCount?: number + categories?: string[] + averageRating?: number + ratingsCount?: number + language?: string + previewLink?: string + infoLink?: string + imageLinks?: { + thumbnail?: string + smallThumbnail?: string + } + industryIdentifiers?: Array<{ + type: string + identifier: string + }> + } +} + /** * Volume information structure shared between search and details responses */ diff --git a/apps/sim/tools/google_books/volume_details.ts b/apps/sim/tools/google_books/volume_details.ts index 114a78361..f50e23986 100644 --- a/apps/sim/tools/google_books/volume_details.ts +++ b/apps/sim/tools/google_books/volume_details.ts @@ -29,9 +29,6 @@ interface GoogleBooksVolumeResponse { identifier: string }> } - error?: { - message?: string - } } export const googleBooksVolumeDetailsTool: ToolConfig< @@ -84,10 +81,6 @@ export const googleBooksVolumeDetailsTool: ToolConfig< transformResponse: async (response: Response) => { const data: GoogleBooksVolumeResponse = await response.json() - if (data.error) { - throw new Error(`Google Books API error: ${data.error.message || 'Unknown error'}`) - } - if (!data.volumeInfo) { throw new Error('Volume not found') } diff --git a/apps/sim/tools/google_books/volume_search.ts b/apps/sim/tools/google_books/volume_search.ts index 72ca1a783..55d90cf9a 100644 --- a/apps/sim/tools/google_books/volume_search.ts +++ b/apps/sim/tools/google_books/volume_search.ts @@ -1,37 +1,11 @@ import type { + GoogleBooksVolumeItem, GoogleBooksVolumeSearchParams, GoogleBooksVolumeSearchResponse, VolumeInfo, } from '@/tools/google_books/types' import type { ToolConfig } from '@/tools/types' -interface GoogleBooksVolumeItem { - id: string - volumeInfo: { - title?: string - subtitle?: string - authors?: string[] - publisher?: string - publishedDate?: string - description?: string - pageCount?: number - categories?: string[] - averageRating?: number - ratingsCount?: number - language?: string - previewLink?: string - infoLink?: string - imageLinks?: { - thumbnail?: string - smallThumbnail?: string - } - industryIdentifiers?: Array<{ - type: string - identifier: string - }> - } -} - function extractVolumeInfo(item: GoogleBooksVolumeItem): VolumeInfo { const info = item.volumeInfo const identifiers = info.industryIdentifiers ?? [] @@ -155,10 +129,6 @@ export const googleBooksVolumeSearchTool: ToolConfig< transformResponse: async (response: Response) => { const data = await response.json() - if (data.error) { - throw new Error(`Google Books API error: ${data.error.message || 'Unknown error'}`) - } - const items: GoogleBooksVolumeItem[] = data.items ?? [] const volumes = items.map(extractVolumeInfo) From dce47a101cb20dae4da14a949f5a2d5a5a478f9d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Feb 2026 15:45:00 -0800 Subject: [PATCH 09/13] Migrate last response to types --- apps/sim/tools/google_books/types.ts | 32 ++++++++++++++++++- apps/sim/tools/google_books/volume_details.ts | 28 +--------------- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/apps/sim/tools/google_books/types.ts b/apps/sim/tools/google_books/types.ts index 3484d576a..363038de5 100644 --- a/apps/sim/tools/google_books/types.ts +++ b/apps/sim/tools/google_books/types.ts @@ -1,7 +1,7 @@ import type { ToolResponse } from '@/tools/types' /** - * Raw volume item from Google Books API response + * Raw volume item from Google Books API search response */ export interface GoogleBooksVolumeItem { id: string @@ -30,6 +30,36 @@ export interface GoogleBooksVolumeItem { } } +/** + * Raw volume response from Google Books API details endpoint + */ +export interface GoogleBooksVolumeResponse { + id: string + volumeInfo: { + title?: string + subtitle?: string + authors?: string[] + publisher?: string + publishedDate?: string + description?: string + pageCount?: number + categories?: string[] + averageRating?: number + ratingsCount?: number + language?: string + previewLink?: string + infoLink?: string + imageLinks?: { + thumbnail?: string + smallThumbnail?: string + } + industryIdentifiers?: Array<{ + type: string + identifier: string + }> + } +} + /** * Volume information structure shared between search and details responses */ diff --git a/apps/sim/tools/google_books/volume_details.ts b/apps/sim/tools/google_books/volume_details.ts index f50e23986..23fe7cd4a 100644 --- a/apps/sim/tools/google_books/volume_details.ts +++ b/apps/sim/tools/google_books/volume_details.ts @@ -1,36 +1,10 @@ import type { GoogleBooksVolumeDetailsParams, GoogleBooksVolumeDetailsResponse, + GoogleBooksVolumeResponse, } from '@/tools/google_books/types' import type { ToolConfig } from '@/tools/types' -interface GoogleBooksVolumeResponse { - id: string - volumeInfo: { - title?: string - subtitle?: string - authors?: string[] - publisher?: string - publishedDate?: string - description?: string - pageCount?: number - categories?: string[] - averageRating?: number - ratingsCount?: number - language?: string - previewLink?: string - infoLink?: string - imageLinks?: { - thumbnail?: string - smallThumbnail?: string - } - industryIdentifiers?: Array<{ - type: string - identifier: string - }> - } -} - export const googleBooksVolumeDetailsTool: ToolConfig< GoogleBooksVolumeDetailsParams, GoogleBooksVolumeDetailsResponse From 602e371a7afadf683bb5989ba19137fbb90c707a Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 12 Feb 2026 19:01:04 -0800 Subject: [PATCH 10/13] refactor(tool-input): subblock-first rendering, component extraction, bug fixes (#3207) * refactor(tool-input): eliminate SyncWrappers, add canonical toggle and dependsOn gating Replace 17+ individual SyncWrapper components with a single centralized ToolSubBlockRenderer that bridges the subblock store with StoredTool.params via synthetic store keys. This reduces ~1000 lines of duplicated wrapper code and ensures tool-input renders subblock components identically to the standalone SubBlock path. - Add ToolSubBlockRenderer with bidirectional store sync - Add basic/advanced mode toggle (ArrowLeftRight) using collaborative functions - Add dependsOn gating via useDependsOnGate (fields disable instead of hiding) - Add paramVisibility field to SubBlockConfig for tool-input visibility control - Pass canonicalModeOverrides through getSubBlocksForToolInput - Show (optional) label for non-user-only fields (LLM can inject at runtime) Co-Authored-By: Claude Opus 4.6 * fix(tool-input): restore optional indicator, fix folder selector and canonical toggle, extract components - Attach resolved paramVisibility to subblocks from getSubBlocksForToolInput - Add labelSuffix prop to SubBlock for "(optional)" badge on user-or-llm params - Fix folder selector missing for tools with canonicalParamId (e.g. Google Drive) - Fix canonical toggle not clickable by letting SubBlock handle dependsOn internally - Extract ParameterWithLabel, ToolSubBlockRenderer, ToolCredentialSelector to components/tools/ - Extract StoredTool interface to types.ts, selection helpers to utils.ts - Remove dead code (mcpError, refreshTools, oldParamIds, initialParams) - Strengthen typing: replace any with proper types on icon components and evaluateParameterCondition * add sibling values to subblock context since subblock store isn't relevant in tool input, and removed unused param * cleanup * fix(tool-input): render uncovered tool params alongside subblocks The SubBlock-first rendering path was hard-returning after rendering subblocks, so tool params without matching subblocks (like inputMapping for workflow tools) were never rendered. Now renders subblocks first, then any remaining displayParams not covered by subblocks via the legacy ParameterWithLabel fallback. Co-Authored-By: Claude Opus 4.6 * fix(tool-input): auto-refresh workflow inputs after redeploy After redeploying a child workflow via the stale badge, the workflow state cache was not invalidated, so WorkflowInputMapperInput kept showing stale input fields until page refresh. Now invalidates workflowKeys.state on deploy success. Co-Authored-By: Claude Opus 4.6 * fix(tool-input): correct workflow selector visibility and tighten (optional) spacing - Set workflowId param to user-only in workflow_executor tool config so "Select Workflow" no longer shows "(optional)" indicator - Tighten (optional) label spacing with -ml-[3px] to counteract parent Label's gap-[6px], making it feel inline with the label text Co-Authored-By: Claude Opus 4.6 * fix(tool-input): align (optional) text to baseline instead of center Use items-baseline instead of items-center on Label flex containers so the smaller (optional) text aligns with the label text baseline rather than sitting slightly below it. Co-Authored-By: Claude Opus 4.6 * fix(tool-input): increase top padding of expanded tool body Bump the expanded tool body container's top padding from 8px to 12px for more breathing room between the header bar and the first parameter. Co-Authored-By: Claude Opus 4.6 * fix(tool-input): apply extra top padding only to SubBlock-first path Revert container padding to py-[8px] (MCP tools were correct). Wrap SubBlock-first output in a div with pt-[4px] so only registry tools get extra breathing room from the container top. Co-Authored-By: Claude Opus 4.6 * fix(tool-input): increase gap between SubBlock params for visual clarity SubBlock's internal gap (10px between label and input) matched the between-parameter gap (10px), making them indistinguishable. Increase the between-parameter gap to 14px so consecutive parameters are visually distinct, matching the separation seen in ParameterWithLabel. Co-Authored-By: Claude Opus 4.6 * fix spacing and optional tag * update styling + move predeploy checks earlier for first time deploys * update change detection to account for synthetic tool ids * fix remaining blocks who had files visibility set to hidden * cleanup * add catch --------- Co-authored-by: Claude Opus 4.6 --- .../components/deploy/hooks/use-deployment.ts | 21 + .../credential-selector.tsx} | 11 +- .../tool-input/components/tools/parameter.tsx | 186 ++ .../components/tools/sub-block-renderer.tsx | 109 + .../components/tool-input/tool-input.test.ts | 37 +- .../components/tool-input/tool-input.tsx | 1972 +++++------------ .../sub-block/components/tool-input/types.ts | 31 + .../sub-block/components/tool-input/utils.ts | 32 + .../editor/components/sub-block/sub-block.tsx | 73 +- .../panel/components/editor/editor.tsx | 2 - apps/sim/blocks/types.ts | 2 + .../executor/handlers/agent/agent-handler.ts | 22 +- apps/sim/hooks/queries/workflows.ts | 4 + .../workflows/comparison/normalize.test.ts | 12 + .../sim/lib/workflows/comparison/normalize.ts | 10 +- apps/sim/providers/types.ts | 2 + apps/sim/providers/utils.ts | 80 +- apps/sim/serializer/index.ts | 8 +- apps/sim/serializer/types.ts | 2 + apps/sim/tools/file/parser.ts | 2 +- apps/sim/tools/jira/add_attachment.ts | 2 +- apps/sim/tools/linear/create_attachment.ts | 2 +- apps/sim/tools/params-resolver.ts | 4 + apps/sim/tools/params.ts | 214 +- apps/sim/tools/pulse/parser.ts | 4 +- apps/sim/tools/reducto/parser.ts | 4 +- apps/sim/tools/sftp/upload.ts | 2 +- apps/sim/tools/vision/tool.ts | 2 +- apps/sim/tools/wordpress/upload_media.ts | 2 +- apps/sim/tools/workflow/executor.ts | 2 +- 30 files changed, 1320 insertions(+), 1536 deletions(-) rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/{tool-credential-selector.tsx => tools/credential-selector.tsx} (96%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/parameter.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts index 1f2a350d8..b6a5d585e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts @@ -1,7 +1,10 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' +import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks' import { useNotificationStore } from '@/stores/notifications' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { mergeSubblockState } from '@/stores/workflows/utils' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('useDeployment') @@ -35,6 +38,24 @@ export function useDeployment({ return { success: true, shouldOpenModal: true } } + const { blocks, edges, loops, parallels } = useWorkflowStore.getState() + const liveBlocks = mergeSubblockState(blocks, workflowId) + const checkResult = runPreDeployChecks({ + blocks: liveBlocks, + edges, + loops, + parallels, + workflowId, + }) + if (!checkResult.passed) { + addNotification({ + level: 'error', + message: checkResult.error || 'Pre-deploy validation failed', + workflowId, + }) + return { success: false, shouldOpenModal: false } + } + setIsDeploying(true) try { const response = await fetch(`/api/workflows/${workflowId}/deploy`, { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx similarity index 96% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx index 0496489d4..255d85907 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx @@ -4,6 +4,7 @@ import { Button, Combobox } from '@/components/emcn/components' import { getCanonicalScopesForProvider, getProviderIdFromServiceId, + getServiceConfigByProviderId, OAUTH_PROVIDERS, type OAuthProvider, type OAuthService, @@ -26,6 +27,11 @@ const getProviderIcon = (providerName: OAuthProvider) => { } const getProviderName = (providerName: OAuthProvider) => { + const serviceConfig = getServiceConfigByProviderId(providerName) + if (serviceConfig) { + return serviceConfig.name + } + const { baseProvider } = parseProvider(providerName) const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] @@ -54,7 +60,7 @@ export function ToolCredentialSelector({ onChange, provider, requiredScopes = [], - label = 'Select account', + label, serviceId, disabled = false, }: ToolCredentialSelectorProps) { @@ -64,6 +70,7 @@ export function ToolCredentialSelector({ const { activeWorkflowId } = useWorkflowRegistry() const selectedId = value || '' + const effectiveLabel = label || `Select ${getProviderName(provider)} account` const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) @@ -203,7 +210,7 @@ export function ToolCredentialSelector({ selectedValue={selectedId} onChange={handleComboboxChange} onOpenChange={handleOpenChange} - placeholder={label} + placeholder={effectiveLabel} disabled={disabled} editable={true} filterOptions={!isForeign} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/parameter.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/parameter.tsx new file mode 100644 index 000000000..d69ad776b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/parameter.tsx @@ -0,0 +1,186 @@ +'use client' + +import type React from 'react' +import { useRef, useState } from 'react' +import { ArrowLeftRight, ArrowUp } from 'lucide-react' +import { Button, Input, Label, Tooltip } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block' + +/** + * Props for a generic parameter with label component + */ +export interface ParameterWithLabelProps { + paramId: string + title: string + isRequired: boolean + visibility: string + wandConfig?: { + enabled: boolean + prompt?: string + placeholder?: string + } + canonicalToggle?: { + mode: 'basic' | 'advanced' + disabled?: boolean + onToggle?: () => void + } + disabled: boolean + isPreview: boolean + children: (wandControlRef: React.MutableRefObject) => React.ReactNode +} + +/** + * Generic wrapper component for parameters that manages wand state and renders label + input + */ +export function ParameterWithLabel({ + paramId, + title, + isRequired, + visibility, + wandConfig, + canonicalToggle, + disabled, + isPreview, + children, +}: ParameterWithLabelProps) { + const [isSearchActive, setIsSearchActive] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const searchInputRef = useRef(null) + const wandControlRef = useRef(null) + + const isWandEnabled = wandConfig?.enabled ?? false + const showWand = isWandEnabled && !isPreview && !disabled + + const handleSearchClick = (): void => { + setIsSearchActive(true) + setTimeout(() => { + searchInputRef.current?.focus() + }, 0) + } + + const handleSearchBlur = (): void => { + if (!searchQuery.trim() && !wandControlRef.current?.isWandStreaming) { + setIsSearchActive(false) + } + } + + const handleSearchChange = (value: string): void => { + setSearchQuery(value) + } + + const handleSearchSubmit = (): void => { + if (searchQuery.trim() && wandControlRef.current) { + wandControlRef.current.onWandTrigger(searchQuery) + setSearchQuery('') + setIsSearchActive(false) + } + } + + const handleSearchCancel = (): void => { + setSearchQuery('') + setIsSearchActive(false) + } + + const isStreaming = wandControlRef.current?.isWandStreaming ?? false + + return ( +
+
+ +
+ {showWand && + (!isSearchActive ? ( + + ) : ( +
+ ) => + handleSearchChange(e.target.value) + } + onBlur={(e: React.FocusEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement | null + if (relatedTarget?.closest('button')) return + handleSearchBlur() + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' && searchQuery.trim() && !isStreaming) { + handleSearchSubmit() + } else if (e.key === 'Escape') { + handleSearchCancel() + } + }} + disabled={isStreaming} + className={cn( + 'h-5 min-w-[80px] flex-1 text-[11px]', + isStreaming && 'text-muted-foreground' + )} + placeholder='Generate with AI...' + /> + +
+ ))} + {canonicalToggle && !isPreview && ( + + + + + +

+ {canonicalToggle.mode === 'advanced' + ? 'Switch to selector' + : 'Switch to manual ID'} +

+
+
+ )} +
+
+
{children(wandControlRef)}
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx new file mode 100644 index 000000000..0f9319ace --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx @@ -0,0 +1,109 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' +import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block' +import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' + +interface ToolSubBlockRendererProps { + blockId: string + subBlockId: string + toolIndex: number + subBlock: BlockSubBlockConfig + effectiveParamId: string + toolParams: Record | undefined + onParamChange: (toolIndex: number, paramId: string, value: string) => void + disabled: boolean + canonicalToggle?: { + mode: 'basic' | 'advanced' + disabled?: boolean + onToggle?: () => void + } +} + +/** + * SubBlock types whose store values are objects/arrays/non-strings. + * tool.params stores strings (via JSON.stringify), so when syncing + * back to the store we parse them to restore the native shape. + */ +const OBJECT_SUBBLOCK_TYPES = new Set(['file-upload', 'table', 'grouped-checkbox-list']) + +/** + * Bridges the subblock store with StoredTool.params via a synthetic store key, + * then delegates all rendering to SubBlock for full parity. + */ +export function ToolSubBlockRenderer({ + blockId, + subBlockId, + toolIndex, + subBlock, + effectiveParamId, + toolParams, + onParamChange, + disabled, + canonicalToggle, +}: ToolSubBlockRendererProps) { + const syntheticId = `${subBlockId}-tool-${toolIndex}-${effectiveParamId}` + const [storeValue, setStoreValue] = useSubBlockValue(blockId, syntheticId) + + const toolParamValue = toolParams?.[effectiveParamId] ?? '' + const isObjectType = OBJECT_SUBBLOCK_TYPES.has(subBlock.type) + + const lastPushedToStoreRef = useRef(null) + const lastPushedToParamsRef = useRef(null) + + useEffect(() => { + if (!toolParamValue && lastPushedToStoreRef.current === null) { + lastPushedToStoreRef.current = toolParamValue + lastPushedToParamsRef.current = toolParamValue + return + } + if (toolParamValue !== lastPushedToStoreRef.current) { + lastPushedToStoreRef.current = toolParamValue + lastPushedToParamsRef.current = toolParamValue + + if (isObjectType && typeof toolParamValue === 'string' && toolParamValue) { + try { + const parsed = JSON.parse(toolParamValue) + if (typeof parsed === 'object' && parsed !== null) { + setStoreValue(parsed) + return + } + } catch { + // Not valid JSON — fall through to set as string + } + } + setStoreValue(toolParamValue) + } + }, [toolParamValue, setStoreValue, isObjectType]) + + useEffect(() => { + if (storeValue == null) return + const stringValue = typeof storeValue === 'string' ? storeValue : JSON.stringify(storeValue) + if (stringValue !== lastPushedToParamsRef.current) { + lastPushedToParamsRef.current = stringValue + lastPushedToStoreRef.current = stringValue + onParamChange(toolIndex, effectiveParamId, stringValue) + } + }, [storeValue, toolIndex, effectiveParamId, onParamChange]) + + const visibility = subBlock.paramVisibility ?? 'user-or-llm' + const isOptionalForUser = visibility !== 'user-only' + + const config = { + ...subBlock, + id: syntheticId, + ...(isOptionalForUser && { required: false }), + } + + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.test.ts index 8d2548c13..44b73e1e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.test.ts @@ -2,37 +2,12 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' - -interface StoredTool { - type: string - title?: string - toolId?: string - params?: Record - customToolId?: string - schema?: any - code?: string - operation?: string - usageControl?: 'auto' | 'force' | 'none' -} - -const isMcpToolAlreadySelected = (selectedTools: StoredTool[], mcpToolId: string): boolean => { - return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId) -} - -const isCustomToolAlreadySelected = ( - selectedTools: StoredTool[], - customToolId: string -): boolean => { - return selectedTools.some( - (tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId - ) -} - -const isWorkflowAlreadySelected = (selectedTools: StoredTool[], workflowId: string): boolean => { - return selectedTools.some( - (tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId - ) -} +import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types' +import { + isCustomToolAlreadySelected, + isMcpToolAlreadySelected, + isWorkflowAlreadySelected, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils' describe('isMcpToolAlreadySelected', () => { describe('basic functionality', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index ff08547ec..f92b8150a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -23,6 +23,7 @@ import { isToolUnavailable, getMcpToolIssue as validateMcpTool, } from '@/lib/mcp/tool-validation' +import type { McpToolSchema } from '@/lib/mcp/types' import { getCanonicalScopesForProvider, getProviderIdFromServiceId, @@ -32,31 +33,26 @@ import { import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { - CheckboxList, - Code, - FileSelectorInput, - FileUpload, - FolderSelectorInput, LongInput, - ProjectSelectorInput, - SheetSelectorInput, ShortInput, - SlackSelectorInput, - SliderInput, - Table, - TimeInput, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components' -import { DocumentSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-selector/document-selector' -import { DocumentTagEntry } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry' -import { KnowledgeBaseSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector' -import { KnowledgeTagFilters } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters' import { type CustomTool, CustomToolModal, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal' -import { ToolCredentialSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector' +import { ToolCredentialSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector' +import { ParameterWithLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/parameter' +import { ToolSubBlockRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer' +import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types' +import { + isCustomToolAlreadySelected, + isMcpToolAlreadySelected, + isWorkflowAlreadySelected, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' +import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block' import { getAllBlocks } from '@/blocks' +import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' import { useMcpTools } from '@/hooks/mcp/use-mcp-tools' import { type CustomTool as CustomToolDefinition, @@ -74,682 +70,59 @@ import { useWorkflowState, useWorkflows, } from '@/hooks/queries/workflows' +import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { usePermissionConfig } from '@/hooks/use-permission-config' import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils' import { useSettingsModalStore } from '@/stores/modals/settings/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { formatParameterLabel, + getSubBlocksForToolInput, getToolParametersConfig, isPasswordParameter, + type SubBlocksForToolInput, type ToolParameterConfig, } from '@/tools/params' import { buildCanonicalIndex, buildPreviewContextValues, type CanonicalIndex, + type CanonicalModeOverrides, evaluateSubBlockCondition, + isCanonicalPair, + resolveCanonicalMode, type SubBlockCondition, } from '@/tools/params-resolver' const logger = createLogger('ToolInput') /** - * Props for the ToolInput component + * Extracts canonical mode overrides scoped to a specific tool type. + * Canonical modes are stored with `{blockType}:{canonicalId}` keys to prevent + * cross-tool collisions when multiple tools share the same canonicalParamId. */ -interface ToolInputProps { - /** Unique identifier for the block */ - blockId: string - /** Unique identifier for the sub-block */ - subBlockId: string - /** Whether component is in preview mode */ - isPreview?: boolean - /** Value to display in preview mode */ - previewValue?: any - /** Whether the input is disabled */ - disabled?: boolean - /** Allow expanding tools in preview mode */ - allowExpandInPreview?: boolean -} - -/** - * Represents a tool selected and configured in the workflow - * - * @remarks - * For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded. - * Everything else (title, schema, code) is loaded dynamically from the database. - * Legacy custom tools with inline schema/code are still supported for backwards compatibility. - */ -interface StoredTool { - /** Block type identifier */ - type: string - /** Display title for the tool (optional for new custom tool format) */ - title?: string - /** Direct tool ID for execution (optional for new custom tool format) */ - toolId?: string - /** Parameter values configured by the user (optional for new custom tool format) */ - params?: Record - /** Whether the tool details are expanded in UI */ - isExpanded?: boolean - /** Database ID for custom tools (new format - reference only) */ - customToolId?: string - /** Tool schema for custom tools (legacy format - inline) */ - schema?: any - /** Implementation code for custom tools (legacy format - inline) */ - code?: string - /** Selected operation for multi-operation tools */ - operation?: string - /** Tool usage control mode for LLM */ - usageControl?: 'auto' | 'force' | 'none' -} - -/** - * Resolves a custom tool reference to its full definition. - * - * @remarks - * Custom tools can be stored in two formats: - * 1. Reference-only (new): `{ customToolId: "...", usageControl: "auto" }` - loads from database - * 2. Inline (legacy): `{ schema: {...}, code: "..." }` - uses embedded definition - * - * @param storedTool - The stored tool reference containing either a customToolId or inline definition - * @param customToolsList - List of custom tools fetched from the database - * @returns The resolved custom tool with schema, code, and title, or `null` if not found - */ -function resolveCustomToolFromReference( - storedTool: StoredTool, - customToolsList: CustomToolDefinition[] -): { schema: any; code: string; title: string } | null { - // If the tool has a customToolId (new reference format), look it up - if (storedTool.customToolId) { - const customTool = customToolsList.find((t) => t.id === storedTool.customToolId) - if (customTool) { - return { - schema: customTool.schema, - code: customTool.code, - title: customTool.title, - } - } - // If not found by ID, fall through to try other methods - logger.warn(`Custom tool not found by ID: ${storedTool.customToolId}`) - } - - // Legacy format: inline schema and code - if (storedTool.schema && storedTool.code !== undefined) { - return { - schema: storedTool.schema, - code: storedTool.code, - title: storedTool.title || '', +function scopeCanonicalOverrides( + overrides: CanonicalModeOverrides | undefined, + blockType: string | undefined +): CanonicalModeOverrides | undefined { + if (!overrides || !blockType) return undefined + const prefix = `${blockType}:` + let scoped: CanonicalModeOverrides | undefined + for (const [key, val] of Object.entries(overrides)) { + if (key.startsWith(prefix) && val) { + if (!scoped) scoped = {} + scoped[key.slice(prefix.length)] = val } } - - return null + return scoped } /** - * Generic sync wrapper that synchronizes store values with local component state. - * - * @remarks - * Used to sync tool parameter values between the workflow store and local controlled inputs. - * Listens for changes in the store and propagates them to the local component via onChange. - * - * @typeParam T - The type of the store value being synchronized - * - * @param blockId - The block identifier for store lookup - * @param paramId - The parameter identifier within the block - * @param value - Current local value - * @param onChange - Callback to update the local value - * @param children - Child components to render - * @param transformer - Optional function to transform store value before comparison - * @returns The children wrapped with synchronization logic + * Renders the input for workflow_executor's inputMapping parameter. + * This is a special case that doesn't map to any SubBlockConfig, so it's kept here. */ -function GenericSyncWrapper({ - blockId, - paramId, - value, - onChange, - children, - transformer, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - children: React.ReactNode - transformer?: (storeValue: T) => string -}) { - const [storeValue] = useSubBlockValue(blockId, paramId) - - useEffect(() => { - if (storeValue != null) { - const transformedValue = transformer ? transformer(storeValue) : String(storeValue) - if (transformedValue !== value) { - onChange(transformedValue) - } - } - }, [storeValue, value, onChange, transformer]) - - return <>{children} -} - -function FileSelectorSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, - previewContextValues, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean - previewContextValues?: Record -}) { - return ( - - - - ) -} - -function SheetSelectorSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, - previewContextValues, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean - previewContextValues?: Record -}) { - return ( - - - - ) -} - -function FolderSelectorSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, - previewContextValues, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean - previewContextValues?: Record -}) { - return ( - - - - ) -} - -function KnowledgeBaseSelectorSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean -}) { - return ( - - - - ) -} - -function DocumentSelectorSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, - previewContextValues, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean - previewContextValues?: Record -}) { - return ( - - - - ) -} - -function DocumentTagEntrySyncWrapper({ - blockId, - paramId, - value, - onChange, - disabled, - previewContextValues, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - disabled: boolean - previewContextValues?: Record -}) { - return ( - - - - ) -} - -function KnowledgeTagFiltersSyncWrapper({ - blockId, - paramId, - value, - onChange, - disabled, - previewContextValues, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - disabled: boolean - previewContextValues?: Record -}) { - return ( - - - - ) -} - -function TableSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean -}) { - return ( - JSON.stringify(storeValue)} - > - - - ) -} - -function TimeInputSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean -}) { - return ( - - - - ) -} - -function SliderInputSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean -}) { - return ( - String(storeValue)} - > - - - ) -} - -function CheckboxListSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean -}) { - return ( - JSON.stringify(storeValue)} - > - - - ) -} - -function ComboboxSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean -}) { - const options = (uiComponent.options || []).map((opt: any) => - typeof opt === 'string' ? { label: opt, value: opt } : { label: opt.label, value: opt.id } - ) - - return ( - - - - ) -} - -function FileUploadSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean -}) { - return ( - JSON.stringify(storeValue)} - > - - - ) -} - -function SlackSelectorSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, - previewContextValues, - selectorType, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean - previewContextValues?: Record - selectorType: 'channel-selector' | 'user-selector' -}) { - return ( - - - - ) -} - -function WorkflowSelectorSyncWrapper({ - blockId, - paramId, - value, - onChange, - uiComponent, - disabled, - workspaceId, - currentWorkflowId, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - uiComponent: any - disabled: boolean - workspaceId: string - currentWorkflowId?: string -}) { - const { data: workflows = [], isLoading } = useWorkflows(workspaceId, { syncRegistry: false }) - - const availableWorkflows = workflows.filter( - (w) => !currentWorkflowId || w.id !== currentWorkflowId - ) - - const options = availableWorkflows.map((workflow) => ({ - label: workflow.name, - value: workflow.id, - })) - - return ( - - - - ) -} - -function WorkflowInputMapperSyncWrapper({ +function WorkflowInputMapperInput({ blockId, paramId, value, @@ -779,7 +152,7 @@ function WorkflowInputMapperSyncWrapper({ }, [value]) const handleFieldChange = useCallback( - (fieldName: string, fieldValue: any) => { + (fieldName: string, fieldValue: string) => { const newValue = { ...parsedValue, [fieldName]: fieldValue } onChange(JSON.stringify(newValue)) }, @@ -812,7 +185,7 @@ function WorkflowInputMapperSyncWrapper({ return (
- {inputFields.map((field: any) => ( + {inputFields.map((field: { name: string; type: string }) => ( void - disabled: boolean - uiComponent: any - currentToolParams?: Record -}) { - const language = (currentToolParams?.language as 'javascript' | 'python') || 'javascript' - - return ( - - - - ) -} - /** * Badge component showing deployment status for workflow tools */ @@ -941,6 +276,66 @@ function WorkflowToolDeployBadge({ ) } +/** + * Props for the ToolInput component + */ +interface ToolInputProps { + /** Unique identifier for the block */ + blockId: string + /** Unique identifier for the sub-block */ + subBlockId: string + /** Whether component is in preview mode */ + isPreview?: boolean + /** Value to display in preview mode */ + previewValue?: any + /** Whether the input is disabled */ + disabled?: boolean + /** Allow expanding tools in preview mode */ + allowExpandInPreview?: boolean +} + +/** + * Resolves a custom tool reference to its full definition. + * + * @remarks + * Custom tools can be stored in two formats: + * 1. Reference-only (new): `{ customToolId: "...", usageControl: "auto" }` - loads from database + * 2. Inline (legacy): `{ schema: {...}, code: "..." }` - uses embedded definition + * + * @param storedTool - The stored tool reference containing either a customToolId or inline definition + * @param customToolsList - List of custom tools fetched from the database + * @returns The resolved custom tool with schema, code, and title, or `null` if not found + */ +function resolveCustomToolFromReference( + storedTool: StoredTool, + customToolsList: CustomToolDefinition[] +): { schema: any; code: string; title: string } | null { + // If the tool has a customToolId (new reference format), look it up + if (storedTool.customToolId) { + const customTool = customToolsList.find((t) => t.id === storedTool.customToolId) + if (customTool) { + return { + schema: customTool.schema, + code: customTool.code, + title: customTool.title, + } + } + // If not found by ID, fall through to try other methods + logger.warn(`Custom tool not found by ID: ${storedTool.customToolId}`) + } + + // Legacy format: inline schema and code + if (storedTool.schema && storedTool.code !== undefined) { + return { + schema: storedTool.schema, + code: storedTool.code, + title: storedTool.title || '', + } + } + + return null +} + /** * Set of built-in tool types that are core platform tools. * @@ -966,6 +361,80 @@ const BUILT_IN_TOOL_TYPES = new Set([ 'workflow', ]) +/** + * Checks if a block supports multiple operations. + * + * @param blockType - The block type to check + * @returns `true` if the block has more than one tool operation available + */ +function hasMultipleOperations(blockType: string): boolean { + const block = getAllBlocks().find((b) => b.type === blockType) + return (block?.tools?.access?.length || 0) > 1 +} + +/** + * Gets the available operation options for a multi-operation tool. + * + * @param blockType - The block type to get operations for + * @returns Array of operation options with label and id properties + */ +function getOperationOptions(blockType: string): { label: string; id: string }[] { + const block = getAllBlocks().find((b) => b.type === blockType) + if (!block || !block.tools?.access) return [] + + const operationSubBlock = block.subBlocks.find((sb) => sb.id === 'operation') + if ( + operationSubBlock && + operationSubBlock.type === 'dropdown' && + Array.isArray(operationSubBlock.options) + ) { + return operationSubBlock.options as { label: string; id: string }[] + } + + return block.tools.access.map((toolId) => { + try { + const toolParams = getToolParametersConfig(toolId) + return { + id: toolId, + label: toolParams?.toolConfig?.name || toolId, + } + } catch (error) { + logger.error(`Error getting tool config for ${toolId}:`, error) + return { id: toolId, label: toolId } + } + }) +} + +/** + * Gets the correct tool ID for a given operation. + * + * @param blockType - The block type + * @param operation - The selected operation (for multi-operation tools) + * @returns The tool ID to use for execution, or `undefined` if not found + */ +function getToolIdForOperation(blockType: string, operation?: string): string | undefined { + const block = getAllBlocks().find((b) => b.type === blockType) + if (!block || !block.tools?.access) return undefined + + if (block.tools.access.length === 1) { + return block.tools.access[0] + } + + if (operation && block.tools?.config?.tool) { + try { + return block.tools.config.tool({ operation }) + } catch (error) { + logger.error('Error selecting tool for operation:', error) + } + } + + if (operation && block.tools.access.includes(operation)) { + return operation + } + + return block.tools.access[0] +} + /** * Creates a styled icon element for tool items in the selection dropdown. * @@ -973,7 +442,10 @@ const BUILT_IN_TOOL_TYPES = new Set([ * @param IconComponent - The Lucide icon component to render * @returns A styled div containing the icon with consistent dimensions */ -function createToolIcon(bgColor: string, IconComponent: any) { +function createToolIcon( + bgColor: string, + IconComponent: React.ComponentType<{ className?: string }> +) { return (
(null) const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState(null) + const canonicalModeOverrides = useWorkflowStore( + useCallback( + (state) => state.blocks[blockId]?.data?.canonicalModes as CanonicalModeOverrides | undefined, + [blockId] + ) + ) + const { collaborativeSetBlockCanonicalMode } = useCollaborativeWorkflow() + const value = isPreview ? previewValue : storeValue const selectedTools: StoredTool[] = @@ -1030,12 +510,7 @@ export const ToolInput = memo(function ToolInput({ const shouldFetchCustomTools = !isPreview || hasReferenceOnlyCustomTools const { data: customTools = [] } = useCustomTools(shouldFetchCustomTools ? workspaceId : '') - const { - mcpTools, - isLoading: mcpLoading, - error: mcpError, - refreshTools, - } = useMcpTools(workspaceId) + const { mcpTools, isLoading: mcpLoading } = useMcpTools(workspaceId) const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpServers(workspaceId) const { data: storedMcpTools = [] } = useStoredMcpTools(workspaceId) @@ -1044,7 +519,6 @@ export const ToolInput = memo(function ToolInput({ const openSettingsModal = useSettingsModalStore((state) => state.openModal) const mcpDataLoading = mcpLoading || mcpServersLoading - // Fetch workflows for the Workflows section in the dropdown const { data: workflowsList = [] } = useWorkflows(workspaceId, { syncRegistry: false }) const availableWorkflows = useMemo( () => workflowsList.filter((w) => w.id !== workflowId), @@ -1082,7 +556,7 @@ export const ToolInput = memo(function ToolInput({ ) || storedMcpTools.find((st) => st.serverId === serverId && st.toolName === toolName) // Use DB schema if available, otherwise use Zustand schema - const schema = storedTool?.schema ?? tool.schema + const schema = storedTool?.schema ?? (tool.schema as McpToolSchema | undefined) return validateMcpTool( { @@ -1225,159 +699,12 @@ export const ToolInput = memo(function ToolInput({ if (hasMultipleOperations(blockType)) { return false } - // Allow multiple instances for workflow and knowledge blocks - // Each instance can target a different workflow/knowledge base if (blockType === 'workflow' || blockType === 'knowledge') { return false } return selectedTools.some((tool) => tool.toolId === toolId) } - /** - * Checks if an MCP tool is already selected. - * - * @param mcpToolId - The MCP tool identifier to check - * @returns `true` if the MCP tool is already selected - */ - const isMcpToolAlreadySelected = (mcpToolId: string): boolean => { - return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId) - } - - /** - * Checks if a custom tool is already selected. - * - * @param customToolId - The custom tool identifier to check - * @returns `true` if the custom tool is already selected - */ - const isCustomToolAlreadySelected = (customToolId: string): boolean => { - return selectedTools.some( - (tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId - ) - } - - /** - * Checks if a workflow is already selected. - * - * @param workflowId - The workflow identifier to check - * @returns `true` if the workflow is already selected - */ - const isWorkflowAlreadySelected = (workflowId: string): boolean => { - return selectedTools.some( - (tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId - ) - } - - /** - * Checks if a block supports multiple operations. - * - * @param blockType - The block type to check - * @returns `true` if the block has more than one tool operation available - */ - const hasMultipleOperations = (blockType: string): boolean => { - const block = getAllBlocks().find((block) => block.type === blockType) - return (block?.tools?.access?.length || 0) > 1 - } - - /** - * Gets the available operation options for a multi-operation tool. - * - * @remarks - * First attempts to find options from the block's operation dropdown subBlock, - * then falls back to creating options from the tools.access array. - * - * @param blockType - The block type to get operations for - * @returns Array of operation options with label and id properties - */ - const getOperationOptions = (blockType: string): { label: string; id: string }[] => { - const block = getAllBlocks().find((block) => block.type === blockType) - if (!block || !block.tools?.access) return [] - - // Look for an operation dropdown in the block's subBlocks - const operationSubBlock = block.subBlocks.find((sb) => sb.id === 'operation') - if ( - operationSubBlock && - operationSubBlock.type === 'dropdown' && - Array.isArray(operationSubBlock.options) - ) { - return operationSubBlock.options as { label: string; id: string }[] - } - - // Fallback: create options from tools.access - return block.tools.access.map((toolId) => { - try { - const toolParams = getToolParametersConfig(toolId) - return { - id: toolId, - label: toolParams?.toolConfig?.name || toolId, - } - } catch (error) { - logger.error(`Error getting tool config for ${toolId}:`, error) - return { - id: toolId, - label: toolId, - } - } - }) - } - - /** - * Gets the correct tool ID for a given operation. - * - * @remarks - * For single-tool blocks, returns the first tool. For multi-operation blocks, - * uses the block's tool selection function or matches the operation to a tool ID. - * - * @param blockType - The block type - * @param operation - The selected operation (for multi-operation tools) - * @returns The tool ID to use for execution, or `undefined` if not found - */ - const getToolIdForOperation = (blockType: string, operation?: string): string | undefined => { - const block = getAllBlocks().find((block) => block.type === blockType) - if (!block || !block.tools?.access) return undefined - - // If there's only one tool, return it - if (block.tools.access.length === 1) { - return block.tools.access[0] - } - - // If there's an operation and a tool selection function, use it - if (operation && block.tools?.config?.tool) { - try { - return block.tools.config.tool({ operation }) - } catch (error) { - logger.error('Error selecting tool for operation:', error) - } - } - - // If there's an operation that matches a tool ID, use it - if (operation && block.tools.access.includes(operation)) { - return operation - } - - // Default to first tool - return block.tools.access[0] - } - - /** - * Initializes tool parameters with empty values. - * - * @remarks - * Returns an empty object as parameters are populated dynamically - * based on user input and default values from the tool configuration. - * - * @param toolId - The tool identifier - * @param params - Array of parameter configurations - * @param instanceId - Optional instance identifier for unique param keys - * @returns Empty parameter object to be populated by the user - */ - const initializeToolParams = ( - toolId: string, - params: ToolParameterConfig[], - instanceId?: string - ): Record => { - return {} - } - const handleSelectTool = useCallback( (toolBlock: (typeof toolBlocks)[0]) => { if (isPreview || disabled) return @@ -1394,7 +721,7 @@ export const ToolInput = memo(function ToolInput({ const toolParams = getToolParametersConfig(toolId, toolBlock.type) if (!toolParams) return - const initialParams = initializeToolParams(toolId, toolParams.userInputParameters, blockId) + const initialParams: Record = {} toolParams.userInputParameters.forEach((param) => { if (param.uiComponent?.value && !initialParams[param.id]) { @@ -1420,18 +747,7 @@ export const ToolInput = memo(function ToolInput({ setOpen(false) }, - [ - isPreview, - disabled, - hasMultipleOperations, - getOperationOptions, - getToolIdForOperation, - isToolAlreadySelected, - initializeToolParams, - blockId, - selectedTools, - setStoreValue, - ] + [isPreview, disabled, isToolAlreadySelected, selectedTools, setStoreValue] ) const handleAddCustomTool = useCallback( @@ -1541,7 +857,7 @@ export const ToolInput = memo(function ToolInput({ customTools.some( (customTool) => customTool.id === toolId && - customTool.schema?.function?.name === tool.schema.function.name + customTool.schema?.function?.name === tool.schema?.function?.name ) ) { return false @@ -1597,10 +913,6 @@ export const ToolInput = memo(function ToolInput({ return } - const initialParams = initializeToolParams(newToolId, toolParams.userInputParameters, blockId) - - const oldToolParams = tool.toolId ? getToolParametersConfig(tool.toolId, tool.type) : null - const oldParamIds = new Set(oldToolParams?.userInputParameters.map((p) => p.id) || []) const newParamIds = new Set(toolParams.userInputParameters.map((p) => p.id)) const preservedParams: Record = {} @@ -1626,21 +938,13 @@ export const ToolInput = memo(function ToolInput({ ...tool, toolId: newToolId, operation, - params: { ...initialParams, ...preservedParams }, // Preserve all compatible existing values + params: preservedParams, } : tool ) ) }, - [ - isPreview, - disabled, - selectedTools, - getToolIdForOperation, - initializeToolParams, - blockId, - setStoreValue, - ] + [isPreview, disabled, selectedTools, getToolIdForOperation, blockId, setStoreValue] ) const handleUsageControlChange = useCallback( @@ -1700,19 +1004,22 @@ export const ToolInput = memo(function ToolInput({ setDragOverIndex(null) } - const handleMcpToolSelect = (newTool: StoredTool, closePopover = true) => { - setStoreValue([ - ...selectedTools.map((tool) => ({ - ...tool, - isExpanded: false, - })), - newTool, - ]) + const handleMcpToolSelect = useCallback( + (newTool: StoredTool, closePopover = true) => { + setStoreValue([ + ...selectedTools.map((tool) => ({ + ...tool, + isExpanded: false, + })), + newTool, + ]) - if (closePopover) { - setOpen(false) - } - } + if (closePopover) { + setOpen(false) + } + }, + [selectedTools, setStoreValue] + ) const handleDrop = (e: React.DragEvent, dropIndex: number) => { if (isPreview || disabled || draggedIndex === null || draggedIndex === dropIndex) return @@ -1735,11 +1042,180 @@ export const ToolInput = memo(function ToolInput({ setDragOverIndex(null) } - const IconComponent = ({ icon: Icon, className }: { icon: any; className?: string }) => { + const IconComponent = ({ + icon: Icon, + className, + }: { + icon?: React.ComponentType<{ className?: string }> + className?: string + }) => { if (!Icon) return null return } + const evaluateParameterCondition = (param: ToolParameterConfig, tool: StoredTool): boolean => { + if (!('uiComponent' in param) || !param.uiComponent?.condition) return true + const currentValues: Record = { operation: tool.operation, ...tool.params } + return evaluateSubBlockCondition( + param.uiComponent.condition as SubBlockCondition, + currentValues + ) + } + + /** + * Renders a parameter input for custom tools, MCP tools, and legacy registry + * tools that don't have SubBlockConfig definitions. + * + * Registry tools with subBlocks use ToolSubBlockRenderer instead. + */ + const renderParameterInput = ( + param: ToolParameterConfig, + value: string, + onChange: (value: string) => void, + toolIndex?: number, + currentToolParams?: Record, + wandControlRef?: React.MutableRefObject + ) => { + const uniqueSubBlockId = + toolIndex !== undefined + ? `${subBlockId}-tool-${toolIndex}-${param.id}` + : `${subBlockId}-${param.id}` + const uiComponent = param.uiComponent + + if (!uiComponent) { + return ( + + ) + } + + switch (uiComponent.type) { + case 'dropdown': + return ( + (option.id ?? option.value) !== '') + .map((option) => ({ + label: option.label, + value: option.id ?? option.value ?? '', + })) || [] + } + value={value} + onChange={onChange} + placeholder={uiComponent.placeholder || 'Select option'} + disabled={disabled} + /> + ) + + case 'switch': + return ( + onChange(checked ? 'true' : 'false')} + /> + ) + + case 'long-input': + return ( + + ) + + case 'short-input': + return ( + + ) + + case 'oauth-input': + return ( + + ) + + case 'workflow-input-mapper': { + const selectedWorkflowId = currentToolParams?.workflowId || '' + return ( + + ) + } + + default: + return ( + + ) + } + } + /** * Generates grouped options for the tool selection combobox. * @@ -1752,7 +1228,6 @@ export const ToolInput = memo(function ToolInput({ const toolGroups = useMemo((): ComboboxOptionGroup[] => { const groups: ComboboxOptionGroup[] = [] - // Actions group (no section header) const actionItems: ComboboxOption[] = [] if (!permissionConfig.disableCustomTools) { actionItems.push({ @@ -1782,12 +1257,11 @@ export const ToolInput = memo(function ToolInput({ groups.push({ items: actionItems }) } - // Custom Tools section if (!permissionConfig.disableCustomTools && customTools.length > 0) { groups.push({ section: 'Custom Tools', items: customTools.map((customTool) => { - const alreadySelected = isCustomToolAlreadySelected(customTool.id) + const alreadySelected = isCustomToolAlreadySelected(selectedTools, customTool.id) return { label: customTool.title, value: `custom-${customTool.id}`, @@ -1812,13 +1286,12 @@ export const ToolInput = memo(function ToolInput({ }) } - // MCP Tools section if (!permissionConfig.disableMcpTools && availableMcpTools.length > 0) { groups.push({ section: 'MCP Tools', items: availableMcpTools.map((mcpTool) => { const server = mcpServers.find((s) => s.id === mcpTool.serverId) - const alreadySelected = isMcpToolAlreadySelected(mcpTool.id) + const alreadySelected = isMcpToolAlreadySelected(selectedTools, mcpTool.id) return { label: mcpTool.name, value: `mcp-${mcpTool.id}`, @@ -1850,11 +1323,9 @@ export const ToolInput = memo(function ToolInput({ }) } - // Split tool blocks into built-in tools and integrations const builtInTools = toolBlocks.filter((block) => BUILT_IN_TOOL_TYPES.has(block.type)) const integrations = toolBlocks.filter((block) => !BUILT_IN_TOOL_TYPES.has(block.type)) - // Built-in Tools section if (builtInTools.length > 0) { groups.push({ section: 'Built-in Tools', @@ -1872,7 +1343,6 @@ export const ToolInput = memo(function ToolInput({ }) } - // Integrations section if (integrations.length > 0) { groups.push({ section: 'Integrations', @@ -1895,7 +1365,7 @@ export const ToolInput = memo(function ToolInput({ groups.push({ section: 'Workflows', items: availableWorkflows.map((workflow) => { - const alreadySelected = isWorkflowAlreadySelected(workflow.id) + const alreadySelected = isWorkflowAlreadySelected(selectedTools, workflow.id) return { label: workflow.name, value: `workflow-${workflow.id}`, @@ -1939,11 +1409,7 @@ export const ToolInput = memo(function ToolInput({ permissionConfig.disableCustomTools, permissionConfig.disableMcpTools, availableWorkflows, - getToolIdForOperation, isToolAlreadySelected, - isMcpToolAlreadySelected, - isCustomToolAlreadySelected, - isWorkflowAlreadySelected, ]) const toolRequiresOAuth = (toolId: string): boolean => { @@ -1956,405 +1422,8 @@ export const ToolInput = memo(function ToolInput({ return toolParams?.toolConfig?.oauth } - const evaluateParameterCondition = (param: any, tool: StoredTool): boolean => { - if (!('uiComponent' in param) || !param.uiComponent?.condition) return true - const currentValues: Record = { operation: tool.operation, ...tool.params } - return evaluateSubBlockCondition( - param.uiComponent.condition as SubBlockCondition, - currentValues - ) - } - - /** - * Renders the appropriate UI component for a tool parameter. - * - * @remarks - * Supports multiple input types including dropdown, switch, long-input, - * short-input, file-selector, table, slider, and more. Falls back to - * ShortInput for unknown types. - * - * @param param - The parameter configuration defining the input type - * @param value - The current parameter value - * @param onChange - Callback to handle value changes - * @param toolIndex - Index of the tool in the selected tools array - * @param currentToolParams - Current values of all tool parameters for dependencies - * @returns JSX element for the parameter input component - */ - const renderParameterInput = ( - param: ToolParameterConfig, - value: string, - onChange: (value: string) => void, - toolIndex?: number, - currentToolParams?: Record - ) => { - const uniqueSubBlockId = - toolIndex !== undefined - ? `${subBlockId}-tool-${toolIndex}-${param.id}` - : `${subBlockId}-${param.id}` - const uiComponent = param.uiComponent - - if (!uiComponent) { - return ( - - ) - } - - switch (uiComponent.type) { - case 'dropdown': - return ( - option.id !== '') - .map((option: any) => ({ - label: option.label, - value: option.id, - })) || [] - } - value={value} - onChange={onChange} - placeholder={uiComponent.placeholder || 'Select option'} - disabled={disabled} - /> - ) - - case 'switch': - return ( - onChange(checked ? 'true' : 'false')} - /> - ) - - case 'long-input': - return ( - - ) - - case 'short-input': - return ( - - ) - - case 'channel-selector': - return ( - - ) - - case 'user-selector': - return ( - - ) - - case 'project-selector': - return ( - - ) - - case 'oauth-input': - return ( - - ) - - case 'file-selector': - return ( - - ) - - case 'sheet-selector': - return ( - - ) - - case 'folder-selector': - return ( - - ) - - case 'table': - return ( - - ) - - case 'combobox': - return ( - - ) - - case 'slider': - return ( - - ) - - case 'checkbox-list': - return ( - - ) - - case 'time-input': - return ( - - ) - - case 'file-upload': - return ( - - ) - - case 'workflow-selector': - return ( - - ) - - case 'workflow-input-mapper': { - const selectedWorkflowId = currentToolParams?.workflowId || '' - return ( - - ) - } - - case 'code': - return ( - - ) - - case 'knowledge-base-selector': - return ( - - ) - - case 'document-selector': - return ( - - ) - - case 'document-tag-entry': - return ( - - ) - - case 'knowledge-tag-filters': - return ( - - ) - - default: - return ( - - ) - } - } - return (
- {/* Add Tool Combobox - always at top */} - {/* Selected Tools List */} {selectedTools.length > 0 && selectedTools.map((tool, toolIndex) => { - // Handle custom tools, MCP tools, and workflow tools differently const isCustomTool = tool.type === 'custom-tool' const isMcpTool = tool.type === 'mcp' const isWorkflowTool = tool.type === 'workflow' @@ -2379,13 +1446,11 @@ export const ToolInput = memo(function ToolInput({ ? toolBlocks.find((block) => block.type === tool.type) : null - // Get the current tool ID (may change based on operation) const currentToolId = !isCustomTool && !isMcpTool ? getToolIdForOperation(tool.type, tool.operation) || tool.toolId || '' : tool.toolId || '' - // Get tool parameters using the new utility with block type for UI components const toolParams = !isCustomTool && !isMcpTool && currentToolId ? getToolParametersConfig(currentToolId, tool.type, { @@ -2394,12 +1459,25 @@ export const ToolInput = memo(function ToolInput({ }) : null - // Build canonical index for proper dependency resolution + const toolScopedOverrides = scopeCanonicalOverrides(canonicalModeOverrides, tool.type) + + const subBlocksResult: SubBlocksForToolInput | null = + !isCustomTool && !isMcpTool && currentToolId + ? getSubBlocksForToolInput( + currentToolId, + tool.type, + { + operation: tool.operation, + ...tool.params, + }, + toolScopedOverrides + ) + : null + const toolCanonicalIndex: CanonicalIndex | null = toolBlock?.subBlocks ? buildCanonicalIndex(toolBlock.subBlocks) : null - // Build preview context with canonical resolution const toolContextValues = toolCanonicalIndex ? buildPreviewContextValues(tool.params || {}, { blockType: tool.type, @@ -2409,12 +1487,10 @@ export const ToolInput = memo(function ToolInput({ }) : tool.params || {} - // For custom tools, resolve from reference (new format) or use inline (legacy) const resolvedCustomTool = isCustomTool ? resolveCustomToolFromReference(tool, customTools) : null - // Derive title and schema from resolved tool or inline data const customToolTitle = isCustomTool ? tool.title || resolvedCustomTool?.title || 'Unknown Tool' : null @@ -2433,8 +1509,6 @@ export const ToolInput = memo(function ToolInput({ ) : [] - // For MCP tools, extract parameters from input schema - // Use cached schema from tool object if available, otherwise fetch from mcpTools const mcpTool = isMcpTool ? mcpTools.find((t) => t.id === tool.toolId) : null const mcpToolSchema = isMcpTool ? tool.schema || mcpTool?.inputSchema : null const mcpToolParams = @@ -2451,28 +1525,27 @@ export const ToolInput = memo(function ToolInput({ ) : [] - // Get all parameters to display - const displayParams = isCustomTool + const useSubBlocks = !isCustomTool && !isMcpTool && subBlocksResult?.subBlocks?.length + const displayParams: ToolParameterConfig[] = isCustomTool ? customToolParams : isMcpTool ? mcpToolParams : toolParams?.userInputParameters || [] + const displaySubBlocks: BlockSubBlockConfig[] = useSubBlocks + ? subBlocksResult!.subBlocks + : [] - // Check if tool requires OAuth const requiresOAuth = !isCustomTool && !isMcpTool && currentToolId && toolRequiresOAuth(currentToolId) const oauthConfig = !isCustomTool && !isMcpTool && currentToolId ? getToolOAuthConfig(currentToolId) : null - // Determine if tool has expandable body content const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type) - const filteredDisplayParams = displayParams.filter((param) => - evaluateParameterCondition(param, tool) - ) - const hasToolBody = - hasOperations || (requiresOAuth && oauthConfig) || filteredDisplayParams.length > 0 + const hasParams = useSubBlocks + ? displaySubBlocks.length > 0 + : displayParams.filter((param) => evaluateParameterCondition(param, tool)).length > 0 + const hasToolBody = hasOperations || (requiresOAuth && oauthConfig) || hasParams - // Only show expansion if tool has body content const isExpandedForDisplay = hasToolBody ? isPreview ? (previewExpanded[toolIndex] ?? !!tool.isExpanded) @@ -2643,7 +1716,6 @@ export const ToolInput = memo(function ToolInput({ {!isCustomTool && isExpandedForDisplay && (
- {/* Operation dropdown for tools with multiple operations */} {(() => { const hasOperations = hasMultipleOperations(tool.type) const operationOptions = hasOperations ? getOperationOptions(tool.type) : [] @@ -2669,23 +1741,23 @@ export const ToolInput = memo(function ToolInput({ ) : null })()} - {/* OAuth credential selector if required */} {requiresOAuth && oauthConfig && (
- Account + Account *
handleParamChange(toolIndex, 'credential', value)} + onChange={(value: string) => + handleParamChange(toolIndex, 'credential', value) + } provider={oauthConfig.provider as OAuthProvider} requiredScopes={ toolBlock?.subBlocks?.find((sb) => sb.id === 'credential') ?.requiredScopes || getCanonicalScopesForProvider(oauthConfig.provider) } - label={`Select ${oauthConfig.provider} account`} serviceId={oauthConfig.provider} disabled={disabled} /> @@ -2693,119 +1765,141 @@ export const ToolInput = memo(function ToolInput({
)} - {/* Tool parameters */} {(() => { - const filteredParams = displayParams.filter((param) => - evaluateParameterCondition(param, tool) - ) - const groupedParams: { [key: string]: ToolParameterConfig[] } = {} - const standaloneParams: ToolParameterConfig[] = [] - - // Group checkbox-list parameters by their UI component title - filteredParams.forEach((param) => { - const paramConfig = param as ToolParameterConfig - if ( - paramConfig.uiComponent?.type === 'checkbox-list' && - paramConfig.uiComponent?.title - ) { - const groupKey = paramConfig.uiComponent.title - if (!groupedParams[groupKey]) { - groupedParams[groupKey] = [] - } - groupedParams[groupKey].push(paramConfig) - } else { - standaloneParams.push(paramConfig) - } - }) - const renderedElements: React.ReactNode[] = [] - // Render grouped checkbox-lists - Object.entries(groupedParams).forEach(([groupTitle, params]) => { - const firstParam = params[0] as ToolParameterConfig - const groupValue = JSON.stringify( - params.reduce( - (acc, p) => ({ ...acc, [p.id]: tool.params?.[p.id] === 'true' }), - {} + if (useSubBlocks && displaySubBlocks.length > 0) { + const coveredParamIds = new Set( + displaySubBlocks.flatMap((sb) => { + const ids = [sb.id] + if (sb.canonicalParamId) ids.push(sb.canonicalParamId) + const cId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id] + if (cId) { + const group = toolCanonicalIndex?.groupsById[cId] + if (group) { + if (group.basicId) ids.push(group.basicId) + ids.push(...group.advancedIds) + } + } + return ids + }) + ) + + displaySubBlocks.forEach((sb) => { + const effectiveParamId = sb.id + const canonicalId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id] + const canonicalGroup = canonicalId + ? toolCanonicalIndex?.groupsById[canonicalId] + : undefined + const hasCanonicalPair = isCanonicalPair(canonicalGroup) + const canonicalMode = + canonicalGroup && hasCanonicalPair + ? resolveCanonicalMode( + canonicalGroup, + { operation: tool.operation, ...tool.params }, + toolScopedOverrides + ) + : undefined + + const canonicalToggleProp = + hasCanonicalPair && canonicalMode && canonicalId + ? { + mode: canonicalMode, + onToggle: () => { + const nextMode = + canonicalMode === 'advanced' ? 'basic' : 'advanced' + collaborativeSetBlockCanonicalMode( + blockId, + `${tool.type}:${canonicalId}`, + nextMode + ) + }, + } + : undefined + + const sbWithTitle = sb.title + ? sb + : { ...sb, title: formatParameterLabel(effectiveParamId) } + + renderedElements.push( + ) + }) + + const uncoveredParams = displayParams.filter( + (param) => + !coveredParamIds.has(param.id) && evaluateParameterCondition(param, tool) ) - renderedElements.push( -
-
- {groupTitle} -
-
- { - try { - const parsed = JSON.parse(value) - params.forEach((param) => { - handleParamChange( - toolIndex, - param.id, - parsed[param.id] ? 'true' : 'false' - ) - }) - } catch (e) { - // Handle error - } - }} - uiComponent={firstParam.uiComponent} - disabled={disabled} - /> -
-
- ) - }) - - // Render standalone parameters - standaloneParams.forEach((param) => { - renderedElements.push( -
-
- {param.uiComponent?.title || formatParameterLabel(param.id)} - {param.required && param.visibility === 'user-only' && ( - * - )} - {param.visibility === 'user-or-llm' && ( - - (optional) - - )} -
-
- {param.uiComponent ? ( + uncoveredParams.forEach((param) => { + renderedElements.push( + + {(wandControlRef: React.MutableRefObject) => renderParameterInput( param, tool.params?.[param.id] || '', (value) => handleParamChange(toolIndex, param.id, value), toolIndex, - toolContextValues as Record + toolContextValues as Record, + wandControlRef ) - ) : ( - handleParamChange(toolIndex, param.id, value)} - /> - )} -
-
+ } + + ) + }) + + return ( +
{renderedElements}
+ ) + } + + const filteredParams = displayParams.filter((param) => + evaluateParameterCondition(param, tool) + ) + + filteredParams.forEach((param) => { + renderedElements.push( + + {(wandControlRef: React.MutableRefObject) => + renderParameterInput( + param, + tool.params?.[param.id] || '', + (value) => handleParamChange(toolIndex, param.id, value), + toolIndex, + toolContextValues as Record, + wandControlRef + ) + } + ) }) @@ -2817,7 +1911,6 @@ export const ToolInput = memo(function ToolInput({ ) })} - {/* Custom Tool Modal */} { @@ -2831,11 +1924,9 @@ export const ToolInput = memo(function ToolInput({ editingToolIndex !== null && selectedTools[editingToolIndex]?.type === 'custom-tool' ? (() => { const storedTool = selectedTools[editingToolIndex] - // Resolve the full tool definition from reference or inline const resolved = resolveCustomToolFromReference(storedTool, customTools) if (resolved) { - // Find the database ID const dbTool = storedTool.customToolId ? customTools.find((t) => t.id === storedTool.customToolId) : customTools.find( @@ -2849,7 +1940,6 @@ export const ToolInput = memo(function ToolInput({ } } - // Fallback to inline definition (legacy format) return { id: customTools.find( (tool) => tool.schema?.function?.name === storedTool.schema?.function?.name diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types.ts new file mode 100644 index 000000000..138b6a562 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types.ts @@ -0,0 +1,31 @@ +/** + * Represents a tool selected and configured in the workflow + * + * @remarks + * For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded. + * Everything else (title, schema, code) is loaded dynamically from the database. + * Legacy custom tools with inline schema/code are still supported for backwards compatibility. + */ +export interface StoredTool { + /** Block type identifier */ + type: string + /** Display title for the tool (optional for new custom tool format) */ + title?: string + /** Direct tool ID for execution (optional for new custom tool format) */ + toolId?: string + /** Parameter values configured by the user (optional for new custom tool format) */ + params?: Record + /** Whether the tool details are expanded in UI */ + isExpanded?: boolean + /** Database ID for custom tools (new format - reference only) */ + customToolId?: string + /** Tool schema for custom tools (legacy format - inline JSON schema) */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + schema?: Record + /** Implementation code for custom tools (legacy format - inline) */ + code?: string + /** Selected operation for multi-operation tools */ + operation?: string + /** Tool usage control mode for LLM */ + usageControl?: 'auto' | 'force' | 'none' +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils.ts new file mode 100644 index 000000000..1110a5808 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils.ts @@ -0,0 +1,32 @@ +import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types' + +/** + * Checks if an MCP tool is already selected. + */ +export function isMcpToolAlreadySelected(selectedTools: StoredTool[], mcpToolId: string): boolean { + return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId) +} + +/** + * Checks if a custom tool is already selected. + */ +export function isCustomToolAlreadySelected( + selectedTools: StoredTool[], + customToolId: string +): boolean { + return selectedTools.some( + (tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId + ) +} + +/** + * Checks if a workflow is already selected. + */ +export function isWorkflowAlreadySelected( + selectedTools: StoredTool[], + workflowId: string +): boolean { + return selectedTools.some( + (tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index c8422f0e7..180b8bb12 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -3,7 +3,6 @@ import { isEqual } from 'lodash' import { AlertTriangle, ArrowLeftRight, ArrowUp, Check, Clipboard } from 'lucide-react' import { Button, Input, Label, Tooltip } from '@/components/emcn/components' import { cn } from '@/lib/core/utils/cn' -import type { FieldDiffStatus } from '@/lib/workflows/diff/types' import { CheckboxList, Code, @@ -69,13 +68,15 @@ interface SubBlockProps { isPreview?: boolean subBlockValues?: Record disabled?: boolean - fieldDiffStatus?: FieldDiffStatus allowExpandInPreview?: boolean canonicalToggle?: { mode: 'basic' | 'advanced' disabled?: boolean onToggle?: () => void } + labelSuffix?: React.ReactNode + /** Provides sibling values for dependency resolution in non-preview contexts (e.g. tool-input) */ + dependencyContext?: Record } /** @@ -162,16 +163,14 @@ const getPreviewValue = ( /** * Renders the label with optional validation and description tooltips. * - * @remarks - * Handles JSON validation indicators for code blocks and required field markers. - * Includes inline AI generate button when wand is enabled. - * * @param config - The sub-block configuration defining the label content * @param isValidJson - Whether the JSON content is valid (for code blocks) * @param subBlockValues - Current values of all subblocks for evaluating conditional requirements - * @param wandState - Optional state and handlers for the AI wand feature - * @param canonicalToggle - Optional canonical toggle metadata and handlers - * @param canonicalToggleIsDisabled - Whether the canonical toggle is disabled + * @param wandState - State and handlers for the inline AI generate feature + * @param canonicalToggle - Metadata and handlers for the basic/advanced mode toggle + * @param canonicalToggleIsDisabled - Whether the canonical toggle is disabled (includes dependsOn gating) + * @param copyState - State and handler for the copy-to-clipboard button + * @param labelSuffix - Additional content rendered after the label text * @returns The label JSX element, or `null` for switch types or when no title is defined */ const renderLabel = ( @@ -202,7 +201,8 @@ const renderLabel = ( showCopyButton: boolean copied: boolean onCopy: () => void - } + }, + labelSuffix?: React.ReactNode ): JSX.Element | null => { if (config.type === 'switch') return null if (!config.title) return null @@ -215,9 +215,10 @@ const renderLabel = ( return (
-
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index a7a5d7c38..9f1905c83 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -571,7 +571,6 @@ export function Editor() { isPreview={false} subBlockValues={subBlockState} disabled={!canEditBlock} - fieldDiffStatus={undefined} allowExpandInPreview={false} canonicalToggle={ isCanonicalSwap && canonicalMode && canonicalId @@ -635,7 +634,6 @@ export function Editor() { isPreview={false} subBlockValues={subBlockState} disabled={!canEditBlock} - fieldDiffStatus={undefined} allowExpandInPreview={false} /> {index < advancedOnlySubBlocks.length - 1 && ( diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 08a716925..8ac262bef 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -196,6 +196,8 @@ export interface SubBlockConfig { type: SubBlockType mode?: 'basic' | 'advanced' | 'both' | 'trigger' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode canonicalParamId?: string + /** Controls parameter visibility in agent/tool-input context */ + paramVisibility?: 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden' required?: | boolean | { diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 2b370471a..f87b3cfde 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -62,9 +62,12 @@ export class AgentBlockHandler implements BlockHandler { await validateModelProvider(ctx.userId, model, ctx) const providerId = getProviderFromModel(model) - const formattedTools = await this.formatTools(ctx, filteredInputs.tools || []) + const formattedTools = await this.formatTools( + ctx, + filteredInputs.tools || [], + block.canonicalModes + ) - // Resolve skill metadata for progressive disclosure const skillInputs = filteredInputs.skills ?? [] let skillMetadata: Array<{ name: string; description: string }> = [] if (skillInputs.length > 0 && ctx.workspaceId) { @@ -221,7 +224,11 @@ export class AgentBlockHandler implements BlockHandler { }) } - private async formatTools(ctx: ExecutionContext, inputTools: ToolInput[]): Promise { + private async formatTools( + ctx: ExecutionContext, + inputTools: ToolInput[], + canonicalModes?: Record + ): Promise { if (!Array.isArray(inputTools)) return [] const filtered = inputTools.filter((tool) => { @@ -249,7 +256,7 @@ export class AgentBlockHandler implements BlockHandler { if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) { return await this.createCustomTool(ctx, tool) } - return this.transformBlockTool(ctx, tool) + return this.transformBlockTool(ctx, tool, canonicalModes) } catch (error) { logger.error(`[AgentHandler] Error creating tool:`, { tool, error }) return null @@ -720,12 +727,17 @@ export class AgentBlockHandler implements BlockHandler { } } - private async transformBlockTool(ctx: ExecutionContext, tool: ToolInput) { + private async transformBlockTool( + ctx: ExecutionContext, + tool: ToolInput, + canonicalModes?: Record + ) { const transformedTool = await transformBlockTool(tool, { selectedOperation: tool.operation, getAllBlocks, getToolAsync: (toolId: string) => getToolAsync(toolId, ctx.workflowId), getTool, + canonicalModes, }) if (transformedTool) { diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 19effa8bd..5e50194c4 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -642,6 +642,10 @@ export function useDeployChildWorkflow() { queryClient.invalidateQueries({ queryKey: workflowKeys.deploymentStatus(variables.workflowId), }) + // Invalidate workflow state so tool input mappings refresh + queryClient.invalidateQueries({ + queryKey: workflowKeys.state(variables.workflowId), + }) // Also invalidate deployment queries queryClient.invalidateQueries({ queryKey: deploymentKeys.info(variables.workflowId), diff --git a/apps/sim/lib/workflows/comparison/normalize.test.ts b/apps/sim/lib/workflows/comparison/normalize.test.ts index 2cf9b925a..9aa6c9b12 100644 --- a/apps/sim/lib/workflows/comparison/normalize.test.ts +++ b/apps/sim/lib/workflows/comparison/normalize.test.ts @@ -645,6 +645,18 @@ describe('Workflow Normalization Utilities', () => { const result = filterSubBlockIds(ids) expect(result).toEqual(['signingSecret']) }) + + it.concurrent('should exclude synthetic tool-input subBlock IDs', () => { + const ids = [ + 'toolConfig', + 'toolConfig-tool-0-query', + 'toolConfig-tool-0-url', + 'toolConfig-tool-1-status', + 'systemPrompt', + ] + const result = filterSubBlockIds(ids) + expect(result).toEqual(['systemPrompt', 'toolConfig']) + }) }) describe('normalizeTriggerConfigValues', () => { diff --git a/apps/sim/lib/workflows/comparison/normalize.ts b/apps/sim/lib/workflows/comparison/normalize.ts index 4a8ce18a2..70a584141 100644 --- a/apps/sim/lib/workflows/comparison/normalize.ts +++ b/apps/sim/lib/workflows/comparison/normalize.ts @@ -411,7 +411,14 @@ export function extractBlockFieldsForComparison(block: BlockState): ExtractedBlo } /** - * Filters subBlock IDs to exclude system and trigger runtime subBlocks. + * Pattern matching synthetic subBlock IDs created by ToolSubBlockRenderer. + * These IDs follow the format `{subBlockId}-tool-{index}-{paramId}` and are + * mirrors of values already stored in toolConfig.value.tools[N].params. + */ +const SYNTHETIC_TOOL_SUBBLOCK_RE = /-tool-\d+-/ + +/** + * Filters subBlock IDs to exclude system, trigger runtime, and synthetic tool subBlocks. * * @param subBlockIds - Array of subBlock IDs to filter * @returns Filtered and sorted array of subBlock IDs @@ -422,6 +429,7 @@ export function filterSubBlockIds(subBlockIds: string[]): string[] { if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id)) return false if (SYSTEM_SUBBLOCK_IDS.some((sysId) => id === sysId || id.startsWith(`${sysId}_`))) return false + if (SYNTHETIC_TOOL_SUBBLOCK_RE.test(id)) return false return true }) .sort() diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 1f1edfe94..cb75153c5 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -112,6 +112,8 @@ export interface ProviderToolConfig { required: string[] } usageControl?: ToolUsageControl + /** Block-level params transformer — converts SubBlock values to tool-ready params */ + paramsTransform?: (params: Record) => Record } export interface Message { diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index fed88f31c..ee1b2bfc7 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -4,6 +4,12 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' import { env } from '@/lib/core/config/env' import { isHosted } from '@/lib/core/config/feature-flags' +import { + buildCanonicalIndex, + type CanonicalGroup, + getCanonicalValues, + isCanonicalPair, +} from '@/lib/workflows/subblocks/visibility' import { isCustomTool } from '@/executor/constants' import { getComputerUseModels, @@ -437,9 +443,10 @@ export async function transformBlockTool( getAllBlocks: () => any[] getTool: (toolId: string) => any getToolAsync?: (toolId: string) => Promise + canonicalModes?: Record } ): Promise { - const { selectedOperation, getAllBlocks, getTool, getToolAsync } = options + const { selectedOperation, getAllBlocks, getTool, getToolAsync, canonicalModes } = options const blockDef = getAllBlocks().find((b: any) => b.type === block.type) if (!blockDef) { @@ -516,12 +523,66 @@ export async function transformBlockTool( uniqueToolId = `${toolConfig.id}_${userProvidedParams.knowledgeBaseId}` } + const blockParamsFn = blockDef?.tools?.config?.params as + | ((p: Record) => Record) + | undefined + const blockInputDefs = blockDef?.inputs as Record | undefined + + const canonicalGroups: CanonicalGroup[] = blockDef?.subBlocks + ? Object.values(buildCanonicalIndex(blockDef.subBlocks).groupsById).filter(isCanonicalPair) + : [] + + const needsTransform = blockParamsFn || blockInputDefs || canonicalGroups.length > 0 + const paramsTransform = needsTransform + ? (params: Record): Record => { + let result = { ...params } + + for (const group of canonicalGroups) { + const { basicValue, advancedValue } = getCanonicalValues(group, result) + const scopedKey = `${block.type}:${group.canonicalId}` + const pairMode = canonicalModes?.[scopedKey] ?? 'basic' + const chosen = pairMode === 'advanced' ? advancedValue : basicValue + + const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) as string[] + sourceIds.forEach((id) => delete result[id]) + + if (chosen !== undefined) { + result[group.canonicalId] = chosen + } + } + + if (blockParamsFn) { + const transformed = blockParamsFn(result) + result = { ...result, ...transformed } + } + + if (blockInputDefs) { + for (const [key, schema] of Object.entries(blockInputDefs)) { + const value = result[key] + if (typeof value === 'string' && value.trim().length > 0) { + const inputType = typeof schema === 'object' ? schema.type : schema + if (inputType === 'json' || inputType === 'array') { + try { + result[key] = JSON.parse(value.trim()) + } catch { + // Not valid JSON — keep as string + } + } + } + } + } + + return result + } + : undefined + return { id: uniqueToolId, name: toolName, description: toolDescription, params: userProvidedParams, parameters: llmSchema, + paramsTransform, } } @@ -1028,7 +1089,11 @@ export function getMaxOutputTokensForModel(model: string): number { * Prepare tool execution parameters, separating tool parameters from system parameters */ export function prepareToolExecution( - tool: { params?: Record; parameters?: Record }, + tool: { + params?: Record + parameters?: Record + paramsTransform?: (params: Record) => Record + }, llmArgs: Record, request: { workflowId?: string @@ -1045,8 +1110,15 @@ export function prepareToolExecution( toolParams: Record executionParams: Record } { - // Use centralized merge logic from tools/params - const toolParams = mergeToolParameters(tool.params || {}, llmArgs) as Record + let toolParams = mergeToolParameters(tool.params || {}, llmArgs) as Record + + if (tool.paramsTransform) { + try { + toolParams = tool.paramsTransform(toolParams) + } catch (err) { + logger.warn('paramsTransform failed, using raw params', { error: err }) + } + } const executionParams = { ...toolParams, diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index 622667d9f..35b675d22 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -280,7 +280,7 @@ export class Serializer { }) } - return { + const serialized: SerializedBlock = { id: block.id, position: block.position, config: { @@ -300,6 +300,12 @@ export class Serializer { }, enabled: block.enabled, } + + if (block.data?.canonicalModes) { + serialized.canonicalModes = block.data.canonicalModes as Record + } + + return serialized } private extractParams(block: BlockState): Record { diff --git a/apps/sim/serializer/types.ts b/apps/sim/serializer/types.ts index 4f89bfb71..8192014a4 100644 --- a/apps/sim/serializer/types.ts +++ b/apps/sim/serializer/types.ts @@ -38,6 +38,8 @@ export interface SerializedBlock { color?: string } enabled: boolean + /** Canonical mode overrides from block.data (used by agent handler for tool param resolution) */ + canonicalModes?: Record } export interface SerializedLoop { diff --git a/apps/sim/tools/file/parser.ts b/apps/sim/tools/file/parser.ts index bcd8826d2..e7740bfa5 100644 --- a/apps/sim/tools/file/parser.ts +++ b/apps/sim/tools/file/parser.ts @@ -95,7 +95,7 @@ export const fileParserTool: ToolConfig = { filePath: { type: 'string', required: false, - visibility: 'user-only', + visibility: 'hidden', description: 'Path to the file(s). Can be a single path, URL, or an array of paths.', }, file: { diff --git a/apps/sim/tools/jira/add_attachment.ts b/apps/sim/tools/jira/add_attachment.ts index 260bcc029..bd890e509 100644 --- a/apps/sim/tools/jira/add_attachment.ts +++ b/apps/sim/tools/jira/add_attachment.ts @@ -36,7 +36,7 @@ export const jiraAddAttachmentTool: ToolConfig, + canonicalModeOverrides?: CanonicalModeOverrides +): SubBlocksForToolInput | null { + try { + const toolConfig = getTool(toolId) + if (!toolConfig) { + logger.warn(`Tool not found: ${toolId}`) + return null + } + + const blockConfigs = getBlockConfigurations() + const blockConfig = blockConfigs[blockType] + if (!blockConfig?.subBlocks?.length) { + return null + } + + const allSubBlocks = blockConfig.subBlocks as BlockSubBlockConfig[] + const canonicalIndex = buildCanonicalIndex(allSubBlocks) + + // Build values for condition evaluation + const values = currentValues || {} + const valuesWithOperation = { ...values } + if (valuesWithOperation.operation === undefined) { + const parts = toolId.split('_') + valuesWithOperation.operation = + parts.length >= 3 ? parts.slice(2).join('_') : parts[parts.length - 1] + } + + // Build a map of tool param IDs to their resolved visibility + const toolParamVisibility: Record = {} + for (const [paramId, param] of Object.entries(toolConfig.params || {})) { + toolParamVisibility[paramId] = + param.visibility ?? (param.required ? 'user-or-llm' : 'user-only') + } + + // Track which canonical groups we've already included (to avoid duplicates) + const includedCanonicalIds = new Set() + + const filtered: BlockSubBlockConfig[] = [] + + for (const sb of allSubBlocks) { + // Skip excluded types + if (EXCLUDED_SUBBLOCK_TYPES.has(sb.type)) continue + + // Skip trigger-mode-only subblocks + if (sb.mode === 'trigger') continue + + // Determine the effective param ID (canonical or subblock id) + const effectiveParamId = sb.canonicalParamId || sb.id + + // Resolve paramVisibility: explicit > inferred from tool params > skip + let visibility = sb.paramVisibility + if (!visibility) { + // Infer from structural checks + if (STRUCTURAL_SUBBLOCK_IDS.has(sb.id)) { + visibility = 'hidden' + } else if (AUTH_SUBBLOCK_TYPES.has(sb.type)) { + visibility = 'hidden' + } else if ( + sb.password && + (sb.id === 'botToken' || sb.id === 'accessToken' || sb.id === 'apiKey') + ) { + // Auth tokens without explicit paramVisibility are hidden + // (they're handled by the OAuth credential selector or structurally) + // But only if they don't have a matching tool param + if (!(sb.id in toolParamVisibility)) { + visibility = 'hidden' + } else { + visibility = toolParamVisibility[sb.id] || 'user-or-llm' + } + } else if (effectiveParamId in toolParamVisibility) { + // Fallback: infer from tool param visibility + visibility = toolParamVisibility[effectiveParamId] + } else if (sb.id in toolParamVisibility) { + visibility = toolParamVisibility[sb.id] + } else if (sb.canonicalParamId) { + // SubBlock has a canonicalParamId that doesn't directly match a tool param. + // This means the block's params() function transforms it before sending to the tool + // (e.g. listFolderId → folderId). These are user-facing inputs, default to user-or-llm. + visibility = 'user-or-llm' + } else { + // SubBlock has no corresponding tool param — skip it + continue + } + } + + // Filter by visibility: exclude hidden and llm-only + if (visibility === 'hidden' || visibility === 'llm-only') continue + + // Evaluate condition against current values + if (sb.condition) { + const conditionMet = evaluateSubBlockCondition( + sb.condition as SubBlockCondition, + valuesWithOperation + ) + if (!conditionMet) continue + } + + // Handle canonical pairs: only include the active mode variant + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[sb.id] + if (canonicalId) { + const group = canonicalIndex.groupsById[canonicalId] + if (group && isCanonicalPair(group)) { + if (includedCanonicalIds.has(canonicalId)) continue + includedCanonicalIds.add(canonicalId) + + // Determine active mode + const mode = resolveCanonicalMode(group, valuesWithOperation, canonicalModeOverrides) + if (mode === 'advanced') { + // Find the advanced variant + const advancedSb = allSubBlocks.find((s) => group.advancedIds.includes(s.id)) + if (advancedSb) { + filtered.push({ ...advancedSb, paramVisibility: visibility }) + } + } else { + // Include basic variant (current sb if it's the basic one) + if (group.basicId === sb.id) { + filtered.push({ ...sb, paramVisibility: visibility }) + } else { + const basicSb = allSubBlocks.find((s) => s.id === group.basicId) + if (basicSb) { + filtered.push({ ...basicSb, paramVisibility: visibility }) + } + } + } + continue + } + } + + // Non-canonical, non-hidden, condition-passing subblock + filtered.push({ ...sb, paramVisibility: visibility }) + } + + return { + toolConfig, + subBlocks: filtered, + oauthConfig: toolConfig.oauth, + } + } catch (error) { + logger.error('Error getting subblocks for tool input:', error) + return null + } +} diff --git a/apps/sim/tools/pulse/parser.ts b/apps/sim/tools/pulse/parser.ts index 805d998ec..182801963 100644 --- a/apps/sim/tools/pulse/parser.ts +++ b/apps/sim/tools/pulse/parser.ts @@ -18,7 +18,7 @@ export const pulseParserTool: ToolConfig = file: { type: 'file', required: false, - visibility: 'hidden', + visibility: 'user-only', description: 'Document file to be processed', }, fileUpload: { @@ -268,7 +268,7 @@ export const pulseParserV2Tool: ToolConfig = { files: { type: 'file[]', required: false, - visibility: 'hidden', + visibility: 'user-only', description: 'Files to upload', }, fileContent: { diff --git a/apps/sim/tools/vision/tool.ts b/apps/sim/tools/vision/tool.ts index 02dba60f2..01d0b9399 100644 --- a/apps/sim/tools/vision/tool.ts +++ b/apps/sim/tools/vision/tool.ts @@ -106,7 +106,7 @@ export const visionToolV2: ToolConfig = { imageFile: { type: 'file', required: true, - visibility: 'hidden', + visibility: 'user-only', description: 'Image file to analyze', }, model: visionTool.params.model, diff --git a/apps/sim/tools/wordpress/upload_media.ts b/apps/sim/tools/wordpress/upload_media.ts index 50bc57eef..7115346aa 100644 --- a/apps/sim/tools/wordpress/upload_media.ts +++ b/apps/sim/tools/wordpress/upload_media.ts @@ -27,7 +27,7 @@ export const uploadMediaTool: ToolConfig Date: Thu, 12 Feb 2026 20:07:40 -0800 Subject: [PATCH 11/13] feat(creators): added referrers, code redemption, campaign tracking, etc (#3198) * feat(creators): added referrers, code redemption, campaign tracking, etc * more * added zod * remove default * remove duplicate index * update admin routes * reran migrations * lint * move userstats record creation inside tx * added reason for already attributed case * cleanup referral attributes --- apps/sim/app/api/attribution/route.ts | 187 + .../sim/app/api/referral-code/redeem/route.ts | 170 + apps/sim/app/api/v1/admin/index.ts | 9 + .../v1/admin/referral-campaigns/[id]/route.ts | 142 + .../api/v1/admin/referral-campaigns/route.ts | 140 + apps/sim/app/api/v1/admin/types.ts | 48 + .../subscription/components/index.ts | 1 + .../components/referral-code/index.ts | 1 + .../referral-code/referral-code.tsx | 103 + .../components/subscription/subscription.tsx | 5 + apps/sim/app/workspace/page.tsx | 2 + apps/sim/hooks/use-referral-attribution.ts | 46 + apps/sim/lib/billing/credits/bonus.ts | 64 + apps/sim/proxy.ts | 35 +- .../db/migrations/0154_bumpy_living_mummy.sql | 41 + .../db/migrations/meta/0154_snapshot.json | 10957 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 55 + 18 files changed, 12012 insertions(+), 1 deletion(-) create mode 100644 apps/sim/app/api/attribution/route.ts create mode 100644 apps/sim/app/api/referral-code/redeem/route.ts create mode 100644 apps/sim/app/api/v1/admin/referral-campaigns/[id]/route.ts create mode 100644 apps/sim/app/api/v1/admin/referral-campaigns/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/referral-code/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/referral-code/referral-code.tsx create mode 100644 apps/sim/hooks/use-referral-attribution.ts create mode 100644 apps/sim/lib/billing/credits/bonus.ts create mode 100644 packages/db/migrations/0154_bumpy_living_mummy.sql create mode 100644 packages/db/migrations/meta/0154_snapshot.json diff --git a/apps/sim/app/api/attribution/route.ts b/apps/sim/app/api/attribution/route.ts new file mode 100644 index 000000000..5fb352482 --- /dev/null +++ b/apps/sim/app/api/attribution/route.ts @@ -0,0 +1,187 @@ +/** + * POST /api/attribution + * + * Automatic UTM-based referral attribution. + * + * Reads the `sim_utm` cookie (set by proxy on auth pages), matches a campaign + * by UTM specificity, and atomically inserts an attribution record + applies + * bonus credits. + * + * Idempotent — the unique constraint on `userId` prevents double-attribution. + */ + +import { db } from '@sim/db' +import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { cookies } from 'next/headers' +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { applyBonusCredits } from '@/lib/billing/credits/bonus' + +const logger = createLogger('AttributionAPI') + +const COOKIE_NAME = 'sim_utm' + +const UtmCookieSchema = z.object({ + utm_source: z.string().optional(), + utm_medium: z.string().optional(), + utm_campaign: z.string().optional(), + utm_content: z.string().optional(), + referrer_url: z.string().optional(), + landing_page: z.string().optional(), + created_at: z.string().optional(), +}) + +/** + * Finds the most specific active campaign matching the given UTM params. + * Null fields on a campaign act as wildcards. Ties broken by newest campaign. + */ +async function findMatchingCampaign(utmData: z.infer) { + const campaigns = await db + .select() + .from(referralCampaigns) + .where(eq(referralCampaigns.isActive, true)) + + let bestMatch: (typeof campaigns)[number] | null = null + let bestScore = -1 + + for (const campaign of campaigns) { + let score = 0 + let mismatch = false + + const fields = [ + { campaignVal: campaign.utmSource, utmVal: utmData.utm_source }, + { campaignVal: campaign.utmMedium, utmVal: utmData.utm_medium }, + { campaignVal: campaign.utmCampaign, utmVal: utmData.utm_campaign }, + { campaignVal: campaign.utmContent, utmVal: utmData.utm_content }, + ] as const + + for (const { campaignVal, utmVal } of fields) { + if (campaignVal === null) continue + if (campaignVal === utmVal) { + score++ + } else { + mismatch = true + break + } + } + + if (!mismatch && score > 0) { + if ( + score > bestScore || + (score === bestScore && + bestMatch && + campaign.createdAt.getTime() > bestMatch.createdAt.getTime()) + ) { + bestScore = score + bestMatch = campaign + } + } + } + + return bestMatch +} + +export async function POST() { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const cookieStore = await cookies() + const utmCookie = cookieStore.get(COOKIE_NAME) + if (!utmCookie?.value) { + return NextResponse.json({ attributed: false, reason: 'no_utm_cookie' }) + } + + let utmData: z.infer + try { + let decoded: string + try { + decoded = decodeURIComponent(utmCookie.value) + } catch { + decoded = utmCookie.value + } + utmData = UtmCookieSchema.parse(JSON.parse(decoded)) + } catch { + logger.warn('Failed to parse UTM cookie', { userId: session.user.id }) + cookieStore.delete(COOKIE_NAME) + return NextResponse.json({ attributed: false, reason: 'invalid_cookie' }) + } + + const matchedCampaign = await findMatchingCampaign(utmData) + if (!matchedCampaign) { + cookieStore.delete(COOKIE_NAME) + return NextResponse.json({ attributed: false, reason: 'no_matching_campaign' }) + } + + const bonusAmount = Number(matchedCampaign.bonusCreditAmount) + + let attributed = false + await db.transaction(async (tx) => { + const [existingStats] = await tx + .select({ id: userStats.id }) + .from(userStats) + .where(eq(userStats.userId, session.user.id)) + .limit(1) + + if (!existingStats) { + await tx.insert(userStats).values({ + id: nanoid(), + userId: session.user.id, + }) + } + + const result = await tx + .insert(referralAttribution) + .values({ + id: nanoid(), + userId: session.user.id, + campaignId: matchedCampaign.id, + utmSource: utmData.utm_source || null, + utmMedium: utmData.utm_medium || null, + utmCampaign: utmData.utm_campaign || null, + utmContent: utmData.utm_content || null, + referrerUrl: utmData.referrer_url || null, + landingPage: utmData.landing_page || null, + bonusCreditAmount: bonusAmount.toString(), + }) + .onConflictDoNothing({ target: referralAttribution.userId }) + .returning({ id: referralAttribution.id }) + + if (result.length > 0) { + await applyBonusCredits(session.user.id, bonusAmount, tx) + attributed = true + } + }) + + if (attributed) { + logger.info('Referral attribution created and bonus credits applied', { + userId: session.user.id, + campaignId: matchedCampaign.id, + campaignName: matchedCampaign.name, + utmSource: utmData.utm_source, + utmCampaign: utmData.utm_campaign, + utmContent: utmData.utm_content, + bonusAmount, + }) + } else { + logger.info('User already attributed, skipping', { userId: session.user.id }) + } + + cookieStore.delete(COOKIE_NAME) + + return NextResponse.json({ + attributed, + bonusAmount: attributed ? bonusAmount : undefined, + reason: attributed ? undefined : 'already_attributed', + }) + } catch (error) { + logger.error('Attribution error', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/referral-code/redeem/route.ts b/apps/sim/app/api/referral-code/redeem/route.ts new file mode 100644 index 000000000..be3cbac90 --- /dev/null +++ b/apps/sim/app/api/referral-code/redeem/route.ts @@ -0,0 +1,170 @@ +/** + * POST /api/referral-code/redeem + * + * Redeem a referral/promo code to receive bonus credits. + * + * Body: + * - code: string — The referral code to redeem + * + * Response: { redeemed: boolean, bonusAmount?: number, error?: string } + * + * Constraints: + * - Enterprise users cannot redeem codes + * - One redemption per user, ever (unique constraint on userId) + * - One redemption per organization for team users (partial unique on organizationId) + */ + +import { db } from '@sim/db' +import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { applyBonusCredits } from '@/lib/billing/credits/bonus' + +const logger = createLogger('ReferralCodeRedemption') + +const RedeemCodeSchema = z.object({ + code: z.string().min(1, 'Code is required'), +}) + +export async function POST(request: Request) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { code } = RedeemCodeSchema.parse(body) + + const subscription = await getHighestPrioritySubscription(session.user.id) + + if (subscription?.plan === 'enterprise') { + return NextResponse.json({ + redeemed: false, + error: 'Enterprise accounts cannot redeem referral codes', + }) + } + + const isTeam = subscription?.plan === 'team' + const orgId = isTeam ? subscription.referenceId : null + + const normalizedCode = code.trim().toUpperCase() + + const [campaign] = await db + .select() + .from(referralCampaigns) + .where(and(eq(referralCampaigns.code, normalizedCode), eq(referralCampaigns.isActive, true))) + .limit(1) + + if (!campaign) { + logger.info('Invalid code redemption attempt', { + userId: session.user.id, + code: normalizedCode, + }) + return NextResponse.json({ error: 'Invalid or expired code' }, { status: 404 }) + } + + const [existingUserAttribution] = await db + .select({ id: referralAttribution.id }) + .from(referralAttribution) + .where(eq(referralAttribution.userId, session.user.id)) + .limit(1) + + if (existingUserAttribution) { + return NextResponse.json({ + redeemed: false, + error: 'You have already redeemed a code', + }) + } + + if (orgId) { + const [existingOrgAttribution] = await db + .select({ id: referralAttribution.id }) + .from(referralAttribution) + .where(eq(referralAttribution.organizationId, orgId)) + .limit(1) + + if (existingOrgAttribution) { + return NextResponse.json({ + redeemed: false, + error: 'A code has already been redeemed for your organization', + }) + } + } + + const bonusAmount = Number(campaign.bonusCreditAmount) + + let redeemed = false + await db.transaction(async (tx) => { + const [existingStats] = await tx + .select({ id: userStats.id }) + .from(userStats) + .where(eq(userStats.userId, session.user.id)) + .limit(1) + + if (!existingStats) { + await tx.insert(userStats).values({ + id: nanoid(), + userId: session.user.id, + }) + } + + const result = await tx + .insert(referralAttribution) + .values({ + id: nanoid(), + userId: session.user.id, + organizationId: orgId, + campaignId: campaign.id, + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmContent: null, + referrerUrl: null, + landingPage: null, + bonusCreditAmount: bonusAmount.toString(), + }) + .onConflictDoNothing() + .returning({ id: referralAttribution.id }) + + if (result.length > 0) { + await applyBonusCredits(session.user.id, bonusAmount, tx) + redeemed = true + } + }) + + if (redeemed) { + logger.info('Referral code redeemed', { + userId: session.user.id, + organizationId: orgId, + code: normalizedCode, + campaignId: campaign.id, + campaignName: campaign.name, + bonusAmount, + }) + } + + if (!redeemed) { + return NextResponse.json({ + redeemed: false, + error: 'You have already redeemed a code', + }) + } + + return NextResponse.json({ + redeemed: true, + bonusAmount, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + logger.error('Referral code redemption error', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/v1/admin/index.ts b/apps/sim/app/api/v1/admin/index.ts index fe6006fad..a22b8e4f3 100644 --- a/apps/sim/app/api/v1/admin/index.ts +++ b/apps/sim/app/api/v1/admin/index.ts @@ -66,6 +66,12 @@ * Credits: * POST /api/v1/admin/credits - Issue credits to user (by userId or email) * + * Referral Campaigns: + * GET /api/v1/admin/referral-campaigns - List campaigns (?active=true/false) + * POST /api/v1/admin/referral-campaigns - Create campaign + * GET /api/v1/admin/referral-campaigns/:id - Get campaign details + * PATCH /api/v1/admin/referral-campaigns/:id - Update campaign fields + * * Access Control (Permission Groups): * GET /api/v1/admin/access-control - List permission groups (?organizationId=X) * DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X) @@ -97,6 +103,7 @@ export type { AdminOrganization, AdminOrganizationBillingSummary, AdminOrganizationDetail, + AdminReferralCampaign, AdminSeatAnalytics, AdminSingleResponse, AdminSubscription, @@ -111,6 +118,7 @@ export type { AdminWorkspaceMember, DbMember, DbOrganization, + DbReferralCampaign, DbSubscription, DbUser, DbUserStats, @@ -139,6 +147,7 @@ export { parseWorkflowVariables, toAdminFolder, toAdminOrganization, + toAdminReferralCampaign, toAdminSubscription, toAdminUser, toAdminWorkflow, diff --git a/apps/sim/app/api/v1/admin/referral-campaigns/[id]/route.ts b/apps/sim/app/api/v1/admin/referral-campaigns/[id]/route.ts new file mode 100644 index 000000000..45f0a230a --- /dev/null +++ b/apps/sim/app/api/v1/admin/referral-campaigns/[id]/route.ts @@ -0,0 +1,142 @@ +/** + * GET /api/v1/admin/referral-campaigns/:id + * + * Get a single referral campaign by ID. + * + * PATCH /api/v1/admin/referral-campaigns/:id + * + * Update campaign fields. All fields are optional. + * + * Body: + * - name: string (non-empty) - Campaign name + * - bonusCreditAmount: number (> 0) - Bonus credits in dollars + * - isActive: boolean - Enable/disable the campaign + * - code: string | null (min 6 chars, auto-uppercased, null to remove) - Redeemable code + * - utmSource: string | null - UTM source match (null = wildcard) + * - utmMedium: string | null - UTM medium match (null = wildcard) + * - utmCampaign: string | null - UTM campaign match (null = wildcard) + * - utmContent: string | null - UTM content match (null = wildcard) + */ + +import { db } from '@sim/db' +import { referralCampaigns } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' +import { + badRequestResponse, + internalErrorResponse, + notFoundResponse, + singleResponse, +} from '@/app/api/v1/admin/responses' +import { toAdminReferralCampaign } from '@/app/api/v1/admin/types' + +const logger = createLogger('AdminReferralCampaignDetailAPI') + +interface RouteParams { + id: string +} + +export const GET = withAdminAuthParams(async (_, context) => { + try { + const { id: campaignId } = await context.params + + const [campaign] = await db + .select() + .from(referralCampaigns) + .where(eq(referralCampaigns.id, campaignId)) + .limit(1) + + if (!campaign) { + return notFoundResponse('Campaign') + } + + logger.info(`Admin API: Retrieved referral campaign ${campaignId}`) + + return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl())) + } catch (error) { + logger.error('Admin API: Failed to get referral campaign', { error }) + return internalErrorResponse('Failed to get referral campaign') + } +}) + +export const PATCH = withAdminAuthParams(async (request, context) => { + try { + const { id: campaignId } = await context.params + const body = await request.json() + + const [existing] = await db + .select() + .from(referralCampaigns) + .where(eq(referralCampaigns.id, campaignId)) + .limit(1) + + if (!existing) { + return notFoundResponse('Campaign') + } + + const updateData: Record = { updatedAt: new Date() } + + if (body.name !== undefined) { + if (typeof body.name !== 'string' || body.name.trim().length === 0) { + return badRequestResponse('name must be a non-empty string') + } + updateData.name = body.name.trim() + } + + if (body.bonusCreditAmount !== undefined) { + if ( + typeof body.bonusCreditAmount !== 'number' || + !Number.isFinite(body.bonusCreditAmount) || + body.bonusCreditAmount <= 0 + ) { + return badRequestResponse('bonusCreditAmount must be a positive number') + } + updateData.bonusCreditAmount = body.bonusCreditAmount.toString() + } + + if (body.isActive !== undefined) { + if (typeof body.isActive !== 'boolean') { + return badRequestResponse('isActive must be a boolean') + } + updateData.isActive = body.isActive + } + + if (body.code !== undefined) { + if (body.code !== null) { + if (typeof body.code !== 'string') { + return badRequestResponse('code must be a string or null') + } + if (body.code.trim().length < 6) { + return badRequestResponse('code must be at least 6 characters') + } + } + updateData.code = body.code ? body.code.trim().toUpperCase() : null + } + + for (const field of ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent'] as const) { + if (body[field] !== undefined) { + if (body[field] !== null && typeof body[field] !== 'string') { + return badRequestResponse(`${field} must be a string or null`) + } + updateData[field] = body[field] || null + } + } + + const [updated] = await db + .update(referralCampaigns) + .set(updateData) + .where(eq(referralCampaigns.id, campaignId)) + .returning() + + logger.info(`Admin API: Updated referral campaign ${campaignId}`, { + fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), + }) + + return singleResponse(toAdminReferralCampaign(updated, getBaseUrl())) + } catch (error) { + logger.error('Admin API: Failed to update referral campaign', { error }) + return internalErrorResponse('Failed to update referral campaign') + } +}) diff --git a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts new file mode 100644 index 000000000..64b711eeb --- /dev/null +++ b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts @@ -0,0 +1,140 @@ +/** + * GET /api/v1/admin/referral-campaigns + * + * List referral campaigns with optional filtering and pagination. + * + * Query Parameters: + * - active: string (optional) - Filter by active status ('true' or 'false') + * - limit: number (default: 50, max: 250) + * - offset: number (default: 0) + * + * POST /api/v1/admin/referral-campaigns + * + * Create a new referral campaign. + * + * Body: + * - name: string (required) - Campaign name + * - bonusCreditAmount: number (required, > 0) - Bonus credits in dollars + * - code: string | null (optional, min 6 chars, auto-uppercased) - Redeemable code + * - utmSource: string | null (optional) - UTM source match (null = wildcard) + * - utmMedium: string | null (optional) - UTM medium match (null = wildcard) + * - utmCampaign: string | null (optional) - UTM campaign match (null = wildcard) + * - utmContent: string | null (optional) - UTM content match (null = wildcard) + */ + +import { db } from '@sim/db' +import { referralCampaigns } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { count, eq, type SQL } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { withAdminAuth } from '@/app/api/v1/admin/middleware' +import { + badRequestResponse, + internalErrorResponse, + listResponse, + singleResponse, +} from '@/app/api/v1/admin/responses' +import { + type AdminReferralCampaign, + createPaginationMeta, + parsePaginationParams, + toAdminReferralCampaign, +} from '@/app/api/v1/admin/types' + +const logger = createLogger('AdminReferralCampaignsAPI') + +export const GET = withAdminAuth(async (request) => { + const url = new URL(request.url) + const { limit, offset } = parsePaginationParams(url) + const activeFilter = url.searchParams.get('active') + + try { + const conditions: SQL[] = [] + if (activeFilter === 'true') { + conditions.push(eq(referralCampaigns.isActive, true)) + } else if (activeFilter === 'false') { + conditions.push(eq(referralCampaigns.isActive, false)) + } + + const whereClause = conditions.length > 0 ? conditions[0] : undefined + const baseUrl = getBaseUrl() + + const [countResult, campaigns] = await Promise.all([ + db.select({ total: count() }).from(referralCampaigns).where(whereClause), + db + .select() + .from(referralCampaigns) + .where(whereClause) + .orderBy(referralCampaigns.createdAt) + .limit(limit) + .offset(offset), + ]) + + const total = countResult[0].total + const data: AdminReferralCampaign[] = campaigns.map((c) => toAdminReferralCampaign(c, baseUrl)) + const pagination = createPaginationMeta(total, limit, offset) + + logger.info(`Admin API: Listed ${data.length} referral campaigns (total: ${total})`) + + return listResponse(data, pagination) + } catch (error) { + logger.error('Admin API: Failed to list referral campaigns', { error }) + return internalErrorResponse('Failed to list referral campaigns') + } +}) + +export const POST = withAdminAuth(async (request) => { + try { + const body = await request.json() + const { name, code, utmSource, utmMedium, utmCampaign, utmContent, bonusCreditAmount } = body + + if (!name || typeof name !== 'string') { + return badRequestResponse('name is required and must be a string') + } + + if ( + typeof bonusCreditAmount !== 'number' || + !Number.isFinite(bonusCreditAmount) || + bonusCreditAmount <= 0 + ) { + return badRequestResponse('bonusCreditAmount must be a positive number') + } + + if (code !== undefined && code !== null) { + if (typeof code !== 'string') { + return badRequestResponse('code must be a string or null') + } + if (code.trim().length < 6) { + return badRequestResponse('code must be at least 6 characters') + } + } + + const id = nanoid() + + const [campaign] = await db + .insert(referralCampaigns) + .values({ + id, + name, + code: code ? code.trim().toUpperCase() : null, + utmSource: utmSource || null, + utmMedium: utmMedium || null, + utmCampaign: utmCampaign || null, + utmContent: utmContent || null, + bonusCreditAmount: bonusCreditAmount.toString(), + }) + .returning() + + logger.info(`Admin API: Created referral campaign ${id}`, { + name, + code: campaign.code, + bonusCreditAmount, + }) + + return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl())) + } catch (error) { + logger.error('Admin API: Failed to create referral campaign', { error }) + return internalErrorResponse('Failed to create referral campaign') + } +}) diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index 615e02d78..d7ec4f5c3 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -8,6 +8,7 @@ import type { member, organization, + referralCampaigns, subscription, user, userStats, @@ -31,6 +32,7 @@ export type DbOrganization = InferSelectModel export type DbSubscription = InferSelectModel export type DbMember = InferSelectModel export type DbUserStats = InferSelectModel +export type DbReferralCampaign = InferSelectModel // ============================================================================= // Pagination @@ -646,3 +648,49 @@ export interface AdminDeployResult { export interface AdminUndeployResult { isDeployed: boolean } + +// ============================================================================= +// Referral Campaign Types +// ============================================================================= + +export interface AdminReferralCampaign { + id: string + name: string + code: string | null + utmSource: string | null + utmMedium: string | null + utmCampaign: string | null + utmContent: string | null + bonusCreditAmount: string + isActive: boolean + signupUrl: string | null + createdAt: string + updatedAt: string +} + +export function toAdminReferralCampaign( + dbCampaign: DbReferralCampaign, + baseUrl: string +): AdminReferralCampaign { + const utmParams = new URLSearchParams() + if (dbCampaign.utmSource) utmParams.set('utm_source', dbCampaign.utmSource) + if (dbCampaign.utmMedium) utmParams.set('utm_medium', dbCampaign.utmMedium) + if (dbCampaign.utmCampaign) utmParams.set('utm_campaign', dbCampaign.utmCampaign) + if (dbCampaign.utmContent) utmParams.set('utm_content', dbCampaign.utmContent) + const query = utmParams.toString() + + return { + id: dbCampaign.id, + name: dbCampaign.name, + code: dbCampaign.code, + utmSource: dbCampaign.utmSource, + utmMedium: dbCampaign.utmMedium, + utmCampaign: dbCampaign.utmCampaign, + utmContent: dbCampaign.utmContent, + bonusCreditAmount: dbCampaign.bonusCreditAmount, + isActive: dbCampaign.isActive, + signupUrl: query ? `${baseUrl}/signup?${query}` : null, + createdAt: dbCampaign.createdAt.toISOString(), + updatedAt: dbCampaign.updatedAt.toISOString(), + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/index.ts index cdfe8a0f5..24f107504 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/index.ts @@ -1,3 +1,4 @@ export { CancelSubscription } from './cancel-subscription' export { CreditBalance } from './credit-balance' export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card' +export { ReferralCode } from './referral-code' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/referral-code/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/referral-code/index.ts new file mode 100644 index 000000000..b1aca728a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/referral-code/index.ts @@ -0,0 +1 @@ +export { ReferralCode } from './referral-code' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/referral-code/referral-code.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/referral-code/referral-code.tsx new file mode 100644 index 000000000..78e8c863d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/referral-code/referral-code.tsx @@ -0,0 +1,103 @@ +'use client' + +import { useState } from 'react' +import { createLogger } from '@sim/logger' +import { Button, Input, Label } from '@/components/emcn' + +const logger = createLogger('ReferralCode') + +interface ReferralCodeProps { + onRedeemComplete?: () => void +} + +/** + * Inline referral/promo code entry field with redeem button. + * One-time use per account — shows success or "already redeemed" state. + */ +export function ReferralCode({ onRedeemComplete }: ReferralCodeProps) { + const [code, setCode] = useState('') + const [isRedeeming, setIsRedeeming] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState<{ bonusAmount: number } | null>(null) + + const handleRedeem = async () => { + const trimmed = code.trim() + if (!trimmed || isRedeeming) return + + setIsRedeeming(true) + setError(null) + + try { + const response = await fetch('/api/referral-code/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: trimmed }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to redeem code') + } + + if (data.redeemed) { + setSuccess({ bonusAmount: data.bonusAmount }) + setCode('') + onRedeemComplete?.() + } else { + setError(data.error || 'Code could not be redeemed') + } + } catch (err) { + logger.error('Referral code redemption failed', { error: err }) + setError(err instanceof Error ? err.message : 'Failed to redeem code') + } finally { + setIsRedeeming(false) + } + } + + if (success) { + return ( +
+ + + +${success.bonusAmount} credits applied + +
+ ) + } + + return ( +
+
+ +
+ { + setCode(e.target.value) + setError(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRedeem() + }} + placeholder='Enter code' + className='h-[32px] w-[140px] text-[12px]' + disabled={isRedeeming} + /> + +
+
+
+ {error && {error}} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx index 7581e8d4f..5ccdde897 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx @@ -17,6 +17,7 @@ import { CancelSubscription, CreditBalance, PlanCard, + ReferralCode, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components' import { ENTERPRISE_PLAN_FEATURES, @@ -549,6 +550,10 @@ export function Subscription() { /> )} + {!subscription.isEnterprise && ( + refetchSubscription()} /> + )} + {/* Next Billing Date - hidden from team members */} {subscription.isPaid && subscriptionData?.data?.periodEnd && diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index 2eba03b70..bd122ec89 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -4,12 +4,14 @@ import { useEffect } from 'react' import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' import { useSession } from '@/lib/auth/auth-client' +import { useReferralAttribution } from '@/hooks/use-referral-attribution' const logger = createLogger('WorkspacePage') export default function WorkspacePage() { const router = useRouter() const { data: session, isPending } = useSession() + useReferralAttribution() useEffect(() => { const redirectToFirstWorkspace = async () => { diff --git a/apps/sim/hooks/use-referral-attribution.ts b/apps/sim/hooks/use-referral-attribution.ts new file mode 100644 index 000000000..a42305b6d --- /dev/null +++ b/apps/sim/hooks/use-referral-attribution.ts @@ -0,0 +1,46 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { createLogger } from '@sim/logger' + +const logger = createLogger('ReferralAttribution') + +const COOKIE_NAME = 'sim_utm' + +const TERMINAL_REASONS = new Set([ + 'invalid_cookie', + 'no_utm_cookie', + 'no_matching_campaign', + 'already_attributed', +]) + +/** + * Fires a one-shot `POST /api/attribution` when a `sim_utm` cookie is present. + * Retries on transient failures; stops on terminal outcomes. + */ +export function useReferralAttribution() { + const calledRef = useRef(false) + + useEffect(() => { + if (calledRef.current) return + if (!document.cookie.includes(COOKIE_NAME)) return + + calledRef.current = true + + fetch('/api/attribution', { method: 'POST' }) + .then((res) => res.json()) + .then((data) => { + if (data.attributed) { + logger.info('Referral attribution successful', { bonusAmount: data.bonusAmount }) + } else if (data.error || TERMINAL_REASONS.has(data.reason)) { + logger.info('Referral attribution skipped', { reason: data.reason || data.error }) + } else { + calledRef.current = false + } + }) + .catch((err) => { + logger.warn('Referral attribution failed, will retry', { error: err }) + calledRef.current = false + }) + }, []) +} diff --git a/apps/sim/lib/billing/credits/bonus.ts b/apps/sim/lib/billing/credits/bonus.ts new file mode 100644 index 000000000..cb0da3056 --- /dev/null +++ b/apps/sim/lib/billing/credits/bonus.ts @@ -0,0 +1,64 @@ +import { db } from '@sim/db' +import { organization, userStats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq, sql } from 'drizzle-orm' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import type { DbOrTx } from '@/lib/db/types' + +const logger = createLogger('BonusCredits') + +/** + * Apply bonus credits to a user (e.g. referral bonuses, promotional codes). + * + * Detects the user's current plan and routes credits accordingly: + * - Free/Pro: adds to `userStats.creditBalance` and increments `currentUsageLimit` + * - Team/Enterprise: adds to `organization.creditBalance` and increments `orgUsageLimit` + * + * Uses direct increment (not recalculation) so it works correctly for free-tier + * users where `setUsageLimitForCredits` would compute planBase=0 and skip the update. + * + * @param tx - Optional Drizzle transaction context. When provided, all DB writes + * participate in the caller's transaction for atomicity. + */ +export async function applyBonusCredits( + userId: string, + amount: number, + tx?: DbOrTx +): Promise { + const dbCtx = tx ?? db + const subscription = await getHighestPrioritySubscription(userId) + const isTeamOrEnterprise = subscription?.plan === 'team' || subscription?.plan === 'enterprise' + + if (isTeamOrEnterprise && subscription?.referenceId) { + const orgId = subscription.referenceId + + await dbCtx + .update(organization) + .set({ + creditBalance: sql`${organization.creditBalance} + ${amount}`, + orgUsageLimit: sql`COALESCE(${organization.orgUsageLimit}, '0')::decimal + ${amount}`, + }) + .where(eq(organization.id, orgId)) + + logger.info('Applied bonus credits to organization', { + userId, + organizationId: orgId, + plan: subscription.plan, + amount, + }) + } else { + await dbCtx + .update(userStats) + .set({ + creditBalance: sql`${userStats.creditBalance} + ${amount}`, + currentUsageLimit: sql`COALESCE(${userStats.currentUsageLimit}, '0')::decimal + ${amount}`, + }) + .where(eq(userStats.userId, userId)) + + logger.info('Applied bonus credits to user', { + userId, + plan: subscription?.plan || 'free', + amount, + }) + } +} diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 36ada3484..b4994d861 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -137,6 +137,36 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null { return null } +const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content'] as const +const UTM_COOKIE_NAME = 'sim_utm' +const UTM_COOKIE_MAX_AGE = 3600 + +/** + * Sets a `sim_utm` cookie when UTM params are present on auth pages. + * Captures UTM values, the HTTP Referer, landing page, and a timestamp. + */ +function setUtmCookie(request: NextRequest, response: NextResponse): void { + const { searchParams, pathname } = request.nextUrl + const hasUtm = UTM_KEYS.some((key) => searchParams.get(key)) + if (!hasUtm) return + + const utmData: Record = {} + for (const key of UTM_KEYS) { + const value = searchParams.get(key) + if (value) utmData[key] = value + } + utmData.referrer_url = request.headers.get('referer') || '' + utmData.landing_page = pathname + utmData.created_at = Date.now().toString() + + response.cookies.set(UTM_COOKIE_NAME, JSON.stringify(utmData), { + path: '/', + maxAge: UTM_COOKIE_MAX_AGE, + sameSite: 'lax', + httpOnly: false, // Client-side hook needs to detect cookie presence + }) +} + export async function proxy(request: NextRequest) { const url = request.nextUrl @@ -148,10 +178,13 @@ export async function proxy(request: NextRequest) { if (url.pathname === '/login' || url.pathname === '/signup') { if (hasActiveSession) { - return NextResponse.redirect(new URL('/workspace', request.url)) + const redirect = NextResponse.redirect(new URL('/workspace', request.url)) + setUtmCookie(request, redirect) + return redirect } const response = NextResponse.next() response.headers.set('Content-Security-Policy', generateRuntimeCSP()) + setUtmCookie(request, response) return response } diff --git a/packages/db/migrations/0154_bumpy_living_mummy.sql b/packages/db/migrations/0154_bumpy_living_mummy.sql new file mode 100644 index 000000000..a8b9eded8 --- /dev/null +++ b/packages/db/migrations/0154_bumpy_living_mummy.sql @@ -0,0 +1,41 @@ +CREATE TABLE "referral_attribution" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "organization_id" text, + "campaign_id" text, + "utm_source" text, + "utm_medium" text, + "utm_campaign" text, + "utm_content" text, + "referrer_url" text, + "landing_page" text, + "bonus_credit_amount" numeric DEFAULT '0' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "referral_attribution_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +CREATE TABLE "referral_campaigns" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "code" text, + "utm_source" text, + "utm_medium" text, + "utm_campaign" text, + "utm_content" text, + "bonus_credit_amount" numeric NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "referral_campaigns_code_unique" UNIQUE("code") +); +--> statement-breakpoint +ALTER TABLE "referral_attribution" ADD CONSTRAINT "referral_attribution_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "referral_attribution" ADD CONSTRAINT "referral_attribution_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "referral_attribution" ADD CONSTRAINT "referral_attribution_campaign_id_referral_campaigns_id_fk" FOREIGN KEY ("campaign_id") REFERENCES "public"."referral_campaigns"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "referral_attribution_user_id_idx" ON "referral_attribution" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "referral_attribution_org_unique_idx" ON "referral_attribution" USING btree ("organization_id") WHERE "referral_attribution"."organization_id" IS NOT NULL;--> statement-breakpoint +CREATE INDEX "referral_attribution_campaign_id_idx" ON "referral_attribution" USING btree ("campaign_id");--> statement-breakpoint +CREATE INDEX "referral_attribution_utm_campaign_idx" ON "referral_attribution" USING btree ("utm_campaign");--> statement-breakpoint +CREATE INDEX "referral_attribution_utm_content_idx" ON "referral_attribution" USING btree ("utm_content");--> statement-breakpoint +CREATE INDEX "referral_attribution_created_at_idx" ON "referral_attribution" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "referral_campaigns_active_idx" ON "referral_campaigns" USING btree ("is_active"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0154_snapshot.json b/packages/db/migrations/meta/0154_snapshot.json new file mode 100644 index 000000000..39b2be845 --- /dev/null +++ b/packages/db/migrations/meta/0154_snapshot.json @@ -0,0 +1,10957 @@ +{ + "id": "49f580f7-7eba-4431-bdf4-61db0e606546", + "prevId": "2652353e-bc06-43fe-a8c6-4d03fe4dac93", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.a2a_agent": { + "name": "a2a_agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "authentication": { + "name": "authentication", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "signatures": { + "name": "signatures", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "a2a_agent_workspace_id_idx": { + "name": "a2a_agent_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workflow_id_idx": { + "name": "a2a_agent_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_created_by_idx": { + "name": "a2a_agent_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_workflow_unique": { + "name": "a2a_agent_workspace_workflow_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_agent_workspace_id_workspace_id_fk": { + "name": "a2a_agent_workspace_id_workspace_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_workflow_id_workflow_id_fk": { + "name": "a2a_agent_workflow_id_workflow_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_created_by_user_id_fk": { + "name": "a2a_agent_created_by_user_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_push_notification_config": { + "name": "a2a_push_notification_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_schemes": { + "name": "auth_schemes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "auth_credentials": { + "name": "auth_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "a2a_push_notification_config_task_id_idx": { + "name": "a2a_push_notification_config_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_push_notification_config_task_unique": { + "name": "a2a_push_notification_config_task_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_push_notification_config_task_id_a2a_task_id_fk": { + "name": "a2a_push_notification_config_task_id_a2a_task_id_fk", + "tableFrom": "a2a_push_notification_config", + "tableTo": "a2a_task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_task": { + "name": "a2a_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "a2a_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "a2a_task_agent_id_idx": { + "name": "a2a_task_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_session_id_idx": { + "name": "a2a_task_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_status_idx": { + "name": "a2a_task_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_execution_id_idx": { + "name": "a2a_task_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_created_at_idx": { + "name": "a2a_task_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_task_agent_id_a2a_agent_id_fk": { + "name": "a2a_task_agent_id_a2a_agent_id_fk", + "tableFrom": "a2a_task", + "tableTo": "a2a_agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_user_provider_unique": { + "name": "account_user_provider_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set": { + "name": "credential_set", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_organization_id_idx": { + "name": "credential_set_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_created_by_idx": { + "name": "credential_set_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_org_name_unique": { + "name": "credential_set_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_invitation": { + "name": "credential_set_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_invitation_set_id_idx": { + "name": "credential_set_invitation_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_expires_at_idx": { + "name": "credential_set_invitation_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_invitation_credential_set_id_credential_set_id_fk": { + "name": "credential_set_invitation_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_accepted_by_user_id_user_id_fk": { + "name": "credential_set_invitation_accepted_by_user_id_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["accepted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_member": { + "name": "credential_set_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_member_set_id_idx": { + "name": "credential_set_member_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_user_id_idx": { + "name": "credential_set_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_unique": { + "name": "credential_set_member_unique", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_member_credential_set_id_credential_set_id_fk": { + "name": "credential_set_member_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form": { + "name": "form", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "show_branding": { + "name": "show_branding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_identifier_idx": { + "name": "form_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_workflow_id_idx": { + "name": "form_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_user_id_idx": { + "name": "form_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_workflow_id_workflow_id_fk": { + "name": "form_workflow_id_workflow_id_fk", + "tableFrom": "form", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_user_id_user_id_fk": { + "name": "form_user_id_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_idx": { + "name": "mcp_servers_workspace_deleted_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "auto_add_new_members": { + "name": "auto_add_new_members", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_organization_id_idx": { + "name": "permission_group_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_org_name_unique": { + "name": "permission_group_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_org_auto_add_unique": { + "name": "permission_group_org_auto_add_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "auto_add_new_members = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_user_id_unique": { + "name": "permission_group_member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_attribution": { + "name": "referral_attribution", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "campaign_id": { + "name": "campaign_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer_url": { + "name": "referrer_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landing_page": { + "name": "landing_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bonus_credit_amount": { + "name": "bonus_credit_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "referral_attribution_user_id_idx": { + "name": "referral_attribution_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_attribution_org_unique_idx": { + "name": "referral_attribution_org_unique_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"referral_attribution\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_attribution_campaign_id_idx": { + "name": "referral_attribution_campaign_id_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_attribution_utm_campaign_idx": { + "name": "referral_attribution_utm_campaign_idx", + "columns": [ + { + "expression": "utm_campaign", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_attribution_utm_content_idx": { + "name": "referral_attribution_utm_content_idx", + "columns": [ + { + "expression": "utm_content", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_attribution_created_at_idx": { + "name": "referral_attribution_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_attribution_user_id_user_id_fk": { + "name": "referral_attribution_user_id_user_id_fk", + "tableFrom": "referral_attribution", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_attribution_organization_id_organization_id_fk": { + "name": "referral_attribution_organization_id_organization_id_fk", + "tableFrom": "referral_attribution", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "referral_attribution_campaign_id_referral_campaigns_id_fk": { + "name": "referral_attribution_campaign_id_referral_campaigns_id_fk", + "tableFrom": "referral_attribution", + "tableTo": "referral_campaigns", + "columnsFrom": ["campaign_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "referral_attribution_user_id_unique": { + "name": "referral_attribution_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_campaigns": { + "name": "referral_campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bonus_credit_amount": { + "name": "bonus_credit_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "referral_campaigns_active_idx": { + "name": "referral_campaigns_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "referral_campaigns_code_unique": { + "name": "referral_campaigns_code_unique", + "nullsNotDistinct": false, + "columns": ["code"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'dark'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_id_idx": { + "name": "skill_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.template_creators": { + "name": "template_creators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reference_type": { + "name": "reference_type", + "type": "template_creator_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_creators_reference_idx": { + "name": "template_creators_reference_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_reference_id_idx": { + "name": "template_creators_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_created_by_idx": { + "name": "template_creators_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_creators_created_by_user_id_fk": { + "name": "template_creators_created_by_user_id_fk", + "tableFrom": "template_creators", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "template_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "required_credentials": { + "name": "required_credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "og_image_url": { + "name": "og_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "templates_status_idx": { + "name": "templates_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_creator_id_idx": { + "name": "templates_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_views_idx": { + "name": "templates_status_views_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_stars_idx": { + "name": "templates_status_stars_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "templates_creator_id_template_creators_id_fk": { + "name": "templates_creator_id_template_creators_id_fk", + "tableFrom": "templates", + "tableTo": "template_creators", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_super_user": { + "name": "is_super_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_a2a_executions": { + "name": "total_a2a_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'20'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id": { + "name": "idx_webhook_on_workflow_id_block_id", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_provider_unique": { + "name": "workspace_byok_provider_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_byok_workspace_idx": { + "name": "workspace_byok_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_files_key_unique": { + "name": "workspace_files_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitation": { + "name": "workspace_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "workspace_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "org_invitation_id": { + "name": "org_invitation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invitation_workspace_id_workspace_id_fk": { + "name": "workspace_invitation_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitation_inviter_id_user_id_fk": { + "name": "workspace_invitation_inviter_id_user_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitation_token_unique": { + "name": "workspace_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.a2a_task_status": { + "name": "a2a_task_status", + "schema": "public", + "values": [ + "submitted", + "working", + "input-required", + "completed", + "failed", + "canceled", + "rejected", + "auth-required", + "unknown" + ] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.credential_set_invitation_status": { + "name": "credential_set_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "expired", "cancelled"] + }, + "public.credential_set_member_status": { + "name": "credential_set_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.template_creator_type": { + "name": "template_creator_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.template_status": { + "name": "template_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": ["workflow", "wand", "copilot", "mcp_copilot"] + }, + "public.workspace_invitation_status": { + "name": "workspace_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 2fa880f2a..2b83d1c90 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1072,6 +1072,13 @@ "when": 1770410282842, "tag": "0153_complete_arclight", "breakpoints": true + }, + { + "idx": 154, + "version": "7", + "when": 1770869658697, + "tag": "0154_bumpy_living_mummy", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index d145c5796..090ab0855 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -726,6 +726,61 @@ export const userStats = pgTable('user_stats', { billingBlockedReason: billingBlockedReasonEnum('billing_blocked_reason'), }) +export const referralCampaigns = pgTable( + 'referral_campaigns', + { + id: text('id').primaryKey(), + name: text('name').notNull(), + code: text('code').unique(), + utmSource: text('utm_source'), + utmMedium: text('utm_medium'), + utmCampaign: text('utm_campaign'), + utmContent: text('utm_content'), + bonusCreditAmount: decimal('bonus_credit_amount').notNull(), + isActive: boolean('is_active').notNull().default(true), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + activeIdx: index('referral_campaigns_active_idx').on(table.isActive), + }) +) + +export const referralAttribution = pgTable( + 'referral_attribution', + { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }) + .unique(), + organizationId: text('organization_id').references(() => organization.id, { + onDelete: 'set null', + }), + campaignId: text('campaign_id').references(() => referralCampaigns.id, { + onDelete: 'set null', + }), + utmSource: text('utm_source'), + utmMedium: text('utm_medium'), + utmCampaign: text('utm_campaign'), + utmContent: text('utm_content'), + referrerUrl: text('referrer_url'), + landingPage: text('landing_page'), + bonusCreditAmount: decimal('bonus_credit_amount').notNull().default('0'), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => ({ + userIdIdx: index('referral_attribution_user_id_idx').on(table.userId), + orgUniqueIdx: uniqueIndex('referral_attribution_org_unique_idx') + .on(table.organizationId) + .where(sql`${table.organizationId} IS NOT NULL`), + campaignIdIdx: index('referral_attribution_campaign_id_idx').on(table.campaignId), + utmCampaignIdx: index('referral_attribution_utm_campaign_idx').on(table.utmCampaign), + utmContentIdx: index('referral_attribution_utm_content_idx').on(table.utmContent), + createdAtIdx: index('referral_attribution_created_at_idx').on(table.createdAt), + }) +) + export const customTools = pgTable( 'custom_tools', { From a337aa7dfebd769f365995b9ea9db9fd623949ea Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 12 Feb 2026 23:56:35 -0800 Subject: [PATCH 12/13] feat(internal): added internal api base url for internal calls (#3212) * feat(internal): added internal api base url for internal calls * make validation on http more lax --- apps/sim/.env.example | 1 + apps/sim/app/api/a2a/serve/[agentId]/utils.ts | 4 +- .../copilot/checkpoints/revert/route.test.ts | 2 +- .../api/copilot/checkpoints/revert/route.ts | 4 +- .../api/mcp/serve/[serverId]/route.test.ts | 1 + .../sim/app/api/mcp/serve/[serverId]/route.ts | 4 +- apps/sim/app/api/templates/[id]/use/route.ts | 23 +++++++----- .../handlers/router/router-handler.ts | 6 +-- apps/sim/executor/utils/http.ts | 5 ++- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/core/utils/urls.ts | 37 ++++++++++++++++--- .../lib/guardrails/validate_hallucination.ts | 4 +- .../knowledge/documents/document-processor.ts | 4 +- .../sim/lib/webhooks/gmail-polling-service.ts | 4 +- apps/sim/lib/webhooks/imap-polling-service.ts | 4 +- .../lib/webhooks/outlook-polling-service.ts | 4 +- apps/sim/lib/webhooks/rss-polling-service.ts | 4 +- apps/sim/tools/index.ts | 10 ++--- apps/sim/tools/openai/image.ts | 4 +- apps/sim/tools/utils.ts | 4 +- helm/sim/values.schema.json | 12 ++++++ helm/sim/values.yaml | 1 + 22 files changed, 95 insertions(+), 48 deletions(-) diff --git a/apps/sim/.env.example b/apps/sim/.env.example index f8e926f88..6c22b09ee 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -13,6 +13,7 @@ BETTER_AUTH_URL=http://localhost:3000 # NextJS (Required) NEXT_PUBLIC_APP_URL=http://localhost:3000 +# INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL # Security (Required) ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables diff --git a/apps/sim/app/api/a2a/serve/[agentId]/utils.ts b/apps/sim/app/api/a2a/serve/[agentId]/utils.ts index 1e8f85588..f46013343 100644 --- a/apps/sim/app/api/a2a/serve/[agentId]/utils.ts +++ b/apps/sim/app/api/a2a/serve/[agentId]/utils.ts @@ -1,7 +1,7 @@ import type { Artifact, Message, PushNotificationConfig, Task, TaskState } from '@a2a-js/sdk' import { v4 as uuidv4 } from 'uuid' import { generateInternalToken } from '@/lib/auth/internal' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' /** A2A v0.3 JSON-RPC method names */ export const A2A_METHODS = { @@ -118,7 +118,7 @@ export interface ExecuteRequestResult { export async function buildExecuteRequest( config: ExecuteRequestConfig ): Promise { - const url = `${getBaseUrl()}/api/workflows/${config.workflowId}/execute` + const url = `${getInternalApiBaseUrl()}/api/workflows/${config.workflowId}/execute` const headers: Record = { 'Content-Type': 'application/json' } let useInternalAuth = false diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts index 7193af66d..aa464170a 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts @@ -18,9 +18,9 @@ describe('Copilot Checkpoints Revert API Route', () => { setupCommonApiMocks() mockCryptoUuid() - // Mock getBaseUrl to return localhost for tests vi.doMock('@/lib/core/utils/urls', () => ({ getBaseUrl: vi.fn(() => 'http://localhost:3000'), + getInternalApiBaseUrl: vi.fn(() => 'http://localhost:3000'), getBaseDomain: vi.fn(() => 'localhost:3000'), getEmailDomain: vi.fn(() => 'localhost:3000'), })) diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts index 72c79a262..7c58a1435 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.ts @@ -11,7 +11,7 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { isUuidV4 } from '@/executor/constants' @@ -99,7 +99,7 @@ export async function POST(request: NextRequest) { } const stateResponse = await fetch( - `${getBaseUrl()}/api/workflows/${checkpoint.workflowId}/state`, + `${getInternalApiBaseUrl()}/api/workflows/${checkpoint.workflowId}/state`, { method: 'PUT', headers: { diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index 976678313..95a3f89ed 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -72,6 +72,7 @@ describe('MCP Serve Route', () => { })) vi.doMock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'http://localhost:3000', + getInternalApiBaseUrl: () => 'http://localhost:3000', })) vi.doMock('@/lib/core/execution-limits', () => ({ getMaxExecutionTimeout: () => 10_000, diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 92a77d8b9..1c694a59a 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -22,7 +22,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid' import { generateInternalToken } from '@/lib/auth/internal' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkflowMcpServeAPI') @@ -285,7 +285,7 @@ async function handleToolsCall( ) } - const executeUrl = `${getBaseUrl()}/api/workflows/${tool.workflowId}/execute` + const executeUrl = `${getInternalApiBaseUrl()}/api/workflows/${tool.workflowId}/execute` const headers: Record = { 'Content-Type': 'application/json' } if (publicServerOwnerId) { diff --git a/apps/sim/app/api/templates/[id]/use/route.ts b/apps/sim/app/api/templates/[id]/use/route.ts index 59c546687..b08d6dfb8 100644 --- a/apps/sim/app/api/templates/[id]/use/route.ts +++ b/apps/sim/app/api/templates/[id]/use/route.ts @@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { type RegenerateStateInput, regenerateWorkflowStateIds, @@ -115,15 +115,18 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ // Step 3: Save the workflow state using the existing state endpoint (like imports do) // Ensure variables in state are remapped for the new workflow as well const workflowStateWithVariables = { ...workflowState, variables: remappedVariables } - const stateResponse = await fetch(`${getBaseUrl()}/api/workflows/${newWorkflowId}/state`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - // Forward the session cookie for authentication - cookie: request.headers.get('cookie') || '', - }, - body: JSON.stringify(workflowStateWithVariables), - }) + const stateResponse = await fetch( + `${getInternalApiBaseUrl()}/api/workflows/${newWorkflowId}/state`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + // Forward the session cookie for authentication + cookie: request.headers.get('cookie') || '', + }, + body: JSON.stringify(workflowStateWithVariables), + } + ) if (!stateResponse.ok) { logger.error(`[${requestId}] Failed to save workflow state for template use`) diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index a42956c66..5c22a1c49 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router' import type { BlockOutput } from '@/blocks/types' @@ -79,7 +79,7 @@ export class RouterBlockHandler implements BlockHandler { const providerId = getProviderFromModel(routerConfig.model) try { - const url = new URL('/api/providers', getBaseUrl()) + const url = new URL('/api/providers', getInternalApiBaseUrl()) if (ctx.userId) url.searchParams.set('userId', ctx.userId) const messages = [{ role: 'user', content: routerConfig.prompt }] @@ -209,7 +209,7 @@ export class RouterBlockHandler implements BlockHandler { const providerId = getProviderFromModel(routerConfig.model) try { - const url = new URL('/api/providers', getBaseUrl()) + const url = new URL('/api/providers', getInternalApiBaseUrl()) if (ctx.userId) url.searchParams.set('userId', ctx.userId) const messages = [{ role: 'user', content: routerConfig.context }] diff --git a/apps/sim/executor/utils/http.ts b/apps/sim/executor/utils/http.ts index 5562e4567..ac4792dd7 100644 --- a/apps/sim/executor/utils/http.ts +++ b/apps/sim/executor/utils/http.ts @@ -1,5 +1,5 @@ import { generateInternalToken } from '@/lib/auth/internal' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { HTTP } from '@/executor/constants' export async function buildAuthHeaders(): Promise> { @@ -16,7 +16,8 @@ export async function buildAuthHeaders(): Promise> { } export function buildAPIUrl(path: string, params?: Record): URL { - const url = new URL(path, getBaseUrl()) + const baseUrl = path.startsWith('/api/') ? getInternalApiBaseUrl() : getBaseUrl() + const url = new URL(path, baseUrl) if (params) { for (const [key, value] of Object.entries(params)) { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 31c9c36ad..b154fbdbb 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -220,6 +220,7 @@ export const env = createEnv({ SOCKET_SERVER_URL: z.string().url().optional(), // WebSocket server URL for real-time features SOCKET_PORT: z.number().optional(), // Port for WebSocket server PORT: z.number().optional(), // Main application port + INTERNAL_API_BASE_URL: z.string().optional(), // Optional internal base URL for server-side self-calls; must include protocol if set (e.g., http://sim-app.namespace.svc.cluster.local:3000) ALLOWED_ORIGINS: z.string().optional(), // CORS allowed origins // OAuth Integration Credentials - All optional, enables third-party integrations diff --git a/apps/sim/lib/core/utils/urls.ts b/apps/sim/lib/core/utils/urls.ts index 5021d4494..5be78eb1d 100644 --- a/apps/sim/lib/core/utils/urls.ts +++ b/apps/sim/lib/core/utils/urls.ts @@ -1,6 +1,19 @@ import { getEnv } from '@/lib/core/config/env' import { isProd } from '@/lib/core/config/feature-flags' +function hasHttpProtocol(url: string): boolean { + return /^https?:\/\//i.test(url) +} + +function normalizeBaseUrl(url: string): string { + if (hasHttpProtocol(url)) { + return url + } + + const protocol = isProd ? 'https://' : 'http://' + return `${protocol}${url}` +} + /** * Returns the base URL of the application from NEXT_PUBLIC_APP_URL * This ensures webhooks, callbacks, and other integrations always use the correct public URL @@ -8,7 +21,7 @@ import { isProd } from '@/lib/core/config/feature-flags' * @throws Error if NEXT_PUBLIC_APP_URL is not configured */ export function getBaseUrl(): string { - const baseUrl = getEnv('NEXT_PUBLIC_APP_URL') + const baseUrl = getEnv('NEXT_PUBLIC_APP_URL')?.trim() if (!baseUrl) { throw new Error( @@ -16,12 +29,26 @@ export function getBaseUrl(): string { ) } - if (baseUrl.startsWith('http://') || baseUrl.startsWith('https://')) { - return baseUrl + return normalizeBaseUrl(baseUrl) +} + +/** + * Returns the base URL used by server-side internal API calls. + * Falls back to NEXT_PUBLIC_APP_URL when INTERNAL_API_BASE_URL is not set. + */ +export function getInternalApiBaseUrl(): string { + const internalBaseUrl = getEnv('INTERNAL_API_BASE_URL')?.trim() + if (!internalBaseUrl) { + return getBaseUrl() } - const protocol = isProd ? 'https://' : 'http://' - return `${protocol}${baseUrl}` + if (!hasHttpProtocol(internalBaseUrl)) { + throw new Error( + 'INTERNAL_API_BASE_URL must include protocol (http:// or https://), e.g. http://sim-app.default.svc.cluster.local:3000' + ) + } + + return internalBaseUrl } /** diff --git a/apps/sim/lib/guardrails/validate_hallucination.ts b/apps/sim/lib/guardrails/validate_hallucination.ts index 621a7d803..658a528fc 100644 --- a/apps/sim/lib/guardrails/validate_hallucination.ts +++ b/apps/sim/lib/guardrails/validate_hallucination.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { executeProviderRequest } from '@/providers' import { getProviderFromModel } from '@/providers/utils' @@ -61,7 +61,7 @@ async function queryKnowledgeBase( }) // Call the knowledge base search API directly - const searchUrl = `${getBaseUrl()}/api/knowledge/search` + const searchUrl = `${getInternalApiBaseUrl()}/api/knowledge/search` const response = await fetch(searchUrl, { method: 'POST', diff --git a/apps/sim/lib/knowledge/documents/document-processor.ts b/apps/sim/lib/knowledge/documents/document-processor.ts index 80789e81b..0185de495 100644 --- a/apps/sim/lib/knowledge/documents/document-processor.ts +++ b/apps/sim/lib/knowledge/documents/document-processor.ts @@ -539,8 +539,8 @@ async function executeMistralOCRRequest( const isInternalRoute = url.startsWith('/') if (isInternalRoute) { - const { getBaseUrl } = await import('@/lib/core/utils/urls') - url = `${getBaseUrl()}${url}` + const { getInternalApiBaseUrl } = await import('@/lib/core/utils/urls') + url = `${getInternalApiBaseUrl()}${url}` } let headers = diff --git a/apps/sim/lib/webhooks/gmail-polling-service.ts b/apps/sim/lib/webhooks/gmail-polling-service.ts index 7e3bcca5d..9b391002e 100644 --- a/apps/sim/lib/webhooks/gmail-polling-service.ts +++ b/apps/sim/lib/webhooks/gmail-polling-service.ts @@ -11,7 +11,7 @@ import { and, eq, isNull, or, sql } from 'drizzle-orm' import { nanoid } from 'nanoid' import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing' import { pollingIdempotency } from '@/lib/core/idempotency/service' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { GmailAttachment } from '@/tools/gmail/types' import { downloadAttachments, extractAttachmentInfo } from '@/tools/gmail/utils' @@ -691,7 +691,7 @@ async function processEmails( `[${requestId}] Sending ${config.includeRawEmail ? 'simplified + raw' : 'simplified'} email payload for ${email.id}` ) - const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}` + const webhookUrl = `${getInternalApiBaseUrl()}/api/webhooks/trigger/${webhookData.path}` const response = await fetch(webhookUrl, { method: 'POST', diff --git a/apps/sim/lib/webhooks/imap-polling-service.ts b/apps/sim/lib/webhooks/imap-polling-service.ts index 9d664531f..37fc4c621 100644 --- a/apps/sim/lib/webhooks/imap-polling-service.ts +++ b/apps/sim/lib/webhooks/imap-polling-service.ts @@ -7,7 +7,7 @@ import type { FetchMessageObject, MailboxLockObject } from 'imapflow' import { ImapFlow } from 'imapflow' import { nanoid } from 'nanoid' import { pollingIdempotency } from '@/lib/core/idempotency/service' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants' const logger = createLogger('ImapPollingService') @@ -639,7 +639,7 @@ async function processEmails( timestamp: new Date().toISOString(), } - const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}` + const webhookUrl = `${getInternalApiBaseUrl()}/api/webhooks/trigger/${webhookData.path}` const response = await fetch(webhookUrl, { method: 'POST', diff --git a/apps/sim/lib/webhooks/outlook-polling-service.ts b/apps/sim/lib/webhooks/outlook-polling-service.ts index 1f1b48e0c..19a807928 100644 --- a/apps/sim/lib/webhooks/outlook-polling-service.ts +++ b/apps/sim/lib/webhooks/outlook-polling-service.ts @@ -12,7 +12,7 @@ import { htmlToText } from 'html-to-text' import { nanoid } from 'nanoid' import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing' import { pollingIdempotency } from '@/lib/core/idempotency' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants' @@ -601,7 +601,7 @@ async function processOutlookEmails( `[${requestId}] Processing email: ${email.subject} from ${email.from?.emailAddress?.address}` ) - const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}` + const webhookUrl = `${getInternalApiBaseUrl()}/api/webhooks/trigger/${webhookData.path}` const response = await fetch(webhookUrl, { method: 'POST', diff --git a/apps/sim/lib/webhooks/rss-polling-service.ts b/apps/sim/lib/webhooks/rss-polling-service.ts index 5fbdeaba3..d75daa62f 100644 --- a/apps/sim/lib/webhooks/rss-polling-service.ts +++ b/apps/sim/lib/webhooks/rss-polling-service.ts @@ -9,7 +9,7 @@ import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants' const logger = createLogger('RssPollingService') @@ -376,7 +376,7 @@ async function processRssItems( timestamp: new Date().toISOString(), } - const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}` + const webhookUrl = `${getInternalApiBaseUrl()}/api/webhooks/trigger/${webhookData.path}` const response = await fetch(webhookUrl, { method: 'POST', diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 040a40a27..9af514aeb 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -6,7 +6,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { parseMcpToolId } from '@/lib/mcp/utils' import { isCustomTool, isMcpTool } from '@/executor/constants' import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver' @@ -285,7 +285,7 @@ export async function executeTool( `[${requestId}] Tool ${toolId} needs access token for credential: ${contextParams.credential}` ) try { - const baseUrl = getBaseUrl() + const baseUrl = getInternalApiBaseUrl() const workflowId = contextParams._context?.workflowId const userId = contextParams._context?.userId @@ -597,12 +597,12 @@ async function executeToolRequest( const requestParams = formatRequestParams(tool, params) try { - const baseUrl = getBaseUrl() const endpointUrl = typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url + const isInternalRoute = endpointUrl.startsWith('/api/') + const baseUrl = isInternalRoute ? getInternalApiBaseUrl() : getBaseUrl() const fullUrlObj = new URL(endpointUrl, baseUrl) - const isInternalRoute = endpointUrl.startsWith('/api/') if (isInternalRoute) { const workflowId = params._context?.workflowId @@ -922,7 +922,7 @@ async function executeMcpTool( const { serverId, toolName } = parseMcpToolId(toolId) - const baseUrl = getBaseUrl() + const baseUrl = getInternalApiBaseUrl() const headers: Record = { 'Content-Type': 'application/json' } diff --git a/apps/sim/tools/openai/image.ts b/apps/sim/tools/openai/image.ts index 3d9f1be5a..2e857d153 100644 --- a/apps/sim/tools/openai/image.ts +++ b/apps/sim/tools/openai/image.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import type { BaseImageRequestBody } from '@/tools/openai/types' import type { ToolConfig } from '@/tools/types' @@ -122,7 +122,7 @@ export const imageTool: ToolConfig = { if (imageUrl && !base64Image) { try { logger.info('Fetching image from URL via proxy...') - const baseUrl = getBaseUrl() + const baseUrl = getInternalApiBaseUrl() const proxyUrl = new URL('/api/tools/image', baseUrl) proxyUrl.searchParams.append('url', imageUrl) diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index 0a7b635fa..e783217a6 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { AGENT, isCustomTool } from '@/executor/constants' import { getCustomTool } from '@/hooks/queries/custom-tools' import { useEnvironmentStore } from '@/stores/settings/environment' @@ -373,7 +373,7 @@ async function fetchCustomToolFromAPI( const identifier = customToolId.replace('custom_', '') try { - const baseUrl = getBaseUrl() + const baseUrl = getInternalApiBaseUrl() const url = new URL('/api/tools/custom', baseUrl) if (workflowId) { diff --git a/helm/sim/values.schema.json b/helm/sim/values.schema.json index 13a6a8142..3aeef472e 100644 --- a/helm/sim/values.schema.json +++ b/helm/sim/values.schema.json @@ -120,6 +120,18 @@ "format": "uri", "description": "Public application URL" }, + "INTERNAL_API_BASE_URL": { + "type": "string", + "anyOf": [ + { + "format": "uri" + }, + { + "const": "" + } + ], + "description": "Optional server-side internal base URL for internal /api self-calls (must include http:// or https://); defaults to NEXT_PUBLIC_APP_URL when unset" + }, "BETTER_AUTH_URL": { "type": "string", "format": "uri", diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index a5fa37d51..9ac47e95e 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -70,6 +70,7 @@ app: # Application URLs NEXT_PUBLIC_APP_URL: "http://localhost:3000" BETTER_AUTH_URL: "http://localhost:3000" + INTERNAL_API_BASE_URL: "" # Optional server-side internal base URL for /api self-calls (include http:// or https://); falls back to NEXT_PUBLIC_APP_URL when empty # SOCKET_SERVER_URL: Auto-detected when realtime.enabled=true (uses internal service) # Only set this if using an external WebSocket service with realtime.enabled=false NEXT_PUBLIC_SOCKET_URL: "http://localhost:3002" # Public WebSocket URL for browsers From 7fbbc7ba7acfe5a97203a446ff941f1cce2389e6 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 13 Feb 2026 00:18:25 -0800 Subject: [PATCH 13/13] fix(tool-input): sync cleared subblock values to tool params (#3214) --- .../tool-input/components/tools/sub-block-renderer.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx index 0f9319ace..81ca1f03c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx @@ -78,8 +78,13 @@ export function ToolSubBlockRenderer({ }, [toolParamValue, setStoreValue, isObjectType]) useEffect(() => { - if (storeValue == null) return - const stringValue = typeof storeValue === 'string' ? storeValue : JSON.stringify(storeValue) + if (storeValue == null && lastPushedToParamsRef.current === null) return + const stringValue = + storeValue == null + ? '' + : typeof storeValue === 'string' + ? storeValue + : JSON.stringify(storeValue) if (stringValue !== lastPushedToParamsRef.current) { lastPushedToParamsRef.current = stringValue lastPushedToStoreRef.current = stringValue