diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts index ccda1314b..185273dbb 100644 --- a/apps/sim/app/api/auth/sso/register/route.ts +++ b/apps/sim/app/api/auth/sso/register/route.ts @@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { auth } from '@/lib/auth' import { env } from '@/lib/core/config/env' +import { REDACTED_MARKER } from '@/lib/core/security/redaction' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('SSO-Register') @@ -236,13 +237,13 @@ export async function POST(request: NextRequest) { oidcConfig: providerConfig.oidcConfig ? { ...providerConfig.oidcConfig, - clientSecret: '[REDACTED]', + clientSecret: REDACTED_MARKER, } : undefined, samlConfig: providerConfig.samlConfig ? { ...providerConfig.samlConfig, - cert: '[REDACTED]', + cert: REDACTED_MARKER, } : undefined, }, diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 8c42b9656..773427e23 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -141,6 +141,23 @@ export async function DELETE( ) } + // Check if deleting this folder would delete the last workflow(s) in the workspace + const workflowsInFolder = await countWorkflowsInFolderRecursively( + id, + existingFolder.workspaceId + ) + const totalWorkflowsInWorkspace = await db + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.workspaceId, existingFolder.workspaceId)) + + if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) { + return NextResponse.json( + { error: 'Cannot delete folder containing the only workflow(s) in the workspace' }, + { status: 400 } + ) + } + // Recursively delete folder and all its contents const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId) @@ -202,6 +219,34 @@ async function deleteFolderRecursively( return stats } +/** + * Counts the number of workflows in a folder and all its subfolders recursively. + */ +async function countWorkflowsInFolderRecursively( + folderId: string, + workspaceId: string +): Promise { + let count = 0 + + const workflowsInFolder = await db + .select({ id: workflow.id }) + .from(workflow) + .where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId))) + + count += workflowsInFolder.length + + const childFolders = await db + .select({ id: workflowFolder.id }) + .from(workflowFolder) + .where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId))) + + for (const childFolder of childFolders) { + count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId) + } + + return count +} + // Helper function to check for circular references async function checkForCircularReference(folderId: string, parentId: string): Promise { let currentParentId: string | null = parentId diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts index 659328dda..284f06937 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts @@ -156,6 +156,7 @@ export async function POST( const validatedData = CreateChunkSchema.parse(searchParams) const docTags = { + // Text tags (7 slots) tag1: doc.tag1 ?? null, tag2: doc.tag2 ?? null, tag3: doc.tag3 ?? null, @@ -163,6 +164,19 @@ export async function POST( tag5: doc.tag5 ?? null, tag6: doc.tag6 ?? null, tag7: doc.tag7 ?? null, + // Number tags (5 slots) + number1: doc.number1 ?? null, + number2: doc.number2 ?? null, + number3: doc.number3 ?? null, + number4: doc.number4 ?? null, + number5: doc.number5 ?? null, + // Date tags (2 slots) + date1: doc.date1 ?? null, + date2: doc.date2 ?? null, + // Boolean tags (3 slots) + boolean1: doc.boolean1 ?? null, + boolean2: doc.boolean2 ?? null, + boolean3: doc.boolean3 ?? null, } const newChunk = await createChunk( diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts index 07f9b9846..710d9eea8 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts @@ -72,6 +72,16 @@ describe('Document By ID API Route', () => { tag5: null, tag6: null, tag7: null, + number1: null, + number2: null, + number3: null, + number4: null, + number5: null, + date1: null, + date2: null, + boolean1: null, + boolean2: null, + boolean3: null, deletedAt: null, } diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts index 66f15071c..6e5495aa7 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts @@ -23,7 +23,7 @@ const UpdateDocumentSchema = z.object({ processingError: z.string().optional(), markFailedDueToTimeout: z.boolean().optional(), retryProcessing: z.boolean().optional(), - // Tag fields + // Text tag fields tag1: z.string().optional(), tag2: z.string().optional(), tag3: z.string().optional(), @@ -31,6 +31,19 @@ const UpdateDocumentSchema = z.object({ tag5: z.string().optional(), tag6: z.string().optional(), tag7: z.string().optional(), + // Number tag fields + number1: z.string().optional(), + number2: z.string().optional(), + number3: z.string().optional(), + number4: z.string().optional(), + number5: z.string().optional(), + // Date tag fields + date1: z.string().optional(), + date2: z.string().optional(), + // Boolean tag fields + boolean1: z.string().optional(), + boolean2: z.string().optional(), + boolean3: z.string().optional(), }) export async function GET( diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts index 1ff73940f..2b22613f6 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts @@ -80,6 +80,16 @@ describe('Knowledge Base Documents API Route', () => { tag5: null, tag6: null, tag7: null, + number1: null, + number2: null, + number3: null, + number4: null, + number5: null, + date1: null, + date2: null, + boolean1: null, + boolean2: null, + boolean3: null, deletedAt: null, } diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index 3195988f0..94f8a0a2b 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -64,6 +64,11 @@ vi.mock('@/app/api/knowledge/utils', () => ({ checkKnowledgeBaseAccess: mockCheckKnowledgeBaseAccess, })) +const mockGetDocumentTagDefinitions = vi.fn() +vi.mock('@/lib/knowledge/tags/service', () => ({ + getDocumentTagDefinitions: mockGetDocumentTagDefinitions, +})) + const mockHandleTagOnlySearch = vi.fn() const mockHandleVectorOnlySearch = vi.fn() const mockHandleTagAndVectorSearch = vi.fn() @@ -156,6 +161,7 @@ describe('Knowledge Search API Route', () => { doc1: 'Document 1', doc2: 'Document 2', }) + mockGetDocumentTagDefinitions.mockClear() vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), @@ -659,8 +665,8 @@ describe('Knowledge Search API Route', () => { describe('Optional Query Search', () => { const mockTagDefinitions = [ - { tagSlot: 'tag1', displayName: 'category' }, - { tagSlot: 'tag2', displayName: 'priority' }, + { tagSlot: 'tag1', displayName: 'category', fieldType: 'text' }, + { tagSlot: 'tag2', displayName: 'priority', fieldType: 'text' }, ] const mockTaggedResults = [ @@ -689,9 +695,7 @@ describe('Knowledge Search API Route', () => { it('should perform tag-only search without query', async () => { const tagOnlyData = { knowledgeBaseIds: 'kb-123', - filters: { - category: 'api', - }, + tagFilters: [{ tagName: 'category', value: 'api', fieldType: 'text', operator: 'eq' }], topK: 10, } @@ -706,10 +710,11 @@ describe('Knowledge Search API Route', () => { }, }) - // Mock tag definitions queries for filter mapping and display mapping - mockDbChain.limit - .mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for filter mapping - .mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for display mapping + // Mock tag definitions for validation + mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) + + // Mock tag definitions queries for display mapping + mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions) // Mock the tag-only search handler mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults) @@ -729,7 +734,9 @@ describe('Knowledge Search API Route', () => { expect(mockHandleTagOnlySearch).toHaveBeenCalledWith({ knowledgeBaseIds: ['kb-123'], topK: 10, - filters: { category: 'api' }, // Note: When no tag definitions are found, it uses the original filter key + structuredFilters: [ + { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined }, + ], }) }) @@ -737,9 +744,7 @@ describe('Knowledge Search API Route', () => { const combinedData = { knowledgeBaseIds: 'kb-123', query: 'test search', - filters: { - category: 'api', - }, + tagFilters: [{ tagName: 'category', value: 'api', fieldType: 'text', operator: 'eq' }], topK: 10, } @@ -754,10 +759,11 @@ describe('Knowledge Search API Route', () => { }, }) - // Mock tag definitions queries for filter mapping and display mapping - mockDbChain.limit - .mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for filter mapping - .mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for display mapping + // Mock tag definitions for validation + mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) + + // Mock tag definitions queries for display mapping + mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions) // Mock the tag + vector search handler mockHandleTagAndVectorSearch.mockResolvedValue(mockSearchResults) @@ -784,7 +790,9 @@ describe('Knowledge Search API Route', () => { expect(mockHandleTagAndVectorSearch).toHaveBeenCalledWith({ knowledgeBaseIds: ['kb-123'], topK: 10, - filters: { category: 'api' }, // Note: When no tag definitions are found, it uses the original filter key + structuredFilters: [ + { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined }, + ], queryVector: JSON.stringify(mockEmbedding), distanceThreshold: 1, // Single KB uses threshold of 1.0 }) @@ -928,10 +936,10 @@ describe('Knowledge Search API Route', () => { it('should handle tag-only search with multiple knowledge bases', async () => { const multiKbTagData = { knowledgeBaseIds: ['kb-123', 'kb-456'], - filters: { - category: 'docs', - priority: 'high', - }, + tagFilters: [ + { tagName: 'category', value: 'docs', fieldType: 'text', operator: 'eq' }, + { tagName: 'priority', value: 'high', fieldType: 'text', operator: 'eq' }, + ], topK: 10, } @@ -951,37 +959,14 @@ describe('Knowledge Search API Route', () => { knowledgeBase: { id: 'kb-456', userId: 'user-123', name: 'Test KB 2' }, }) - // Reset all mocks before setting up specific behavior - Object.values(mockDbChain).forEach((fn) => { - if (typeof fn === 'function') { - fn.mockClear().mockReturnThis() - } - }) + // Mock tag definitions for validation + mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) - // Create fresh mocks for multiple database calls needed for multi-KB tag search - const mockTagDefsQuery1 = { - ...mockDbChain, - limit: vi.fn().mockResolvedValue(mockTagDefinitions), - } - const mockTagSearchQuery = { - ...mockDbChain, - limit: vi.fn().mockResolvedValue(mockTaggedResults), - } - const mockTagDefsQuery2 = { - ...mockDbChain, - limit: vi.fn().mockResolvedValue(mockTagDefinitions), - } - const mockTagDefsQuery3 = { - ...mockDbChain, - limit: vi.fn().mockResolvedValue(mockTagDefinitions), - } + // Mock the tag-only search handler + mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults) - // Chain the mocks for: tag defs, search, display mapping KB1, display mapping KB2 - mockDbChain.select - .mockReturnValueOnce(mockTagDefsQuery1) - .mockReturnValueOnce(mockTagSearchQuery) - .mockReturnValueOnce(mockTagDefsQuery2) - .mockReturnValueOnce(mockTagDefsQuery3) + // Mock tag definitions queries for display mapping + mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions) const req = createMockRequest('POST', multiKbTagData) const { POST } = await import('@/app/api/knowledge/search/route') @@ -1076,6 +1061,11 @@ describe('Knowledge Search API Route', () => { }, }) + // Mock tag definitions for validation + mockGetDocumentTagDefinitions.mockResolvedValue([ + { tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }, + ]) + mockHandleTagOnlySearch.mockResolvedValue([ { id: 'chunk-2', @@ -1108,13 +1098,15 @@ describe('Knowledge Search API Route', () => { const mockTagDefs = { select: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(), - where: vi.fn().mockResolvedValue([]), + where: vi + .fn() + .mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]), } mockDbChain.select.mockReturnValueOnce(mockTagDefs) const req = createMockRequest('POST', { knowledgeBaseIds: ['kb-123'], - filters: { tag1: 'api' }, + tagFilters: [{ tagName: 'tag1', value: 'api', fieldType: 'text', operator: 'eq' }], topK: 10, }) @@ -1143,6 +1135,11 @@ describe('Knowledge Search API Route', () => { }, }) + // Mock tag definitions for validation + mockGetDocumentTagDefinitions.mockResolvedValue([ + { tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }, + ]) + mockHandleTagAndVectorSearch.mockResolvedValue([ { id: 'chunk-3', @@ -1176,14 +1173,16 @@ describe('Knowledge Search API Route', () => { const mockTagDefs = { select: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(), - where: vi.fn().mockResolvedValue([]), + where: vi + .fn() + .mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]), } mockDbChain.select.mockReturnValueOnce(mockTagDefs) const req = createMockRequest('POST', { knowledgeBaseIds: ['kb-123'], query: 'relevant content', - filters: { tag1: 'guide' }, + tagFilters: [{ tagName: 'tag1', value: 'guide', fieldType: 'text', operator: 'eq' }], topK: 10, }) diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index 1bd86c40a..4172ebc2d 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -1,8 +1,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { generateRequestId } from '@/lib/core/utils/request' -import { TAG_SLOTS } from '@/lib/knowledge/constants' +import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { StructuredFilter } from '@/lib/knowledge/types' import { createLogger } from '@/lib/logs/console/logger' import { estimateTokenCount } from '@/lib/tokenization/estimators' import { getUserId } from '@/app/api/auth/oauth/utils' @@ -20,6 +22,16 @@ import { calculateCost } from '@/providers/utils' const logger = createLogger('VectorSearchAPI') +/** Structured tag filter with operator support */ +const StructuredTagFilterSchema = z.object({ + tagName: z.string(), + tagSlot: z.string().optional(), + fieldType: z.enum(['text', 'number', 'date', 'boolean']).default('text'), + operator: z.string().default('eq'), + value: z.union([z.string(), z.number(), z.boolean()]), + valueTo: z.union([z.string(), z.number()]).optional(), +}) + const VectorSearchSchema = z .object({ knowledgeBaseIds: z.union([ @@ -39,18 +51,17 @@ const VectorSearchSchema = z .nullable() .default(10) .transform((val) => val ?? 10), - filters: z - .record(z.string()) + tagFilters: z + .array(StructuredTagFilterSchema) .optional() .nullable() - .transform((val) => val || undefined), // Allow dynamic filter keys (display names) + .transform((val) => val || undefined), }) .refine( (data) => { - // Ensure at least query or filters are provided const hasQuery = data.query && data.query.trim().length > 0 - const hasFilters = data.filters && Object.keys(data.filters).length > 0 - return hasQuery || hasFilters + const hasTagFilters = data.tagFilters && data.tagFilters.length > 0 + return hasQuery || hasTagFilters }, { message: 'Please provide either a search query or tag filters to search your knowledge base', @@ -88,45 +99,81 @@ export async function POST(request: NextRequest) { ) // Map display names to tag slots for filtering - let mappedFilters: Record = {} - if (validatedData.filters && accessibleKbIds.length > 0) { - try { - // Fetch tag definitions for the first accessible KB (since we're using single KB now) - const kbId = accessibleKbIds[0] - const tagDefs = await getDocumentTagDefinitions(kbId) + let structuredFilters: StructuredFilter[] = [] - logger.debug(`[${requestId}] Found tag definitions:`, tagDefs) - logger.debug(`[${requestId}] Original filters:`, validatedData.filters) + // Handle tag filters + if (validatedData.tagFilters && accessibleKbIds.length > 0) { + const kbId = accessibleKbIds[0] + const tagDefs = await getDocumentTagDefinitions(kbId) - // Create mapping from display name to tag slot - const displayNameToSlot: Record = {} - tagDefs.forEach((def) => { - displayNameToSlot[def.displayName] = def.tagSlot - }) + // Create mapping from display name to tag slot and fieldType + const displayNameToTagDef: Record = {} + tagDefs.forEach((def) => { + displayNameToTagDef[def.displayName] = { + tagSlot: def.tagSlot, + fieldType: def.fieldType, + } + }) - // Map the filters and handle OR logic - Object.entries(validatedData.filters).forEach(([key, value]) => { - if (value) { - const tagSlot = displayNameToSlot[key] || key // Fallback to key if no mapping found + // Validate all tag filters first + const undefinedTags: string[] = [] + const typeErrors: string[] = [] - // Check if this is an OR filter (contains |OR| separator) - if (value.includes('|OR|')) { - logger.debug( - `[${requestId}] OR filter detected: "${key}" -> "${tagSlot}" = "${value}"` - ) - } + for (const filter of validatedData.tagFilters) { + const tagDef = displayNameToTagDef[filter.tagName] - mappedFilters[tagSlot] = value - logger.debug(`[${requestId}] Mapped filter: "${key}" -> "${tagSlot}" = "${value}"`) - } - }) + // Check if tag exists + if (!tagDef) { + undefinedTags.push(filter.tagName) + continue + } - logger.debug(`[${requestId}] Final mapped filters:`, mappedFilters) - } catch (error) { - logger.error(`[${requestId}] Filter mapping error:`, error) - // If mapping fails, use original filters - mappedFilters = validatedData.filters + // Validate value type using shared validation + const validationError = validateTagValue( + filter.tagName, + String(filter.value), + tagDef.fieldType + ) + if (validationError) { + typeErrors.push(validationError) + } } + + // Throw combined error if there are any validation issues + if (undefinedTags.length > 0 || typeErrors.length > 0) { + const errorParts: string[] = [] + + if (undefinedTags.length > 0) { + errorParts.push(buildUndefinedTagsError(undefinedTags)) + } + + if (typeErrors.length > 0) { + errorParts.push(...typeErrors) + } + + return NextResponse.json({ error: errorParts.join('\n') }, { status: 400 }) + } + + // Build structured filters with validated data + structuredFilters = validatedData.tagFilters.map((filter) => { + const tagDef = displayNameToTagDef[filter.tagName]! + const tagSlot = filter.tagSlot || tagDef.tagSlot + const fieldType = filter.fieldType || tagDef.fieldType + + logger.debug( + `[${requestId}] Structured filter: ${filter.tagName} -> ${tagSlot} (${fieldType}) ${filter.operator} ${filter.value}` + ) + + return { + tagSlot, + fieldType, + operator: filter.operator, + value: filter.value, + valueTo: filter.valueTo, + } + }) + + logger.debug(`[${requestId}] Processed ${structuredFilters.length} structured filters`) } if (accessibleKbIds.length === 0) { @@ -155,26 +202,29 @@ export async function POST(request: NextRequest) { let results: SearchResult[] - const hasFilters = mappedFilters && Object.keys(mappedFilters).length > 0 + const hasFilters = structuredFilters && structuredFilters.length > 0 if (!hasQuery && hasFilters) { // Tag-only search without vector similarity - logger.debug(`[${requestId}] Executing tag-only search with filters:`, mappedFilters) + logger.debug(`[${requestId}] Executing tag-only search with filters:`, structuredFilters) results = await handleTagOnlySearch({ knowledgeBaseIds: accessibleKbIds, topK: validatedData.topK, - filters: mappedFilters, + structuredFilters, }) } else if (hasQuery && hasFilters) { // Tag + Vector search - logger.debug(`[${requestId}] Executing tag + vector search with filters:`, mappedFilters) + logger.debug( + `[${requestId}] Executing tag + vector search with filters:`, + structuredFilters + ) const strategy = getQueryStrategy(accessibleKbIds.length, validatedData.topK) const queryVector = JSON.stringify(await queryEmbeddingPromise) results = await handleTagAndVectorSearch({ knowledgeBaseIds: accessibleKbIds, topK: validatedData.topK, - filters: mappedFilters, + structuredFilters, queryVector, distanceThreshold: strategy.distanceThreshold, }) @@ -257,9 +307,9 @@ export async function POST(request: NextRequest) { // Create tags object with display names const tags: Record = {} - TAG_SLOTS.forEach((slot) => { + ALL_TAG_SLOTS.forEach((slot) => { const tagValue = (result as any)[slot] - if (tagValue) { + if (tagValue !== null && tagValue !== undefined) { const displayName = kbTagMap[slot] || slot logger.debug( `[${requestId}] Mapping ${slot}="${tagValue}" -> "${displayName}"="${tagValue}"` diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 1834873f5..882d65853 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -54,7 +54,7 @@ describe('Knowledge Search Utils', () => { const params = { knowledgeBaseIds: ['kb-123'], topK: 10, - filters: {}, + structuredFilters: [], } await expect(handleTagOnlySearch(params)).rejects.toThrow( @@ -66,14 +66,14 @@ describe('Knowledge Search Utils', () => { const params = { knowledgeBaseIds: ['kb-123'], topK: 10, - filters: { tag1: 'api' }, + structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }], } // This test validates the function accepts the right parameters // The actual database interaction is tested via route tests expect(params.knowledgeBaseIds).toEqual(['kb-123']) expect(params.topK).toBe(10) - expect(params.filters).toEqual({ tag1: 'api' }) + expect(params.structuredFilters).toHaveLength(1) }) }) @@ -123,7 +123,7 @@ describe('Knowledge Search Utils', () => { const params = { knowledgeBaseIds: ['kb-123'], topK: 10, - filters: {}, + structuredFilters: [], queryVector: JSON.stringify([0.1, 0.2, 0.3]), distanceThreshold: 0.8, } @@ -137,7 +137,7 @@ describe('Knowledge Search Utils', () => { const params = { knowledgeBaseIds: ['kb-123'], topK: 10, - filters: { tag1: 'api' }, + structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }], distanceThreshold: 0.8, } @@ -150,7 +150,7 @@ describe('Knowledge Search Utils', () => { const params = { knowledgeBaseIds: ['kb-123'], topK: 10, - filters: { tag1: 'api' }, + structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }], queryVector: JSON.stringify([0.1, 0.2, 0.3]), } @@ -163,7 +163,7 @@ describe('Knowledge Search Utils', () => { const params = { knowledgeBaseIds: ['kb-123'], topK: 10, - filters: { tag1: 'api' }, + structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }], queryVector: JSON.stringify([0.1, 0.2, 0.3]), distanceThreshold: 0.8, } @@ -171,7 +171,7 @@ describe('Knowledge Search Utils', () => { // This test validates the function accepts the right parameters expect(params.knowledgeBaseIds).toEqual(['kb-123']) expect(params.topK).toBe(10) - expect(params.filters).toEqual({ tag1: 'api' }) + expect(params.structuredFilters).toHaveLength(1) expect(params.queryVector).toBe(JSON.stringify([0.1, 0.2, 0.3])) expect(params.distanceThreshold).toBe(0.8) }) diff --git a/apps/sim/app/api/knowledge/search/utils.ts b/apps/sim/app/api/knowledge/search/utils.ts index b25b07fb2..74b47664d 100644 --- a/apps/sim/app/api/knowledge/search/utils.ts +++ b/apps/sim/app/api/knowledge/search/utils.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { document, embedding } from '@sim/db/schema' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import type { StructuredFilter } from '@/lib/knowledge/types' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('KnowledgeSearchUtils') @@ -34,6 +35,7 @@ export interface SearchResult { content: string documentId: string chunkIndex: number + // Text tags tag1: string | null tag2: string | null tag3: string | null @@ -41,6 +43,19 @@ export interface SearchResult { tag5: string | null tag6: string | null tag7: string | null + // Number tags (5 slots) + number1: number | null + number2: number | null + number3: number | null + number4: number | null + number5: number | null + // Date tags (2 slots) + date1: Date | null + date2: Date | null + // Boolean tags (3 slots) + boolean1: boolean | null + boolean2: boolean | null + boolean3: boolean | null distance: number knowledgeBaseId: string } @@ -48,7 +63,7 @@ export interface SearchResult { export interface SearchParams { knowledgeBaseIds: string[] topK: number - filters?: Record + structuredFilters?: StructuredFilter[] queryVector?: string distanceThreshold?: number } @@ -56,46 +71,230 @@ export interface SearchParams { // Use shared embedding utility export { generateSearchEmbedding } from '@/lib/knowledge/embeddings' -function getTagFilters(filters: Record, embedding: any) { - return Object.entries(filters).map(([key, value]) => { - // Handle OR logic within same tag - const values = value.includes('|OR|') ? value.split('|OR|') : [value] - logger.debug(`[getTagFilters] Processing ${key}="${value}" -> values:`, values) +/** All valid tag slot keys */ +const TAG_SLOT_KEYS = [ + // Text tags (7 slots) + 'tag1', + 'tag2', + 'tag3', + 'tag4', + 'tag5', + 'tag6', + 'tag7', + // Number tags (5 slots) + 'number1', + 'number2', + 'number3', + 'number4', + 'number5', + // Date tags (2 slots) + 'date1', + 'date2', + // Boolean tags (3 slots) + 'boolean1', + 'boolean2', + 'boolean3', +] as const - const getColumnForKey = (key: string) => { - switch (key) { - case 'tag1': - return embedding.tag1 - case 'tag2': - return embedding.tag2 - case 'tag3': - return embedding.tag3 - case 'tag4': - return embedding.tag4 - case 'tag5': - return embedding.tag5 - case 'tag6': - return embedding.tag6 - case 'tag7': - return embedding.tag7 - default: - return null - } +type TagSlotKey = (typeof TAG_SLOT_KEYS)[number] + +function isTagSlotKey(key: string): key is TagSlotKey { + return TAG_SLOT_KEYS.includes(key as TagSlotKey) +} + +/** Common fields selected for search results */ +const getSearchResultFields = (distanceExpr: any) => ({ + id: embedding.id, + content: embedding.content, + documentId: embedding.documentId, + chunkIndex: embedding.chunkIndex, + // Text tags + tag1: embedding.tag1, + tag2: embedding.tag2, + tag3: embedding.tag3, + tag4: embedding.tag4, + tag5: embedding.tag5, + tag6: embedding.tag6, + tag7: embedding.tag7, + // Number tags (5 slots) + number1: embedding.number1, + number2: embedding.number2, + number3: embedding.number3, + number4: embedding.number4, + number5: embedding.number5, + // Date tags (2 slots) + date1: embedding.date1, + date2: embedding.date2, + // Boolean tags (3 slots) + boolean1: embedding.boolean1, + boolean2: embedding.boolean2, + boolean3: embedding.boolean3, + distance: distanceExpr, + knowledgeBaseId: embedding.knowledgeBaseId, +}) + +/** + * Build a single SQL condition for a filter + */ +function buildFilterCondition(filter: StructuredFilter, embeddingTable: any) { + const { tagSlot, fieldType, operator, value, valueTo } = filter + + if (!isTagSlotKey(tagSlot)) { + logger.debug(`[getStructuredTagFilters] Unknown tag slot: ${tagSlot}`) + return null + } + + const column = embeddingTable[tagSlot] + if (!column) return null + + logger.debug( + `[getStructuredTagFilters] Processing ${tagSlot} (${fieldType}) ${operator} ${value}` + ) + + // Handle text operators + if (fieldType === 'text') { + const stringValue = String(value) + switch (operator) { + case 'eq': + return sql`LOWER(${column}) = LOWER(${stringValue})` + case 'neq': + return sql`LOWER(${column}) != LOWER(${stringValue})` + case 'contains': + return sql`LOWER(${column}) LIKE LOWER(${`%${stringValue}%`})` + case 'not_contains': + return sql`LOWER(${column}) NOT LIKE LOWER(${`%${stringValue}%`})` + case 'starts_with': + return sql`LOWER(${column}) LIKE LOWER(${`${stringValue}%`})` + case 'ends_with': + return sql`LOWER(${column}) LIKE LOWER(${`%${stringValue}`})` + default: + return sql`LOWER(${column}) = LOWER(${stringValue})` + } + } + + // Handle number operators + if (fieldType === 'number') { + const numValue = typeof value === 'number' ? value : Number.parseFloat(String(value)) + if (Number.isNaN(numValue)) return null + + switch (operator) { + case 'eq': + return sql`${column} = ${numValue}` + case 'neq': + return sql`${column} != ${numValue}` + case 'gt': + return sql`${column} > ${numValue}` + case 'gte': + return sql`${column} >= ${numValue}` + case 'lt': + return sql`${column} < ${numValue}` + case 'lte': + return sql`${column} <= ${numValue}` + case 'between': + if (valueTo !== undefined) { + const numValueTo = + typeof valueTo === 'number' ? valueTo : Number.parseFloat(String(valueTo)) + if (Number.isNaN(numValueTo)) return sql`${column} = ${numValue}` + return sql`${column} >= ${numValue} AND ${column} <= ${numValueTo}` + } + return sql`${column} = ${numValue}` + default: + return sql`${column} = ${numValue}` + } + } + + // Handle date operators - expects YYYY-MM-DD format from frontend + if (fieldType === 'date') { + const dateStr = String(value) + // Validate YYYY-MM-DD format + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + logger.debug(`[getStructuredTagFilters] Invalid date format: ${dateStr}, expected YYYY-MM-DD`) + return null } - const column = getColumnForKey(key) - if (!column) return sql`1=1` // No-op for unknown keys - - if (values.length === 1) { - // Single value - simple equality - logger.debug(`[getTagFilters] Single value filter: ${key} = ${values[0]}`) - return sql`LOWER(${column}) = LOWER(${values[0]})` + switch (operator) { + case 'eq': + return sql`${column}::date = ${dateStr}::date` + case 'neq': + return sql`${column}::date != ${dateStr}::date` + case 'gt': + return sql`${column}::date > ${dateStr}::date` + case 'gte': + return sql`${column}::date >= ${dateStr}::date` + case 'lt': + return sql`${column}::date < ${dateStr}::date` + case 'lte': + return sql`${column}::date <= ${dateStr}::date` + case 'between': + if (valueTo !== undefined) { + const dateStrTo = String(valueTo) + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStrTo)) { + return sql`${column}::date = ${dateStr}::date` + } + return sql`${column}::date >= ${dateStr}::date AND ${column}::date <= ${dateStrTo}::date` + } + return sql`${column}::date = ${dateStr}::date` + default: + return sql`${column}::date = ${dateStr}::date` } - // Multiple values - OR logic - logger.debug(`[getTagFilters] OR filter: ${key} IN (${values.join(', ')})`) - const orConditions = values.map((v) => sql`LOWER(${column}) = LOWER(${v})`) - return sql`(${sql.join(orConditions, sql` OR `)})` - }) + } + + // Handle boolean operators + if (fieldType === 'boolean') { + const boolValue = value === true || value === 'true' + switch (operator) { + case 'eq': + return sql`${column} = ${boolValue}` + case 'neq': + return sql`${column} != ${boolValue}` + default: + return sql`${column} = ${boolValue}` + } + } + + // Fallback to equality + return sql`${column} = ${value}` +} + +/** + * Build SQL conditions from structured filters with operator support + * - Same tag multiple times: OR logic + * - Different tags: AND logic + */ +function getStructuredTagFilters(filters: StructuredFilter[], embeddingTable: any) { + // Group filters by tagSlot + const filtersBySlot = new Map() + for (const filter of filters) { + const slot = filter.tagSlot + if (!filtersBySlot.has(slot)) { + filtersBySlot.set(slot, []) + } + filtersBySlot.get(slot)!.push(filter) + } + + // Build conditions: OR within same slot, AND across different slots + const conditions: ReturnType[] = [] + + for (const [slot, slotFilters] of filtersBySlot) { + const slotConditions = slotFilters + .map((f) => buildFilterCondition(f, embeddingTable)) + .filter((c): c is ReturnType => c !== null) + + if (slotConditions.length === 0) continue + + if (slotConditions.length === 1) { + // Single condition for this slot + conditions.push(slotConditions[0]) + } else { + // Multiple conditions for same slot - OR them together + logger.debug( + `[getStructuredTagFilters] OR'ing ${slotConditions.length} conditions for ${slot}` + ) + conditions.push(sql`(${sql.join(slotConditions, sql` OR `)})`) + } + } + + return conditions } export function getQueryStrategy(kbCount: number, topK: number) { @@ -113,8 +312,10 @@ export function getQueryStrategy(kbCount: number, topK: number) { async function executeTagFilterQuery( knowledgeBaseIds: string[], - filters: Record + structuredFilters: StructuredFilter[] ): Promise<{ id: string }[]> { + const tagFilterConditions = getStructuredTagFilters(structuredFilters, embedding) + if (knowledgeBaseIds.length === 1) { return await db .select({ id: embedding.id }) @@ -125,7 +326,7 @@ async function executeTagFilterQuery( eq(embedding.knowledgeBaseId, knowledgeBaseIds[0]), eq(embedding.enabled, true), isNull(document.deletedAt), - ...getTagFilters(filters, embedding) + ...tagFilterConditions ) ) } @@ -138,7 +339,7 @@ async function executeTagFilterQuery( inArray(embedding.knowledgeBaseId, knowledgeBaseIds), eq(embedding.enabled, true), isNull(document.deletedAt), - ...getTagFilters(filters, embedding) + ...tagFilterConditions ) ) } @@ -154,21 +355,11 @@ async function executeVectorSearchOnIds( } return await db - .select({ - id: embedding.id, - content: embedding.content, - documentId: embedding.documentId, - chunkIndex: embedding.chunkIndex, - tag1: embedding.tag1, - tag2: embedding.tag2, - tag3: embedding.tag3, - tag4: embedding.tag4, - tag5: embedding.tag5, - tag6: embedding.tag6, - tag7: embedding.tag7, - distance: sql`${embedding.embedding} <=> ${queryVector}::vector`.as('distance'), - knowledgeBaseId: embedding.knowledgeBaseId, - }) + .select( + getSearchResultFields( + sql`${embedding.embedding} <=> ${queryVector}::vector`.as('distance') + ) + ) .from(embedding) .innerJoin(document, eq(embedding.documentId, document.id)) .where( @@ -183,15 +374,16 @@ async function executeVectorSearchOnIds( } export async function handleTagOnlySearch(params: SearchParams): Promise { - const { knowledgeBaseIds, topK, filters } = params + const { knowledgeBaseIds, topK, structuredFilters } = params - if (!filters || Object.keys(filters).length === 0) { + if (!structuredFilters || structuredFilters.length === 0) { throw new Error('Tag filters are required for tag-only search') } - logger.debug(`[handleTagOnlySearch] Executing tag-only search with filters:`, filters) + logger.debug(`[handleTagOnlySearch] Executing tag-only search with filters:`, structuredFilters) const strategy = getQueryStrategy(knowledgeBaseIds.length, topK) + const tagFilterConditions = getStructuredTagFilters(structuredFilters, embedding) if (strategy.useParallel) { // Parallel approach for many KBs @@ -199,21 +391,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise { return await db - .select({ - id: embedding.id, - content: embedding.content, - documentId: embedding.documentId, - chunkIndex: embedding.chunkIndex, - tag1: embedding.tag1, - tag2: embedding.tag2, - tag3: embedding.tag3, - tag4: embedding.tag4, - tag5: embedding.tag5, - tag6: embedding.tag6, - tag7: embedding.tag7, - distance: sql`0`.as('distance'), // No distance for tag-only searches - knowledgeBaseId: embedding.knowledgeBaseId, - }) + .select(getSearchResultFields(sql`0`.as('distance'))) .from(embedding) .innerJoin(document, eq(embedding.documentId, document.id)) .where( @@ -221,7 +399,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise`0`.as('distance'), // No distance for tag-only searches - knowledgeBaseId: embedding.knowledgeBaseId, - }) + .select(getSearchResultFields(sql`0`.as('distance'))) .from(embedding) .innerJoin(document, eq(embedding.documentId, document.id)) .where( @@ -254,7 +418,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise`${embedding.embedding} <=> ${queryVector}::vector`.as('distance') + if (strategy.useParallel) { // Parallel approach for many KBs const parallelLimit = Math.ceil(topK / knowledgeBaseIds.length) + 5 const queryPromises = knowledgeBaseIds.map(async (kbId) => { return await db - .select({ - id: embedding.id, - content: embedding.content, - documentId: embedding.documentId, - chunkIndex: embedding.chunkIndex, - tag1: embedding.tag1, - tag2: embedding.tag2, - tag3: embedding.tag3, - tag4: embedding.tag4, - tag5: embedding.tag5, - tag6: embedding.tag6, - tag7: embedding.tag7, - distance: sql`${embedding.embedding} <=> ${queryVector}::vector`.as('distance'), - knowledgeBaseId: embedding.knowledgeBaseId, - }) + .select(getSearchResultFields(distanceExpr)) .from(embedding) .innerJoin(document, eq(embedding.documentId, document.id)) .where( @@ -312,21 +464,7 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise`${embedding.embedding} <=> ${queryVector}::vector`.as('distance'), - knowledgeBaseId: embedding.knowledgeBaseId, - }) + .select(getSearchResultFields(distanceExpr)) .from(embedding) .innerJoin(document, eq(embedding.documentId, document.id)) .where( @@ -342,19 +480,22 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise { - const { knowledgeBaseIds, topK, filters, queryVector, distanceThreshold } = params + const { knowledgeBaseIds, topK, structuredFilters, queryVector, distanceThreshold } = params - if (!filters || Object.keys(filters).length === 0) { + if (!structuredFilters || structuredFilters.length === 0) { throw new Error('Tag filters are required for tag and vector search') } if (!queryVector || !distanceThreshold) { throw new Error('Query vector and distance threshold are required for tag and vector search') } - logger.debug(`[handleTagAndVectorSearch] Executing tag + vector search with filters:`, filters) + logger.debug( + `[handleTagAndVectorSearch] Executing tag + vector search with filters:`, + structuredFilters + ) // Step 1: Filter by tags first - const tagFilteredIds = await executeTagFilterQuery(knowledgeBaseIds, filters) + const tagFilteredIds = await executeTagFilterQuery(knowledgeBaseIds, structuredFilters) if (tagFilteredIds.length === 0) { logger.debug(`[handleTagAndVectorSearch] No results found after tag filtering`) diff --git a/apps/sim/app/api/knowledge/utils.ts b/apps/sim/app/api/knowledge/utils.ts index d1fc0d867..b1f796a1a 100644 --- a/apps/sim/app/api/knowledge/utils.ts +++ b/apps/sim/app/api/knowledge/utils.ts @@ -35,7 +35,7 @@ export interface DocumentData { enabled: boolean deletedAt?: Date | null uploadedAt: Date - // Document tags + // Text tags tag1?: string | null tag2?: string | null tag3?: string | null @@ -43,6 +43,19 @@ export interface DocumentData { tag5?: string | null tag6?: string | null tag7?: string | null + // Number tags (5 slots) + number1?: number | null + number2?: number | null + number3?: number | null + number4?: number | null + number5?: number | null + // Date tags (2 slots) + date1?: Date | null + date2?: Date | null + // Boolean tags (3 slots) + boolean1?: boolean | null + boolean2?: boolean | null + boolean3?: boolean | null } export interface EmbeddingData { @@ -58,7 +71,7 @@ export interface EmbeddingData { embeddingModel: string startOffset: number endOffset: number - // Tag fields for filtering + // Text tags tag1?: string | null tag2?: string | null tag3?: string | null @@ -66,6 +79,19 @@ export interface EmbeddingData { tag5?: string | null tag6?: string | null tag7?: string | null + // Number tags (5 slots) + number1?: number | null + number2?: number | null + number3?: number | null + number4?: number | null + number5?: number | null + // Date tags (2 slots) + date1?: Date | null + date2?: Date | null + // Boolean tags (3 slots) + boolean1?: boolean | null + boolean2?: boolean | null + boolean3?: boolean | null enabled: boolean createdAt: Date updatedAt: Date @@ -232,6 +258,27 @@ export async function checkDocumentWriteAccess( processingStartedAt: document.processingStartedAt, processingCompletedAt: document.processingCompletedAt, knowledgeBaseId: document.knowledgeBaseId, + // Text tags + tag1: document.tag1, + tag2: document.tag2, + tag3: document.tag3, + tag4: document.tag4, + tag5: document.tag5, + tag6: document.tag6, + tag7: document.tag7, + // Number tags (5 slots) + number1: document.number1, + number2: document.number2, + number3: document.number3, + number4: document.number4, + number5: document.number5, + // Date tags (2 slots) + date1: document.date1, + date2: document.date2, + // Boolean tags (3 slots) + boolean1: document.boolean1, + boolean2: document.boolean2, + boolean3: document.boolean3, }) .from(document) .where(and(eq(document.id, documentId), isNull(document.deletedAt))) diff --git a/apps/sim/app/api/tools/stagehand/agent/route.ts b/apps/sim/app/api/tools/stagehand/agent/route.ts index 1e5f480fc..d1aeeb095 100644 --- a/apps/sim/app/api/tools/stagehand/agent/route.ts +++ b/apps/sim/app/api/tools/stagehand/agent/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { env } from '@/lib/core/config/env' +import { isSensitiveKey, REDACTED_MARKER } from '@/lib/core/security/redaction' import { createLogger } from '@/lib/logs/console/logger' import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils' @@ -188,7 +189,7 @@ export async function POST(request: NextRequest) { if (variablesObject && Object.keys(variablesObject).length > 0) { const safeVarKeys = Object.keys(variablesObject).map((key) => { - return key.toLowerCase().includes('password') ? `${key}: [REDACTED]` : key + return isSensitiveKey(key) ? `${key}: ${REDACTED_MARKER}` : key }) logger.info('Variables available for task', { variables: safeVarKeys }) } diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index def0eff81..abae66199 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -14,6 +14,7 @@ const mockGetWorkflowById = vi.fn() const mockGetWorkflowAccessContext = vi.fn() const mockDbDelete = vi.fn() const mockDbUpdate = vi.fn() +const mockDbSelect = vi.fn() vi.mock('@/lib/auth', () => ({ getSession: () => mockGetSession(), @@ -49,6 +50,7 @@ vi.mock('@sim/db', () => ({ db: { delete: () => mockDbDelete(), update: () => mockDbUpdate(), + select: () => mockDbSelect(), }, workflow: {}, })) @@ -327,6 +329,13 @@ describe('Workflow By ID API Route', () => { isWorkspaceOwner: false, }) + // Mock db.select() to return multiple workflows so deletion is allowed + mockDbSelect.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]), + }), + }) + mockDbDelete.mockReturnValue({ where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]), }) @@ -347,6 +356,46 @@ describe('Workflow By ID API Route', () => { expect(data.success).toBe(true) }) + it('should prevent deletion of the last workflow in workspace', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'Test Workflow', + workspaceId: 'workspace-456', + } + + mockGetSession.mockResolvedValue({ + user: { id: 'user-123' }, + }) + + mockGetWorkflowById.mockResolvedValue(mockWorkflow) + mockGetWorkflowAccessContext.mockResolvedValue({ + workflow: mockWorkflow, + workspaceOwnerId: 'workspace-456', + workspacePermission: 'admin', + isOwner: true, + isWorkspaceOwner: false, + }) + + // Mock db.select() to return only 1 workflow (the one being deleted) + mockDbSelect.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]), + }), + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'DELETE', + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const response = await DELETE(req, { params }) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('Cannot delete the only workflow in the workspace') + }) + it.concurrent('should deny deletion for non-admin users', async () => { const mockWorkflow = { id: 'workflow-123', diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index e62c245da..c4bab613d 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -228,6 +228,21 @@ export async function DELETE( return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + // Check if this is the last workflow in the workspace + if (workflowData.workspaceId) { + const totalWorkflowsInWorkspace = await db + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.workspaceId, workflowData.workspaceId)) + + if (totalWorkflowsInWorkspace.length <= 1) { + return NextResponse.json( + { error: 'Cannot delete the only workflow in the workspace' }, + { status: 400 } + ) + } + } + // Check if workflow has published templates before deletion const { searchParams } = new URL(request.url) const checkTemplates = searchParams.get('check-templates') === 'true' diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts index 11d94cb0f..f29df67dc 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts @@ -98,23 +98,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const workspaceRows = await db - .select({ billedAccountUserId: workspace.billedAccountUserId }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) - - if (!workspaceRows.length) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - if (workspaceRows[0].billedAccountUserId !== userId) { - return NextResponse.json( - { error: 'Only the workspace billing account can create workspace API keys' }, - { status: 403 } - ) - } - const body = await request.json() const { name } = CreateKeySchema.parse(body) @@ -202,23 +185,6 @@ export async function DELETE( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const workspaceRows = await db - .select({ billedAccountUserId: workspace.billedAccountUserId }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) - - if (!workspaceRows.length) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - if (workspaceRows[0].billedAccountUserId !== userId) { - return NextResponse.json( - { error: 'Only the workspace billing account can delete workspace API keys' }, - { status: 403 } - ) - } - const body = await request.json() const { keys } = DeleteKeysSchema.parse(body) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx index d35568d70..1f40cd20d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx @@ -5,6 +5,7 @@ import { Loader2 } from 'lucide-react' import { Button, Combobox, + DatePicker, Input, Label, Modal, @@ -15,7 +16,7 @@ import { Trash, } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' -import { MAX_TAG_SLOTS, TAG_SLOTS, type TagSlot } from '@/lib/knowledge/constants' +import { ALL_TAG_SLOTS, type AllTagSlot, MAX_TAG_SLOTS } from '@/lib/knowledge/constants' import type { DocumentTag } from '@/lib/knowledge/tags/types' import { createLogger } from '@/lib/logs/console/logger' import { @@ -28,6 +29,54 @@ import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store' const logger = createLogger('DocumentTagsModal') +/** Field type display labels */ +const FIELD_TYPE_LABELS: Record = { + text: 'Text', + number: 'Number', + date: 'Date', + boolean: 'Boolean', +} + +/** + * Gets the appropriate value when changing field types. + * Clears value when type changes to allow placeholder to show. + */ +function getValueForFieldType( + newFieldType: string, + currentFieldType: string, + currentValue: string +): string { + return newFieldType === currentFieldType ? currentValue : '' +} + +/** Format value for display based on field type */ +function formatValueForDisplay(value: string, fieldType: string): string { + if (!value) return '' + switch (fieldType) { + case 'boolean': + return value === 'true' ? 'True' : 'False' + case 'date': + try { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + // For UTC dates, display the UTC date to prevent timezone shifts + // e.g., 2002-05-16T00:00:00.000Z should show as "May 16, 2002" not "May 15, 2002" + if (typeof value === 'string' && (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value))) { + return new Date( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate() + ).toLocaleDateString() + } + return date.toLocaleDateString() + } catch { + return value + } + default: + return value + } +} + interface DocumentTagsModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -67,17 +116,21 @@ export function DocumentTagsModal({ const buildDocumentTags = useCallback((docData: DocumentData, definitions: TagDefinition[]) => { const tags: DocumentTag[] = [] - TAG_SLOTS.forEach((slot) => { - const value = docData[slot] as string | null | undefined + ALL_TAG_SLOTS.forEach((slot) => { + const rawValue = docData[slot] const definition = definitions.find((def) => def.tagSlot === slot) - if (value?.trim() && definition) { - tags.push({ - slot, - displayName: definition.displayName, - fieldType: definition.fieldType, - value: value.trim(), - }) + if (rawValue !== null && rawValue !== undefined && definition) { + // Convert value to string for storage + const stringValue = String(rawValue).trim() + if (stringValue) { + tags.push({ + slot, + displayName: definition.displayName, + fieldType: definition.fieldType, + value: stringValue, + }) + } } }) @@ -95,13 +148,15 @@ export function DocumentTagsModal({ try { const tagData: Record = {} - TAG_SLOTS.forEach((slot) => { - tagData[slot] = '' - }) - - tagsToSave.forEach((tag) => { - if (tag.value.trim()) { - tagData[tag.slot] = tag.value.trim() + // Only include tags that have values (omit empty ones) + // Use empty string for slots that should be cleared + ALL_TAG_SLOTS.forEach((slot) => { + const tag = tagsToSave.find((t) => t.slot === slot) + if (tag?.value.trim()) { + tagData[slot] = tag.value.trim() + } else { + // Use empty string to clear a tag (API schema expects string, not null) + tagData[slot] = '' } }) @@ -117,8 +172,8 @@ export function DocumentTagsModal({ throw new Error('Failed to update document tags') } - updateDocumentInStore(knowledgeBaseId, documentId, tagData) - onDocumentUpdate?.(tagData) + updateDocumentInStore(knowledgeBaseId, documentId, tagData as Record) + onDocumentUpdate?.(tagData as Record) await fetchTagDefinitions() } catch (error) { @@ -279,7 +334,7 @@ export function DocumentTagsModal({ const newDefinition: TagDefinitionInput = { displayName: formData.displayName, fieldType: formData.fieldType, - tagSlot: targetSlot as TagSlot, + tagSlot: targetSlot as AllTagSlot, } if (saveTagDefinitions) { @@ -359,20 +414,7 @@ export function DocumentTagsModal({
- - - {documentTags.length === 0 && !isCreatingTag && ( -
-

- No tags added yet. Add tags to help organize this document. -

-
- )} + {documentTags.map((tag, index) => (
@@ -383,9 +425,12 @@ export function DocumentTagsModal({ {tag.displayName} + + {FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType} +
- {tag.value} + {formatValueForDisplay(tag.value, tag.fieldType)}
- {/* Type selector commented out - only "text" type is currently supported -
- - -
- */} -
- - setEditTagForm({ ...editTagForm, value: e.target.value }) - } - placeholder='Enter tag value' - onKeyDown={(e) => { - if (e.key === 'Enter' && canSaveTag) { - e.preventDefault() - saveDocumentTag() + {editTagForm.fieldType === 'boolean' ? ( + setEditTagForm({ ...editTagForm, value })} + placeholder='Select value' + /> + ) : editTagForm.fieldType === 'number' ? ( + { + const val = e.target.value + // Allow empty, digits, decimal point, and negative sign + if (val === '' || /^-?\d*\.?\d*$/.test(val)) { + setEditTagForm({ ...editTagForm, value: val }) + } + }} + placeholder='Enter number' + inputMode='decimal' + onKeyDown={(e) => { + if (e.key === 'Enter' && canSaveTag) { + e.preventDefault() + saveDocumentTag() + } + if (e.key === 'Escape') { + e.preventDefault() + cancelEditingTag() + } + }} + /> + ) : editTagForm.fieldType === 'date' ? ( + setEditTagForm({ ...editTagForm, value })} + placeholder='Select date' + /> + ) : ( + + setEditTagForm({ ...editTagForm, value: e.target.value }) } - if (e.key === 'Escape') { - e.preventDefault() - cancelEditingTag() - } - }} - /> + placeholder='Enter tag value' + onKeyDown={(e) => { + if (e.key === 'Enter' && canSaveTag) { + e.preventDefault() + saveDocumentTag() + } + if (e.key === 'Escape') { + e.preventDefault() + cancelEditingTag() + } + }} + /> + )}
@@ -500,7 +588,7 @@ export function DocumentTagsModal({
))} - {!isTagEditing && ( + {documentTags.length > 0 && !isTagEditing && ( + {documentTags.length > 0 && ( + + )}
- {/* Type selector commented out - only "text" type is currently supported
- + + setCreateTagForm({ ...createTagForm, fieldType: value }) + } + placeholder='Select type' + /> + {!hasAvailableSlots(createTagForm.fieldType) && ( + + No available slots for this type. Choose a different type. + + )}
- */}
- ) : null + ) } - // Show pre-fill button if there are available tags and only empty rows - const showPreFillButton = - tagDefinitions.length > 0 && - rows.length === 1 && - !rows[0].cells.tagName && - !rows[0].cells.value && - !isPreview && - !disabled - return ( -
- {showPreFillButton && ( -
- -
- )} -
- +
+
+
{renderHeader()} - + {rows.map((row, rowIndex) => ( - + {renderTagNameCell(row, rowIndex)} - {renderTypeCell(row, rowIndex)} {renderValueCell(row, rowIndex)} {renderDeleteButton(rowIndex)} @@ -551,24 +375,13 @@ export function DocumentTagEntry({
- {/* Add Row Button and Tag slots usage indicator */} + {/* Add Tag Button */} {!isPreview && !disabled && ( -
- - - {/* Tag slots usage indicator */} -
- {tagDefinitions.length + newTagsBeingCreated} of {MAX_TAG_SLOTS} tag slots used -
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx index dec77c5ad..38bfef146 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx @@ -2,10 +2,10 @@ import { useState } from 'react' import { Plus } from 'lucide-react' -import { Trash } from '@/components/emcn/icons/trash' -import { Button } from '@/components/ui/button' +import { Button, Combobox, type ComboboxOption, Label, Trash } from '@/components/emcn' import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' +import { FIELD_TYPE_LABELS, getPlaceholderForFieldType } from '@/lib/knowledge/constants' +import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { checkTagTrigger, @@ -20,14 +20,22 @@ import { useSubBlockValue } from '../../hooks/use-sub-block-value' interface TagFilter { id: string tagName: string + tagSlot?: string + fieldType: FilterFieldType + operator: string tagValue: string + valueTo?: string // For 'between' operator } interface TagFilterRow { id: string cells: { tagName: string + tagSlot?: string + fieldType: FilterFieldType + operator: string value: string + valueTo?: string } } @@ -47,21 +55,15 @@ export function KnowledgeTagFilters({ previewValue, }: KnowledgeTagFiltersProps) { const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) - - // Hook for immediate tag/dropdown selections const emitTagSelection = useTagSelection(blockId, subBlock.id) - // Get the knowledge base ID from other sub-blocks const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId') const knowledgeBaseId = knowledgeBaseIdValue || null - // Use KB tag definitions hook to get available tags const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId) - // Get accessible prefixes for variable highlighting const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) - // State for managing tag dropdown const [activeTagDropdown, setActiveTagDropdown] = useState<{ rowIndex: number showTags: boolean @@ -70,14 +72,15 @@ export function KnowledgeTagFilters({ element?: HTMLElement | null } | null>(null) - // State for dropdown visibility - one for each row - const [dropdownStates, setDropdownStates] = useState>({}) - - // Parse the current value to extract filters const parseFilters = (filterValue: string | null): TagFilter[] => { if (!filterValue) return [] try { - return JSON.parse(filterValue) + const parsed = JSON.parse(filterValue) + return parsed.map((f: TagFilter) => ({ + ...f, + fieldType: f.fieldType || 'text', + operator: f.operator || 'eq', + })) } catch { return [] } @@ -86,20 +89,23 @@ export function KnowledgeTagFilters({ const currentValue = isPreview ? previewValue : storeValue const filters = parseFilters(currentValue || null) - // Transform filters to table format for display const rows: TagFilterRow[] = filters.length > 0 ? filters.map((filter) => ({ id: filter.id, cells: { tagName: filter.tagName || '', + tagSlot: filter.tagSlot, + fieldType: filter.fieldType || 'text', + operator: filter.operator || 'eq', value: filter.tagValue || '', + valueTo: filter.valueTo, }, })) : [ { id: 'empty-row-0', - cells: { tagName: '', value: '' }, + cells: { tagName: '', fieldType: 'text', operator: '', value: '' }, }, ] @@ -109,27 +115,72 @@ export function KnowledgeTagFilters({ setStoreValue(value) } - const handleCellChange = (rowIndex: number, column: string, value: string) => { + const rowsToFilters = (rowsToConvert: TagFilterRow[]): TagFilter[] => { + return rowsToConvert + .filter((row) => row.cells.tagName?.trim()) + .map((row) => ({ + id: row.id, + tagName: row.cells.tagName || '', + tagSlot: row.cells.tagSlot, + fieldType: row.cells.fieldType || 'text', + operator: row.cells.operator || 'eq', + tagValue: row.cells.value || '', + valueTo: row.cells.valueTo, + })) + } + + const handleCellChange = (rowIndex: number, column: string, value: string | FilterFieldType) => { if (isPreview || disabled) return + const updatedRows = [...rows].map((row, idx) => { + if (idx === rowIndex) { + const newCells = { ...row.cells, [column]: value } + + if (column === 'fieldType') { + const operators = getOperatorsForFieldType(value as FilterFieldType) + newCells.operator = operators[0]?.value || 'eq' + newCells.value = '' + newCells.valueTo = undefined + } + + if (column === 'operator' && value !== 'between') { + newCells.valueTo = undefined + } + + return { ...row, cells: newCells } + } + return row + }) + + updateFilters(rowsToFilters(updatedRows)) + } + + const handleTagNameSelection = (rowIndex: number, tagName: string) => { + if (isPreview || disabled) return + + const tagDef = tagDefinitions.find((t) => t.displayName === tagName) + const fieldType = (tagDef?.fieldType || 'text') as FilterFieldType + const operators = getOperatorsForFieldType(fieldType) + const updatedRows = [...rows].map((row, idx) => { if (idx === rowIndex) { return { ...row, - cells: { ...row.cells, [column]: value }, + cells: { + ...row.cells, + tagName, + tagSlot: tagDef?.tagSlot, + fieldType, + operator: operators[0]?.value || 'eq', + value: '', + valueTo: undefined, + }, } } return row }) - // Convert back to TagFilter format - keep all rows, even empty ones - const updatedFilters = updatedRows.map((row) => ({ - id: row.id, - tagName: row.cells.tagName || '', - tagValue: row.cells.value || '', - })) - - updateFilters(updatedFilters) + updateFilters(rowsToFilters(updatedRows)) } const handleTagDropdownSelection = (rowIndex: number, column: string, value: string) => { @@ -145,36 +196,36 @@ export function KnowledgeTagFilters({ return row }) - // Convert back to TagFilter format - keep all rows, even empty ones - const updatedFilters = updatedRows.map((row) => ({ - id: row.id, - tagName: row.cells.tagName || '', - tagValue: row.cells.value || '', - })) - - const jsonValue = updatedFilters.length > 0 ? JSON.stringify(updatedFilters) : null + const jsonValue = + rowsToFilters(updatedRows).length > 0 ? JSON.stringify(rowsToFilters(updatedRows)) : null emitTagSelection(jsonValue) } const handleAddRow = () => { if (isPreview || disabled) return - const newRowId = `filter-${filters.length}-${Math.random().toString(36).substr(2, 9)}` - const newFilters = [...filters, { id: newRowId, tagName: '', tagValue: '' }] - updateFilters(newFilters) + const newRowId = `filter-${filters.length}-${Math.random().toString(36).slice(2, 11)}` + const newFilter: TagFilter = { + id: newRowId, + tagName: '', + fieldType: 'text', + operator: 'eq', + tagValue: '', + } + updateFilters([...filters, newFilter]) } const handleDeleteRow = (rowIndex: number) => { - if (isPreview || disabled || rows.length <= 1) return + if (isPreview || disabled) return + + if (rows.length <= 1) { + // Clear the single row instead of deleting + setStoreValue(null) + return + } + const updatedRows = rows.filter((_, idx) => idx !== rowIndex) - - const updatedFilters = updatedRows.map((row) => ({ - id: row.id, - tagName: row.cells.tagName || '', - tagValue: row.cells.value || '', - })) - - updateFilters(updatedFilters) + updateFilters(rowsToFilters(updatedRows)) } if (isPreview) { @@ -191,108 +242,88 @@ export function KnowledgeTagFilters({ } const renderHeader = () => ( - - - Tag Name - Value + + + + Tag + + + Operator + + + Value + ) const renderTagNameCell = (row: TagFilterRow, rowIndex: number) => { const cellValue = row.cells.tagName || '' - const showDropdown = dropdownStates[rowIndex] || false - const setShowDropdown = (show: boolean) => { - setDropdownStates((prev) => ({ ...prev, [rowIndex]: show })) - } - - const handleDropdownClick = (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - if (!disabled && !isLoading) { - if (!showDropdown) { - setShowDropdown(true) - } - } - } - - const handleFocus = () => { - if (!disabled && !isLoading) { - setShowDropdown(true) - } - } - - const handleBlur = () => { - // Delay closing to allow dropdown selection - setTimeout(() => setShowDropdown(false), 150) - } + const tagOptions: ComboboxOption[] = tagDefinitions.map((tag) => ({ + value: tag.displayName, + label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`, + })) return ( - -
- -
-
- {formatDisplayText(cellValue || 'Select tag', { - accessiblePrefixes, - highlightAll: !accessiblePrefixes, - })} -
-
- {showDropdown && tagDefinitions.length > 0 && ( -
-
-
- {tagDefinitions.map((tag) => ( -
{ - e.preventDefault() - handleCellChange(rowIndex, 'tagName', tag.displayName) - setShowDropdown(false) - }} - > - {tag.displayName} -
- ))} -
-
-
- )} -
+ + handleTagNameSelection(rowIndex, value)} + disabled={disabled || isLoading} + placeholder='Select tag' + className='!border-0 !bg-transparent hover:!bg-transparent px-[10px] py-[8px] font-medium text-sm leading-[21px] focus-visible:ring-0 focus-visible:ring-offset-0 [&>span]:truncate' + /> + + ) + } + + const renderOperatorCell = (row: TagFilterRow, rowIndex: number) => { + const fieldType = row.cells.fieldType || 'text' + const operator = row.cells.operator || '' + const operators = getOperatorsForFieldType(fieldType) + const isOperatorDisabled = disabled || !row.cells.tagName + + const operatorOptions: ComboboxOption[] = operators.map((op) => ({ + value: op.value, + label: op.label, + })) + + return ( + + handleCellChange(rowIndex, 'operator', value)} + disabled={isOperatorDisabled} + placeholder='Select operator' + className='!border-0 !bg-transparent hover:!bg-transparent px-[10px] py-[8px] font-medium text-sm leading-[21px] focus-visible:ring-0 focus-visible:ring-offset-0 [&>span]:truncate' + /> ) } const renderValueCell = (row: TagFilterRow, rowIndex: number) => { const cellValue = row.cells.value || '' + const fieldType = row.cells.fieldType || 'text' + const operator = row.cells.operator || 'eq' + const isBetween = operator === 'between' + const valueTo = row.cells.valueTo || '' + const isDisabled = disabled || !row.cells.tagName + const placeholder = getPlaceholderForFieldType(fieldType) - return ( - -
- { - const newValue = e.target.value - const cursorPosition = e.target.selectionStart ?? 0 + const renderInput = (value: string, column: 'value' | 'valueTo') => ( +
+ { + const newValue = e.target.value + const cursorPosition = e.target.selectionStart ?? 0 - handleCellChange(rowIndex, 'value', newValue) + handleCellChange(rowIndex, column, newValue) - // Check for tag trigger + if (column === 'value') { const tagTrigger = checkTagTrigger(newValue, cursorPosition) setActiveTagDropdown({ @@ -302,58 +333,78 @@ export function KnowledgeTagFilters({ activeSourceBlockId: null, element: e.target, }) - }} - onFocus={(e) => { - if (!disabled) { - setActiveTagDropdown({ - rowIndex, - showTags: false, - cursorPosition: 0, - activeSourceBlockId: null, - element: e.target, - }) - } - }} - onBlur={() => { + } + }} + onFocus={(e) => { + if (!isDisabled && column === 'value') { + setActiveTagDropdown({ + rowIndex, + showTags: false, + cursorPosition: 0, + activeSourceBlockId: null, + element: e.target, + }) + } + }} + onBlur={() => { + if (column === 'value') { setTimeout(() => setActiveTagDropdown(null), 200) - }} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setActiveTagDropdown(null) - } - }} - disabled={disabled} - autoComplete='off' - className='w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0' - /> -
-
- {formatDisplayText(cellValue, { - accessiblePrefixes, - highlightAll: !accessiblePrefixes, - })} -
+ } + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setActiveTagDropdown(null) + } + }} + disabled={isDisabled} + autoComplete='off' + placeholder={placeholder} + className='w-full border-0 bg-transparent px-[10px] py-[8px] font-medium text-sm text-transparent leading-[21px] caret-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0' + /> +
+
+ {formatDisplayText(value || '', { + accessiblePrefixes, + highlightAll: !accessiblePrefixes, + })}
+
+ ) + + if (isBetween) { + return ( + +
+ {renderInput(cellValue, 'value')} + to + {renderInput(valueTo, 'valueTo')} +
+ + ) + } + + return ( + + {renderInput(cellValue, 'value')} ) } const renderDeleteButton = (rowIndex: number) => { - const canDelete = !isPreview && !disabled + if (isPreview || disabled) return null - return canDelete ? ( + return ( - ) : null + ) } if (isLoading) { @@ -361,14 +412,18 @@ export function KnowledgeTagFilters({ } return ( -
-
- +
+
+
{renderHeader()} - + {rows.map((row, rowIndex) => ( - + {renderTagNameCell(row, rowIndex)} + {renderOperatorCell(row, rowIndex)} {renderValueCell(row, rowIndex)} {renderDeleteButton(rowIndex)} @@ -400,7 +455,7 @@ export function KnowledgeTagFilters({ {/* Add Filter Button */} {!isPreview && !disabled && (
- 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 882a96839..7d209c115 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 @@ -982,6 +982,11 @@ export 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) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index a24d3ff9e..d548ee0be 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -134,29 +134,111 @@ const isMessagesArray = (value: unknown): value is Array<{ role: string; content ) } +/** + * Type guard for tag filter array (used in knowledge block filters) + */ +interface TagFilterItem { + id: string + tagName: string + fieldType?: string + operator?: string + tagValue: string +} + +const isTagFilterArray = (value: unknown): value is TagFilterItem[] => { + if (!Array.isArray(value) || value.length === 0) return false + const firstItem = value[0] + return ( + typeof firstItem === 'object' && + firstItem !== null && + 'tagName' in firstItem && + 'tagValue' in firstItem + ) +} + +/** + * Type guard for document tag entry array (used in knowledge block create document) + */ +interface DocumentTagItem { + id: string + tagName: string + fieldType?: string + value: string +} + +const isDocumentTagArray = (value: unknown): value is DocumentTagItem[] => { + if (!Array.isArray(value) || value.length === 0) return false + const firstItem = value[0] + return ( + typeof firstItem === 'object' && + firstItem !== null && + 'tagName' in firstItem && + 'value' in firstItem && + !('tagValue' in firstItem) // Distinguish from tag filters + ) +} + +/** + * Attempts to parse a JSON string, returns the parsed value or the original value if parsing fails + */ +const tryParseJson = (value: unknown): unknown => { + if (typeof value !== 'string') return value + try { + const trimmed = value.trim() + if ( + (trimmed.startsWith('[') && trimmed.endsWith(']')) || + (trimmed.startsWith('{') && trimmed.endsWith('}')) + ) { + return JSON.parse(trimmed) + } + } catch { + // Not valid JSON, return original + } + return value +} + /** * Formats a subblock value for display, intelligently handling nested objects and arrays. */ const getDisplayValue = (value: unknown): string => { if (value == null || value === '') return '-' - if (isMessagesArray(value)) { - const firstMessage = value[0] + // Try parsing JSON strings first + const parsedValue = tryParseJson(value) + + if (isMessagesArray(parsedValue)) { + const firstMessage = parsedValue[0] if (!firstMessage?.content || firstMessage.content.trim() === '') return '-' const content = firstMessage.content.trim() return content.length > 50 ? `${content.slice(0, 50)}...` : content } - if (isVariableAssignmentsArray(value)) { - const names = value.map((a) => a.variableName).filter((name): name is string => !!name) + if (isVariableAssignmentsArray(parsedValue)) { + const names = parsedValue.map((a) => a.variableName).filter((name): name is string => !!name) if (names.length === 0) return '-' if (names.length === 1) return names[0] if (names.length === 2) return `${names[0]}, ${names[1]}` return `${names[0]}, ${names[1]} +${names.length - 2}` } - if (isTableRowArray(value)) { - const nonEmptyRows = value.filter((row) => { + if (isTagFilterArray(parsedValue)) { + const validFilters = parsedValue.filter((f) => f.tagName?.trim()) + if (validFilters.length === 0) return '-' + if (validFilters.length === 1) return validFilters[0].tagName + if (validFilters.length === 2) return `${validFilters[0].tagName}, ${validFilters[1].tagName}` + return `${validFilters[0].tagName}, ${validFilters[1].tagName} +${validFilters.length - 2}` + } + + if (isDocumentTagArray(parsedValue)) { + const validTags = parsedValue.filter((t) => t.tagName?.trim()) + if (validTags.length === 0) return '-' + if (validTags.length === 1) return validTags[0].tagName + if (validTags.length === 2) return `${validTags[0].tagName}, ${validTags[1].tagName}` + return `${validTags[0].tagName}, ${validTags[1].tagName} +${validTags.length - 2}` + } + + if (isTableRowArray(parsedValue)) { + const nonEmptyRows = parsedValue.filter((row) => { const cellValues = Object.values(row.cells) return cellValues.some((cell) => cell && cell.trim() !== '') }) @@ -175,16 +257,16 @@ const getDisplayValue = (value: unknown): string => { return `${nonEmptyRows.length} rows` } - if (isFieldFormatArray(value)) { - const namedFields = value.filter((field) => field.name && field.name.trim() !== '') + if (isFieldFormatArray(parsedValue)) { + const namedFields = parsedValue.filter((field) => field.name && field.name.trim() !== '') if (namedFields.length === 0) return '-' if (namedFields.length === 1) return namedFields[0].name if (namedFields.length === 2) return `${namedFields[0].name}, ${namedFields[1].name}` return `${namedFields[0].name}, ${namedFields[1].name} +${namedFields.length - 2}` } - if (isPlainObject(value)) { - const entries = Object.entries(value).filter( + if (isPlainObject(parsedValue)) { + const entries = Object.entries(parsedValue).filter( ([, val]) => val !== null && val !== undefined && val !== '' ) @@ -201,8 +283,10 @@ const getDisplayValue = (value: unknown): string => { return entries.length > 2 ? `${preview} +${entries.length - 2}` : preview } - if (Array.isArray(value)) { - const nonEmptyItems = value.filter((item) => item !== null && item !== undefined && item !== '') + if (Array.isArray(parsedValue)) { + const nonEmptyItems = parsedValue.filter( + (item) => item !== null && item !== undefined && item !== '' + ) if (nonEmptyItems.length === 0) return '-' const getItemDisplayValue = (item: unknown): string => { @@ -220,10 +304,11 @@ const getDisplayValue = (value: unknown): string => { return `${getItemDisplayValue(nonEmptyItems[0])}, ${getItemDisplayValue(nonEmptyItems[1])} +${nonEmptyItems.length - 2}` } + // For non-array, non-object values, use original value for string conversion const stringValue = String(value) if (stringValue === '[object Object]') { try { - const json = JSON.stringify(value) + const json = JSON.stringify(parsedValue) if (json.length <= 40) return json return `${json.slice(0, 37)}...` } catch { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 42d3c4041..b5a7bf3f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -18,6 +18,7 @@ import { useShallow } from 'zustand/react/shallow' import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access' import { createLogger } from '@/lib/logs/console/logger' import type { OAuthProvider } from '@/lib/oauth' +import { DEFAULT_HORIZONTAL_SPACING } from '@/lib/workflows/autolayout/constants' import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -32,6 +33,7 @@ import { import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors' import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index' import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block' +import type { SubflowNodeData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node' import { TrainingModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal' import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' @@ -523,7 +525,7 @@ const WorkflowContent = React.memo(() => { useEffect(() => { const handleRemoveFromSubflow = (event: Event) => { const customEvent = event as CustomEvent<{ blockId: string }> - const { blockId } = customEvent.detail || ({} as any) + const blockId = customEvent.detail?.blockId if (!blockId) return try { @@ -555,6 +557,7 @@ const WorkflowContent = React.memo(() => { const candidates = Object.entries(blocks) .filter(([id, block]) => { if (!block.enabled) return false + if (block.type === 'response') return false const node = nodeIndex.get(id) if (!node) return false @@ -601,6 +604,152 @@ const WorkflowContent = React.memo(() => { return 'source' }, []) + /** Creates a standardized edge object for workflow connections. */ + const createEdgeObject = useCallback( + (sourceId: string, targetId: string, sourceHandle: string): Edge => ({ + id: crypto.randomUUID(), + source: sourceId, + target: targetId, + sourceHandle, + targetHandle: 'target', + type: 'workflowEdge', + }), + [] + ) + + /** Gets the appropriate start handle for a container node (loop or parallel). */ + const getContainerStartHandle = useCallback( + (containerId: string): string => { + const containerNode = getNodes().find((n) => n.id === containerId) + return (containerNode?.data as SubflowNodeData)?.kind === 'loop' + ? 'loop-start-source' + : 'parallel-start-source' + }, + [getNodes] + ) + + /** Finds the closest non-response block to a position within a set of blocks. */ + const findClosestBlockInSet = useCallback( + ( + candidateBlocks: { id: string; type: string; position: { x: number; y: number } }[], + targetPosition: { x: number; y: number } + ): { id: string; type: string; position: { x: number; y: number } } | undefined => { + return candidateBlocks + .filter((b) => b.type !== 'response') + .map((b) => ({ + block: b, + distance: Math.sqrt( + (b.position.x - targetPosition.x) ** 2 + (b.position.y - targetPosition.y) ** 2 + ), + })) + .sort((a, b) => a.distance - b.distance)[0]?.block + }, + [] + ) + + /** + * Attempts to create an auto-connect edge for a new block being added. + * Returns the edge object if auto-connect should occur, or undefined otherwise. + * + * @param position - The position where the new block will be placed + * @param targetBlockId - The ID of the new block being added + * @param options - Configuration for auto-connect behavior + */ + const tryCreateAutoConnectEdge = useCallback( + ( + position: { x: number; y: number }, + targetBlockId: string, + options: { + blockType: string + enableTriggerMode?: boolean + targetParentId?: string | null + existingChildBlocks?: { id: string; type: string; position: { x: number; y: number } }[] + containerId?: string + } + ): Edge | undefined => { + const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled + if (!isAutoConnectEnabled) return undefined + + // Don't auto-connect starter or annotation-only blocks + if (options.blockType === 'starter' || isAnnotationOnlyBlock(options.blockType)) { + return undefined + } + + // Check if target is a trigger block + const targetBlockConfig = getBlock(options.blockType) + const isTargetTrigger = + options.enableTriggerMode || targetBlockConfig?.category === 'triggers' + if (isTargetTrigger) return undefined + + // Case 1: Adding block inside a container with existing children + if (options.existingChildBlocks && options.existingChildBlocks.length > 0) { + const closestBlock = findClosestBlockInSet(options.existingChildBlocks, position) + if (closestBlock) { + const sourceHandle = determineSourceHandle({ + id: closestBlock.id, + type: closestBlock.type, + }) + return createEdgeObject(closestBlock.id, targetBlockId, sourceHandle) + } + return undefined + } + + // Case 2: Adding block inside an empty container - connect from container start + if ( + options.containerId && + (!options.existingChildBlocks || options.existingChildBlocks.length === 0) + ) { + const startHandle = getContainerStartHandle(options.containerId) + return createEdgeObject(options.containerId, targetBlockId, startHandle) + } + + // Case 3: Adding block at root level - use findClosestOutput + const closestBlock = findClosestOutput(position) + if (!closestBlock) return undefined + + // Don't create cross-container edges + const closestBlockParentId = blocks[closestBlock.id]?.data?.parentId + if (closestBlockParentId && !options.targetParentId) { + return undefined + } + + const sourceHandle = determineSourceHandle(closestBlock) + return createEdgeObject(closestBlock.id, targetBlockId, sourceHandle) + }, + [ + blocks, + findClosestOutput, + determineSourceHandle, + createEdgeObject, + getContainerStartHandle, + findClosestBlockInSet, + ] + ) + + /** + * Checks if adding a trigger block would violate constraints and shows notification if so. + * @returns true if validation failed (caller should return early), false if ok to proceed + */ + const checkTriggerConstraints = useCallback( + (blockType: string): boolean => { + const issue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType) + if (issue) { + const message = + issue.issue === 'legacy' + ? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.' + : `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before adding a new one.` + addNotification({ + level: 'error', + message, + workflowId: activeWorkflowId || undefined, + }) + return true + } + return false + }, + [blocks, addNotification, activeWorkflowId] + ) + /** * Shared handler for drops of toolbar items onto the workflow canvas. * @@ -629,21 +778,10 @@ const WorkflowContent = React.memo(() => { const baseName = data.type === 'loop' ? 'Loop' : 'Parallel' const name = getUniqueBlockName(baseName, blocks) - const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled - let autoConnectEdge - if (isAutoConnectEnabled) { - const closestBlock = findClosestOutput(position) - if (closestBlock) { - autoConnectEdge = { - id: crypto.randomUUID(), - source: closestBlock.id, - target: id, - sourceHandle: determineSourceHandle(closestBlock), - targetHandle: 'target', - type: 'workflowEdge', - } - } - } + const autoConnectEdge = tryCreateAutoConnectEdge(position, id, { + blockType: data.type, + targetParentId: null, + }) addBlock( id, @@ -651,8 +789,8 @@ const WorkflowContent = React.memo(() => { name, position, { - width: 500, - height: 300, + width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, type: 'subflowNode', }, undefined, @@ -674,12 +812,7 @@ const WorkflowContent = React.memo(() => { const id = crypto.randomUUID() // Prefer semantic default names for triggers; then ensure unique numbering centrally const defaultTriggerNameDrop = TriggerUtils.getDefaultTriggerName(data.type) - const baseName = - data.type === 'loop' - ? 'Loop' - : data.type === 'parallel' - ? 'Parallel' - : defaultTriggerNameDrop || blockConfig!.name + const baseName = defaultTriggerNameDrop || blockConfig.name const name = getUniqueBlockName(baseName, blocks) if (containerInfo) { @@ -711,70 +844,18 @@ const WorkflowContent = React.memo(() => { estimateBlockDimensions(data.type) ) - // Capture existing child blocks before adding the new one - const existingChildBlocks = Object.values(blocks).filter( - (b) => b.data?.parentId === containerInfo.loopId - ) + // Capture existing child blocks for auto-connect + const existingChildBlocks = Object.values(blocks) + .filter((b) => b.data?.parentId === containerInfo.loopId) + .map((b) => ({ id: b.id, type: b.type, position: b.position })) - // Auto-connect logic for blocks inside containers - const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled - let autoConnectEdge - if ( - isAutoConnectEnabled && - data.type !== 'starter' && - !isAnnotationOnlyBlock(data.type) - ) { - if (existingChildBlocks.length > 0) { - // Connect to the nearest existing child block within the container - const closestBlock = existingChildBlocks - .map((b) => ({ - block: b, - distance: Math.sqrt( - (b.position.x - relativePosition.x) ** 2 + - (b.position.y - relativePosition.y) ** 2 - ), - })) - .sort((a, b) => a.distance - b.distance)[0]?.block - - if (closestBlock) { - // Don't create edges into trigger blocks or annotation blocks - const targetBlockConfig = getBlock(data.type) - const isTargetTrigger = - data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers' - - if (!isTargetTrigger) { - const sourceHandle = determineSourceHandle({ - id: closestBlock.id, - type: closestBlock.type, - }) - autoConnectEdge = { - id: crypto.randomUUID(), - source: closestBlock.id, - target: id, - sourceHandle, - targetHandle: 'target', - type: 'workflowEdge', - } - } - } - } else { - // No existing children: connect from the container's start handle to the moved node - const containerNode = getNodes().find((n) => n.id === containerInfo.loopId) - const startSourceHandle = - (containerNode?.data as any)?.kind === 'loop' - ? 'loop-start-source' - : 'parallel-start-source' - - autoConnectEdge = { - id: crypto.randomUUID(), - source: containerInfo.loopId, - target: id, - sourceHandle: startSourceHandle, - targetHandle: 'target', - type: 'workflowEdge', - } - } - } + const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, { + blockType: data.type, + enableTriggerMode: data.enableTriggerMode, + targetParentId: containerInfo.loopId, + existingChildBlocks, + containerId: containerInfo.loopId, + }) // Add block with parent info AND autoConnectEdge (atomic operation) addBlock( @@ -796,49 +877,13 @@ const WorkflowContent = React.memo(() => { resizeLoopNodesWrapper() } else { // Centralized trigger constraints - const dropIssue = TriggerUtils.getTriggerAdditionIssue(blocks, data.type) - if (dropIssue) { - const message = - dropIssue.issue === 'legacy' - ? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.' - : `A workflow can only have one ${dropIssue.triggerName} trigger block. Please remove the existing one before adding a new one.` - addNotification({ - level: 'error', - message, - workflowId: activeWorkflowId || undefined, - }) - return - } + if (checkTriggerConstraints(data.type)) return - // Regular auto-connect logic - const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled - let autoConnectEdge - if ( - isAutoConnectEnabled && - data.type !== 'starter' && - !isAnnotationOnlyBlock(data.type) - ) { - const closestBlock = findClosestOutput(position) - if (closestBlock) { - // Don't create edges into trigger blocks or annotation blocks - const targetBlockConfig = getBlock(data.type) - const isTargetTrigger = - data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers' - - if (!isTargetTrigger) { - const sourceHandle = determineSourceHandle(closestBlock) - - autoConnectEdge = { - id: crypto.randomUUID(), - source: closestBlock.id, - target: id, - sourceHandle, - targetHandle: 'target', - type: 'workflowEdge', - } - } - } - } + const autoConnectEdge = tryCreateAutoConnectEdge(position, id, { + blockType: data.type, + enableTriggerMode: data.enableTriggerMode, + targetParentId: null, + }) // Regular canvas drop with auto-connect edge // Use enableTriggerMode from drag data if present (when dragging from Triggers tab) @@ -861,14 +906,13 @@ const WorkflowContent = React.memo(() => { }, [ blocks, - getNodes, - findClosestOutput, - determineSourceHandle, isPointInLoopNode, resizeLoopNodesWrapper, addBlock, addNotification, activeWorkflowId, + tryCreateAutoConnectEdge, + checkTriggerConstraints, ] ) @@ -885,44 +929,73 @@ const WorkflowContent = React.memo(() => { if (!type) return if (type === 'connectionBlock') return + // Calculate smart position - to the right of existing root-level blocks + const calculateSmartPosition = (): { x: number; y: number } => { + // Get all root-level blocks (no parentId) + const rootBlocks = Object.values(blocks).filter((b) => !b.data?.parentId) + + if (rootBlocks.length === 0) { + // No blocks yet, use viewport center + return screenToFlowPosition({ + x: window.innerWidth / 2, + y: window.innerHeight / 2, + }) + } + + // Find the rightmost block + let maxRight = Number.NEGATIVE_INFINITY + let rightmostBlockY = 0 + for (const block of rootBlocks) { + const blockWidth = + block.type === 'loop' || block.type === 'parallel' + ? block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH + : BLOCK_DIMENSIONS.FIXED_WIDTH + const blockRight = block.position.x + blockWidth + if (blockRight > maxRight) { + maxRight = blockRight + rightmostBlockY = block.position.y + } + } + + // Position to the right with autolayout spacing + const position = { + x: maxRight + DEFAULT_HORIZONTAL_SPACING, + y: rightmostBlockY, + } + + // Ensure position doesn't overlap any container + let container = isPointInLoopNode(position) + while (container) { + position.x = + container.loopPosition.x + container.dimensions.width + DEFAULT_HORIZONTAL_SPACING + container = isPointInLoopNode(position) + } + + return position + } + + const basePosition = calculateSmartPosition() + // Special handling for container nodes (loop or parallel) if (type === 'loop' || type === 'parallel') { const id = crypto.randomUUID() const baseName = type === 'loop' ? 'Loop' : 'Parallel' const name = getUniqueBlockName(baseName, blocks) - const centerPosition = screenToFlowPosition({ - x: window.innerWidth / 2, - y: window.innerHeight / 2, + const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, { + blockType: type, + targetParentId: null, }) - // Auto-connect logic for container nodes - const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled - let autoConnectEdge - if (isAutoConnectEnabled) { - const closestBlock = findClosestOutput(centerPosition) - if (closestBlock) { - const sourceHandle = determineSourceHandle(closestBlock) - autoConnectEdge = { - id: crypto.randomUUID(), - source: closestBlock.id, - target: id, - sourceHandle, - targetHandle: 'target', - type: 'workflowEdge', - } - } - } - // Add the container node with default dimensions and auto-connect edge addBlock( id, type, name, - centerPosition, + basePosition, { - width: 500, - height: 300, + width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, type: 'subflowNode', }, undefined, @@ -939,11 +1012,8 @@ const WorkflowContent = React.memo(() => { return } - // Calculate the center position of the viewport - const centerPosition = screenToFlowPosition({ - x: window.innerWidth / 2, - y: window.innerHeight / 2, - }) + // Check trigger constraints first + if (checkTriggerConstraints(type)) return // Create a new block with a unique ID const id = crypto.randomUUID() @@ -952,51 +1022,11 @@ const WorkflowContent = React.memo(() => { const baseName = defaultTriggerName || blockConfig.name const name = getUniqueBlockName(baseName, blocks) - // Auto-connect logic - const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled - let autoConnectEdge - if (isAutoConnectEnabled && type !== 'starter' && !isAnnotationOnlyBlock(type)) { - const closestBlock = findClosestOutput(centerPosition) - logger.info('Closest block found:', closestBlock) - if (closestBlock) { - // Don't create edges into trigger blocks or annotation blocks - const targetBlockConfig = blockConfig - const isTargetTrigger = enableTriggerMode || targetBlockConfig?.category === 'triggers' - - if (!isTargetTrigger) { - const sourceHandle = determineSourceHandle(closestBlock) - - autoConnectEdge = { - id: crypto.randomUUID(), - source: closestBlock.id, - target: id, - sourceHandle, - targetHandle: 'target', - type: 'workflowEdge', - } - logger.info('Auto-connect edge created:', autoConnectEdge) - } else { - logger.info('Skipping auto-connect into trigger block', { - target: type, - }) - } - } - } - - // Centralized trigger constraints - const additionIssue = TriggerUtils.getTriggerAdditionIssue(blocks, type) - if (additionIssue) { - const message = - additionIssue.issue === 'legacy' - ? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.' - : `A workflow can only have one ${additionIssue.triggerName} trigger block. Please remove the existing one before adding a new one.` - addNotification({ - level: 'error', - message, - workflowId: activeWorkflowId || undefined, - }) - return - } + const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, { + blockType: type, + enableTriggerMode, + targetParentId: null, + }) // Add the block to the workflow with auto-connect edge // Enable trigger mode if this is a trigger-capable block from the triggers tab @@ -1004,7 +1034,7 @@ const WorkflowContent = React.memo(() => { id, type, name, - centerPosition, + basePosition, undefined, undefined, undefined, @@ -1025,11 +1055,12 @@ const WorkflowContent = React.memo(() => { screenToFlowPosition, blocks, addBlock, - findClosestOutput, - determineSourceHandle, + tryCreateAutoConnectEdge, + isPointInLoopNode, effectivePermissions.canEdit, addNotification, activeWorkflowId, + checkTriggerConstraints, ]) /** @@ -1220,12 +1251,12 @@ const WorkflowContent = React.memo(() => { const containerNode = getNodes().find((n) => n.id === containerInfo.loopId) if ( containerNode?.type === 'subflowNode' && - (containerNode.data as any)?.kind === 'loop' + (containerNode.data as SubflowNodeData)?.kind === 'loop' ) { containerElement.classList.add('loop-node-drag-over') } else if ( containerNode?.type === 'subflowNode' && - (containerNode.data as any)?.kind === 'parallel' + (containerNode.data as SubflowNodeData)?.kind === 'parallel' ) { containerElement.classList.add('parallel-node-drag-over') } @@ -1424,8 +1455,8 @@ const WorkflowContent = React.memo(() => { data: { ...block.data, name: block.name, - width: block.data?.width || 500, - height: block.data?.height || 300, + width: block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: block.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, kind: block.type === 'loop' ? 'loop' : 'parallel', }, }) @@ -1484,8 +1515,8 @@ const WorkflowContent = React.memo(() => { }, // Include dynamic dimensions for container resizing calculations (must match rendered size) // Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions - width: 250, // Standard width for both block types - height: Math.max(block.height || 100, 100), // Use calculated height with minimum + width: BLOCK_DIMENSIONS.FIXED_WIDTH, + height: Math.max(block.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT), }) }) @@ -1572,7 +1603,7 @@ const WorkflowContent = React.memo(() => { /** * Effect to resize loops when nodes change (add/remove/position change). * Runs on structural changes only - not during drag (position-only changes). - * Skips during loading to avoid unnecessary work. + * Skips during loading. */ useEffect(() => { // Skip during initial render when nodes aren't loaded yet or workflow not ready @@ -1794,12 +1825,15 @@ const WorkflowContent = React.memo(() => { const containerAbsolutePos = getNodeAbsolutePosition(n.id) // Get dimensions based on node type (must match actual rendered dimensions) - const nodeWidth = node.type === 'subflowNode' ? node.data?.width || 500 : 250 // All workflow blocks use w-[250px] in workflow-block.tsx + const nodeWidth = + node.type === 'subflowNode' + ? node.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH + : BLOCK_DIMENSIONS.FIXED_WIDTH const nodeHeight = node.type === 'subflowNode' - ? node.data?.height || 300 - : Math.max(node.height || 100, 100) // Use actual node height with minimum 100 + ? node.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT + : Math.max(node.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT) // Check intersection using absolute coordinates const nodeRect = { @@ -1811,9 +1845,10 @@ const WorkflowContent = React.memo(() => { const containerRect = { left: containerAbsolutePos.x, - right: containerAbsolutePos.x + (n.data?.width || 500), + right: containerAbsolutePos.x + (n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH), top: containerAbsolutePos.y, - bottom: containerAbsolutePos.y + (n.data?.height || 300), + bottom: + containerAbsolutePos.y + (n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT), } // Check intersection with absolute coordinates for accurate detection @@ -1829,7 +1864,9 @@ const WorkflowContent = React.memo(() => { container: n, depth: getNodeDepth(n.id), // Calculate size for secondary sorting - size: (n.data?.width || 500) * (n.data?.height || 300), + size: + (n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH) * + (n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT), })) // Update potential parent if there's at least one intersecting container node @@ -1857,12 +1894,12 @@ const WorkflowContent = React.memo(() => { // Apply appropriate class based on container type if ( bestContainerMatch.container.type === 'subflowNode' && - (bestContainerMatch.container.data as any)?.kind === 'loop' + (bestContainerMatch.container.data as SubflowNodeData)?.kind === 'loop' ) { containerElement.classList.add('loop-node-drag-over') } else if ( bestContainerMatch.container.type === 'subflowNode' && - (bestContainerMatch.container.data as any)?.kind === 'parallel' + (bestContainerMatch.container.data as SubflowNodeData)?.kind === 'parallel' ) { containerElement.classList.add('parallel-node-drag-over') } @@ -2034,62 +2071,19 @@ const WorkflowContent = React.memo(() => { y: nodeAbsPosBefore.y - containerAbsPosBefore.y - headerHeight - topPadding, } - // Prepare edges that will be added when moving into the container - const edgesToAdd: any[] = [] - // Auto-connect when moving an existing block into a container - const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled - // Don't auto-connect annotation blocks (like note blocks) - if (isAutoConnectEnabled && !isAnnotationOnlyBlock(node.data?.type)) { - // Existing children in the target container (excluding the moved node) - const existingChildBlocks = Object.values(blocks).filter( - (b) => b.data?.parentId === potentialParentId && b.id !== node.id - ) + const existingChildBlocks = Object.values(blocks) + .filter((b) => b.data?.parentId === potentialParentId && b.id !== node.id) + .map((b) => ({ id: b.id, type: b.type, position: b.position })) - if (existingChildBlocks.length > 0) { - // Connect from nearest existing child inside the container - const closestBlock = existingChildBlocks - .map((b) => ({ - block: b, - distance: Math.sqrt( - (b.position.x - relativePositionBefore.x) ** 2 + - (b.position.y - relativePositionBefore.y) ** 2 - ), - })) - .sort((a, b) => a.distance - b.distance)[0]?.block + const autoConnectEdge = tryCreateAutoConnectEdge(relativePositionBefore, node.id, { + blockType: node.data?.type || '', + targetParentId: potentialParentId, + existingChildBlocks, + containerId: potentialParentId, + }) - if (closestBlock) { - const sourceHandle = determineSourceHandle({ - id: closestBlock.id, - type: closestBlock.type, - }) - edgesToAdd.push({ - id: crypto.randomUUID(), - source: closestBlock.id, - target: node.id, - sourceHandle, - targetHandle: 'target', - type: 'workflowEdge', - }) - } - } else { - // No children: connect from the container's start handle to the moved node - const containerNode = getNodes().find((n) => n.id === potentialParentId) - const startSourceHandle = - (containerNode?.data as any)?.kind === 'loop' - ? 'loop-start-source' - : 'parallel-start-source' - - edgesToAdd.push({ - id: crypto.randomUUID(), - source: potentialParentId, - target: node.id, - sourceHandle: startSourceHandle, - targetHandle: 'target', - type: 'workflowEdge', - }) - } - } + const edgesToAdd: Edge[] = autoConnectEdge ? [autoConnectEdge] : [] // Skip recording these edges separately since they're part of the parent update window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } })) @@ -2114,7 +2108,7 @@ const WorkflowContent = React.memo(() => { updateNodeParent, collaborativeUpdateBlockPosition, addEdge, - determineSourceHandle, + tryCreateAutoConnectEdge, blocks, edgesForDisplay, removeEdgesForNode, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx index d7b5bfa69..bbe606381 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx @@ -490,6 +490,20 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {

Enter a name for your API key to help you identify it later.

+ {/* Hidden decoy fields to prevent browser autofill */} + { @@ -499,6 +513,12 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) { placeholder='e.g., Development, Production' className='h-9' autoFocus + name='api_key_label' + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' /> {createError && (

diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx index 247d8a2e8..36a26b6a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx @@ -2,7 +2,15 @@ import { useEffect, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn' +import { + Button, + Label, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from '@/components/emcn' import { useSession, useSubscription } from '@/lib/auth/auth-client' import { getSubscriptionStatus } from '@/lib/billing/client/utils' import { cn } from '@/lib/core/utils/cn' @@ -68,7 +76,6 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub if (subscriptionStatus.isTeam && activeOrgId) { referenceId = activeOrgId - // Get subscription ID for team/enterprise subscriptionId = subData?.data?.id } @@ -132,14 +139,12 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub referenceId = activeOrgId subscriptionId = subData?.data?.id } else { - // For personal subscriptions, use user ID and let better-auth find the subscription referenceId = session.user.id subscriptionId = undefined } logger.info('Restoring subscription', { referenceId, subscriptionId }) - // Build restore params - only include subscriptionId if we have one (team/enterprise) const restoreParams: any = { referenceId } if (subscriptionId) { restoreParams.subscriptionId = subscriptionId @@ -150,7 +155,6 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub logger.info('Subscription restored successfully', result) } - // Invalidate queries to refresh data await queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() }) if (activeOrgId) { await queryClient.invalidateQueries({ queryKey: organizationKeys.detail(activeOrgId) }) @@ -175,10 +179,8 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub if (!date) return 'end of current billing period' try { - // Ensure we have a valid Date object const dateObj = date instanceof Date ? date : new Date(date) - // Check if the date is valid if (Number.isNaN(dateObj.getTime())) { return 'end of current billing period' } @@ -196,20 +198,17 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub const periodEndDate = getPeriodEndDate() - // Check if subscription is set to cancel at period end const isCancelAtPeriodEnd = subscriptionData?.cancelAtPeriodEnd === true return ( <>

-
- - {isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'} - +
+ {isCancelAtPeriodEnd && ( -

+ You'll keep access until {formatDate(periodEndDate)} -

+ )}
+ - + Add Credits -
-

- Credits are used before overage charges. Min $10, max $1,000. -

-
- - {success ? ( -
-

+ + {success ? ( +

Credits added successfully!

-
- ) : ( -
-
- -
- - $ - - handleAmountChange(e.target.value)} - placeholder='50' - className='pl-7' - disabled={isPurchasing} - /> -
- {error && {error}} -
- -
-

- Credits are non-refundable and don't expire. They'll be applied automatically to - your {entityType === 'organization' ? 'team' : ''} usage. + ) : ( + <> +

+ Credits are used before overage charges. Min $10, max $1,000.

-
-
- )} +
+ +
+ + $ + + handleAmountChange(e.target.value)} + placeholder='50' + className='pl-7' + disabled={isPurchasing} + /> +
+ {error && {error}} +
+ +
+

+ Credits are non-refundable and don't expire. They'll be applied automatically + to your {entityType === 'organization' ? 'team' : ''} usage. +

+
+ + )} + {!success && ( - + + + + Workspace admins + {workspaceAdmins.map((admin: any) => ( + { + if (admin.userId === billedAccountUserId) return + try { + await updateWorkspaceSettings({ billedAccountUserId: admin.userId }) + } catch (error) { + // Error is already logged in updateWorkspaceSettings + } + }} + > + {admin.email} + + ))} + + )}
)} @@ -614,11 +619,14 @@ function BillingUsageNotificationsToggle() { return (
-
- Usage notifications - Email me when I reach 80% usage +
+ + + Email me when I reach 80% usage +
{ diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx index 4285142fe..15b36fffb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx @@ -141,12 +141,37 @@ export function MemberInvitationCard({ {/* Main invitation input */}
+ {/* Hidden decoy fields to prevent browser autofill */} + + {emailError && (

diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx index 40e1754c8..582ca27d3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx @@ -55,16 +55,31 @@ export function NoOrganizationView({ {/* Form fields - clean layout without card */}

+ {/* Hidden decoy field to prevent browser autofill */} +
-
@@ -116,31 +131,52 @@ export function NoOrganizationView({
+ {/* Hidden decoy field to prevent browser autofill */} +
-
-
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx index da8d69355..3a3a4e53c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx @@ -154,7 +154,7 @@ export function TeamMembers({
{teamItems.map((item) => (
- {/* Member info */} + {/* Left section: Avatar + Name/Role + Action buttons */}
{/* Avatar */} {/* Name and email */} -
+
{item.name} {item.type === 'member' && ( @@ -188,51 +188,50 @@ export function TeamMembers({
{item.email}
- {/* Usage stats - matching subscription layout */} + {/* Action buttons */} {isAdminOrOwner && ( -
-
-
Usage
-
- {isLoadingUsage && item.type === 'member' ? ( - - ) : ( - item.usage - )} -
-
+ <> + {/* Admin/Owner can remove other members */} + {item.type === 'member' && + item.role !== 'owner' && + item.email !== currentUserEmail && ( + + )} + + {/* Admin can cancel invitations */} + {item.type === 'invitation' && ( + + )} + + )} +
+ + {/* Right section: Usage column (right-aligned) */} + {isAdminOrOwner && ( +
+
Usage
+
+ {isLoadingUsage && item.type === 'member' ? ( + + ) : ( + item.usage + )}
- )} -
- - {/* Actions */} -
- {/* Admin/Owner can remove other members */} - {isAdminOrOwner && - item.type === 'member' && - item.role !== 'owner' && - item.email !== currentUserEmail && ( - - )} - - {/* Admin can cancel invitations */} - {isAdminOrOwner && item.type === 'invitation' && ( - - )} -
+
+ )}
))}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/team-seats.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/team-seats.tsx index 06809f411..83442f724 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/team-seats.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/team-seats.tsx @@ -5,11 +5,10 @@ import { type ComboboxOption, Label, Modal, + ModalBody, ModalContent, - ModalDescription, ModalFooter, ModalHeader, - ModalTitle, Tooltip, } from '@/components/emcn' import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants' @@ -55,50 +54,53 @@ export function TeamSeats({ const totalMonthlyCost = selectedSeats * costPerSeat const costChange = currentSeats ? (selectedSeats - currentSeats) * costPerSeat : 0 - const handleConfirm = async () => { - await onConfirm(selectedSeats) - } - const seatOptions: ComboboxOption[] = [1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 40, 50].map((num) => ({ value: num.toString(), - label: `${num} ${num === 1 ? 'seat' : 'seats'} ($${num * costPerSeat}/month)`, + label: `${num} ${num === 1 ? 'seat' : 'seats'}`, })) return ( - - - {title} - {description} - + + {title} + +

{description}

-
- - setSelectedSeats(Number.parseInt(value))} - placeholder='Select number of seats' - /> +
+ + 0 ? selectedSeats.toString() : ''} + onChange={(value) => { + const num = Number.parseInt(value, 10) + if (!Number.isNaN(num) && num > 0) { + setSelectedSeats(num) + } + }} + placeholder='Select or enter number of seats' + editable + disabled={isLoading} + /> +
-

+

Your team will have {selectedSeats} {selectedSeats === 1 ? 'seat' : 'seats'} with a total of ${totalMonthlyCost} inference credits per month.

{showCostBreakdown && currentSeats !== undefined && ( -
-
+
+
Current seats: - {currentSeats} + {currentSeats}
-
+
New seats: - {selectedSeats} + {selectedSeats}
-
- Monthly cost change: - +
+ Monthly cost change: + {costChange > 0 ? '+' : ''}${costChange}
@@ -106,19 +108,14 @@ export function TeamSeats({ )} {error && ( -

+

{error instanceof Error && error.message ? error.message : String(error)}

)} -
+ - @@ -127,22 +124,15 @@ export function TeamSeats({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile.tsx index 1b955bbb6..882e59f42 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile.tsx @@ -390,11 +390,26 @@ export function TemplateProfile() { disabled={isUploadingProfilePicture} />
+ {/* Hidden decoy field to prevent browser autofill */} + updateField('name', e.target.value)} className='h-9 flex-1' + name='profile_display_name' + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' />
{uploadError &&

{uploadError}

} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index c1a87b63e..f29103dd5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import clsx from 'clsx' import { ChevronRight, Folder, FolderOpen } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' @@ -15,7 +15,11 @@ import { useItemRename, } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' -import { useDeleteFolder, useDuplicateFolder } from '@/app/workspace/[workspaceId]/w/hooks' +import { + useCanDelete, + useDeleteFolder, + useDuplicateFolder, +} from '@/app/workspace/[workspaceId]/w/hooks' import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders' import { useCreateWorkflow } from '@/hooks/queries/workflows' import type { FolderTreeNode } from '@/stores/folders/store' @@ -52,6 +56,9 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { const createFolderMutation = useCreateFolder() const userPermissions = useUserPermissionsContext() + const { canDeleteFolder } = useCanDelete({ workspaceId }) + const canDelete = useMemo(() => canDeleteFolder(folder.id), [canDeleteFolder, folder.id]) + // Delete modal state const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) @@ -316,7 +323,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending} disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending} disableDuplicate={!userPermissions.canEdit} - disableDelete={!userPermissions.canEdit} + disableDelete={!userPermissions.canEdit || !canDelete} /> {/* Delete Modal */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index a4018c63d..ceeab77d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -14,6 +14,7 @@ import { useItemRename, } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { + useCanDelete, useDeleteWorkflow, useDuplicateWorkflow, useExportWorkflow, @@ -44,10 +45,14 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf const userPermissions = useUserPermissionsContext() const isSelected = selectedWorkflows.has(workflow.id) + // Can delete check hook + const { canDeleteWorkflows } = useCanDelete({ workspaceId }) + // Delete modal state const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) const [workflowIdsToDelete, setWorkflowIdsToDelete] = useState([]) const [deleteModalNames, setDeleteModalNames] = useState('') + const [canDeleteCaptured, setCanDeleteCaptured] = useState(true) // Presence avatars state const [hasAvatars, setHasAvatars] = useState(false) @@ -172,10 +177,13 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0], } + // Check if the captured selection can be deleted + setCanDeleteCaptured(canDeleteWorkflows(workflowIds)) + // If already selected with multiple selections, keep all selections handleContextMenuBase(e) }, - [workflow.id, workflows, handleContextMenuBase] + [workflow.id, workflows, handleContextMenuBase, canDeleteWorkflows] ) // Rename hook @@ -319,7 +327,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf disableRename={!userPermissions.canEdit} disableDuplicate={!userPermissions.canEdit} disableExport={!userPermissions.canEdit} - disableDelete={!userPermissions.canEdit} + disableDelete={!userPermissions.canEdit || !canDeleteCaptured} /> {/* Delete Confirmation Modal */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx index a4749cde7..3aaba70b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx @@ -677,16 +677,48 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr Invite members to {workspaceName || 'Workspace'} -
+
+ {/* Hidden decoy fields to prevent browser autofill */} + +
{invalidEmails.map((email, index) => ( ))} setInputValue(e.target.value)} @@ -726,6 +759,13 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr )} autoFocus={userPerms.canAdmin} disabled={isSubmitting || !userPerms.canAdmin} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck={false} + data-lpignore='true' + data-form-type='other' + aria-autocomplete='none' />
diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/index.ts index 5b4c1153a..1ebc8bfbf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/index.ts @@ -1,3 +1,4 @@ +export { useCanDelete } from './use-can-delete' export { useDeleteFolder } from './use-delete-folder' export { useDeleteWorkflow } from './use-delete-workflow' export { useDuplicateFolder } from './use-duplicate-folder' diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts new file mode 100644 index 000000000..f206fa90c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts @@ -0,0 +1,130 @@ +import { useCallback, useMemo } from 'react' +import { useFolderStore } from '@/stores/folders/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +interface UseCanDeleteProps { + /** + * Current workspace ID + */ + workspaceId: string +} + +interface UseCanDeleteReturn { + /** + * Checks if the given workflow IDs can be deleted. + * Returns false if deleting them would leave no workflows in the workspace. + */ + canDeleteWorkflows: (workflowIds: string[]) => boolean + /** + * Checks if the given folder can be deleted. + * Returns false if deleting it would leave no workflows in the workspace. + */ + canDeleteFolder: (folderId: string) => boolean + /** + * Total number of workflows in the workspace. + */ + totalWorkflows: number +} + +/** + * Hook for checking if workflows or folders can be deleted. + * Prevents deletion if it would leave the workspace with no workflows. + * + * Uses pre-computed lookup maps for O(1) access instead of repeated filter() calls. + * + * @param props - Hook configuration + * @returns Functions to check deletion eligibility + */ +export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteReturn { + const { workflows } = useWorkflowRegistry() + const { folders } = useFolderStore() + + /** + * Pre-computed data structures for efficient lookups + */ + const { totalWorkflows, workflowIdSet, workflowsByFolderId, childFoldersByParentId } = + useMemo(() => { + const workspaceWorkflows = Object.values(workflows).filter( + (w) => w.workspaceId === workspaceId + ) + + const idSet = new Set(workspaceWorkflows.map((w) => w.id)) + + const byFolderId = new Map() + for (const w of workspaceWorkflows) { + if (w.folderId) { + byFolderId.set(w.folderId, (byFolderId.get(w.folderId) || 0) + 1) + } + } + + const childrenByParent = new Map() + for (const folder of Object.values(folders)) { + if (folder.workspaceId === workspaceId && folder.parentId) { + const children = childrenByParent.get(folder.parentId) || [] + children.push(folder.id) + childrenByParent.set(folder.parentId, children) + } + } + + return { + totalWorkflows: workspaceWorkflows.length, + workflowIdSet: idSet, + workflowsByFolderId: byFolderId, + childFoldersByParentId: childrenByParent, + } + }, [workflows, folders, workspaceId]) + + /** + * Count workflows in a folder and all its subfolders recursively. + * Uses pre-computed maps for efficient lookups. + */ + const countWorkflowsInFolder = useCallback( + (folderId: string): number => { + let count = workflowsByFolderId.get(folderId) || 0 + + const childFolders = childFoldersByParentId.get(folderId) + if (childFolders) { + for (const childId of childFolders) { + count += countWorkflowsInFolder(childId) + } + } + + return count + }, + [workflowsByFolderId, childFoldersByParentId] + ) + + /** + * Check if the given workflow IDs can be deleted. + * Returns false if deleting would remove all workflows from the workspace. + */ + const canDeleteWorkflows = useCallback( + (workflowIds: string[]): boolean => { + const workflowsToDelete = workflowIds.filter((id) => workflowIdSet.has(id)).length + + // Must have at least one workflow remaining after deletion + return totalWorkflows > 0 && workflowsToDelete < totalWorkflows + }, + [totalWorkflows, workflowIdSet] + ) + + /** + * Check if the given folder can be deleted. + * Empty folders are always deletable. Folders containing all workspace workflows are not. + */ + const canDeleteFolder = useCallback( + (folderId: string): boolean => { + const workflowsInFolder = countWorkflowsInFolder(folderId) + + if (workflowsInFolder === 0) return true + return workflowsInFolder < totalWorkflows + }, + [totalWorkflows, countWorkflowsInFolder] + ) + + return { + canDeleteWorkflows, + canDeleteFolder, + totalWorkflows, + } +} diff --git a/apps/sim/blocks/blocks/knowledge.ts b/apps/sim/blocks/blocks/knowledge.ts index 92ad316d5..01a896c84 100644 --- a/apps/sim/blocks/blocks/knowledge.ts +++ b/apps/sim/blocks/blocks/knowledge.ts @@ -57,7 +57,6 @@ export const KnowledgeBlock: BlockConfig = { type: 'knowledge-tag-filters', placeholder: 'Add tag filters', condition: { field: 'operation', value: 'search' }, - mode: 'advanced', }, { id: 'documentId', diff --git a/apps/sim/components/emcn/components/date-picker/date-picker.tsx b/apps/sim/components/emcn/components/date-picker/date-picker.tsx new file mode 100644 index 000000000..c7acb6a7a --- /dev/null +++ b/apps/sim/components/emcn/components/date-picker/date-picker.tsx @@ -0,0 +1,409 @@ +/** + * DatePicker component with calendar dropdown for date selection. + * Uses Radix UI Popover primitives for positioning and accessibility. + * + * @example + * ```tsx + * // Basic date picker + * setDate(dateString)} + * placeholder="Select date" + * /> + * ``` + */ + +'use client' + +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react' +import { Button } from '@/components/emcn/components/button/button' +import { + Popover, + PopoverAnchor, + PopoverContent, +} from '@/components/emcn/components/popover/popover' +import { cn } from '@/lib/core/utils/cn' + +/** + * Variant styles for the date picker trigger button. + * Matches the combobox and input styling patterns. + */ +const datePickerVariants = cva( + 'flex w-full rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] dark:bg-[var(--surface-9)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] dark:placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 hover:border-[var(--surface-14)] hover:bg-[var(--surface-9)] dark:hover:border-[var(--surface-13)] dark:hover:bg-[var(--surface-11)]', + { + variants: { + variant: { + default: '', + }, + size: { + default: 'py-[6px] text-sm', + sm: 'py-[5px] text-[12px]', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +) + +export interface DatePickerProps + extends Omit, 'onChange'>, + VariantProps { + /** Current selected date value (YYYY-MM-DD string or Date) */ + value?: string | Date + /** Callback when date changes, returns YYYY-MM-DD format */ + onChange?: (value: string) => void + /** Placeholder text when no value is selected */ + placeholder?: string + /** Whether the picker is disabled */ + disabled?: boolean + /** Size variant */ + size?: 'default' | 'sm' +} + +/** + * Month names for calendar display. + */ +const MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +] + +/** + * Day abbreviations for calendar header. + */ +const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] + +/** + * Gets the number of days in a given month. + */ +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate() +} + +/** + * Gets the day of the week (0-6) for the first day of the month. + */ +function getFirstDayOfMonth(year: number, month: number): number { + return new Date(year, month, 1).getDay() +} + +/** + * Formats a date for display in the trigger button. + */ +function formatDateForDisplay(date: Date | null): string { + if (!date) return '' + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +/** + * Formats a date as YYYY-MM-DD string. + */ +function formatDateAsString(year: number, month: number, day: number): string { + const m = (month + 1).toString().padStart(2, '0') + const d = day.toString().padStart(2, '0') + return `${year}-${m}-${d}` +} + +/** + * Parses a string or Date value into a Date object. + * Handles various date formats including YYYY-MM-DD and ISO strings. + */ +function parseDate(value: string | Date | undefined): Date | null { + if (!value) return null + + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) return null + return value + } + + try { + // Handle YYYY-MM-DD format (treat as local date) + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + const [year, month, day] = value.split('-').map(Number) + return new Date(year, month - 1, day) + } + + // Handle ISO strings with timezone (extract date part as local) + if (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value)) { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + // Use UTC date components to prevent timezone shift + return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) + } + + // Fallback: try parsing as-is + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date + } catch { + return null + } +} + +/** + * DatePicker component matching emcn design patterns. + * Provides a calendar dropdown for date selection. + */ +const DatePicker = React.forwardRef( + ( + { className, variant, size, value, onChange, placeholder = 'Select date', disabled, ...props }, + ref + ) => { + const [open, setOpen] = React.useState(false) + const selectedDate = parseDate(value) + + const [viewMonth, setViewMonth] = React.useState(() => { + const d = selectedDate || new Date() + return d.getMonth() + }) + const [viewYear, setViewYear] = React.useState(() => { + const d = selectedDate || new Date() + return d.getFullYear() + }) + + // Update view when value changes externally + React.useEffect(() => { + if (selectedDate) { + setViewMonth(selectedDate.getMonth()) + setViewYear(selectedDate.getFullYear()) + } + }, [value]) + + /** + * Handles selection of a specific day in the calendar. + */ + const handleSelectDate = React.useCallback( + (day: number) => { + onChange?.(formatDateAsString(viewYear, viewMonth, day)) + setOpen(false) + }, + [viewYear, viewMonth, onChange] + ) + + /** + * Navigates to the previous month. + */ + const goToPrevMonth = React.useCallback(() => { + if (viewMonth === 0) { + setViewMonth(11) + setViewYear((prev) => prev - 1) + } else { + setViewMonth((prev) => prev - 1) + } + }, [viewMonth]) + + /** + * Navigates to the next month. + */ + const goToNextMonth = React.useCallback(() => { + if (viewMonth === 11) { + setViewMonth(0) + setViewYear((prev) => prev + 1) + } else { + setViewMonth((prev) => prev + 1) + } + }, [viewMonth]) + + /** + * Selects today's date and closes the picker. + */ + const handleSelectToday = React.useCallback(() => { + const now = new Date() + setViewMonth(now.getMonth()) + setViewYear(now.getFullYear()) + onChange?.(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate())) + setOpen(false) + }, [onChange]) + + const daysInMonth = getDaysInMonth(viewYear, viewMonth) + const firstDayOfMonth = getFirstDayOfMonth(viewYear, viewMonth) + + /** + * Checks if a day is today's date. + */ + const isToday = React.useCallback( + (day: number) => { + const today = new Date() + return ( + today.getDate() === day && + today.getMonth() === viewMonth && + today.getFullYear() === viewYear + ) + }, + [viewMonth, viewYear] + ) + + /** + * Checks if a day is the currently selected date. + */ + const isSelected = React.useCallback( + (day: number) => { + return ( + selectedDate && + selectedDate.getDate() === day && + selectedDate.getMonth() === viewMonth && + selectedDate.getFullYear() === viewYear + ) + }, + [selectedDate, viewMonth, viewYear] + ) + + // Build calendar grid + const calendarDays = React.useMemo(() => { + const days: (number | null)[] = [] + for (let i = 0; i < firstDayOfMonth; i++) { + days.push(null) + } + for (let day = 1; day <= daysInMonth; day++) { + days.push(day) + } + return days + }, [firstDayOfMonth, daysInMonth]) + + /** + * Handles keyboard events on the trigger. + */ + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (!disabled && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + setOpen(!open) + } + }, + [disabled, open] + ) + + /** + * Handles click on the trigger. + */ + const handleTriggerClick = React.useCallback(() => { + if (!disabled) { + setOpen(!open) + } + }, [disabled, open]) + + return ( + +
+ +
+ + {selectedDate ? formatDateForDisplay(selectedDate) : placeholder} + + +
+
+ + + {/* Calendar Header */} +
+ + + {MONTHS[viewMonth]} {viewYear} + + +
+ + {/* Day Headers */} +
+ {DAYS.map((day) => ( +
+ {day} +
+ ))} +
+ + {/* Calendar Grid */} +
+ {calendarDays.map((day, index) => ( +
+ {day !== null && ( + + )} +
+ ))} +
+ + {/* Today Button */} +
+ +
+
+
+
+ ) + } +) + +DatePicker.displayName = 'DatePicker' + +export { DatePicker, datePickerVariants } diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts index 34ad94ee2..78d9931ca 100644 --- a/apps/sim/components/emcn/components/index.ts +++ b/apps/sim/components/emcn/components/index.ts @@ -10,7 +10,8 @@ export { languages, } from './code/code' export { Combobox, type ComboboxOption } from './combobox/combobox' -export { Input } from './input/input' +export { DatePicker, type DatePickerProps, datePickerVariants } from './date-picker/date-picker' +export { Input, type InputProps, inputVariants } from './input/input' export { Label } from './label/label' export { MODAL_SIZES, diff --git a/apps/sim/components/emcn/components/input/input.tsx b/apps/sim/components/emcn/components/input/input.tsx index 982837049..eb0e9eaff 100644 --- a/apps/sim/components/emcn/components/input/input.tsx +++ b/apps/sim/components/emcn/components/input/input.tsx @@ -1,7 +1,30 @@ +/** + * A minimal input component matching the emcn design system. + * + * @example + * ```tsx + * import { Input } from '@/components/emcn' + * + * // Basic usage + * + * + * // Controlled input + * setValue(e.target.value)} /> + * + * // Disabled state + * + * ``` + * + * @see inputVariants for available styling variants + */ import * as React from 'react' import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/core/utils/cn' +/** + * Variant styles for the Input component. + * Currently supports a 'default' variant. + */ const inputVariants = cva( 'flex w-full rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] dark:bg-[var(--surface-9)] px-[8px] py-[6px] font-medium font-sans text-sm text-foreground transition-colors placeholder:text-[var(--text-muted)] dark:placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50', { @@ -16,6 +39,10 @@ const inputVariants = cva( } ) +/** + * Props for the Input component. + * Extends native input attributes with variant support. + */ export interface InputProps extends React.InputHTMLAttributes, VariantProps {} diff --git a/apps/sim/components/emcn/components/popover/popover.tsx b/apps/sim/components/emcn/components/popover/popover.tsx index fce043c39..71f6f0225 100644 --- a/apps/sim/components/emcn/components/popover/popover.tsx +++ b/apps/sim/components/emcn/components/popover/popover.tsx @@ -60,7 +60,7 @@ import { cn } from '@/lib/core/utils/cn' * Uses fast transitions (duration-75) to prevent hover state "jumping" during rapid mouse movement. */ const POPOVER_ITEM_BASE_CLASSES = - 'flex h-[25px] min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] text-[12px] transition-colors duration-75 dark:text-[var(--text-primary)] [&_svg]:transition-colors [&_svg]:duration-75 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed' + 'flex h-[25px] min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] text-[12px] transition-colors duration-75 dark:text-[var(--text-primary)] [&_svg]:transition-colors [&_svg]:duration-75' /** * Variant-specific active state styles for popover items. @@ -247,6 +247,11 @@ export interface PopoverContentProps * @default false */ border?: boolean + /** + * When true, the popover will flip to avoid collisions with viewport edges + * @default true + */ + avoidCollisions?: boolean } /** @@ -279,6 +284,7 @@ const PopoverContent = React.forwardRef< sideOffset, collisionPadding = 8, border = false, + avoidCollisions = true, ...restProps }, ref @@ -328,7 +334,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={effectiveSideOffset} collisionPadding={collisionPadding} - avoidCollisions={true} + avoidCollisions={avoidCollisions} sticky='partial' onWheel={handleWheel} {...restProps} @@ -425,7 +431,10 @@ export interface PopoverItemProps extends React.HTMLAttributes { * ``` */ const PopoverItem = React.forwardRef( - ({ className, active, rootOnly, disabled, showCheck = false, children, ...props }, ref) => { + ( + { className, active, rootOnly, disabled, showCheck = false, children, onClick, ...props }, + ref + ) => { // Try to get context - if not available, we're outside Popover (shouldn't happen) const context = React.useContext(PopoverContext) const variant = context?.variant || 'default' @@ -435,18 +444,28 @@ const PopoverItem = React.forwardRef( return null } + const handleClick = (e: React.MouseEvent) => { + if (disabled) { + e.stopPropagation() + return + } + onClick?.(e) + } + return (
{children} @@ -707,8 +726,10 @@ const PopoverSearch = React.forwardRef( } React.useEffect(() => { + setSearchQuery('') + onValueChange?.('') inputRef.current?.focus() - }, []) + }, [setSearchQuery, onValueChange]) return (
diff --git a/apps/sim/executor/dag/construction/edges.test.ts b/apps/sim/executor/dag/construction/edges.test.ts new file mode 100644 index 000000000..3859ca086 --- /dev/null +++ b/apps/sim/executor/dag/construction/edges.test.ts @@ -0,0 +1,567 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DAG, DAGNode } from '@/executor/dag/builder' +import type { SerializedBlock, SerializedLoop, SerializedWorkflow } from '@/serializer/types' +import { EdgeConstructor } from './edges' + +vi.mock('@/lib/logs/console/logger', () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + })), +})) + +function createMockBlock(id: string, type = 'function', config: any = {}): SerializedBlock { + return { + id, + metadata: { id: type, name: `Block ${id}` }, + position: { x: 0, y: 0 }, + config: { tool: type, params: config }, + inputs: {}, + outputs: {}, + enabled: true, + } +} + +function createMockNode(id: string): DAGNode { + return { + id, + block: createMockBlock(id), + outgoingEdges: new Map(), + incomingEdges: new Set(), + metadata: {}, + } +} + +function createMockDAG(nodeIds: string[]): DAG { + const nodes = new Map() + for (const id of nodeIds) { + nodes.set(id, createMockNode(id)) + } + return { + nodes, + loopConfigs: new Map(), + parallelConfigs: new Map(), + } +} + +function createMockWorkflow( + blocks: SerializedBlock[], + connections: Array<{ + source: string + target: string + sourceHandle?: string + targetHandle?: string + }>, + loops: Record = {}, + parallels: Record = {} +): SerializedWorkflow { + return { + version: '1', + blocks, + connections, + loops, + parallels, + } +} + +describe('EdgeConstructor', () => { + let edgeConstructor: EdgeConstructor + + beforeEach(() => { + edgeConstructor = new EdgeConstructor() + }) + + describe('Edge ID generation (bug fix verification)', () => { + it('should generate unique edge IDs for multiple edges to same target with different handles', () => { + const conditionId = 'condition-1' + const targetId = 'target-1' + + const conditionBlock = createMockBlock(conditionId, 'condition', { + conditions: JSON.stringify([ + { id: 'if-id', label: 'if', condition: 'true' }, + { id: 'else-id', label: 'else', condition: '' }, + ]), + }) + + const workflow = createMockWorkflow( + [conditionBlock, createMockBlock(targetId)], + [ + { source: conditionId, target: targetId, sourceHandle: 'condition-if-id' }, + { source: conditionId, target: targetId, sourceHandle: 'condition-else-id' }, + ] + ) + + const dag = createMockDAG([conditionId, targetId]) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([conditionId, targetId]), + new Map() + ) + + const conditionNode = dag.nodes.get(conditionId)! + + // Should have 2 edges, not 1 (the bug was that they would overwrite each other) + expect(conditionNode.outgoingEdges.size).toBe(2) + + // Verify edge IDs are unique and include the sourceHandle + const edgeIds = Array.from(conditionNode.outgoingEdges.keys()) + expect(edgeIds).toContain(`${conditionId}→${targetId}-condition-if-id`) + expect(edgeIds).toContain(`${conditionId}→${targetId}-condition-else-id`) + }) + + it('should generate edge ID without handle suffix when no sourceHandle', () => { + const sourceId = 'source-1' + const targetId = 'target-1' + + const workflow = createMockWorkflow( + [createMockBlock(sourceId), createMockBlock(targetId)], + [{ source: sourceId, target: targetId }] + ) + + const dag = createMockDAG([sourceId, targetId]) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([sourceId, targetId]), + new Map() + ) + + const sourceNode = dag.nodes.get(sourceId)! + const edgeIds = Array.from(sourceNode.outgoingEdges.keys()) + + expect(edgeIds).toContain(`${sourceId}→${targetId}`) + }) + }) + + describe('Condition block edge wiring', () => { + it('should wire condition block edges with proper condition prefixes', () => { + const conditionId = 'condition-1' + const target1Id = 'target-1' + const target2Id = 'target-2' + + const conditionBlock = createMockBlock(conditionId, 'condition', { + conditions: JSON.stringify([ + { id: 'cond-if', label: 'if', condition: 'x > 5' }, + { id: 'cond-else', label: 'else', condition: '' }, + ]), + }) + + const workflow = createMockWorkflow( + [conditionBlock, createMockBlock(target1Id), createMockBlock(target2Id)], + [ + { source: conditionId, target: target1Id, sourceHandle: 'condition-cond-if' }, + { source: conditionId, target: target2Id, sourceHandle: 'condition-cond-else' }, + ] + ) + + const dag = createMockDAG([conditionId, target1Id, target2Id]) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([conditionId, target1Id, target2Id]), + new Map() + ) + + const conditionNode = dag.nodes.get(conditionId)! + + expect(conditionNode.outgoingEdges.size).toBe(2) + + // Verify edges have correct targets and handles + const edges = Array.from(conditionNode.outgoingEdges.values()) + const ifEdge = edges.find((e) => e.sourceHandle === 'condition-cond-if') + const elseEdge = edges.find((e) => e.sourceHandle === 'condition-cond-else') + + expect(ifEdge?.target).toBe(target1Id) + expect(elseEdge?.target).toBe(target2Id) + }) + + it('should handle condition block with if→A, elseif→B, else→A pattern', () => { + const conditionId = 'condition-1' + const targetAId = 'target-a' + const targetBId = 'target-b' + + const conditionBlock = createMockBlock(conditionId, 'condition', { + conditions: JSON.stringify([ + { id: 'if-id', label: 'if', condition: 'x == 1' }, + { id: 'elseif-id', label: 'else if', condition: 'x == 2' }, + { id: 'else-id', label: 'else', condition: '' }, + ]), + }) + + const workflow = createMockWorkflow( + [conditionBlock, createMockBlock(targetAId), createMockBlock(targetBId)], + [ + { source: conditionId, target: targetAId, sourceHandle: 'condition-if-id' }, + { source: conditionId, target: targetBId, sourceHandle: 'condition-elseif-id' }, + { source: conditionId, target: targetAId, sourceHandle: 'condition-else-id' }, + ] + ) + + const dag = createMockDAG([conditionId, targetAId, targetBId]) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([conditionId, targetAId, targetBId]), + new Map() + ) + + const conditionNode = dag.nodes.get(conditionId)! + + // Should have 3 edges (if→A, elseif→B, else→A) + expect(conditionNode.outgoingEdges.size).toBe(3) + + // Target A should have 2 incoming edges (from if and else) + const targetANode = dag.nodes.get(targetAId)! + expect(targetANode.incomingEdges.has(conditionId)).toBe(true) + + // Target B should have 1 incoming edge (from elseif) + const targetBNode = dag.nodes.get(targetBId)! + expect(targetBNode.incomingEdges.has(conditionId)).toBe(true) + }) + }) + + describe('Router block edge wiring', () => { + it('should wire router block edges with router prefix', () => { + const routerId = 'router-1' + const target1Id = 'target-1' + const target2Id = 'target-2' + + const routerBlock = createMockBlock(routerId, 'router') + + const workflow = createMockWorkflow( + [routerBlock, createMockBlock(target1Id), createMockBlock(target2Id)], + [ + { source: routerId, target: target1Id }, + { source: routerId, target: target2Id }, + ] + ) + + const dag = createMockDAG([routerId, target1Id, target2Id]) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([routerId, target1Id, target2Id]), + new Map() + ) + + const routerNode = dag.nodes.get(routerId)! + const edges = Array.from(routerNode.outgoingEdges.values()) + + // Router edges should have router- prefix with target ID + expect(edges[0].sourceHandle).toBe(`router-${target1Id}`) + expect(edges[1].sourceHandle).toBe(`router-${target2Id}`) + }) + }) + + describe('Simple linear workflow', () => { + it('should wire linear workflow correctly', () => { + const block1Id = 'block-1' + const block2Id = 'block-2' + const block3Id = 'block-3' + + const workflow = createMockWorkflow( + [createMockBlock(block1Id), createMockBlock(block2Id), createMockBlock(block3Id)], + [ + { source: block1Id, target: block2Id }, + { source: block2Id, target: block3Id }, + ] + ) + + const dag = createMockDAG([block1Id, block2Id, block3Id]) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([block1Id, block2Id, block3Id]), + new Map() + ) + + // Block 1 → Block 2 + const block1Node = dag.nodes.get(block1Id)! + expect(block1Node.outgoingEdges.size).toBe(1) + expect(Array.from(block1Node.outgoingEdges.values())[0].target).toBe(block2Id) + + // Block 2 → Block 3 + const block2Node = dag.nodes.get(block2Id)! + expect(block2Node.outgoingEdges.size).toBe(1) + expect(Array.from(block2Node.outgoingEdges.values())[0].target).toBe(block3Id) + expect(block2Node.incomingEdges.has(block1Id)).toBe(true) + + // Block 3 has incoming from Block 2 + const block3Node = dag.nodes.get(block3Id)! + expect(block3Node.incomingEdges.has(block2Id)).toBe(true) + }) + }) + + describe('Edge reachability', () => { + it('should not wire edges to blocks not in DAG nodes', () => { + const block1Id = 'block-1' + const block2Id = 'block-2' + const unreachableId = 'unreachable' + + const workflow = createMockWorkflow( + [createMockBlock(block1Id), createMockBlock(block2Id), createMockBlock(unreachableId)], + [ + { source: block1Id, target: block2Id }, + { source: block1Id, target: unreachableId }, + ] + ) + + // Only create DAG nodes for block1 and block2 (not unreachable) + const dag = createMockDAG([block1Id, block2Id]) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([block1Id, block2Id]), + new Map() + ) + + const block1Node = dag.nodes.get(block1Id)! + + // Should only have edge to block2, not unreachable (not in DAG) + expect(block1Node.outgoingEdges.size).toBe(1) + expect(Array.from(block1Node.outgoingEdges.values())[0].target).toBe(block2Id) + }) + + it('should check both reachableBlocks and dag.nodes for edge validity', () => { + const block1Id = 'block-1' + const block2Id = 'block-2' + + const workflow = createMockWorkflow( + [createMockBlock(block1Id), createMockBlock(block2Id)], + [{ source: block1Id, target: block2Id }] + ) + + const dag = createMockDAG([block1Id, block2Id]) + + // Block2 exists in DAG but not in reachableBlocks - edge should still be wired + // because isEdgeReachable checks: reachableBlocks.has(target) || dag.nodes.has(target) + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([block1Id]), // Only block1 is "reachable" but block2 exists in DAG + new Map() + ) + + const block1Node = dag.nodes.get(block1Id)! + expect(block1Node.outgoingEdges.size).toBe(1) + }) + }) + + describe('Error edge handling', () => { + it('should preserve error sourceHandle', () => { + const sourceId = 'source-1' + const successTargetId = 'success-target' + const errorTargetId = 'error-target' + + const workflow = createMockWorkflow( + [ + createMockBlock(sourceId), + createMockBlock(successTargetId), + createMockBlock(errorTargetId), + ], + [ + { source: sourceId, target: successTargetId, sourceHandle: 'source' }, + { source: sourceId, target: errorTargetId, sourceHandle: 'error' }, + ] + ) + + const dag = createMockDAG([sourceId, successTargetId, errorTargetId]) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set(), + new Set([sourceId, successTargetId, errorTargetId]), + new Map() + ) + + const sourceNode = dag.nodes.get(sourceId)! + const edges = Array.from(sourceNode.outgoingEdges.values()) + + const successEdge = edges.find((e) => e.target === successTargetId) + const errorEdge = edges.find((e) => e.target === errorTargetId) + + expect(successEdge?.sourceHandle).toBe('source') + expect(errorEdge?.sourceHandle).toBe('error') + }) + }) + + describe('Loop sentinel wiring', () => { + it('should wire loop sentinels to nodes with no incoming edges from within loop', () => { + const loopId = 'loop-1' + const nodeInLoopId = 'node-in-loop' + const sentinelStartId = `loop-${loopId}-sentinel-start` + const sentinelEndId = `loop-${loopId}-sentinel-end` + + // Create DAG with sentinels - nodeInLoop has no incoming edges from loop nodes + // so it will be identified as a start node + const dag = createMockDAG([nodeInLoopId, sentinelStartId, sentinelEndId]) + dag.loopConfigs.set(loopId, { + id: loopId, + nodes: [nodeInLoopId], + iterations: 5, + loopType: 'for', + } as SerializedLoop) + + const workflow = createMockWorkflow([createMockBlock(nodeInLoopId)], [], { + [loopId]: { + id: loopId, + nodes: [nodeInLoopId], + iterations: 5, + loopType: 'for', + } as SerializedLoop, + }) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set([nodeInLoopId]), + new Set([nodeInLoopId, sentinelStartId, sentinelEndId]), + new Map() + ) + + // Sentinel start should have edge to node in loop (it's a start node - no incoming from loop) + const sentinelStartNode = dag.nodes.get(sentinelStartId)! + expect(sentinelStartNode.outgoingEdges.size).toBe(1) + const startEdge = Array.from(sentinelStartNode.outgoingEdges.values())[0] + expect(startEdge.target).toBe(nodeInLoopId) + + // Node in loop should have edge to sentinel end (it's a terminal node - no outgoing to loop) + const nodeInLoopNode = dag.nodes.get(nodeInLoopId)! + const hasEdgeToEnd = Array.from(nodeInLoopNode.outgoingEdges.values()).some( + (e) => e.target === sentinelEndId + ) + expect(hasEdgeToEnd).toBe(true) + + // Sentinel end should have loop_continue edge back to start + const sentinelEndNode = dag.nodes.get(sentinelEndId)! + const continueEdge = Array.from(sentinelEndNode.outgoingEdges.values()).find( + (e) => e.sourceHandle === 'loop_continue' + ) + expect(continueEdge?.target).toBe(sentinelStartId) + }) + + it('should identify multiple start and terminal nodes in loop', () => { + const loopId = 'loop-1' + const node1Id = 'node-1' + const node2Id = 'node-2' + const sentinelStartId = `loop-${loopId}-sentinel-start` + const sentinelEndId = `loop-${loopId}-sentinel-end` + + // Create DAG with two nodes in loop - both are start and terminal (no edges between them) + const dag = createMockDAG([node1Id, node2Id, sentinelStartId, sentinelEndId]) + dag.loopConfigs.set(loopId, { + id: loopId, + nodes: [node1Id, node2Id], + iterations: 3, + loopType: 'for', + } as SerializedLoop) + + const workflow = createMockWorkflow( + [createMockBlock(node1Id), createMockBlock(node2Id)], + [], + { + [loopId]: { + id: loopId, + nodes: [node1Id, node2Id], + iterations: 3, + loopType: 'for', + } as SerializedLoop, + } + ) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set([node1Id, node2Id]), + new Set([node1Id, node2Id, sentinelStartId, sentinelEndId]), + new Map() + ) + + // Sentinel start should have edges to both nodes (both are start nodes) + const sentinelStartNode = dag.nodes.get(sentinelStartId)! + expect(sentinelStartNode.outgoingEdges.size).toBe(2) + + // Both nodes should have edges to sentinel end (both are terminal nodes) + const node1 = dag.nodes.get(node1Id)! + const node2 = dag.nodes.get(node2Id)! + expect(Array.from(node1.outgoingEdges.values()).some((e) => e.target === sentinelEndId)).toBe( + true + ) + expect(Array.from(node2.outgoingEdges.values()).some((e) => e.target === sentinelEndId)).toBe( + true + ) + }) + }) + + describe('Cross-loop boundary detection', () => { + it('should not wire edges that cross loop boundaries', () => { + const outsideId = 'outside' + const insideId = 'inside' + const loopId = 'loop-1' + + const workflow = createMockWorkflow( + [createMockBlock(outsideId), createMockBlock(insideId)], + [{ source: outsideId, target: insideId }], + { + [loopId]: { + id: loopId, + nodes: [insideId], + iterations: 5, + loopType: 'for', + } as SerializedLoop, + } + ) + + const dag = createMockDAG([outsideId, insideId]) + dag.loopConfigs.set(loopId, { + id: loopId, + nodes: [insideId], + iterations: 5, + loopType: 'for', + } as SerializedLoop) + + edgeConstructor.execute( + workflow, + dag, + new Set(), + new Set([insideId]), + new Set([outsideId, insideId]), + new Map() + ) + + // Edge should not be wired because it crosses loop boundary + const outsideNode = dag.nodes.get(outsideId)! + expect(outsideNode.outgoingEdges.size).toBe(0) + }) + }) +}) diff --git a/apps/sim/executor/dag/construction/edges.ts b/apps/sim/executor/dag/construction/edges.ts index 821fc2952..2b652a5db 100644 --- a/apps/sim/executor/dag/construction/edges.ts +++ b/apps/sim/executor/dag/construction/edges.ts @@ -578,7 +578,7 @@ export class EdgeConstructor { return } - const edgeId = `${sourceId}→${targetId}` + const edgeId = `${sourceId}→${targetId}${sourceHandle ? `-${sourceHandle}` : ''}` sourceNode.outgoingEdges.set(edgeId, { target: targetId, diff --git a/apps/sim/executor/execution/edge-manager.test.ts b/apps/sim/executor/execution/edge-manager.test.ts new file mode 100644 index 000000000..3470c2d67 --- /dev/null +++ b/apps/sim/executor/execution/edge-manager.test.ts @@ -0,0 +1,1052 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DAG, DAGNode } from '@/executor/dag/builder' +import type { DAGEdge } from '@/executor/dag/types' +import type { SerializedBlock } from '@/serializer/types' +import { EdgeManager } from './edge-manager' + +vi.mock('@/lib/logs/console/logger', () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + })), +})) + +function createMockBlock(id: string): SerializedBlock { + return { + id, + metadata: { id: 'test', name: 'Test Block' }, + position: { x: 0, y: 0 }, + config: { tool: '', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + } +} + +function createMockNode( + id: string, + outgoingEdges: DAGEdge[] = [], + incomingEdges: string[] = [] +): DAGNode { + const outEdgesMap = new Map() + outgoingEdges.forEach((edge, i) => { + outEdgesMap.set(`edge-${i}`, edge) + }) + + return { + id, + block: createMockBlock(id), + outgoingEdges: outEdgesMap, + incomingEdges: new Set(incomingEdges), + metadata: {}, + } +} + +function createMockDAG(nodes: Map): DAG { + return { + nodes, + loopConfigs: new Map(), + parallelConfigs: new Map(), + } +} + +describe('EdgeManager', () => { + describe('Happy path - basic workflows', () => { + it('should handle simple linear flow (A → B → C)', () => { + const blockAId = 'block-a' + const blockBId = 'block-b' + const blockCId = 'block-c' + + const blockANode = createMockNode(blockAId, [{ target: blockBId }]) + const blockBNode = createMockNode(blockBId, [{ target: blockCId }], [blockAId]) + const blockCNode = createMockNode(blockCId, [], [blockBId]) + + const nodes = new Map([ + [blockAId, blockANode], + [blockBId, blockBNode], + [blockCId, blockCNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // A completes → B becomes ready + const readyAfterA = edgeManager.processOutgoingEdges(blockANode, { result: 'done' }) + expect(readyAfterA).toContain(blockBId) + expect(readyAfterA).not.toContain(blockCId) + + // B completes → C becomes ready + const readyAfterB = edgeManager.processOutgoingEdges(blockBNode, { result: 'done' }) + expect(readyAfterB).toContain(blockCId) + }) + + it('should handle branching and each branch executing independently', () => { + const startId = 'start' + const branch1Id = 'branch-1' + const branch2Id = 'branch-2' + + const startNode = createMockNode(startId, [ + { target: branch1Id, sourceHandle: 'condition-opt1' }, + { target: branch2Id, sourceHandle: 'condition-opt2' }, + ]) + + const branch1Node = createMockNode(branch1Id, [], [startId]) + const branch2Node = createMockNode(branch2Id, [], [startId]) + + const nodes = new Map([ + [startId, startNode], + [branch1Id, branch1Node], + [branch2Id, branch2Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Select option 1 + const readyNodes = edgeManager.processOutgoingEdges(startNode, { selectedOption: 'opt1' }) + expect(readyNodes).toContain(branch1Id) + expect(readyNodes).not.toContain(branch2Id) + }) + + it('should process standard block output with result', () => { + const sourceId = 'source' + const targetId = 'target' + + const sourceNode = createMockNode(sourceId, [{ target: targetId }]) + const targetNode = createMockNode(targetId, [], [sourceId]) + + const nodes = new Map([ + [sourceId, sourceNode], + [targetId, targetNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Normal block output + const output = { + result: { data: 'test' }, + content: 'Hello world', + tokens: { prompt: 10, completion: 20, total: 30 }, + } + + const readyNodes = edgeManager.processOutgoingEdges(sourceNode, output) + expect(readyNodes).toContain(targetId) + }) + + it('should handle multiple sequential blocks completing in order', () => { + const block1Id = 'block-1' + const block2Id = 'block-2' + const block3Id = 'block-3' + const block4Id = 'block-4' + + const block1Node = createMockNode(block1Id, [{ target: block2Id }]) + const block2Node = createMockNode(block2Id, [{ target: block3Id }], [block1Id]) + const block3Node = createMockNode(block3Id, [{ target: block4Id }], [block2Id]) + const block4Node = createMockNode(block4Id, [], [block3Id]) + + const nodes = new Map([ + [block1Id, block1Node], + [block2Id, block2Node], + [block3Id, block3Node], + [block4Id, block4Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Process through the chain + let ready = edgeManager.processOutgoingEdges(block1Node, {}) + expect(ready).toEqual([block2Id]) + + ready = edgeManager.processOutgoingEdges(block2Node, {}) + expect(ready).toEqual([block3Id]) + + ready = edgeManager.processOutgoingEdges(block3Node, {}) + expect(ready).toEqual([block4Id]) + + ready = edgeManager.processOutgoingEdges(block4Node, {}) + expect(ready).toEqual([]) + }) + }) + + describe('Multiple condition edges to same target', () => { + it('should not cascade-deactivate when multiple edges from same source go to same target', () => { + const conditionId = 'condition-1' + const function1Id = 'function-1' + const function2Id = 'function-2' + + const conditionNode = createMockNode(conditionId, [ + { target: function1Id, sourceHandle: 'condition-if' }, + { target: function1Id, sourceHandle: 'condition-else' }, + ]) + + const function1Node = createMockNode(function1Id, [{ target: function2Id }], [conditionId]) + + const function2Node = createMockNode(function2Id, [], [function1Id]) + + const nodes = new Map([ + [conditionId, conditionNode], + [function1Id, function1Node], + [function2Id, function2Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { selectedOption: 'if' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).toContain(function1Id) + expect(function1Node.incomingEdges.size).toBe(0) + }) + + it('should handle "else if" selected when "if" points to same target', () => { + const conditionId = 'condition-1' + const function1Id = 'function-1' + + const conditionNode = createMockNode(conditionId, [ + { target: function1Id, sourceHandle: 'condition-if-id' }, + { target: function1Id, sourceHandle: 'condition-elseif-id' }, + ]) + + const function1Node = createMockNode(function1Id, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [function1Id, function1Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { selectedOption: 'elseif-id' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).toContain(function1Id) + }) + + it('should handle condition with if→A, elseif→B, else→A pattern', () => { + const conditionId = 'condition-1' + const function1Id = 'function-1' + const function2Id = 'function-2' + + const conditionNode = createMockNode(conditionId, [ + { target: function1Id, sourceHandle: 'condition-if' }, + { target: function2Id, sourceHandle: 'condition-elseif' }, + { target: function1Id, sourceHandle: 'condition-else' }, + ]) + + const function1Node = createMockNode(function1Id, [], [conditionId]) + const function2Node = createMockNode(function2Id, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [function1Id, function1Node], + [function2Id, function2Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { selectedOption: 'if' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + expect(readyNodes).toContain(function1Id) + expect(readyNodes).not.toContain(function2Id) + }) + + it('should activate correct target when elseif is selected (iteration 2)', () => { + const conditionId = 'condition-1' + const function1Id = 'function-1' + const function2Id = 'function-2' + + const conditionNode = createMockNode(conditionId, [ + { target: function1Id, sourceHandle: 'condition-if' }, + { target: function2Id, sourceHandle: 'condition-elseif' }, + { target: function1Id, sourceHandle: 'condition-else' }, + ]) + + const function1Node = createMockNode(function1Id, [], [conditionId]) + const function2Node = createMockNode(function2Id, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [function1Id, function1Node], + [function2Id, function2Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { selectedOption: 'elseif' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).toContain(function2Id) + expect(readyNodes).not.toContain(function1Id) + }) + + it('should activate Function1 when else is selected (iteration 3+)', () => { + const conditionId = 'condition-1' + const function1Id = 'function-1' + const function2Id = 'function-2' + + const conditionNode = createMockNode(conditionId, [ + { target: function1Id, sourceHandle: 'condition-if' }, + { target: function2Id, sourceHandle: 'condition-elseif' }, + { target: function1Id, sourceHandle: 'condition-else' }, + ]) + + const function1Node = createMockNode(function1Id, [], [conditionId]) + const function2Node = createMockNode(function2Id, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [function1Id, function1Node], + [function2Id, function2Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { selectedOption: 'else' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).toContain(function1Id) + expect(readyNodes).not.toContain(function2Id) + }) + }) + + describe('Cascade deactivation', () => { + it('should cascade-deactivate descendants when ALL edges to target are deactivated', () => { + const conditionId = 'condition-1' + const function1Id = 'function-1' + const function2Id = 'function-2' + + const conditionNode = createMockNode(conditionId, [ + { target: function1Id, sourceHandle: 'condition-if' }, + ]) + + const function1Node = createMockNode(function1Id, [{ target: function2Id }], [conditionId]) + + const function2Node = createMockNode(function2Id, [], [function1Id]) + + const nodes = new Map([ + [conditionId, conditionNode], + [function1Id, function1Node], + [function2Id, function2Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { selectedOption: 'else' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).not.toContain(function1Id) + }) + }) + + describe('Exact workflow reproduction: modern-atoll', () => { + const conditionId = '63353190-ed15-427b-af6b-c0967ba06010' + const function1Id = '576cc8a3-c3f3-40f5-a515-8320462b8162' + const function2Id = 'b96067c5-0c5c-4a91-92bd-299e8c4ab42d' + + const ifConditionId = '63353190-ed15-427b-af6b-c0967ba06010-if' + const elseIfConditionId = '63353190-ed15-427b-af6b-c0967ba06010-else-if-1766204485970' + const elseConditionId = '63353190-ed15-427b-af6b-c0967ba06010-else' + + function setupWorkflow() { + const conditionNode = createMockNode(conditionId, [ + { target: function1Id, sourceHandle: `condition-${ifConditionId}` }, + { target: function2Id, sourceHandle: `condition-${elseIfConditionId}` }, + { target: function1Id, sourceHandle: `condition-${elseConditionId}` }, + ]) + + const function1Node = createMockNode(function1Id, [], [conditionId]) + const function2Node = createMockNode(function2Id, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [function1Id, function1Node], + [function2Id, function2Node], + ]) + + return createMockDAG(nodes) + } + + it('iteration 1: if selected (loop.index == 1) should activate Function 1', () => { + const dag = setupWorkflow() + const edgeManager = new EdgeManager(dag) + const conditionNode = dag.nodes.get(conditionId)! + + const output = { selectedOption: ifConditionId } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).toContain(function1Id) + expect(readyNodes).not.toContain(function2Id) + }) + + it('iteration 2: else if selected (loop.index == 2) should activate Function 2', () => { + const dag = setupWorkflow() + const edgeManager = new EdgeManager(dag) + const conditionNode = dag.nodes.get(conditionId)! + + const output = { selectedOption: elseIfConditionId } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).toContain(function2Id) + expect(readyNodes).not.toContain(function1Id) + }) + + it('iteration 3+: else selected (loop.index > 2) should activate Function 1', () => { + const dag = setupWorkflow() + const edgeManager = new EdgeManager(dag) + const conditionNode = dag.nodes.get(conditionId)! + + const output = { selectedOption: elseConditionId } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).toContain(function1Id) + expect(readyNodes).not.toContain(function2Id) + }) + + it('should handle multiple iterations correctly (simulating loop)', () => { + const dag = setupWorkflow() + const edgeManager = new EdgeManager(dag) + const conditionNode = dag.nodes.get(conditionId)! + + // Iteration 1: if selected + { + dag.nodes.get(function1Id)!.incomingEdges = new Set([conditionId]) + dag.nodes.get(function2Id)!.incomingEdges = new Set([conditionId]) + edgeManager.clearDeactivatedEdges() + + const output = { selectedOption: ifConditionId } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + expect(readyNodes).toContain(function1Id) + expect(readyNodes).not.toContain(function2Id) + } + + // Iteration 2: else if selected + { + dag.nodes.get(function1Id)!.incomingEdges = new Set([conditionId]) + dag.nodes.get(function2Id)!.incomingEdges = new Set([conditionId]) + edgeManager.clearDeactivatedEdges() + + const output = { selectedOption: elseIfConditionId } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + expect(readyNodes).toContain(function2Id) + expect(readyNodes).not.toContain(function1Id) + } + + // Iteration 3: else selected + { + dag.nodes.get(function1Id)!.incomingEdges = new Set([conditionId]) + dag.nodes.get(function2Id)!.incomingEdges = new Set([conditionId]) + edgeManager.clearDeactivatedEdges() + + const output = { selectedOption: elseConditionId } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + expect(readyNodes).toContain(function1Id) + expect(readyNodes).not.toContain(function2Id) + } + }) + }) + + describe('Error/Success edge handling', () => { + it('should activate error edge when output has error', () => { + const sourceId = 'source-1' + const successTargetId = 'success-target' + const errorTargetId = 'error-target' + + const sourceNode = createMockNode(sourceId, [ + { target: successTargetId, sourceHandle: 'source' }, + { target: errorTargetId, sourceHandle: 'error' }, + ]) + + const successNode = createMockNode(successTargetId, [], [sourceId]) + const errorNode = createMockNode(errorTargetId, [], [sourceId]) + + const nodes = new Map([ + [sourceId, sourceNode], + [successTargetId, successNode], + [errorTargetId, errorNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { error: 'Something went wrong' } + const readyNodes = edgeManager.processOutgoingEdges(sourceNode, output) + + expect(readyNodes).toContain(errorTargetId) + expect(readyNodes).not.toContain(successTargetId) + }) + + it('should activate source edge when no error', () => { + const sourceId = 'source-1' + const successTargetId = 'success-target' + const errorTargetId = 'error-target' + + const sourceNode = createMockNode(sourceId, [ + { target: successTargetId, sourceHandle: 'source' }, + { target: errorTargetId, sourceHandle: 'error' }, + ]) + + const successNode = createMockNode(successTargetId, [], [sourceId]) + const errorNode = createMockNode(errorTargetId, [], [sourceId]) + + const nodes = new Map([ + [sourceId, sourceNode], + [successTargetId, successNode], + [errorTargetId, errorNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { result: 'success' } + const readyNodes = edgeManager.processOutgoingEdges(sourceNode, output) + + expect(readyNodes).toContain(successTargetId) + expect(readyNodes).not.toContain(errorTargetId) + }) + }) + + describe('Router edge handling', () => { + it('should activate only the selected route', () => { + const routerId = 'router-1' + const route1Id = 'route-1' + const route2Id = 'route-2' + const route3Id = 'route-3' + + const routerNode = createMockNode(routerId, [ + { target: route1Id, sourceHandle: 'router-route1' }, + { target: route2Id, sourceHandle: 'router-route2' }, + { target: route3Id, sourceHandle: 'router-route3' }, + ]) + + const route1Node = createMockNode(route1Id, [], [routerId]) + const route2Node = createMockNode(route2Id, [], [routerId]) + const route3Node = createMockNode(route3Id, [], [routerId]) + + const nodes = new Map([ + [routerId, routerNode], + [route1Id, route1Node], + [route2Id, route2Node], + [route3Id, route3Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const output = { selectedRoute: 'route2' } + const readyNodes = edgeManager.processOutgoingEdges(routerNode, output) + + expect(readyNodes).toContain(route2Id) + expect(readyNodes).not.toContain(route1Id) + expect(readyNodes).not.toContain(route3Id) + }) + }) + + describe('Node with multiple incoming sources', () => { + it('should wait for all incoming edges before becoming ready', () => { + const source1Id = 'source-1' + const source2Id = 'source-2' + const targetId = 'target' + + const source1Node = createMockNode(source1Id, [{ target: targetId }]) + const source2Node = createMockNode(source2Id, [{ target: targetId }]) + const targetNode = createMockNode(targetId, [], [source1Id, source2Id]) + + const nodes = new Map([ + [source1Id, source1Node], + [source2Id, source2Node], + [targetId, targetNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Process first source + const readyAfterFirst = edgeManager.processOutgoingEdges(source1Node, {}) + expect(readyAfterFirst).not.toContain(targetId) + + // Process second source + const readyAfterSecond = edgeManager.processOutgoingEdges(source2Node, {}) + expect(readyAfterSecond).toContain(targetId) + }) + }) + + describe('clearDeactivatedEdgesForNodes', () => { + it('should clear deactivated edges for specified nodes', () => { + const conditionId = 'condition-1' + const function1Id = 'function-1' + + const conditionNode = createMockNode(conditionId, [ + { target: function1Id, sourceHandle: 'condition-if' }, + ]) + const function1Node = createMockNode(function1Id, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [function1Id, function1Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Deactivate edge by selecting non-existent option + edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'nonexistent' }) + + // Clear deactivated edges for condition node + edgeManager.clearDeactivatedEdgesForNodes(new Set([conditionId])) + + // Restore incoming edge and try again + function1Node.incomingEdges.add(conditionId) + + // Now select "if" - should work since edge is no longer deactivated + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'if' }) + expect(readyNodes).toContain(function1Id) + }) + }) + + describe('restoreIncomingEdge', () => { + it('should restore an incoming edge to a target node', () => { + const sourceId = 'source-1' + const targetId = 'target-1' + + const sourceNode = createMockNode(sourceId, [{ target: targetId }]) + const targetNode = createMockNode(targetId, [], []) + + const nodes = new Map([ + [sourceId, sourceNode], + [targetId, targetNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + expect(targetNode.incomingEdges.has(sourceId)).toBe(false) + + edgeManager.restoreIncomingEdge(targetId, sourceId) + + expect(targetNode.incomingEdges.has(sourceId)).toBe(true) + }) + }) + + describe('Diamond pattern (convergent paths)', () => { + it('should handle diamond: condition splits then converges at merge point', () => { + const conditionId = 'condition-1' + const branchAId = 'branch-a' + const branchBId = 'branch-b' + const mergeId = 'merge-point' + + const conditionNode = createMockNode(conditionId, [ + { target: branchAId, sourceHandle: 'condition-if' }, + { target: branchBId, sourceHandle: 'condition-else' }, + ]) + + const branchANode = createMockNode(branchAId, [{ target: mergeId }], [conditionId]) + const branchBNode = createMockNode(branchBId, [{ target: mergeId }], [conditionId]) + const mergeNode = createMockNode(mergeId, [], [branchAId, branchBId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [branchAId, branchANode], + [branchBId, branchBNode], + [mergeId, mergeNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Select "if" branch + const output = { selectedOption: 'if' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + // Branch A should be ready + expect(readyNodes).toContain(branchAId) + expect(readyNodes).not.toContain(branchBId) + + // Process branch A completing + const mergeReady = edgeManager.processOutgoingEdges(branchANode, {}) + + // Merge point should be ready since branch B was deactivated + expect(mergeReady).toContain(mergeId) + }) + + it('should wait for both branches when both are active (parallel merge)', () => { + const source1Id = 'source-1' + const source2Id = 'source-2' + const mergeId = 'merge-point' + + const source1Node = createMockNode(source1Id, [{ target: mergeId }]) + const source2Node = createMockNode(source2Id, [{ target: mergeId }]) + const mergeNode = createMockNode(mergeId, [], [source1Id, source2Id]) + + const nodes = new Map([ + [source1Id, source1Node], + [source2Id, source2Node], + [mergeId, mergeNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Process first source + const readyAfterFirst = edgeManager.processOutgoingEdges(source1Node, {}) + expect(readyAfterFirst).not.toContain(mergeId) + + // Process second source + const readyAfterSecond = edgeManager.processOutgoingEdges(source2Node, {}) + expect(readyAfterSecond).toContain(mergeId) + }) + }) + + describe('Error edge cascading', () => { + it('should cascade-deactivate success path when error occurs', () => { + const sourceId = 'source' + const successId = 'success-handler' + const errorId = 'error-handler' + const afterSuccessId = 'after-success' + + const sourceNode = createMockNode(sourceId, [ + { target: successId, sourceHandle: 'source' }, + { target: errorId, sourceHandle: 'error' }, + ]) + + const successNode = createMockNode(successId, [{ target: afterSuccessId }], [sourceId]) + const errorNode = createMockNode(errorId, [], [sourceId]) + const afterSuccessNode = createMockNode(afterSuccessId, [], [successId]) + + const nodes = new Map([ + [sourceId, sourceNode], + [successId, successNode], + [errorId, errorNode], + [afterSuccessId, afterSuccessNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Source produces an error + const output = { error: 'Something failed' } + const readyNodes = edgeManager.processOutgoingEdges(sourceNode, output) + + // Error handler should be ready, success handler should not + expect(readyNodes).toContain(errorId) + expect(readyNodes).not.toContain(successId) + }) + + it('should cascade-deactivate error path when success occurs', () => { + const sourceId = 'source' + const successId = 'success-handler' + const errorId = 'error-handler' + const afterErrorId = 'after-error' + + const sourceNode = createMockNode(sourceId, [ + { target: successId, sourceHandle: 'source' }, + { target: errorId, sourceHandle: 'error' }, + ]) + + const successNode = createMockNode(successId, [], [sourceId]) + const errorNode = createMockNode(errorId, [{ target: afterErrorId }], [sourceId]) + const afterErrorNode = createMockNode(afterErrorId, [], [errorId]) + + const nodes = new Map([ + [sourceId, sourceNode], + [successId, successNode], + [errorId, errorNode], + [afterErrorId, afterErrorNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Source succeeds + const output = { result: 'success' } + const readyNodes = edgeManager.processOutgoingEdges(sourceNode, output) + + // Success handler should be ready, error handler should not + expect(readyNodes).toContain(successId) + expect(readyNodes).not.toContain(errorId) + }) + + it('should handle error edge to same target as success edge', () => { + const sourceId = 'source' + const handlerId = 'handler' + + const sourceNode = createMockNode(sourceId, [ + { target: handlerId, sourceHandle: 'source' }, + { target: handlerId, sourceHandle: 'error' }, + ]) + + const handlerNode = createMockNode(handlerId, [], [sourceId]) + + const nodes = new Map([ + [sourceId, sourceNode], + [handlerId, handlerNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // When error occurs, handler should still be ready via error edge + const errorOutput = { error: 'Failed' } + const readyWithError = edgeManager.processOutgoingEdges(sourceNode, errorOutput) + expect(readyWithError).toContain(handlerId) + }) + }) + + describe('Chained conditions', () => { + it('should handle sequential conditions (condition1 → condition2)', () => { + const condition1Id = 'condition-1' + const condition2Id = 'condition-2' + const target1Id = 'target-1' + const target2Id = 'target-2' + + const condition1Node = createMockNode(condition1Id, [ + { target: condition2Id, sourceHandle: 'condition-if' }, + { target: target1Id, sourceHandle: 'condition-else' }, + ]) + + const condition2Node = createMockNode( + condition2Id, + [ + { target: target2Id, sourceHandle: 'condition-if' }, + { target: target1Id, sourceHandle: 'condition-else' }, + ], + [condition1Id] + ) + + const target1Node = createMockNode(target1Id, [], [condition1Id, condition2Id]) + const target2Node = createMockNode(target2Id, [], [condition2Id]) + + const nodes = new Map([ + [condition1Id, condition1Node], + [condition2Id, condition2Node], + [target1Id, target1Node], + [target2Id, target2Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // First condition: select "if" → goes to condition2 + const ready1 = edgeManager.processOutgoingEdges(condition1Node, { selectedOption: 'if' }) + expect(ready1).toContain(condition2Id) + expect(ready1).not.toContain(target1Id) + + // Second condition: select "else" → goes to target1 + const ready2 = edgeManager.processOutgoingEdges(condition2Node, { selectedOption: 'else' }) + expect(ready2).toContain(target1Id) + expect(ready2).not.toContain(target2Id) + }) + }) + + describe('Loop edge handling', () => { + it('should skip backwards edge when skipBackwardsEdge is true', () => { + const loopStartId = 'loop-start' + const loopBodyId = 'loop-body' + + const loopStartNode = createMockNode(loopStartId, [ + { target: loopBodyId, sourceHandle: 'loop-start-source' }, + ]) + + // Use correct constant: loop_continue (with underscore) + const loopBodyNode = createMockNode( + loopBodyId, + [{ target: loopStartId, sourceHandle: 'loop_continue' }], + [loopStartId] + ) + + const nodes = new Map([ + [loopStartId, loopStartNode], + [loopBodyId, loopBodyNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Process with skipBackwardsEdge = true + const readyNodes = edgeManager.processOutgoingEdges(loopBodyNode, {}, true) + + // Loop start should NOT be activated because we're skipping backwards edges + expect(readyNodes).not.toContain(loopStartId) + }) + + it('should include backwards edge when skipBackwardsEdge is false', () => { + const loopStartId = 'loop-start' + const loopBodyId = 'loop-body' + + // Use correct constant: loop_continue (with underscore) + const loopBodyNode = createMockNode(loopBodyId, [ + { target: loopStartId, sourceHandle: 'loop_continue' }, + ]) + + const loopStartNode = createMockNode(loopStartId, [], [loopBodyId]) + + const nodes = new Map([ + [loopStartId, loopStartNode], + [loopBodyId, loopBodyNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Process without skipping backwards edges + const readyNodes = edgeManager.processOutgoingEdges(loopBodyNode, {}, false) + + // Loop start should be activated + expect(readyNodes).toContain(loopStartId) + }) + + it('should handle loop-exit vs loop-continue based on selectedRoute', () => { + const loopCheckId = 'loop-check' + const loopBodyId = 'loop-body' + const afterLoopId = 'after-loop' + + // Use correct constants: loop_continue, loop_exit (with underscores) + const loopCheckNode = createMockNode(loopCheckId, [ + { target: loopBodyId, sourceHandle: 'loop_continue' }, + { target: afterLoopId, sourceHandle: 'loop_exit' }, + ]) + + const loopBodyNode = createMockNode(loopBodyId, [], [loopCheckId]) + const afterLoopNode = createMockNode(afterLoopId, [], [loopCheckId]) + + const nodes = new Map([ + [loopCheckId, loopCheckNode], + [loopBodyId, loopBodyNode], + [afterLoopId, afterLoopNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Test loop-exit selection using the correct constant value + const exitOutput = { selectedRoute: 'loop_exit' } + const exitReady = edgeManager.processOutgoingEdges(loopCheckNode, exitOutput) + expect(exitReady).toContain(afterLoopId) + expect(exitReady).not.toContain(loopBodyId) + }) + }) + + describe('Complex routing patterns', () => { + it('should handle 3+ conditions pointing to same target', () => { + const conditionId = 'condition-1' + const targetId = 'target' + const altTargetId = 'alt-target' + + const conditionNode = createMockNode(conditionId, [ + { target: targetId, sourceHandle: 'condition-cond1' }, + { target: targetId, sourceHandle: 'condition-cond2' }, + { target: targetId, sourceHandle: 'condition-cond3' }, + { target: altTargetId, sourceHandle: 'condition-else' }, + ]) + + const targetNode = createMockNode(targetId, [], [conditionId]) + const altTargetNode = createMockNode(altTargetId, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [targetId, targetNode], + [altTargetId, altTargetNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Select middle condition + const output = { selectedOption: 'cond2' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + expect(readyNodes).toContain(targetId) + expect(readyNodes).not.toContain(altTargetId) + }) + + it('should handle no matching condition (all edges deactivated)', () => { + const conditionId = 'condition-1' + const target1Id = 'target-1' + const target2Id = 'target-2' + + const conditionNode = createMockNode(conditionId, [ + { target: target1Id, sourceHandle: 'condition-cond1' }, + { target: target2Id, sourceHandle: 'condition-cond2' }, + ]) + + const target1Node = createMockNode(target1Id, [], [conditionId]) + const target2Node = createMockNode(target2Id, [], [conditionId]) + + const nodes = new Map([ + [conditionId, conditionNode], + [target1Id, target1Node], + [target2Id, target2Node], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // Select non-existent condition + const output = { selectedOption: 'nonexistent' } + const readyNodes = edgeManager.processOutgoingEdges(conditionNode, output) + + // No nodes should be ready + expect(readyNodes).not.toContain(target1Id) + expect(readyNodes).not.toContain(target2Id) + expect(readyNodes).toHaveLength(0) + }) + }) + + describe('Edge with no sourceHandle (default edge)', () => { + it('should activate edge without sourceHandle by default', () => { + const sourceId = 'source' + const targetId = 'target' + + const sourceNode = createMockNode(sourceId, [{ target: targetId }]) + const targetNode = createMockNode(targetId, [], [sourceId]) + + const nodes = new Map([ + [sourceId, sourceNode], + [targetId, targetNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + const readyNodes = edgeManager.processOutgoingEdges(sourceNode, {}) + + expect(readyNodes).toContain(targetId) + }) + + it('should not activate default edge when error occurs', () => { + const sourceId = 'source' + const targetId = 'target' + const errorTargetId = 'error-target' + + const sourceNode = createMockNode(sourceId, [ + { target: targetId }, + { target: errorTargetId, sourceHandle: 'error' }, + ]) + + const targetNode = createMockNode(targetId, [], [sourceId]) + const errorTargetNode = createMockNode(errorTargetId, [], [sourceId]) + + const nodes = new Map([ + [sourceId, sourceNode], + [targetId, targetNode], + [errorTargetId, errorTargetNode], + ]) + + const dag = createMockDAG(nodes) + const edgeManager = new EdgeManager(dag) + + // When no explicit error, default edge should be activated + const successReady = edgeManager.processOutgoingEdges(sourceNode, { result: 'ok' }) + expect(successReady).toContain(targetId) + }) + }) +}) diff --git a/apps/sim/executor/execution/edge-manager.ts b/apps/sim/executor/execution/edge-manager.ts index ec69512e7..dc04a4cec 100644 --- a/apps/sim/executor/execution/edge-manager.ts +++ b/apps/sim/executor/execution/edge-manager.ts @@ -18,7 +18,10 @@ export class EdgeManager { ): string[] { const readyNodes: string[] = [] const activatedTargets: string[] = [] + const edgesToDeactivate: Array<{ target: string; handle?: string }> = [] + // First pass: categorize edges as activating or deactivating + // Don't modify incomingEdges yet - we need the original state for deactivation checks for (const [edgeId, edge] of node.outgoingEdges) { if (skipBackwardsEdge && this.isBackwardsEdge(edge.sourceHandle)) { continue @@ -32,23 +35,31 @@ export class EdgeManager { edge.sourceHandle === EDGE.LOOP_EXIT if (!isLoopEdge) { - this.deactivateEdgeAndDescendants(node.id, edge.target, edge.sourceHandle) + edgesToDeactivate.push({ target: edge.target, handle: edge.sourceHandle }) } - continue } - const targetNode = this.dag.nodes.get(edge.target) - if (!targetNode) { - logger.warn('Target node not found', { target: edge.target }) - continue - } - - targetNode.incomingEdges.delete(node.id) activatedTargets.push(edge.target) } - // Check readiness after all edges processed to ensure cascade deactivations are complete + // Second pass: process deactivations while incomingEdges is still intact + // This ensures hasActiveIncomingEdges can find all potential sources + for (const { target, handle } of edgesToDeactivate) { + this.deactivateEdgeAndDescendants(node.id, target, handle) + } + + // Third pass: update incomingEdges for activated targets + for (const targetId of activatedTargets) { + const targetNode = this.dag.nodes.get(targetId) + if (!targetNode) { + logger.warn('Target node not found', { target: targetId }) + continue + } + targetNode.incomingEdges.delete(node.id) + } + + // Fourth pass: check readiness after all edge processing is complete for (const targetId of activatedTargets) { const targetNode = this.dag.nodes.get(targetId) if (targetNode && this.isNodeReady(targetNode)) { @@ -162,7 +173,10 @@ export class EdgeManager { const targetNode = this.dag.nodes.get(targetId) if (!targetNode) return - const hasOtherActiveIncoming = this.hasActiveIncomingEdges(targetNode, sourceId) + // Check if target has other active incoming edges + // Pass the specific edge key being deactivated, not just source ID, + // to handle multiple edges from same source to same target (e.g., condition branches) + const hasOtherActiveIncoming = this.hasActiveIncomingEdges(targetNode, edgeKey) if (!hasOtherActiveIncoming) { for (const [_, outgoingEdge] of targetNode.outgoingEdges) { this.deactivateEdgeAndDescendants(targetId, outgoingEdge.target, outgoingEdge.sourceHandle) @@ -170,10 +184,13 @@ export class EdgeManager { } } - private hasActiveIncomingEdges(node: DAGNode, excludeSourceId: string): boolean { + /** + * Checks if a node has any active incoming edges besides the one being excluded. + * This properly handles the case where multiple edges from the same source go to + * the same target (e.g., multiple condition branches pointing to one block). + */ + private hasActiveIncomingEdges(node: DAGNode, excludeEdgeKey: string): boolean { for (const incomingSourceId of node.incomingEdges) { - if (incomingSourceId === excludeSourceId) continue - const incomingNode = this.dag.nodes.get(incomingSourceId) if (!incomingNode) continue @@ -184,6 +201,8 @@ export class EdgeManager { node.id, incomingEdge.sourceHandle ) + // Skip the specific edge being excluded, but check other edges from same source + if (incomingEdgeKey === excludeEdgeKey) continue if (!this.deactivatedEdges.has(incomingEdgeKey)) { return true } diff --git a/apps/sim/executor/handlers/condition/condition-handler.test.ts b/apps/sim/executor/handlers/condition/condition-handler.test.ts index 7dd6e5cee..abc415948 100644 --- a/apps/sim/executor/handlers/condition/condition-handler.test.ts +++ b/apps/sim/executor/handlers/condition/condition-handler.test.ts @@ -21,9 +21,18 @@ vi.mock('@/tools', () => ({ executeTool: vi.fn(), })) +vi.mock('@/executor/utils/block-data', () => ({ + collectBlockData: vi.fn(() => ({ + blockData: { 'source-block-1': { value: 10, text: 'hello' } }, + blockNameMapping: { 'Source Block': 'source-block-1' }, + })), +})) + +import { collectBlockData } from '@/executor/utils/block-data' import { executeTool } from '@/tools' const mockExecuteTool = executeTool as ReturnType +const mockCollectBlockData = collectBlockData as ReturnType /** * Simulates what the function_execute tool does when evaluating condition code @@ -34,8 +43,6 @@ function simulateConditionExecution(code: string): { error?: string } { try { - // The code is in format: "const context = {...};\nreturn Boolean(...)" - // We need to execute it and return the result const fn = new Function(code) const result = fn() return { success: true, output: { result } } @@ -55,8 +62,6 @@ describe('ConditionBlockHandler', () => { let mockSourceBlock: SerializedBlock let mockTargetBlock1: SerializedBlock let mockTargetBlock2: SerializedBlock - let mockResolver: any - let mockPathTracker: any beforeEach(() => { mockSourceBlock = { @@ -113,18 +118,11 @@ describe('ConditionBlockHandler', () => { ], } - mockResolver = { - resolveVariableReferences: vi.fn((expr) => expr), - resolveBlockReferences: vi.fn((expr) => expr), - resolveEnvVariables: vi.fn((expr) => expr), - } - - mockPathTracker = {} - - handler = new ConditionBlockHandler(mockPathTracker, mockResolver) + handler = new ConditionBlockHandler() mockContext = { workflowId: 'test-workflow-id', + workspaceId: 'test-workspace-id', blockStates: new Map([ [ mockSourceBlock.id, @@ -137,7 +135,8 @@ describe('ConditionBlockHandler', () => { ]), blockLogs: [], metadata: { duration: 0 }, - environmentVariables: {}, + environmentVariables: { API_KEY: 'test-key' }, + workflowVariables: { userName: { name: 'userName', value: 'john', type: 'plain' } }, decisions: { router: new Map(), condition: new Map() }, loopExecutions: new Map(), executedBlocks: new Set([mockSourceBlock.id]), @@ -178,26 +177,41 @@ describe('ConditionBlockHandler', () => { selectedOption: 'cond1', } - mockResolver.resolveVariableReferences.mockReturnValue('context.value > 5') - mockResolver.resolveBlockReferences.mockReturnValue('context.value > 5') - mockResolver.resolveEnvVariables.mockReturnValue('context.value > 5') - const result = await handler.execute(mockContext, mockBlock, inputs) - expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith( - 'context.value > 5', - mockBlock - ) - expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith( - 'context.value > 5', - mockContext, - mockBlock - ) - expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('context.value > 5') expect(result).toEqual(expectedOutput) expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1') }) + it('should pass correct parameters to function_execute tool', async () => { + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.value > 5' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + await handler.execute(mockContext, mockBlock, inputs) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ + code: expect.stringContaining('context.value > 5'), + timeout: 5000, + envVars: mockContext.environmentVariables, + workflowVariables: mockContext.workflowVariables, + blockData: { 'source-block-1': { value: 10, text: 'hello' } }, + blockNameMapping: { 'Source Block': 'source-block-1' }, + _context: { + workflowId: 'test-workflow-id', + workspaceId: 'test-workspace-id', + }, + }), + false, + false, + mockContext + ) + }) + it('should select the else path if other conditions fail', async () => { const conditions = [ { id: 'cond1', title: 'if', value: 'context.value < 0' }, // Should fail (10 < 0 is false) @@ -217,22 +231,8 @@ describe('ConditionBlockHandler', () => { selectedOption: 'else1', } - mockResolver.resolveVariableReferences.mockReturnValue('context.value < 0') - mockResolver.resolveBlockReferences.mockReturnValue('context.value < 0') - mockResolver.resolveEnvVariables.mockReturnValue('context.value < 0') - const result = await handler.execute(mockContext, mockBlock, inputs) - expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith( - 'context.value < 0', - mockBlock - ) - expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith( - 'context.value < 0', - mockContext, - mockBlock - ) - expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('context.value < 0') expect(result).toEqual(expectedOutput) expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1') }) @@ -245,101 +245,6 @@ describe('ConditionBlockHandler', () => { ) }) - it('should resolve references in conditions before evaluation', async () => { - const conditions = [ - { id: 'cond1', title: 'if', value: '{{source-block-1.value}} > 5' }, - { id: 'else1', title: 'else', value: '' }, - ] - const inputs = { conditions: JSON.stringify(conditions) } - - mockResolver.resolveVariableReferences.mockReturnValue('{{source-block-1.value}} > 5') - mockResolver.resolveBlockReferences.mockReturnValue('10 > 5') - mockResolver.resolveEnvVariables.mockReturnValue('10 > 5') - - await handler.execute(mockContext, mockBlock, inputs) - - expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith( - '{{source-block-1.value}} > 5', - mockBlock - ) - expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith( - '{{source-block-1.value}} > 5', - mockContext, - mockBlock - ) - expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('10 > 5') - expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1') - }) - - it('should resolve variable references in conditions', async () => { - const conditions = [ - { id: 'cond1', title: 'if', value: ' !== null' }, - { id: 'else1', title: 'else', value: '' }, - ] - const inputs = { conditions: JSON.stringify(conditions) } - - mockResolver.resolveVariableReferences.mockReturnValue('"john" !== null') - mockResolver.resolveBlockReferences.mockReturnValue('"john" !== null') - mockResolver.resolveEnvVariables.mockReturnValue('"john" !== null') - - await handler.execute(mockContext, mockBlock, inputs) - - expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith( - ' !== null', - mockBlock - ) - expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith( - '"john" !== null', - mockContext, - mockBlock - ) - expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('"john" !== null') - expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1') - }) - - it('should resolve environment variables in conditions', async () => { - const conditions = [ - { id: 'cond1', title: 'if', value: '{{POOP}} === "hi"' }, - { id: 'else1', title: 'else', value: '' }, - ] - const inputs = { conditions: JSON.stringify(conditions) } - - mockResolver.resolveVariableReferences.mockReturnValue('{{POOP}} === "hi"') - mockResolver.resolveBlockReferences.mockReturnValue('{{POOP}} === "hi"') - mockResolver.resolveEnvVariables.mockReturnValue('"hi" === "hi"') - - await handler.execute(mockContext, mockBlock, inputs) - - expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith( - '{{POOP}} === "hi"', - mockBlock - ) - expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith( - '{{POOP}} === "hi"', - mockContext, - mockBlock - ) - expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('{{POOP}} === "hi"') - expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1') - }) - - it('should throw error if reference resolution fails', async () => { - const conditions = [ - { id: 'cond1', title: 'if', value: '{{invalid-ref}}' }, - { id: 'else1', title: 'else', value: '' }, - ] - const inputs = { conditions: JSON.stringify(conditions) } - - const resolutionError = new Error('Could not resolve reference: invalid-ref') - mockResolver.resolveVariableReferences.mockImplementation(() => { - throw resolutionError - }) - - await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( - 'Failed to resolve references in condition: Could not resolve reference: invalid-ref' - ) - }) - it('should handle evaluation errors gracefully', async () => { const conditions = [ { id: 'cond1', title: 'if', value: 'context.nonExistentProperty.doSomething()' }, @@ -347,12 +252,6 @@ describe('ConditionBlockHandler', () => { ] const inputs = { conditions: JSON.stringify(conditions) } - mockResolver.resolveVariableReferences.mockReturnValue( - 'context.nonExistentProperty.doSomething()' - ) - mockResolver.resolveBlockReferences.mockReturnValue('context.nonExistentProperty.doSomething()') - mockResolver.resolveEnvVariables.mockReturnValue('context.nonExistentProperty.doSomething()') - await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( /Evaluation error in condition "if".*doSomething/ ) @@ -367,10 +266,6 @@ describe('ConditionBlockHandler', () => { blockStates: new Map(), } - mockResolver.resolveVariableReferences.mockReturnValue('true') - mockResolver.resolveBlockReferences.mockReturnValue('true') - mockResolver.resolveEnvVariables.mockReturnValue('true') - const result = await handler.execute(contextWithoutSource, mockBlock, inputs) expect(result).toHaveProperty('conditionResult', true) @@ -383,10 +278,6 @@ describe('ConditionBlockHandler', () => { mockContext.workflow!.blocks = [mockSourceBlock, mockBlock, mockTargetBlock2] - mockResolver.resolveVariableReferences.mockReturnValue('true') - mockResolver.resolveBlockReferences.mockReturnValue('true') - mockResolver.resolveEnvVariables.mockReturnValue('true') - await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( `Target block ${mockTargetBlock1.id} not found` ) @@ -408,16 +299,6 @@ describe('ConditionBlockHandler', () => { }, ] - mockResolver.resolveVariableReferences - .mockReturnValueOnce('false') - .mockReturnValueOnce('context.value === 99') - mockResolver.resolveBlockReferences - .mockReturnValueOnce('false') - .mockReturnValueOnce('context.value === 99') - mockResolver.resolveEnvVariables - .mockReturnValueOnce('false') - .mockReturnValueOnce('context.value === 99') - const result = await handler.execute(mockContext, mockBlock, inputs) expect((result as any).conditionResult).toBe(false) @@ -433,13 +314,317 @@ describe('ConditionBlockHandler', () => { ] const inputs = { conditions: JSON.stringify(conditions) } - mockResolver.resolveVariableReferences.mockReturnValue('context.item === "apple"') - mockResolver.resolveBlockReferences.mockReturnValue('context.item === "apple"') - mockResolver.resolveEnvVariables.mockReturnValue('context.item === "apple"') - const result = await handler.execute(mockContext, mockBlock, inputs) expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1') expect((result as any).selectedOption).toBe('else1') }) + + it('should use collectBlockData to gather block state', async () => { + const conditions = [ + { id: 'cond1', title: 'if', value: 'true' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + await handler.execute(mockContext, mockBlock, inputs) + + expect(mockCollectBlockData).toHaveBeenCalledWith(mockContext) + }) + + it('should handle function_execute tool failure', async () => { + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.value > 5' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + mockExecuteTool.mockResolvedValueOnce({ + success: false, + error: 'Execution timeout', + }) + + await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( + /Evaluation error in condition "if".*Execution timeout/ + ) + }) + + describe('Multiple branches to same target', () => { + it('should handle if and else pointing to same target', async () => { + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.value > 5' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + // Both branches point to the same target + mockContext.workflow!.connections = [ + { source: mockSourceBlock.id, target: mockBlock.id }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' }, + ] + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).conditionResult).toBe(true) + expect((result as any).selectedOption).toBe('cond1') + expect((result as any).selectedPath).toEqual({ + blockId: mockTargetBlock1.id, + blockType: 'target', + blockTitle: 'Target Block 1', + }) + }) + + it('should select else branch to same target when if fails', async () => { + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.value < 0' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + // Both branches point to the same target + mockContext.workflow!.connections = [ + { source: mockSourceBlock.id, target: mockBlock.id }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' }, + ] + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).conditionResult).toBe(true) + expect((result as any).selectedOption).toBe('else1') + expect((result as any).selectedPath).toEqual({ + blockId: mockTargetBlock1.id, + blockType: 'target', + blockTitle: 'Target Block 1', + }) + }) + + it('should handle if→A, elseif→B, else→A pattern', async () => { + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.value === 1' }, + { id: 'cond2', title: 'else if', value: 'context.value === 2' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + mockContext.workflow!.connections = [ + { source: mockSourceBlock.id, target: mockBlock.id }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' }, + { source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-cond2' }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' }, + ] + + // value is 10, so else should be selected (pointing to target 1) + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).conditionResult).toBe(true) + expect((result as any).selectedOption).toBe('else1') + expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock1.id) + }) + }) + + describe('Condition evaluation with different data types', () => { + it('should evaluate string comparison conditions', async () => { + ;(mockContext.blockStates as any).set(mockSourceBlock.id, { + output: { name: 'test', status: 'active' }, + executed: true, + executionTime: 100, + }) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.status === "active"' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).selectedOption).toBe('cond1') + }) + + it('should evaluate boolean conditions', async () => { + ;(mockContext.blockStates as any).set(mockSourceBlock.id, { + output: { isEnabled: true, count: 5 }, + executed: true, + executionTime: 100, + }) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.isEnabled' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).selectedOption).toBe('cond1') + }) + + it('should evaluate array length conditions', async () => { + ;(mockContext.blockStates as any).set(mockSourceBlock.id, { + output: { items: [1, 2, 3, 4, 5] }, + executed: true, + executionTime: 100, + }) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.items.length > 3' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).selectedOption).toBe('cond1') + }) + + it('should evaluate null/undefined check conditions', async () => { + ;(mockContext.blockStates as any).set(mockSourceBlock.id, { + output: { data: null }, + executed: true, + executionTime: 100, + }) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.data === null' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).selectedOption).toBe('cond1') + }) + }) + + describe('Multiple else-if conditions', () => { + it('should evaluate multiple else-if conditions in order', async () => { + ;(mockContext.blockStates as any).set(mockSourceBlock.id, { + output: { score: 75 }, + executed: true, + executionTime: 100, + }) + + const mockTargetBlock3: SerializedBlock = { + id: 'target-block-3', + metadata: { id: 'target', name: 'Target Block 3' }, + position: { x: 100, y: 200 }, + config: { tool: 'target_tool_3', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + } + + mockContext.workflow!.blocks!.push(mockTargetBlock3) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.score >= 90' }, + { id: 'cond2', title: 'else if', value: 'context.score >= 70' }, + { id: 'cond3', title: 'else if', value: 'context.score >= 50' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + mockContext.workflow!.connections = [ + { source: mockSourceBlock.id, target: mockBlock.id }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' }, + { source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-cond2' }, + { source: mockBlock.id, target: mockTargetBlock3.id, sourceHandle: 'condition-cond3' }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' }, + ] + + const result = await handler.execute(mockContext, mockBlock, inputs) + + // Score is 75, so second condition (>=70) should match + expect((result as any).selectedOption).toBe('cond2') + expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock2.id) + }) + + it('should skip to else when all else-if fail', async () => { + ;(mockContext.blockStates as any).set(mockSourceBlock.id, { + output: { score: 30 }, + executed: true, + executionTime: 100, + }) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.score >= 90' }, + { id: 'cond2', title: 'else if', value: 'context.score >= 70' }, + { id: 'cond3', title: 'else if', value: 'context.score >= 50' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).selectedOption).toBe('else1') + }) + }) + + describe('Condition with no outgoing edge', () => { + it('should return null path when condition matches but has no edge', async () => { + const conditions = [ + { id: 'cond1', title: 'if', value: 'true' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + // No connection for cond1 + mockContext.workflow!.connections = [ + { source: mockSourceBlock.id, target: mockBlock.id }, + { source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-else1' }, + ] + + const result = await handler.execute(mockContext, mockBlock, inputs) + + // Condition matches but no edge for it + expect((result as any).conditionResult).toBe(false) + expect((result as any).selectedPath).toBeNull() + }) + }) + + describe('Empty conditions handling', () => { + it('should handle empty conditions array', async () => { + const conditions: unknown[] = [] + const inputs = { conditions: JSON.stringify(conditions) } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).conditionResult).toBe(false) + expect((result as any).selectedPath).toBeNull() + expect((result as any).selectedOption).toBeNull() + }) + + it('should handle conditions passed as array directly', async () => { + const conditions = [ + { id: 'cond1', title: 'if', value: 'true' }, + { id: 'else1', title: 'else', value: '' }, + ] + // Pass as array instead of JSON string + const inputs = { conditions } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect((result as any).selectedOption).toBe('cond1') + }) + }) + + describe('Virtual block ID handling', () => { + it('should use currentVirtualBlockId for decision key when available', async () => { + mockContext.currentVirtualBlockId = 'virtual-block-123' + + const conditions = [ + { id: 'cond1', title: 'if', value: 'true' }, + { id: 'else1', title: 'else', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + + await handler.execute(mockContext, mockBlock, inputs) + + // Decision should be stored under virtual block ID, not actual block ID + expect(mockContext.decisions.condition.get('virtual-block-123')).toBe('cond1') + expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false) + }) + }) }) diff --git a/apps/sim/executor/handlers/condition/condition-handler.ts b/apps/sim/executor/handlers/condition/condition-handler.ts index 07b0c3336..f6a71565b 100644 --- a/apps/sim/executor/handlers/condition/condition-handler.ts +++ b/apps/sim/executor/handlers/condition/condition-handler.ts @@ -2,6 +2,7 @@ import { createLogger } from '@/lib/logs/console/logger' import type { BlockOutput } from '@/blocks/types' import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' +import { collectBlockData } from '@/executor/utils/block-data' import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' @@ -10,43 +11,32 @@ const logger = createLogger('ConditionBlockHandler') const CONDITION_TIMEOUT_MS = 5000 /** - * Evaluates a single condition expression with variable/block reference resolution - * Returns true if condition is met, false otherwise + * Evaluates a single condition expression. + * Variable resolution is handled consistently with the function block via the function_execute tool. + * Returns true if condition is met, false otherwise. */ export async function evaluateConditionExpression( ctx: ExecutionContext, conditionExpression: string, - block: SerializedBlock, - resolver: any, providedEvalContext?: Record ): Promise { const evalContext = providedEvalContext || {} - let resolvedConditionValue = conditionExpression - try { - if (resolver) { - const resolvedVars = resolver.resolveVariableReferences(conditionExpression, block) - const resolvedRefs = resolver.resolveBlockReferences(resolvedVars, ctx, block) - resolvedConditionValue = resolver.resolveEnvVariables(resolvedRefs) - } - } catch (resolveError: any) { - logger.error(`Failed to resolve references in condition: ${resolveError.message}`, { - conditionExpression, - resolveError, - }) - throw new Error(`Failed to resolve references in condition: ${resolveError.message}`) - } - try { const contextSetup = `const context = ${JSON.stringify(evalContext)};` - const code = `${contextSetup}\nreturn Boolean(${resolvedConditionValue})` + const code = `${contextSetup}\nreturn Boolean(${conditionExpression})` + + const { blockData, blockNameMapping } = collectBlockData(ctx) const result = await executeTool( 'function_execute', { code, timeout: CONDITION_TIMEOUT_MS, - envVars: {}, + envVars: ctx.environmentVariables || {}, + workflowVariables: ctx.workflowVariables || {}, + blockData, + blockNameMapping, _context: { workflowId: ctx.workflowId, workspaceId: ctx.workspaceId, @@ -60,26 +50,20 @@ export async function evaluateConditionExpression( if (!result.success) { logger.error(`Failed to evaluate condition: ${result.error}`, { originalCondition: conditionExpression, - resolvedCondition: resolvedConditionValue, evalContext, error: result.error, }) - throw new Error( - `Evaluation error in condition: ${result.error}. (Resolved: ${resolvedConditionValue})` - ) + throw new Error(`Evaluation error in condition: ${result.error}`) } return Boolean(result.output?.result) } catch (evalError: any) { logger.error(`Failed to evaluate condition: ${evalError.message}`, { originalCondition: conditionExpression, - resolvedCondition: resolvedConditionValue, evalContext, evalError, }) - throw new Error( - `Evaluation error in condition: ${evalError.message}. (Resolved: ${resolvedConditionValue})` - ) + throw new Error(`Evaluation error in condition: ${evalError.message}`) } } @@ -87,11 +71,6 @@ export async function evaluateConditionExpression( * Handler for Condition blocks that evaluate expressions to determine execution paths. */ export class ConditionBlockHandler implements BlockHandler { - constructor( - private pathTracker?: any, - private resolver?: any - ) {} - canHandle(block: SerializedBlock): boolean { return block.metadata?.id === BlockType.CONDITION } @@ -104,7 +83,7 @@ export class ConditionBlockHandler implements BlockHandler { const conditions = this.parseConditions(inputs.conditions) const sourceBlockId = ctx.workflow?.connections.find((conn) => conn.target === block.id)?.source - const evalContext = this.buildEvaluationContext(ctx, block.id, sourceBlockId) + const evalContext = this.buildEvaluationContext(ctx, sourceBlockId) const sourceOutput = sourceBlockId ? ctx.blockStates.get(sourceBlockId)?.output : null const outgoingConnections = ctx.workflow?.connections.filter((conn) => conn.source === block.id) @@ -113,8 +92,7 @@ export class ConditionBlockHandler implements BlockHandler { conditions, outgoingConnections || [], evalContext, - ctx, - block + ctx ) if (!selectedConnection || !selectedCondition) { @@ -158,7 +136,6 @@ export class ConditionBlockHandler implements BlockHandler { private buildEvaluationContext( ctx: ExecutionContext, - blockId: string, sourceBlockId?: string ): Record { let evalContext: Record = {} @@ -180,8 +157,7 @@ export class ConditionBlockHandler implements BlockHandler { conditions: Array<{ id: string; title: string; value: string }>, outgoingConnections: Array<{ source: string; target: string; sourceHandle?: string }>, evalContext: Record, - ctx: ExecutionContext, - block: SerializedBlock + ctx: ExecutionContext ): Promise<{ selectedConnection: { target: string; sourceHandle?: string } | null selectedCondition: { id: string; title: string; value: string } | null @@ -200,8 +176,6 @@ export class ConditionBlockHandler implements BlockHandler { const conditionMet = await evaluateConditionExpression( ctx, conditionValueString, - block, - this.resolver, evalContext ) @@ -211,13 +185,6 @@ export class ConditionBlockHandler implements BlockHandler { return { selectedConnection: connection, selectedCondition: condition } } // Condition is true but has no outgoing edge - branch ends gracefully - logger.info( - `Condition "${condition.title}" is true but has no outgoing edge - branch ending`, - { - blockId: block.id, - conditionId: condition.id, - } - ) return { selectedConnection: null, selectedCondition: null } } } catch (error: any) { @@ -228,18 +195,13 @@ export class ConditionBlockHandler implements BlockHandler { const elseCondition = conditions.find((c) => c.title === CONDITION.ELSE_TITLE) if (elseCondition) { - logger.warn(`No condition met, selecting 'else' path`, { blockId: block.id }) const elseConnection = this.findConnectionForCondition(outgoingConnections, elseCondition.id) if (elseConnection) { return { selectedConnection: elseConnection, selectedCondition: elseCondition } } - logger.info(`No condition matched and else has no connection - branch ending`, { - blockId: block.id, - }) return { selectedConnection: null, selectedCondition: null } } - logger.info(`No condition matched and no else block - branch ending`, { blockId: block.id }) return { selectedConnection: null, selectedCondition: null } } diff --git a/apps/sim/hooks/use-knowledge-base-tag-definitions.ts b/apps/sim/hooks/use-knowledge-base-tag-definitions.ts index 57c0cac36..b753307a7 100644 --- a/apps/sim/hooks/use-knowledge-base-tag-definitions.ts +++ b/apps/sim/hooks/use-knowledge-base-tag-definitions.ts @@ -1,14 +1,14 @@ 'use client' import { useCallback, useEffect, useState } from 'react' -import type { TagSlot } from '@/lib/knowledge/constants' +import type { AllTagSlot } from '@/lib/knowledge/constants' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('useKnowledgeBaseTagDefinitions') export interface TagDefinition { id: string - tagSlot: TagSlot + tagSlot: AllTagSlot displayName: string fieldType: string createdAt: string diff --git a/apps/sim/hooks/use-tag-definitions.ts b/apps/sim/hooks/use-tag-definitions.ts index 5e15b2786..46ac68b86 100644 --- a/apps/sim/hooks/use-tag-definitions.ts +++ b/apps/sim/hooks/use-tag-definitions.ts @@ -1,14 +1,14 @@ 'use client' import { useCallback, useEffect, useState } from 'react' -import type { TagSlot } from '@/lib/knowledge/constants' +import type { AllTagSlot } from '@/lib/knowledge/constants' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('useTagDefinitions') export interface TagDefinition { id: string - tagSlot: TagSlot + tagSlot: AllTagSlot displayName: string fieldType: string createdAt: string @@ -16,7 +16,7 @@ export interface TagDefinition { } export interface TagDefinitionInput { - tagSlot: TagSlot + tagSlot: AllTagSlot displayName: string fieldType: string // Optional: for editing existing definitions diff --git a/apps/sim/instrumentation-client.ts b/apps/sim/instrumentation-client.ts index 59cfe3017..a2e1f5f86 100644 --- a/apps/sim/instrumentation-client.ts +++ b/apps/sim/instrumentation-client.ts @@ -3,6 +3,7 @@ */ import { env } from './lib/core/config/env' +import { sanitizeEventData } from './lib/core/security/redaction' if (typeof window !== 'undefined') { const TELEMETRY_STATUS_KEY = 'simstudio-telemetry-status' @@ -41,37 +42,6 @@ if (typeof window !== 'undefined') { } } - /** - * Sanitize event data to remove sensitive information - */ - function sanitizeEvent(event: any): any { - const patterns = ['password', 'token', 'secret', 'key', 'auth', 'credential', 'private'] - const sensitiveRe = new RegExp(patterns.join('|'), 'i') - - const scrubString = (s: string) => (s && sensitiveRe.test(s) ? '[redacted]' : s) - - if (event == null) return event - if (typeof event === 'string') return scrubString(event) - if (typeof event !== 'object') return event - - if (Array.isArray(event)) { - return event.map((item) => sanitizeEvent(item)) - } - - const sanitized: Record = {} - for (const [key, value] of Object.entries(event)) { - const lowerKey = key.toLowerCase() - if (patterns.some((p) => lowerKey.includes(p))) continue - - if (typeof value === 'string') sanitized[key] = scrubString(value) - else if (Array.isArray(value)) sanitized[key] = value.map((v) => sanitizeEvent(v)) - else if (value && typeof value === 'object') sanitized[key] = sanitizeEvent(value) - else sanitized[key] = value - } - - return sanitized - } - /** * Flush batch of events to server */ @@ -84,7 +54,7 @@ if (typeof window !== 'undefined') { batchTimer = null } - const sanitizedBatch = batch.map(sanitizeEvent) + const sanitizedBatch = batch.map(sanitizeEventData) const payload = JSON.stringify({ category: 'batch', diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index 76193d91e..616751995 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { createPinnedUrl, - sanitizeForLogging, validateAlphanumericId, validateEnum, validateFileExtension, @@ -11,6 +10,7 @@ import { validateUrlWithDNS, validateUUID, } from '@/lib/core/security/input-validation' +import { sanitizeForLogging } from '@/lib/core/security/redaction' describe('validatePathSegment', () => { describe('valid inputs', () => { diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index cf1970d33..e84c7f8f4 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -556,29 +556,6 @@ export function validateFileExtension( return { isValid: true, sanitized: normalizedExt } } -/** - * Sanitizes a string for safe logging (removes potential sensitive data patterns) - * - * @param value - The value to sanitize - * @param maxLength - Maximum length to return (default: 100) - * @returns Sanitized string safe for logging - */ -export function sanitizeForLogging(value: string, maxLength = 100): string { - if (!value) return '' - - // Truncate long values - let sanitized = value.substring(0, maxLength) - - // Mask common sensitive patterns - sanitized = sanitized - .replace(/Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi, 'Bearer [REDACTED]') - .replace(/password['":\s]*['"]\w+['"]/gi, 'password: "[REDACTED]"') - .replace(/token['":\s]*['"]\w+['"]/gi, 'token: "[REDACTED]"') - .replace(/api[_-]?key['":\s]*['"]\w+['"]/gi, 'api_key: "[REDACTED]"') - - return sanitized -} - /** * Validates Microsoft Graph API resource IDs * diff --git a/apps/sim/lib/core/security/redaction.test.ts b/apps/sim/lib/core/security/redaction.test.ts new file mode 100644 index 000000000..bf3e700fa --- /dev/null +++ b/apps/sim/lib/core/security/redaction.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, it } from 'vitest' +import { + isSensitiveKey, + REDACTED_MARKER, + redactApiKeys, + redactSensitiveValues, + sanitizeEventData, + sanitizeForLogging, +} from './redaction' + +describe('REDACTED_MARKER', () => { + it.concurrent('should be the standard marker', () => { + expect(REDACTED_MARKER).toBe('[REDACTED]') + }) +}) + +describe('isSensitiveKey', () => { + describe('exact matches', () => { + it.concurrent('should match apiKey variations', () => { + expect(isSensitiveKey('apiKey')).toBe(true) + expect(isSensitiveKey('api_key')).toBe(true) + expect(isSensitiveKey('api-key')).toBe(true) + expect(isSensitiveKey('APIKEY')).toBe(true) + expect(isSensitiveKey('API_KEY')).toBe(true) + }) + + it.concurrent('should match token variations', () => { + expect(isSensitiveKey('access_token')).toBe(true) + expect(isSensitiveKey('refresh_token')).toBe(true) + expect(isSensitiveKey('auth_token')).toBe(true) + expect(isSensitiveKey('accessToken')).toBe(true) + }) + + it.concurrent('should match secret variations', () => { + expect(isSensitiveKey('client_secret')).toBe(true) + expect(isSensitiveKey('clientSecret')).toBe(true) + expect(isSensitiveKey('secret')).toBe(true) + }) + + it.concurrent('should match other sensitive keys', () => { + expect(isSensitiveKey('private_key')).toBe(true) + expect(isSensitiveKey('authorization')).toBe(true) + expect(isSensitiveKey('bearer')).toBe(true) + expect(isSensitiveKey('private')).toBe(true) + expect(isSensitiveKey('auth')).toBe(true) + expect(isSensitiveKey('password')).toBe(true) + expect(isSensitiveKey('credential')).toBe(true) + }) + }) + + describe('suffix matches', () => { + it.concurrent('should match keys ending in secret', () => { + expect(isSensitiveKey('clientSecret')).toBe(true) + expect(isSensitiveKey('appSecret')).toBe(true) + expect(isSensitiveKey('mySecret')).toBe(true) + }) + + it.concurrent('should match keys ending in password', () => { + expect(isSensitiveKey('userPassword')).toBe(true) + expect(isSensitiveKey('dbPassword')).toBe(true) + expect(isSensitiveKey('adminPassword')).toBe(true) + }) + + it.concurrent('should match keys ending in token', () => { + expect(isSensitiveKey('accessToken')).toBe(true) + expect(isSensitiveKey('refreshToken')).toBe(true) + expect(isSensitiveKey('bearerToken')).toBe(true) + }) + + it.concurrent('should match keys ending in credential', () => { + expect(isSensitiveKey('userCredential')).toBe(true) + expect(isSensitiveKey('dbCredential')).toBe(true) + }) + }) + + describe('non-sensitive keys (no false positives)', () => { + it.concurrent('should not match keys with sensitive words as prefix only', () => { + expect(isSensitiveKey('tokenCount')).toBe(false) + expect(isSensitiveKey('tokenizer')).toBe(false) + expect(isSensitiveKey('secretKey')).toBe(false) + expect(isSensitiveKey('passwordStrength')).toBe(false) + expect(isSensitiveKey('authMethod')).toBe(false) + }) + + it.concurrent('should match keys ending with sensitive words (intentional)', () => { + expect(isSensitiveKey('hasSecret')).toBe(true) + expect(isSensitiveKey('userPassword')).toBe(true) + expect(isSensitiveKey('sessionToken')).toBe(true) + }) + + it.concurrent('should not match normal field names', () => { + expect(isSensitiveKey('name')).toBe(false) + expect(isSensitiveKey('email')).toBe(false) + expect(isSensitiveKey('id')).toBe(false) + expect(isSensitiveKey('value')).toBe(false) + expect(isSensitiveKey('data')).toBe(false) + expect(isSensitiveKey('count')).toBe(false) + expect(isSensitiveKey('status')).toBe(false) + }) + }) +}) + +describe('redactSensitiveValues', () => { + it.concurrent('should redact Bearer tokens', () => { + const input = 'Authorization: Bearer abc123xyz456' + const result = redactSensitiveValues(input) + expect(result).toBe('Authorization: Bearer [REDACTED]') + expect(result).not.toContain('abc123xyz456') + }) + + it.concurrent('should redact Basic auth', () => { + const input = 'Authorization: Basic dXNlcjpwYXNz' + const result = redactSensitiveValues(input) + expect(result).toBe('Authorization: Basic [REDACTED]') + }) + + it.concurrent('should redact API key prefixes', () => { + const input = 'Using key sk-1234567890abcdefghijklmnop' + const result = redactSensitiveValues(input) + expect(result).toContain('[REDACTED]') + expect(result).not.toContain('sk-1234567890abcdefghijklmnop') + }) + + it.concurrent('should redact JSON-style password fields', () => { + const input = 'password: "mysecretpass123"' + const result = redactSensitiveValues(input) + expect(result).toContain('[REDACTED]') + expect(result).not.toContain('mysecretpass123') + }) + + it.concurrent('should redact JSON-style token fields', () => { + const input = 'token: "tokenvalue123"' + const result = redactSensitiveValues(input) + expect(result).toContain('[REDACTED]') + expect(result).not.toContain('tokenvalue123') + }) + + it.concurrent('should redact JSON-style api_key fields', () => { + const input = 'api_key: "key123456"' + const result = redactSensitiveValues(input) + expect(result).toContain('[REDACTED]') + expect(result).not.toContain('key123456') + }) + + it.concurrent('should not modify safe strings', () => { + const input = 'This is a normal string with no secrets' + const result = redactSensitiveValues(input) + expect(result).toBe(input) + }) + + it.concurrent('should handle empty strings', () => { + expect(redactSensitiveValues('')).toBe('') + }) + + it.concurrent('should handle null/undefined gracefully', () => { + expect(redactSensitiveValues(null as any)).toBe(null) + expect(redactSensitiveValues(undefined as any)).toBe(undefined) + }) +}) + +describe('redactApiKeys', () => { + describe('object redaction', () => { + it.concurrent('should redact sensitive keys in flat objects', () => { + const obj = { + apiKey: 'secret-key', + api_key: 'another-secret', + access_token: 'token-value', + secret: 'secret-value', + password: 'password-value', + normalField: 'normal-value', + } + + const result = redactApiKeys(obj) + + expect(result.apiKey).toBe('[REDACTED]') + expect(result.api_key).toBe('[REDACTED]') + expect(result.access_token).toBe('[REDACTED]') + expect(result.secret).toBe('[REDACTED]') + expect(result.password).toBe('[REDACTED]') + expect(result.normalField).toBe('normal-value') + }) + + it.concurrent('should redact sensitive keys in nested objects', () => { + const obj = { + config: { + apiKey: 'secret-key', + normalField: 'normal-value', + }, + } + + const result = redactApiKeys(obj) + + expect(result.config.apiKey).toBe('[REDACTED]') + expect(result.config.normalField).toBe('normal-value') + }) + + it.concurrent('should redact sensitive keys in arrays', () => { + const arr = [{ apiKey: 'secret-key-1' }, { apiKey: 'secret-key-2' }] + + const result = redactApiKeys(arr) + + expect(result[0].apiKey).toBe('[REDACTED]') + expect(result[1].apiKey).toBe('[REDACTED]') + }) + + it.concurrent('should handle deeply nested structures', () => { + const obj = { + users: [ + { + name: 'John', + credentials: { + apiKey: 'secret-key', + username: 'john_doe', + }, + }, + ], + config: { + database: { + password: 'db-password', + host: 'localhost', + }, + }, + } + + const result = redactApiKeys(obj) + + expect(result.users[0].name).toBe('John') + expect(result.users[0].credentials.apiKey).toBe('[REDACTED]') + expect(result.users[0].credentials.username).toBe('john_doe') + expect(result.config.database.password).toBe('[REDACTED]') + expect(result.config.database.host).toBe('localhost') + }) + }) + + describe('primitive handling', () => { + it.concurrent('should return primitives unchanged', () => { + expect(redactApiKeys('string')).toBe('string') + expect(redactApiKeys(123)).toBe(123) + expect(redactApiKeys(true)).toBe(true) + expect(redactApiKeys(null)).toBe(null) + expect(redactApiKeys(undefined)).toBe(undefined) + }) + }) + + describe('no false positives', () => { + it.concurrent('should not redact keys with sensitive words as prefix only', () => { + const obj = { + tokenCount: 100, + secretKey: 'not-actually-secret', + passwordStrength: 'strong', + authMethod: 'oauth', + } + + const result = redactApiKeys(obj) + + expect(result.tokenCount).toBe(100) + expect(result.secretKey).toBe('not-actually-secret') + expect(result.passwordStrength).toBe('strong') + expect(result.authMethod).toBe('oauth') + }) + }) +}) + +describe('sanitizeForLogging', () => { + it.concurrent('should truncate long strings', () => { + const longString = 'a'.repeat(200) + const result = sanitizeForLogging(longString, 50) + expect(result.length).toBe(50) + }) + + it.concurrent('should use default max length of 100', () => { + const longString = 'a'.repeat(200) + const result = sanitizeForLogging(longString) + expect(result.length).toBe(100) + }) + + it.concurrent('should redact sensitive patterns', () => { + const input = 'Bearer abc123xyz456' + const result = sanitizeForLogging(input) + expect(result).toContain('[REDACTED]') + expect(result).not.toContain('abc123xyz456') + }) + + it.concurrent('should handle empty strings', () => { + expect(sanitizeForLogging('')).toBe('') + }) + + it.concurrent('should not modify safe short strings', () => { + const input = 'Safe string' + const result = sanitizeForLogging(input) + expect(result).toBe(input) + }) +}) + +describe('sanitizeEventData', () => { + describe('object sanitization', () => { + it.concurrent('should remove sensitive keys entirely', () => { + const event = { + action: 'login', + apiKey: 'secret-key', + password: 'secret-pass', + userId: '123', + } + + const result = sanitizeEventData(event) + + expect(result.action).toBe('login') + expect(result.userId).toBe('123') + expect(result).not.toHaveProperty('apiKey') + expect(result).not.toHaveProperty('password') + }) + + it.concurrent('should redact sensitive patterns in string values', () => { + const event = { + message: 'Auth: Bearer abc123token', + normal: 'normal value', + } + + const result = sanitizeEventData(event) + + expect(result.message).toContain('[REDACTED]') + expect(result.message).not.toContain('abc123token') + expect(result.normal).toBe('normal value') + }) + + it.concurrent('should handle nested objects', () => { + const event = { + user: { + id: '123', + accessToken: 'secret-token', + }, + } + + const result = sanitizeEventData(event) + + expect(result.user.id).toBe('123') + expect(result.user).not.toHaveProperty('accessToken') + }) + + it.concurrent('should handle arrays', () => { + const event = { + items: [ + { id: 1, apiKey: 'key1' }, + { id: 2, apiKey: 'key2' }, + ], + } + + const result = sanitizeEventData(event) + + expect(result.items[0].id).toBe(1) + expect(result.items[0]).not.toHaveProperty('apiKey') + expect(result.items[1].id).toBe(2) + expect(result.items[1]).not.toHaveProperty('apiKey') + }) + }) + + describe('primitive handling', () => { + it.concurrent('should return primitives appropriately', () => { + expect(sanitizeEventData(null)).toBe(null) + expect(sanitizeEventData(undefined)).toBe(undefined) + expect(sanitizeEventData(123)).toBe(123) + expect(sanitizeEventData(true)).toBe(true) + }) + + it.concurrent('should redact sensitive patterns in top-level strings', () => { + const result = sanitizeEventData('Bearer secrettoken123') + expect(result).toContain('[REDACTED]') + }) + + it.concurrent('should not redact normal strings', () => { + const result = sanitizeEventData('normal string') + expect(result).toBe('normal string') + }) + }) + + describe('no false positives', () => { + it.concurrent('should not remove keys with sensitive words in middle', () => { + const event = { + tokenCount: 500, + isAuthenticated: true, + hasSecretFeature: false, + } + + const result = sanitizeEventData(event) + + expect(result.tokenCount).toBe(500) + expect(result.isAuthenticated).toBe(true) + expect(result.hasSecretFeature).toBe(false) + }) + }) +}) diff --git a/apps/sim/lib/core/security/redaction.ts b/apps/sim/lib/core/security/redaction.ts index 64e795deb..cf1909cce 100644 --- a/apps/sim/lib/core/security/redaction.ts +++ b/apps/sim/lib/core/security/redaction.ts @@ -1,28 +1,122 @@ /** - * Recursively redacts API keys in an object - * @param obj The object to redact API keys from - * @returns A new object with API keys redacted + * Centralized redaction utilities for sensitive data */ -export const redactApiKeys = (obj: any): any => { - if (!obj || typeof obj !== 'object') { + +/** Standard marker used for all redacted values */ +export const REDACTED_MARKER = '[REDACTED]' + +/** + * Patterns for sensitive key names (case-insensitive matching) + * These patterns match common naming conventions for sensitive data + */ +const SENSITIVE_KEY_PATTERNS: RegExp[] = [ + /^api[_-]?key$/i, + /^access[_-]?token$/i, + /^refresh[_-]?token$/i, + /^client[_-]?secret$/i, + /^private[_-]?key$/i, + /^auth[_-]?token$/i, + /^.*secret$/i, + /^.*password$/i, + /^.*token$/i, + /^.*credential$/i, + /^authorization$/i, + /^bearer$/i, + /^private$/i, + /^auth$/i, +] + +/** + * Patterns for sensitive values in strings (for redacting values, not keys) + * Each pattern has a replacement function + */ +const SENSITIVE_VALUE_PATTERNS: Array<{ + pattern: RegExp + replacement: string +}> = [ + // Bearer tokens + { + pattern: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi, + replacement: `Bearer ${REDACTED_MARKER}`, + }, + // Basic auth + { + pattern: /Basic\s+[A-Za-z0-9+/]+=*/gi, + replacement: `Basic ${REDACTED_MARKER}`, + }, + // API keys that look like sk-..., pk-..., etc. + { + pattern: /\b(sk|pk|api|key)[_-][A-Za-z0-9\-._]{20,}\b/gi, + replacement: REDACTED_MARKER, + }, + // JSON-style password fields: password: "value" or password: 'value' + { + pattern: /password['":\s]*['"][^'"]+['"]/gi, + replacement: `password: "${REDACTED_MARKER}"`, + }, + // JSON-style token fields: token: "value" or token: 'value' + { + pattern: /token['":\s]*['"][^'"]+['"]/gi, + replacement: `token: "${REDACTED_MARKER}"`, + }, + // JSON-style api_key fields: api_key: "value" or api-key: "value" + { + pattern: /api[_-]?key['":\s]*['"][^'"]+['"]/gi, + replacement: `api_key: "${REDACTED_MARKER}"`, + }, +] + +/** + * Checks if a key name matches any sensitive pattern + * @param key - The key name to check + * @returns True if the key is considered sensitive + */ +export function isSensitiveKey(key: string): boolean { + const lowerKey = key.toLowerCase() + return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(lowerKey)) +} + +/** + * Redacts sensitive patterns from a string value + * @param value - The string to redact + * @returns The string with sensitive patterns redacted + */ +export function redactSensitiveValues(value: string): string { + if (!value || typeof value !== 'string') { + return value + } + + let result = value + for (const { pattern, replacement } of SENSITIVE_VALUE_PATTERNS) { + result = result.replace(pattern, replacement) + } + return result +} + +/** + * Recursively redacts sensitive data (API keys, passwords, tokens, etc.) from an object + * + * @param obj - The object to redact sensitive data from + * @returns A new object with sensitive data redacted + */ +export function redactApiKeys(obj: any): any { + if (obj === null || obj === undefined) { + return obj + } + + if (typeof obj !== 'object') { return obj } if (Array.isArray(obj)) { - return obj.map(redactApiKeys) + return obj.map((item) => redactApiKeys(item)) } const result: Record = {} for (const [key, value] of Object.entries(obj)) { - if ( - key.toLowerCase() === 'apikey' || - key.toLowerCase() === 'api_key' || - key.toLowerCase() === 'access_token' || - /\bsecret\b/i.test(key.toLowerCase()) || - /\bpassword\b/i.test(key.toLowerCase()) - ) { - result[key] = '***REDACTED***' + if (isSensitiveKey(key)) { + result[key] = REDACTED_MARKER } else if (typeof value === 'object' && value !== null) { result[key] = redactApiKeys(value) } else { @@ -32,3 +126,64 @@ export const redactApiKeys = (obj: any): any => { return result } + +/** + * Sanitizes a string for safe logging by truncating and redacting sensitive patterns + * + * @param value - The string to sanitize + * @param maxLength - Maximum length of the output (default: 100) + * @returns The sanitized string + */ +export function sanitizeForLogging(value: string, maxLength = 100): string { + if (!value) return '' + + let sanitized = value.substring(0, maxLength) + + sanitized = redactSensitiveValues(sanitized) + + return sanitized +} + +/** + * Sanitizes event data for error reporting/analytics + * + * @param event - The event data to sanitize + * @returns Sanitized event data safe for external reporting + */ +export function sanitizeEventData(event: any): any { + if (event === null || event === undefined) { + return event + } + + if (typeof event === 'string') { + return redactSensitiveValues(event) + } + + if (typeof event !== 'object') { + return event + } + + if (Array.isArray(event)) { + return event.map((item) => sanitizeEventData(item)) + } + + const sanitized: Record = {} + + for (const [key, value] of Object.entries(event)) { + if (isSensitiveKey(key)) { + continue + } + + if (typeof value === 'string') { + sanitized[key] = redactSensitiveValues(value) + } else if (Array.isArray(value)) { + sanitized[key] = value.map((v) => sanitizeEventData(v)) + } else if (value && typeof value === 'object') { + sanitized[key] = sanitizeEventData(value) + } else { + sanitized[key] = value + } + } + + return sanitized +} diff --git a/apps/sim/lib/core/utils.test.ts b/apps/sim/lib/core/utils.test.ts index 93dfc2a8e..154b14efc 100644 --- a/apps/sim/lib/core/utils.test.ts +++ b/apps/sim/lib/core/utils.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getRotatingApiKey } from '@/lib/core/config/api-keys' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' -import { redactApiKeys } from '@/lib/core/security/redaction' import { cn } from '@/lib/core/utils/cn' import { formatDate, @@ -229,86 +228,6 @@ describe('getTimezoneAbbreviation', () => { }) }) -describe('redactApiKeys', () => { - it.concurrent('should redact API keys in objects', () => { - const obj = { - apiKey: 'secret-key', - api_key: 'another-secret', - access_token: 'token-value', - secret: 'secret-value', - password: 'password-value', - normalField: 'normal-value', - } - - const result = redactApiKeys(obj) - - expect(result.apiKey).toBe('***REDACTED***') - expect(result.api_key).toBe('***REDACTED***') - expect(result.access_token).toBe('***REDACTED***') - expect(result.secret).toBe('***REDACTED***') - expect(result.password).toBe('***REDACTED***') - expect(result.normalField).toBe('normal-value') - }) - - it.concurrent('should redact API keys in nested objects', () => { - const obj = { - config: { - apiKey: 'secret-key', - normalField: 'normal-value', - }, - } - - const result = redactApiKeys(obj) - - expect(result.config.apiKey).toBe('***REDACTED***') - expect(result.config.normalField).toBe('normal-value') - }) - - it.concurrent('should redact API keys in arrays', () => { - const arr = [{ apiKey: 'secret-key-1' }, { apiKey: 'secret-key-2' }] - - const result = redactApiKeys(arr) - - expect(result[0].apiKey).toBe('***REDACTED***') - expect(result[1].apiKey).toBe('***REDACTED***') - }) - - it.concurrent('should handle primitive values', () => { - expect(redactApiKeys('string')).toBe('string') - expect(redactApiKeys(123)).toBe(123) - expect(redactApiKeys(null)).toBe(null) - expect(redactApiKeys(undefined)).toBe(undefined) - }) - - it.concurrent('should handle complex nested structures', () => { - const obj = { - users: [ - { - name: 'John', - credentials: { - apiKey: 'secret-key', - username: 'john_doe', - }, - }, - ], - config: { - database: { - password: 'db-password', - host: 'localhost', - }, - }, - } - - const result = redactApiKeys(obj) - - expect(result.users[0].name).toBe('John') - expect(result.users[0].credentials.apiKey).toBe('***REDACTED***') - expect(result.users[0].credentials.username).toBe('john_doe') - expect(result.config.database.password).toBe('***REDACTED***') - expect(result.config.database.host).toBe('localhost') - }) -}) - describe('validateName', () => { it.concurrent('should remove invalid characters', () => { const result = validateName('test@#$%name') diff --git a/apps/sim/lib/core/utils/display-filters.ts b/apps/sim/lib/core/utils/display-filters.ts index c55a7669c..6e04322e9 100644 --- a/apps/sim/lib/core/utils/display-filters.ts +++ b/apps/sim/lib/core/utils/display-filters.ts @@ -1,6 +1,13 @@ -/** - * Type guard to check if an object is a UserFile - */ +const MAX_STRING_LENGTH = 15000 +const MAX_DEPTH = 50 + +function truncateString(value: string, maxLength = MAX_STRING_LENGTH): string { + if (value.length <= maxLength) { + return value + } + return `${value.substring(0, maxLength)}... [truncated ${value.length - maxLength} chars]` +} + export function isUserFile(candidate: unknown): candidate is { id: string name: string @@ -23,11 +30,6 @@ export function isUserFile(candidate: unknown): candidate is { ) } -/** - * Filter function that transforms UserFile objects for display - * Removes internal fields: key, context - * Keeps user-friendly fields: id, name, url, size, type - */ function filterUserFile(data: any): any { if (isUserFile(data)) { const { id, name, url, size, type } = data @@ -36,50 +38,152 @@ function filterUserFile(data: any): any { return data } -/** - * Registry of filter functions to apply to data for cleaner display in logs/console. - * Add new filter functions here to handle additional data types. - */ -const DISPLAY_FILTERS = [ - filterUserFile, - // Add more filters here as needed -] +const DISPLAY_FILTERS = [filterUserFile] -/** - * Generic helper to filter internal/technical fields from data for cleaner display in logs and console. - * Applies all registered filters recursively to the data structure. - * - * To add a new filter: - * 1. Create a filter function that checks and transforms a specific data type - * 2. Add it to the DISPLAY_FILTERS array above - * - * @param data - Data to filter (objects, arrays, primitives) - * @returns Filtered data with internal fields removed - */ export function filterForDisplay(data: any): any { - if (!data || typeof data !== 'object') { - return data - } - - // Apply all registered filters - const filtered = data - for (const filterFn of DISPLAY_FILTERS) { - const result = filterFn(filtered) - if (result !== filtered) { - // Filter matched and transformed the data - return result - } - } - - // No filters matched - recursively filter nested structures - if (Array.isArray(filtered)) { - return filtered.map(filterForDisplay) - } - - // Recursively filter object properties - const result: any = {} - for (const [key, value] of Object.entries(filtered)) { - result[key] = filterForDisplay(value) - } - return result + const seen = new WeakSet() + return filterForDisplayInternal(data, seen, 0) +} + +function getObjectType(data: unknown): string { + return Object.prototype.toString.call(data).slice(8, -1) +} + +function filterForDisplayInternal(data: any, seen: WeakSet, depth: number): any { + try { + if (data === null || data === undefined) { + return data + } + + const dataType = typeof data + + if (dataType === 'string') { + // Remove null bytes which are not allowed in PostgreSQL JSONB + const sanitized = data.includes('\u0000') ? data.replace(/\u0000/g, '') : data + return truncateString(sanitized) + } + + if (dataType === 'number') { + if (Number.isNaN(data)) { + return '[NaN]' + } + if (!Number.isFinite(data)) { + return data > 0 ? '[Infinity]' : '[-Infinity]' + } + return data + } + + if (dataType === 'boolean') { + return data + } + + if (dataType === 'bigint') { + return `[BigInt: ${data.toString()}]` + } + + if (dataType === 'symbol') { + return `[Symbol: ${data.toString()}]` + } + + if (dataType === 'function') { + return `[Function: ${data.name || 'anonymous'}]` + } + + if (dataType !== 'object') { + return '[Unknown Type]' + } + + if (seen.has(data)) { + return '[Circular Reference]' + } + + if (depth > MAX_DEPTH) { + return '[Max Depth Exceeded]' + } + + const objectType = getObjectType(data) + + switch (objectType) { + case 'Date': { + const timestamp = (data as Date).getTime() + if (Number.isNaN(timestamp)) { + return '[Invalid Date]' + } + return (data as Date).toISOString() + } + + case 'RegExp': + return (data as RegExp).toString() + + case 'URL': + return (data as URL).toString() + + case 'Error': { + const err = data as Error + return { + name: err.name, + message: truncateString(err.message), + stack: err.stack ? truncateString(err.stack) : undefined, + } + } + + case 'ArrayBuffer': + return `[ArrayBuffer: ${(data as ArrayBuffer).byteLength} bytes]` + + case 'Map': { + const obj: Record = {} + for (const [key, value] of (data as Map).entries()) { + const keyStr = typeof key === 'string' ? key : String(key) + obj[keyStr] = filterForDisplayInternal(value, seen, depth + 1) + } + return obj + } + + case 'Set': + return Array.from(data as Set).map((item) => + filterForDisplayInternal(item, seen, depth + 1) + ) + + case 'WeakMap': + return '[WeakMap]' + + case 'WeakSet': + return '[WeakSet]' + + case 'WeakRef': + return '[WeakRef]' + + case 'Promise': + return '[Promise]' + } + + if (ArrayBuffer.isView(data)) { + return `[${objectType}: ${(data as ArrayBufferView).byteLength} bytes]` + } + + seen.add(data) + + for (const filterFn of DISPLAY_FILTERS) { + const result = filterFn(data) + if (result !== data) { + return filterForDisplayInternal(result, seen, depth + 1) + } + } + + if (Array.isArray(data)) { + return data.map((item) => filterForDisplayInternal(item, seen, depth + 1)) + } + + const result: Record = {} + for (const key of Object.keys(data)) { + try { + result[key] = filterForDisplayInternal(data[key], seen, depth + 1) + } catch { + result[key] = '[Error accessing property]' + } + } + return result + } catch { + return '[Unserializable]' + } } diff --git a/apps/sim/lib/knowledge/chunks/service.ts b/apps/sim/lib/knowledge/chunks/service.ts index 50cd0bc59..c66a39fae 100644 --- a/apps/sim/lib/knowledge/chunks/service.ts +++ b/apps/sim/lib/knowledge/chunks/service.ts @@ -92,7 +92,7 @@ export async function queryChunks( export async function createChunk( knowledgeBaseId: string, documentId: string, - docTags: Record, + docTags: Record, chunkData: CreateChunkData, requestId: string ): Promise { @@ -131,14 +131,27 @@ export async function createChunk( embeddingModel: 'text-embedding-3-small', startOffset: 0, // Manual chunks don't have document offsets endOffset: chunkData.content.length, - // Inherit tags from parent document - tag1: docTags.tag1, - tag2: docTags.tag2, - tag3: docTags.tag3, - tag4: docTags.tag4, - tag5: docTags.tag5, - tag6: docTags.tag6, - tag7: docTags.tag7, + // Inherit text tags from parent document + tag1: docTags.tag1 as string | null, + tag2: docTags.tag2 as string | null, + tag3: docTags.tag3 as string | null, + tag4: docTags.tag4 as string | null, + tag5: docTags.tag5 as string | null, + tag6: docTags.tag6 as string | null, + tag7: docTags.tag7 as string | null, + // Inherit number tags from parent document (5 slots) + number1: docTags.number1 as number | null, + number2: docTags.number2 as number | null, + number3: docTags.number3 as number | null, + number4: docTags.number4 as number | null, + number5: docTags.number5 as number | null, + // Inherit date tags from parent document (2 slots) + date1: docTags.date1 as Date | null, + date2: docTags.date2 as Date | null, + // Inherit boolean tags from parent document (3 slots) + boolean1: docTags.boolean1 as boolean | null, + boolean2: docTags.boolean2 as boolean | null, + boolean3: docTags.boolean3 as boolean | null, enabled: chunkData.enabled ?? true, createdAt: now, updatedAt: now, diff --git a/apps/sim/lib/knowledge/constants.ts b/apps/sim/lib/knowledge/constants.ts index c63ba68d1..3ed4b5e4e 100644 --- a/apps/sim/lib/knowledge/constants.ts +++ b/apps/sim/lib/knowledge/constants.ts @@ -3,18 +3,55 @@ export const TAG_SLOT_CONFIG = { slots: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const, maxSlots: 7, }, + number: { + slots: ['number1', 'number2', 'number3', 'number4', 'number5'] as const, + maxSlots: 5, + }, + date: { + slots: ['date1', 'date2'] as const, + maxSlots: 2, + }, + boolean: { + slots: ['boolean1', 'boolean2', 'boolean3'] as const, + maxSlots: 3, + }, } as const export const SUPPORTED_FIELD_TYPES = Object.keys(TAG_SLOT_CONFIG) as Array< keyof typeof TAG_SLOT_CONFIG > +/** Text tag slots (for backwards compatibility) */ export const TAG_SLOTS = TAG_SLOT_CONFIG.text.slots +/** All tag slots across all field types */ +export const ALL_TAG_SLOTS = [ + ...TAG_SLOT_CONFIG.text.slots, + ...TAG_SLOT_CONFIG.number.slots, + ...TAG_SLOT_CONFIG.date.slots, + ...TAG_SLOT_CONFIG.boolean.slots, +] as const + export const MAX_TAG_SLOTS = TAG_SLOT_CONFIG.text.maxSlots +/** Type for text tag slots (for backwards compatibility) */ export type TagSlot = (typeof TAG_SLOTS)[number] +/** Type for all tag slots */ +export type AllTagSlot = (typeof ALL_TAG_SLOTS)[number] + +/** Type for number tag slots */ +export type NumberTagSlot = (typeof TAG_SLOT_CONFIG.number.slots)[number] + +/** Type for date tag slots */ +export type DateTagSlot = (typeof TAG_SLOT_CONFIG.date.slots)[number] + +/** Type for boolean tag slots */ +export type BooleanTagSlot = (typeof TAG_SLOT_CONFIG.boolean.slots)[number] + +/** + * Get the available slots for a field type + */ export function getSlotsForFieldType(fieldType: string): readonly string[] { const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG] if (!config) { @@ -22,3 +59,52 @@ export function getSlotsForFieldType(fieldType: string): readonly string[] { } return config.slots } + +/** + * Get the field type for a tag slot + */ +export function getFieldTypeForSlot(tagSlot: string): keyof typeof TAG_SLOT_CONFIG | null { + for (const [fieldType, config] of Object.entries(TAG_SLOT_CONFIG)) { + if ((config.slots as readonly string[]).includes(tagSlot)) { + return fieldType as keyof typeof TAG_SLOT_CONFIG + } + } + return null +} + +/** + * Check if a slot is valid for a given field type + */ +export function isValidSlotForFieldType(tagSlot: string, fieldType: string): boolean { + const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG] + if (!config) { + return false + } + return (config.slots as readonly string[]).includes(tagSlot) +} + +/** + * Display labels for field types + */ +export const FIELD_TYPE_LABELS: Record = { + text: 'Text', + number: 'Number', + date: 'Date', + boolean: 'Boolean', +} + +/** + * Get placeholder text for value input based on field type + */ +export function getPlaceholderForFieldType(fieldType: string): string { + switch (fieldType) { + case 'boolean': + return 'true or false' + case 'number': + return 'Enter number' + case 'date': + return 'YYYY-MM-DD' + default: + return 'Enter value' + } +} diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index c9ff61282..8a3be03e0 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -5,12 +5,18 @@ import { tasks } from '@trigger.dev/sdk' import { and, asc, desc, eq, inArray, isNull, sql } from 'drizzle-orm' import { env } from '@/lib/core/config/env' import { getStorageMethod, isRedisStorage } from '@/lib/core/storage' -import { getSlotsForFieldType, type TAG_SLOT_CONFIG } from '@/lib/knowledge/constants' import { processDocument } from '@/lib/knowledge/documents/document-processor' import { DocumentProcessingQueue } from '@/lib/knowledge/documents/queue' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import { generateEmbeddings } from '@/lib/knowledge/embeddings' -import { getNextAvailableSlot } from '@/lib/knowledge/tags/service' +import { + buildUndefinedTagsError, + parseBooleanValue, + parseDateValue, + parseNumberValue, + validateTagValue, +} from '@/lib/knowledge/tags/utils' +import type { ProcessedDocumentTags } from '@/lib/knowledge/types' import { createLogger } from '@/lib/logs/console/logger' import type { DocumentProcessingPayload } from '@/background/knowledge-processing' @@ -113,80 +119,194 @@ export interface DocumentTagData { } /** - * Process structured document tags and create tag definitions + * Process structured document tags and validate them against existing definitions + * Throws an error if a tag doesn't exist or if the value doesn't match the expected type */ export async function processDocumentTags( knowledgeBaseId: string, tagData: DocumentTagData[], requestId: string -): Promise> { - const result: Record = {} +): Promise { + // Helper to set a tag value with proper typing + const setTagValue = ( + tags: ProcessedDocumentTags, + slot: string, + value: string | number | Date | boolean | null + ): void => { + switch (slot) { + case 'tag1': + tags.tag1 = value as string | null + break + case 'tag2': + tags.tag2 = value as string | null + break + case 'tag3': + tags.tag3 = value as string | null + break + case 'tag4': + tags.tag4 = value as string | null + break + case 'tag5': + tags.tag5 = value as string | null + break + case 'tag6': + tags.tag6 = value as string | null + break + case 'tag7': + tags.tag7 = value as string | null + break + case 'number1': + tags.number1 = value as number | null + break + case 'number2': + tags.number2 = value as number | null + break + case 'number3': + tags.number3 = value as number | null + break + case 'number4': + tags.number4 = value as number | null + break + case 'number5': + tags.number5 = value as number | null + break + case 'date1': + tags.date1 = value as Date | null + break + case 'date2': + tags.date2 = value as Date | null + break + case 'boolean1': + tags.boolean1 = value as boolean | null + break + case 'boolean2': + tags.boolean2 = value as boolean | null + break + case 'boolean3': + tags.boolean3 = value as boolean | null + break + } + } - const textSlots = getSlotsForFieldType('text') - textSlots.forEach((slot) => { - result[slot] = null - }) + const result: ProcessedDocumentTags = { + tag1: null, + tag2: null, + tag3: null, + tag4: null, + tag5: null, + tag6: null, + tag7: null, + number1: null, + number2: null, + number3: null, + number4: null, + number5: null, + date1: null, + date2: null, + boolean1: null, + boolean2: null, + boolean3: null, + } if (!Array.isArray(tagData) || tagData.length === 0) { return result } - try { - const existingDefinitions = await db - .select() - .from(knowledgeBaseTagDefinitions) - .where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId)) + // Fetch existing tag definitions + const existingDefinitions = await db + .select() + .from(knowledgeBaseTagDefinitions) + .where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId)) - const existingByName = new Map(existingDefinitions.map((def) => [def.displayName, def])) - const existingBySlot = new Map(existingDefinitions.map((def) => [def.tagSlot as string, def])) + const existingByName = new Map(existingDefinitions.map((def) => [def.displayName, def])) - for (const tag of tagData) { - if (!tag.tagName?.trim() || !tag.value?.trim()) continue + // First pass: collect all validation errors + const undefinedTags: string[] = [] + const typeErrors: string[] = [] - const tagName = tag.tagName.trim() - const fieldType = tag.fieldType - const value = tag.value.trim() + for (const tag of tagData) { + // Skip if no tag name + if (!tag.tagName?.trim()) continue - let targetSlot: string | null = null + const tagName = tag.tagName.trim() + const fieldType = tag.fieldType || 'text' - // Check if tag definition already exists - const existingDef = existingByName.get(tagName) - if (existingDef) { - targetSlot = existingDef.tagSlot - } else { - // Find next available slot using the tags service function - targetSlot = await getNextAvailableSlot(knowledgeBaseId, fieldType, existingBySlot) + // For boolean, check if value is defined; for others, check if value is non-empty + const hasValue = + fieldType === 'boolean' + ? tag.value !== undefined && tag.value !== null && tag.value !== '' + : tag.value?.trim && tag.value.trim().length > 0 - // Create new tag definition if we have a slot - if (targetSlot) { - const newDefinition = { - id: randomUUID(), - knowledgeBaseId, - tagSlot: targetSlot as (typeof TAG_SLOT_CONFIG.text.slots)[number], - displayName: tagName, - fieldType, - createdAt: new Date(), - updatedAt: new Date(), - } + if (!hasValue) continue - await db.insert(knowledgeBaseTagDefinitions).values(newDefinition) - existingBySlot.set(targetSlot, newDefinition) - - logger.info(`[${requestId}] Created tag definition: ${tagName} -> ${targetSlot}`) - } - } - - // Assign value to the slot - if (targetSlot) { - result[targetSlot] = value - } + // Check if tag exists + const existingDef = existingByName.get(tagName) + if (!existingDef) { + undefinedTags.push(tagName) + continue } - return result - } catch (error) { - logger.error(`[${requestId}] Error processing document tags:`, error) - return result + // Validate value type using shared validation + const rawValue = typeof tag.value === 'string' ? tag.value.trim() : tag.value + const actualFieldType = existingDef.fieldType || fieldType + const validationError = validateTagValue(tagName, String(rawValue), actualFieldType) + if (validationError) { + typeErrors.push(validationError) + } } + + // Throw combined error if there are any validation issues + if (undefinedTags.length > 0 || typeErrors.length > 0) { + const errorParts: string[] = [] + + if (undefinedTags.length > 0) { + errorParts.push(buildUndefinedTagsError(undefinedTags)) + } + + if (typeErrors.length > 0) { + errorParts.push(...typeErrors) + } + + throw new Error(errorParts.join('\n')) + } + + // Second pass: process valid tags + for (const tag of tagData) { + if (!tag.tagName?.trim()) continue + + const tagName = tag.tagName.trim() + const fieldType = tag.fieldType || 'text' + + const hasValue = + fieldType === 'boolean' + ? tag.value !== undefined && tag.value !== null && tag.value !== '' + : tag.value?.trim && tag.value.trim().length > 0 + + if (!hasValue) continue + + const existingDef = existingByName.get(tagName) + if (!existingDef) continue // Already validated above + + const targetSlot = existingDef.tagSlot + const actualFieldType = existingDef.fieldType || fieldType + const rawValue = typeof tag.value === 'string' ? tag.value.trim() : tag.value + const stringValue = String(rawValue).trim() + + // Assign value to the slot with proper type conversion (values already validated) + if (actualFieldType === 'boolean') { + setTagValue(result, targetSlot, parseBooleanValue(stringValue) ?? false) + } else if (actualFieldType === 'number') { + setTagValue(result, targetSlot, parseNumberValue(stringValue)) + } else if (actualFieldType === 'date') { + setTagValue(result, targetSlot, parseDateValue(stringValue)) + } else { + setTagValue(result, targetSlot, stringValue) + } + + logger.info(`[${requestId}] Set tag ${tagName} (${targetSlot}) = ${stringValue}`) + } + + return result } /** @@ -375,6 +495,7 @@ export async function processDocumentAsync( const documentRecord = await db .select({ + // Text tags (7 slots) tag1: document.tag1, tag2: document.tag2, tag3: document.tag3, @@ -382,6 +503,19 @@ export async function processDocumentAsync( tag5: document.tag5, tag6: document.tag6, tag7: document.tag7, + // Number tags (5 slots) + number1: document.number1, + number2: document.number2, + number3: document.number3, + number4: document.number4, + number5: document.number5, + // Date tags (2 slots) + date1: document.date1, + date2: document.date2, + // Boolean tags (3 slots) + boolean1: document.boolean1, + boolean2: document.boolean2, + boolean3: document.boolean3, }) .from(document) .where(eq(document.id, documentId)) @@ -404,7 +538,7 @@ export async function processDocumentAsync( embeddingModel: 'text-embedding-3-small', startOffset: chunk.metadata.startIndex, endOffset: chunk.metadata.endIndex, - // Copy tags from document + // Copy text tags from document (7 slots) tag1: documentTags.tag1, tag2: documentTags.tag2, tag3: documentTags.tag3, @@ -412,6 +546,19 @@ export async function processDocumentAsync( tag5: documentTags.tag5, tag6: documentTags.tag6, tag7: documentTags.tag7, + // Copy number tags from document (5 slots) + number1: documentTags.number1, + number2: documentTags.number2, + number3: documentTags.number3, + number4: documentTags.number4, + number5: documentTags.number5, + // Copy date tags from document (2 slots) + date1: documentTags.date1, + date2: documentTags.date2, + // Copy boolean tags from document (3 slots) + boolean1: documentTags.boolean1, + boolean2: documentTags.boolean2, + boolean3: documentTags.boolean3, createdAt: now, updatedAt: now, })) @@ -568,15 +715,7 @@ export async function createDocumentRecords( for (const docData of documents) { const documentId = randomUUID() - let processedTags: Record = { - tag1: null, - tag2: null, - tag3: null, - tag4: null, - tag5: null, - tag6: null, - tag7: null, - } + let processedTags: Record = {} if (docData.documentTagsData) { try { @@ -585,7 +724,12 @@ export async function createDocumentRecords( processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId) } } catch (error) { - logger.warn(`[${requestId}] Failed to parse documentTagsData for bulk document:`, error) + // Re-throw validation errors, only catch JSON parse errors + if (error instanceof SyntaxError) { + logger.warn(`[${requestId}] Failed to parse documentTagsData for bulk document:`, error) + } else { + throw error + } } } @@ -602,14 +746,27 @@ export async function createDocumentRecords( processingStatus: 'pending' as const, enabled: true, uploadedAt: now, - // Use processed tags if available, otherwise fall back to individual tag fields - tag1: processedTags.tag1 || docData.tag1 || null, - tag2: processedTags.tag2 || docData.tag2 || null, - tag3: processedTags.tag3 || docData.tag3 || null, - tag4: processedTags.tag4 || docData.tag4 || null, - tag5: processedTags.tag5 || docData.tag5 || null, - tag6: processedTags.tag6 || docData.tag6 || null, - tag7: processedTags.tag7 || docData.tag7 || null, + // Text tags - use processed tags if available, otherwise fall back to individual tag fields + tag1: processedTags.tag1 ?? docData.tag1 ?? null, + tag2: processedTags.tag2 ?? docData.tag2 ?? null, + tag3: processedTags.tag3 ?? docData.tag3 ?? null, + tag4: processedTags.tag4 ?? docData.tag4 ?? null, + tag5: processedTags.tag5 ?? docData.tag5 ?? null, + tag6: processedTags.tag6 ?? docData.tag6 ?? null, + tag7: processedTags.tag7 ?? docData.tag7 ?? null, + // Number tags (5 slots) + number1: processedTags.number1 ?? null, + number2: processedTags.number2 ?? null, + number3: processedTags.number3 ?? null, + number4: processedTags.number4 ?? null, + number5: processedTags.number5 ?? null, + // Date tags (2 slots) + date1: processedTags.date1 ?? null, + date2: processedTags.date2 ?? null, + // Boolean tags (3 slots) + boolean1: processedTags.boolean1 ?? null, + boolean2: processedTags.boolean2 ?? null, + boolean3: processedTags.boolean3 ?? null, } documentRecords.push(newDocument) @@ -679,6 +836,7 @@ export async function getDocuments( processingError: string | null enabled: boolean uploadedAt: Date + // Text tags tag1: string | null tag2: string | null tag3: string | null @@ -686,6 +844,19 @@ export async function getDocuments( tag5: string | null tag6: string | null tag7: string | null + // Number tags + number1: number | null + number2: number | null + number3: number | null + number4: number | null + number5: number | null + // Date tags + date1: Date | null + date2: Date | null + // Boolean tags + boolean1: boolean | null + boolean2: boolean | null + boolean3: boolean | null }> pagination: { total: number @@ -772,7 +943,7 @@ export async function getDocuments( processingError: document.processingError, enabled: document.enabled, uploadedAt: document.uploadedAt, - // Include tags in response + // Text tags (7 slots) tag1: document.tag1, tag2: document.tag2, tag3: document.tag3, @@ -780,6 +951,19 @@ export async function getDocuments( tag5: document.tag5, tag6: document.tag6, tag7: document.tag7, + // Number tags (5 slots) + number1: document.number1, + number2: document.number2, + number3: document.number3, + number4: document.number4, + number5: document.number5, + // Date tags (2 slots) + date1: document.date1, + date2: document.date2, + // Boolean tags (3 slots) + boolean1: document.boolean1, + boolean2: document.boolean2, + boolean3: document.boolean3, }) .from(document) .where(and(...whereConditions)) @@ -807,6 +991,7 @@ export async function getDocuments( processingError: doc.processingError, enabled: doc.enabled, uploadedAt: doc.uploadedAt, + // Text tags tag1: doc.tag1, tag2: doc.tag2, tag3: doc.tag3, @@ -814,6 +999,19 @@ export async function getDocuments( tag5: doc.tag5, tag6: doc.tag6, tag7: doc.tag7, + // Number tags + number1: doc.number1, + number2: doc.number2, + number3: doc.number3, + number4: doc.number4, + number5: doc.number5, + // Date tags + date1: doc.date1, + date2: doc.date2, + // Boolean tags + boolean1: doc.boolean1, + boolean2: doc.boolean2, + boolean3: doc.boolean3, })), pagination: { total, @@ -883,14 +1081,28 @@ export async function createSingleDocument( const now = new Date() // Process structured tag data if provided - let processedTags: Record = { - tag1: documentData.tag1 || null, - tag2: documentData.tag2 || null, - tag3: documentData.tag3 || null, - tag4: documentData.tag4 || null, - tag5: documentData.tag5 || null, - tag6: documentData.tag6 || null, - tag7: documentData.tag7 || null, + let processedTags: Record = { + // Text tags (7 slots) + tag1: documentData.tag1 ?? null, + tag2: documentData.tag2 ?? null, + tag3: documentData.tag3 ?? null, + tag4: documentData.tag4 ?? null, + tag5: documentData.tag5 ?? null, + tag6: documentData.tag6 ?? null, + tag7: documentData.tag7 ?? null, + // Number tags (5 slots) + number1: null, + number2: null, + number3: null, + number4: null, + number5: null, + // Date tags (2 slots) + date1: null, + date2: null, + // Boolean tags (3 slots) + boolean1: null, + boolean2: null, + boolean3: null, } if (documentData.documentTagsData) { @@ -901,7 +1113,12 @@ export async function createSingleDocument( processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId) } } catch (error) { - logger.warn(`[${requestId}] Failed to parse documentTagsData:`, error) + // Re-throw validation errors, only catch JSON parse errors + if (error instanceof SyntaxError) { + logger.warn(`[${requestId}] Failed to parse documentTagsData:`, error) + } else { + throw error + } } } @@ -1183,6 +1400,7 @@ export async function updateDocument( characterCount?: number processingStatus?: 'pending' | 'processing' | 'completed' | 'failed' processingError?: string + // Text tags tag1?: string tag2?: string tag3?: string @@ -1190,6 +1408,19 @@ export async function updateDocument( tag5?: string tag6?: string tag7?: string + // Number tags + number1?: string + number2?: string + number3?: string + number4?: string + number5?: string + // Date tags + date1?: string + date2?: string + // Boolean tags + boolean1?: string + boolean2?: string + boolean3?: string }, requestId: string ): Promise<{ @@ -1215,6 +1446,16 @@ export async function updateDocument( tag5: string | null tag6: string | null tag7: string | null + number1: number | null + number2: number | null + number3: number | null + number4: number | null + number5: number | null + date1: Date | null + date2: Date | null + boolean1: boolean | null + boolean2: boolean | null + boolean3: boolean | null deletedAt: Date | null }> { const dbUpdateData: Partial<{ @@ -1234,9 +1475,38 @@ export async function updateDocument( tag5: string | null tag6: string | null tag7: string | null + number1: number | null + number2: number | null + number3: number | null + number4: number | null + number5: number | null + date1: Date | null + date2: Date | null + boolean1: boolean | null + boolean2: boolean | null + boolean3: boolean | null }> = {} - const TAG_SLOTS = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const - type TagSlot = (typeof TAG_SLOTS)[number] + // All tag slots across all field types + const ALL_TAG_SLOTS = [ + 'tag1', + 'tag2', + 'tag3', + 'tag4', + 'tag5', + 'tag6', + 'tag7', + 'number1', + 'number2', + 'number3', + 'number4', + 'number5', + 'date1', + 'date2', + 'boolean1', + 'boolean2', + 'boolean3', + ] as const + type TagSlot = (typeof ALL_TAG_SLOTS)[number] // Regular field updates if (updateData.filename !== undefined) dbUpdateData.filename = updateData.filename @@ -1250,23 +1520,49 @@ export async function updateDocument( if (updateData.processingError !== undefined) dbUpdateData.processingError = updateData.processingError - TAG_SLOTS.forEach((slot: TagSlot) => { + // Helper to convert string values to proper types for the database + const convertTagValue = ( + slot: string, + value: string | undefined + ): string | number | Date | boolean | null => { + if (value === undefined || value === '') return null + + // Number slots + if (slot.startsWith('number')) { + return parseNumberValue(value) + } + + // Date slots + if (slot.startsWith('date')) { + return parseDateValue(value) + } + + // Boolean slots + if (slot.startsWith('boolean')) { + return parseBooleanValue(value) ?? false + } + + // Text slots: keep as string + return value || null + } + + ALL_TAG_SLOTS.forEach((slot: TagSlot) => { const updateValue = (updateData as any)[slot] if (updateValue !== undefined) { - ;(dbUpdateData as any)[slot] = updateValue + ;(dbUpdateData as any)[slot] = convertTagValue(slot, updateValue) } }) await db.transaction(async (tx) => { await tx.update(document).set(dbUpdateData).where(eq(document.id, documentId)) - const hasTagUpdates = TAG_SLOTS.some((field) => (updateData as any)[field] !== undefined) + const hasTagUpdates = ALL_TAG_SLOTS.some((field) => (updateData as any)[field] !== undefined) if (hasTagUpdates) { - const embeddingUpdateData: Record = {} - TAG_SLOTS.forEach((field) => { + const embeddingUpdateData: Record = {} + ALL_TAG_SLOTS.forEach((field) => { if ((updateData as any)[field] !== undefined) { - embeddingUpdateData[field] = (updateData as any)[field] || null + embeddingUpdateData[field] = convertTagValue(field, (updateData as any)[field]) } }) @@ -1313,6 +1609,16 @@ export async function updateDocument( tag5: doc.tag5, tag6: doc.tag6, tag7: doc.tag7, + number1: doc.number1, + number2: doc.number2, + number3: doc.number3, + number4: doc.number4, + number5: doc.number5, + date1: doc.date1, + date2: doc.date2, + boolean1: doc.boolean1, + boolean2: doc.boolean2, + boolean3: doc.boolean3, deletedAt: doc.deletedAt, } } diff --git a/apps/sim/lib/knowledge/filters/index.ts b/apps/sim/lib/knowledge/filters/index.ts new file mode 100644 index 000000000..26bee6598 --- /dev/null +++ b/apps/sim/lib/knowledge/filters/index.ts @@ -0,0 +1,2 @@ +export * from './query-builder' +export * from './types' diff --git a/apps/sim/lib/knowledge/filters/query-builder.ts b/apps/sim/lib/knowledge/filters/query-builder.ts new file mode 100644 index 000000000..83bad1175 --- /dev/null +++ b/apps/sim/lib/knowledge/filters/query-builder.ts @@ -0,0 +1,393 @@ +import { document, embedding } from '@sim/db/schema' +import { and, eq, gt, gte, ilike, lt, lte, ne, not, or, type SQL } from 'drizzle-orm' +import type { + BooleanFilterCondition, + DateFilterCondition, + FilterCondition, + FilterGroup, + NumberFilterCondition, + SimpleTagFilter, + TagFilter, + TextFilterCondition, +} from './types' + +/** + * Valid tag slots that can be used in filters + */ +const VALID_TEXT_SLOTS = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const +const VALID_NUMBER_SLOTS = ['number1', 'number2', 'number3', 'number4', 'number5'] as const +const VALID_DATE_SLOTS = ['date1', 'date2'] as const +const VALID_BOOLEAN_SLOTS = ['boolean1', 'boolean2', 'boolean3'] as const + +type TextSlot = (typeof VALID_TEXT_SLOTS)[number] +type NumberSlot = (typeof VALID_NUMBER_SLOTS)[number] +type DateSlot = (typeof VALID_DATE_SLOTS)[number] +type BooleanSlot = (typeof VALID_BOOLEAN_SLOTS)[number] + +/** + * Validates that a tag slot is valid for the given field type + */ +function isValidSlotForType( + slot: string, + fieldType: string +): slot is TextSlot | NumberSlot | DateSlot | BooleanSlot { + switch (fieldType) { + case 'text': + return (VALID_TEXT_SLOTS as readonly string[]).includes(slot) + case 'number': + return (VALID_NUMBER_SLOTS as readonly string[]).includes(slot) + case 'date': + return (VALID_DATE_SLOTS as readonly string[]).includes(slot) + case 'boolean': + return (VALID_BOOLEAN_SLOTS as readonly string[]).includes(slot) + default: + return false + } +} + +/** + * Build SQL condition for a text filter + */ +function buildTextCondition( + condition: TextFilterCondition, + table: typeof document | typeof embedding +): SQL | null { + const { tagSlot, operator, value } = condition + + if (!isValidSlotForType(tagSlot, 'text')) { + return null + } + + const column = table[tagSlot as TextSlot] + if (!column) return null + + switch (operator) { + case 'eq': + return eq(column, value) + case 'neq': + return ne(column, value) + case 'contains': + return ilike(column, `%${value}%`) + case 'not_contains': + return not(ilike(column, `%${value}%`)) + case 'starts_with': + return ilike(column, `${value}%`) + case 'ends_with': + return ilike(column, `%${value}`) + default: + return null + } +} + +/** + * Build SQL condition for a number filter + */ +function buildNumberCondition( + condition: NumberFilterCondition, + table: typeof document | typeof embedding +): SQL | null { + const { tagSlot, operator, value, valueTo } = condition + + if (!isValidSlotForType(tagSlot, 'number')) { + return null + } + + const column = table[tagSlot as NumberSlot] + if (!column) return null + + switch (operator) { + case 'eq': + return eq(column, value) + case 'neq': + return ne(column, value) + case 'gt': + return gt(column, value) + case 'gte': + return gte(column, value) + case 'lt': + return lt(column, value) + case 'lte': + return lte(column, value) + case 'between': + if (valueTo !== undefined) { + return and(gte(column, value), lte(column, valueTo)) ?? null + } + return null + default: + return null + } +} + +/** + * Parse a YYYY-MM-DD date string into a UTC Date object. + */ +function parseDateValue(value: string): Date | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null + const [year, month, day] = value.split('-').map(Number) + return new Date(Date.UTC(year, month - 1, day)) +} + +/** + * Build SQL condition for a date filter. + * Expects date values in YYYY-MM-DD format. + */ +function buildDateCondition( + condition: DateFilterCondition, + table: typeof document | typeof embedding +): SQL | null { + const { tagSlot, operator, value, valueTo } = condition + + if (!isValidSlotForType(tagSlot, 'date')) { + return null + } + + const column = table[tagSlot as DateSlot] + if (!column) return null + + const dateValue = parseDateValue(value) + if (!dateValue) return null + + switch (operator) { + case 'eq': + return eq(column, dateValue) + case 'neq': + return ne(column, dateValue) + case 'gt': + return gt(column, dateValue) + case 'gte': + return gte(column, dateValue) + case 'lt': + return lt(column, dateValue) + case 'lte': + return lte(column, dateValue) + case 'between': + if (valueTo !== undefined) { + const dateValueTo = parseDateValue(valueTo) + if (!dateValueTo) return null + return and(gte(column, dateValue), lte(column, dateValueTo)) ?? null + } + return null + default: + return null + } +} + +/** + * Build SQL condition for a boolean filter + */ +function buildBooleanCondition( + condition: BooleanFilterCondition, + table: typeof document | typeof embedding +): SQL | null { + const { tagSlot, operator, value } = condition + + if (!isValidSlotForType(tagSlot, 'boolean')) { + return null + } + + const column = table[tagSlot as BooleanSlot] + if (!column) return null + + switch (operator) { + case 'eq': + return eq(column, value) + case 'neq': + return ne(column, value) + default: + return null + } +} + +/** + * Build SQL condition for a single filter condition + */ +function buildCondition( + condition: FilterCondition, + table: typeof document | typeof embedding +): SQL | null { + switch (condition.fieldType) { + case 'text': + return buildTextCondition(condition, table) + case 'number': + return buildNumberCondition(condition, table) + case 'date': + return buildDateCondition(condition, table) + case 'boolean': + return buildBooleanCondition(condition, table) + default: + return null + } +} + +/** + * Build SQL condition for a filter group + */ +function buildGroupCondition( + group: FilterGroup, + table: typeof document | typeof embedding +): SQL | null { + const conditions = group.conditions + .map((condition) => buildCondition(condition, table)) + .filter((c): c is SQL => c !== null) + + if (conditions.length === 0) { + return null + } + + if (conditions.length === 1) { + return conditions[0] + } + + return (group.operator === 'AND' ? and(...conditions) : or(...conditions)) ?? null +} + +/** + * Build SQL WHERE clause from a TagFilter + * Supports nested groups with AND/OR logic + */ +export function buildTagFilterQuery( + filter: TagFilter, + table: typeof document | typeof embedding +): SQL | null { + const groupConditions = filter.groups + .map((group) => buildGroupCondition(group, table)) + .filter((c): c is SQL => c !== null) + + if (groupConditions.length === 0) { + return null + } + + if (groupConditions.length === 1) { + return groupConditions[0] + } + + return (filter.rootOperator === 'AND' ? and(...groupConditions) : or(...groupConditions)) ?? null +} + +/** + * Build SQL WHERE clause from a SimpleTagFilter + * For flat filter structures without nested groups + */ +export function buildSimpleTagFilterQuery( + filter: SimpleTagFilter, + table: typeof document | typeof embedding +): SQL | null { + const conditions = filter.conditions + .map((condition) => buildCondition(condition, table)) + .filter((c): c is SQL => c !== null) + + if (conditions.length === 0) { + return null + } + + if (conditions.length === 1) { + return conditions[0] + } + + return (filter.operator === 'AND' ? and(...conditions) : or(...conditions)) ?? null +} + +/** + * Build SQL WHERE clause from an array of filter conditions + * Combines all conditions with AND by default + */ +export function buildFilterConditionsQuery( + conditions: FilterCondition[], + table: typeof document | typeof embedding, + operator: 'AND' | 'OR' = 'AND' +): SQL | null { + return buildSimpleTagFilterQuery({ operator, conditions }, table) +} + +/** + * Convenience function to build filter for document table + */ +export function buildDocumentFilterQuery(filter: TagFilter | SimpleTagFilter): SQL | null { + if ('rootOperator' in filter) { + return buildTagFilterQuery(filter, document) + } + return buildSimpleTagFilterQuery(filter, document) +} + +/** + * Convenience function to build filter for embedding table + */ +export function buildEmbeddingFilterQuery(filter: TagFilter | SimpleTagFilter): SQL | null { + if ('rootOperator' in filter) { + return buildTagFilterQuery(filter, embedding) + } + return buildSimpleTagFilterQuery(filter, embedding) +} + +/** + * Validate a filter condition + * Returns an array of validation errors, empty if valid + */ +export function validateFilterCondition(condition: FilterCondition): string[] { + const errors: string[] = [] + + if (!isValidSlotForType(condition.tagSlot, condition.fieldType)) { + errors.push(`Invalid tag slot "${condition.tagSlot}" for field type "${condition.fieldType}"`) + } + + switch (condition.fieldType) { + case 'text': + if (typeof condition.value !== 'string') { + errors.push('Text filter value must be a string') + } + break + case 'number': + if (typeof condition.value !== 'number' || Number.isNaN(condition.value)) { + errors.push('Number filter value must be a valid number') + } + if (condition.operator === 'between' && condition.valueTo === undefined) { + errors.push('Between operator requires a second value') + } + if (condition.valueTo !== undefined && typeof condition.valueTo !== 'number') { + errors.push('Number filter second value must be a valid number') + } + break + case 'date': + if (typeof condition.value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(condition.value)) { + errors.push('Date filter value must be in YYYY-MM-DD format') + } + if (condition.operator === 'between' && condition.valueTo === undefined) { + errors.push('Between operator requires a second value') + } + if ( + condition.valueTo !== undefined && + (typeof condition.valueTo !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(condition.valueTo)) + ) { + errors.push('Date filter second value must be in YYYY-MM-DD format') + } + break + case 'boolean': + if (typeof condition.value !== 'boolean') { + errors.push('Boolean filter value must be true or false') + } + break + } + + return errors +} + +/** + * Validate all conditions in a filter + */ +export function validateFilter(filter: TagFilter | SimpleTagFilter): string[] { + const errors: string[] = [] + + if ('rootOperator' in filter) { + for (const group of filter.groups) { + for (const condition of group.conditions) { + errors.push(...validateFilterCondition(condition)) + } + } + } else { + for (const condition of filter.conditions) { + errors.push(...validateFilterCondition(condition)) + } + } + + return errors +} diff --git a/apps/sim/lib/knowledge/filters/types.ts b/apps/sim/lib/knowledge/filters/types.ts new file mode 100644 index 000000000..af8f5406c --- /dev/null +++ b/apps/sim/lib/knowledge/filters/types.ts @@ -0,0 +1,191 @@ +/** + * Filter operators for different field types + */ + +/** + * Text filter operators + */ +export type TextOperator = 'eq' | 'neq' | 'contains' | 'not_contains' | 'starts_with' | 'ends_with' + +/** + * Number filter operators + */ +export type NumberOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'between' + +/** + * Date filter operators + */ +export type DateOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'between' + +/** + * Boolean filter operators + */ +export type BooleanOperator = 'eq' | 'neq' + +/** + * All filter operators union + */ +export type FilterOperator = TextOperator | NumberOperator | DateOperator | BooleanOperator + +/** + * Field types supported for filtering + */ +export type FilterFieldType = 'text' | 'number' | 'date' | 'boolean' + +/** + * Logical operators for combining filters + */ +export type LogicalOperator = 'AND' | 'OR' + +/** + * Base filter condition interface + */ +interface BaseFilterCondition { + tagSlot: string + fieldType: FilterFieldType +} + +/** + * Text filter condition + */ +export interface TextFilterCondition extends BaseFilterCondition { + fieldType: 'text' + operator: TextOperator + value: string +} + +/** + * Number filter condition + */ +export interface NumberFilterCondition extends BaseFilterCondition { + fieldType: 'number' + operator: NumberOperator + value: number + valueTo?: number // For 'between' operator +} + +/** + * Date filter condition + */ +export interface DateFilterCondition extends BaseFilterCondition { + fieldType: 'date' + operator: DateOperator + value: string // ISO date string + valueTo?: string // For 'between' operator (ISO date string) +} + +/** + * Boolean filter condition + */ +export interface BooleanFilterCondition extends BaseFilterCondition { + fieldType: 'boolean' + operator: BooleanOperator + value: boolean +} + +/** + * Union of all filter conditions + */ +export type FilterCondition = + | TextFilterCondition + | NumberFilterCondition + | DateFilterCondition + | BooleanFilterCondition + +/** + * Filter group with logical operator + */ +export interface FilterGroup { + operator: LogicalOperator + conditions: FilterCondition[] +} + +/** + * Complete filter query structure + * Supports nested groups with AND/OR logic + */ +export interface TagFilter { + rootOperator: LogicalOperator + groups: FilterGroup[] +} + +/** + * Simplified flat filter structure for simple use cases + */ +export interface SimpleTagFilter { + operator: LogicalOperator + conditions: FilterCondition[] +} + +/** + * Operator metadata for UI display + */ +export interface OperatorInfo { + value: string + label: string + requiresSecondValue?: boolean +} + +/** + * Text operators metadata + */ +export const TEXT_OPERATORS: OperatorInfo[] = [ + { value: 'eq', label: 'equals' }, + { value: 'neq', label: 'not equals' }, + { value: 'contains', label: 'contains' }, + { value: 'not_contains', label: 'does not contain' }, + { value: 'starts_with', label: 'starts with' }, + { value: 'ends_with', label: 'ends with' }, +] + +/** + * Number operators metadata + */ +export const NUMBER_OPERATORS: OperatorInfo[] = [ + { value: 'eq', label: 'equals' }, + { value: 'neq', label: 'not equals' }, + { value: 'gt', label: 'greater than' }, + { value: 'gte', label: 'greater than or equal' }, + { value: 'lt', label: 'less than' }, + { value: 'lte', label: 'less than or equal' }, + { value: 'between', label: 'between', requiresSecondValue: true }, +] + +/** + * Date operators metadata + */ +export const DATE_OPERATORS: OperatorInfo[] = [ + { value: 'eq', label: 'equals' }, + { value: 'neq', label: 'not equals' }, + { value: 'gt', label: 'after' }, + { value: 'gte', label: 'on or after' }, + { value: 'lt', label: 'before' }, + { value: 'lte', label: 'on or before' }, + { value: 'between', label: 'between', requiresSecondValue: true }, +] + +/** + * Boolean operators metadata + */ +export const BOOLEAN_OPERATORS: OperatorInfo[] = [ + { value: 'eq', label: 'is' }, + { value: 'neq', label: 'is not' }, +] + +/** + * Get operators for a field type + */ +export function getOperatorsForFieldType(fieldType: FilterFieldType): OperatorInfo[] { + switch (fieldType) { + case 'text': + return TEXT_OPERATORS + case 'number': + return NUMBER_OPERATORS + case 'date': + return DATE_OPERATORS + case 'boolean': + return BOOLEAN_OPERATORS + default: + return [] + } +} diff --git a/apps/sim/lib/knowledge/tags/service.ts b/apps/sim/lib/knowledge/tags/service.ts index 54061883c..66d663f58 100644 --- a/apps/sim/lib/knowledge/tags/service.ts +++ b/apps/sim/lib/knowledge/tags/service.ts @@ -2,11 +2,7 @@ import { randomUUID } from 'crypto' import { db } from '@sim/db' import { document, embedding, knowledgeBaseTagDefinitions } from '@sim/db/schema' import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm' -import { - getSlotsForFieldType, - SUPPORTED_FIELD_TYPES, - type TAG_SLOT_CONFIG, -} from '@/lib/knowledge/constants' +import { getSlotsForFieldType, SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants' import type { BulkTagDefinitionsData, DocumentTagDefinition } from '@/lib/knowledge/tags/types' import type { CreateTagDefinitionData, @@ -17,14 +13,45 @@ import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('TagsService') -const VALID_TAG_SLOTS = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const +/** Text tag slots */ +const VALID_TEXT_SLOTS = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const -function validateTagSlot(tagSlot: string): asserts tagSlot is (typeof VALID_TAG_SLOTS)[number] { - if (!VALID_TAG_SLOTS.includes(tagSlot as (typeof VALID_TAG_SLOTS)[number])) { +const VALID_NUMBER_SLOTS = ['number1', 'number2', 'number3', 'number4', 'number5'] as const +/** Date tag slots (reduced to 2 for write performance) */ +const VALID_DATE_SLOTS = ['date1', 'date2'] as const +/** Boolean tag slots */ +const VALID_BOOLEAN_SLOTS = ['boolean1', 'boolean2', 'boolean3'] as const + +/** All valid tag slots combined */ +const VALID_TAG_SLOTS = [ + ...VALID_TEXT_SLOTS, + ...VALID_NUMBER_SLOTS, + ...VALID_DATE_SLOTS, + ...VALID_BOOLEAN_SLOTS, +] as const + +type ValidTagSlot = (typeof VALID_TAG_SLOTS)[number] + +/** + * Validates that a tag slot is a valid slot name + */ +function validateTagSlot(tagSlot: string): asserts tagSlot is ValidTagSlot { + if (!VALID_TAG_SLOTS.includes(tagSlot as ValidTagSlot)) { throw new Error(`Invalid tag slot: ${tagSlot}. Must be one of: ${VALID_TAG_SLOTS.join(', ')}`) } } +/** + * Get the field type for a tag slot + */ +function getFieldTypeForSlot(tagSlot: string): string | null { + if ((VALID_TEXT_SLOTS as readonly string[]).includes(tagSlot)) return 'text' + if ((VALID_NUMBER_SLOTS as readonly string[]).includes(tagSlot)) return 'number' + if ((VALID_DATE_SLOTS as readonly string[]).includes(tagSlot)) return 'date' + if ((VALID_BOOLEAN_SLOTS as readonly string[]).includes(tagSlot)) return 'boolean' + return null +} + /** * Get the next available slot for a knowledge base and field type */ @@ -215,7 +242,7 @@ export async function createOrUpdateTagDefinitionsBulk( const newDefinition = { id, knowledgeBaseId, - tagSlot: finalTagSlot as (typeof TAG_SLOT_CONFIG.text.slots)[number], + tagSlot: finalTagSlot as ValidTagSlot, displayName, fieldType, createdAt: now, @@ -466,7 +493,7 @@ export async function createTagDefinition( const newDefinition = { id: tagDefinitionId, knowledgeBaseId: data.knowledgeBaseId, - tagSlot: data.tagSlot as (typeof TAG_SLOT_CONFIG.text.slots)[number], + tagSlot: data.tagSlot as ValidTagSlot, displayName: data.displayName, fieldType: data.fieldType, createdAt: now, @@ -562,21 +589,31 @@ export async function getTagUsage( const tagSlot = def.tagSlot validateTagSlot(tagSlot) + // Build WHERE conditions based on field type + // Text columns need both IS NOT NULL and != '' checks + // Numeric/date/boolean columns only need IS NOT NULL + const fieldType = getFieldTypeForSlot(tagSlot) + const isTextColumn = fieldType === 'text' + + const whereConditions = [ + eq(document.knowledgeBaseId, knowledgeBaseId), + isNull(document.deletedAt), + isNotNull(sql`${sql.raw(tagSlot)}`), + ] + + // Only add empty string check for text columns + if (isTextColumn) { + whereConditions.push(sql`${sql.raw(tagSlot)} != ''`) + } + const documentsWithTag = await db .select({ id: document.id, filename: document.filename, - tagValue: sql`${sql.raw(tagSlot)}`, + tagValue: sql`${sql.raw(tagSlot)}::text`, }) .from(document) - .where( - and( - eq(document.knowledgeBaseId, knowledgeBaseId), - isNull(document.deletedAt), - isNotNull(sql`${sql.raw(tagSlot)}`), - sql`${sql.raw(tagSlot)} != ''` - ) - ) + .where(and(...whereConditions)) usage.push({ tagName: def.displayName, diff --git a/apps/sim/lib/knowledge/tags/utils.ts b/apps/sim/lib/knowledge/tags/utils.ts new file mode 100644 index 000000000..713a04cd4 --- /dev/null +++ b/apps/sim/lib/knowledge/tags/utils.ts @@ -0,0 +1,89 @@ +/** + * Validate a tag value against its expected field type + * Returns an error message if invalid, or null if valid + */ +export function validateTagValue(tagName: string, value: string, fieldType: string): string | null { + const stringValue = String(value).trim() + + switch (fieldType) { + case 'boolean': { + const lowerValue = stringValue.toLowerCase() + if (lowerValue !== 'true' && lowerValue !== 'false') { + return `Tag "${tagName}" expects a boolean value (true/false), but received "${value}"` + } + return null + } + case 'number': { + const numValue = Number(stringValue) + if (Number.isNaN(numValue)) { + return `Tag "${tagName}" expects a number value, but received "${value}"` + } + return null + } + case 'date': { + // Check format first + if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) { + return `Tag "${tagName}" expects a date in YYYY-MM-DD format, but received "${value}"` + } + // Validate the date is actually valid (e.g., reject 2024-02-31) + const [year, month, day] = stringValue.split('-').map(Number) + const date = new Date(year, month - 1, day) + if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { + return `Tag "${tagName}" has an invalid date: "${value}"` + } + return null + } + default: + return null + } +} + +/** + * Build error message for undefined tags + */ +export function buildUndefinedTagsError(undefinedTags: string[]): string { + const tagList = undefinedTags.map((t) => `"${t}"`).join(', ') + return `The following tags are not defined in this knowledge base: ${tagList}. Please define them at the knowledge base level first.` +} + +/** + * Parse a string to number with strict validation + * Returns null if invalid + */ +export function parseNumberValue(value: string): number | null { + const num = Number(value) + return Number.isNaN(num) ? null : num +} + +/** + * Parse a string to Date with strict YYYY-MM-DD validation + * Returns null if invalid format or invalid date + */ +export function parseDateValue(value: string): Date | null { + const stringValue = String(value).trim() + + // Must be YYYY-MM-DD format + if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) { + return null + } + + // Validate the date is actually valid (e.g., reject 2024-02-31) + const [year, month, day] = stringValue.split('-').map(Number) + const date = new Date(year, month - 1, day) + if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { + return null + } + + return date +} + +/** + * Parse a string to boolean with strict validation + * Returns null if not 'true' or 'false' + */ +export function parseBooleanValue(value: string): boolean | null { + const lowerValue = String(value).trim().toLowerCase() + if (lowerValue === 'true') return true + if (lowerValue === 'false') return false + return null +} diff --git a/apps/sim/lib/knowledge/types.ts b/apps/sim/lib/knowledge/types.ts index 7ec36fc01..932845a61 100644 --- a/apps/sim/lib/knowledge/types.ts +++ b/apps/sim/lib/knowledge/types.ts @@ -48,3 +48,40 @@ export interface UpdateTagDefinitionData { displayName?: string fieldType?: string } + +/** Tag filter for knowledge base search */ +export interface StructuredFilter { + tagName?: string // Human-readable name (input from frontend) + tagSlot: string // Database column (resolved from tagName) + fieldType: string + operator: string + value: string | number | boolean + valueTo?: string | number +} + +/** Processed document tags ready for database storage */ +export interface ProcessedDocumentTags { + // Text tags + tag1: string | null + tag2: string | null + tag3: string | null + tag4: string | null + tag5: string | null + tag6: string | null + tag7: string | null + // Number tags + number1: number | null + number2: number | null + number3: number | null + number4: number | null + number5: number | null + // Date tags + date1: Date | null + date2: Date | null + // Boolean tags + boolean1: boolean | null + boolean2: boolean | null + boolean3: boolean | null + // Index signature for dynamic access + [key: string]: string | number | Date | boolean | null +} diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 390a60456..aad6cfb4b 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -230,6 +230,7 @@ export class ExecutionLogger implements IExecutionLoggerService { traceSpans?: TraceSpan[] workflowInput?: any isResume?: boolean // If true, merge with existing data instead of replacing + level?: 'info' | 'error' // Optional override for log level (used in cost-only fallback) }): Promise { const { executionId, @@ -240,6 +241,7 @@ export class ExecutionLogger implements IExecutionLoggerService { traceSpans, workflowInput, isResume, + level: levelOverride, } = params logger.debug(`Completing workflow execution ${executionId}`, { isResume }) @@ -256,6 +258,7 @@ export class ExecutionLogger implements IExecutionLoggerService { } // Determine if workflow failed by checking trace spans for errors + // Use the override if provided (for cost-only fallback scenarios) const hasErrors = traceSpans?.some((span: any) => { const checkSpanForErrors = (s: any): boolean => { if (s.status === 'error') return true @@ -267,7 +270,7 @@ export class ExecutionLogger implements IExecutionLoggerService { return checkSpanForErrors(span) }) - const level = hasErrors ? 'error' : 'info' + const level = levelOverride ?? (hasErrors ? 'error' : 'info') // Extract files from trace spans, final output, and workflow input const executionFiles = this.extractFilesFromExecution(traceSpans, finalOutput, workflowInput) diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index f7c4f1277..1d2edfd74 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -29,7 +29,7 @@ export interface SessionCompleteParams { endedAt?: string totalDurationMs?: number finalOutput?: any - traceSpans?: any[] + traceSpans?: TraceSpan[] workflowInput?: any } @@ -331,20 +331,85 @@ export class LoggingSession { try { await this.complete(params) } catch (error) { - // Error already logged in complete(), log a summary here + const errorMsg = error instanceof Error ? error.message : String(error) logger.warn( - `[${this.requestId || 'unknown'}] Logging completion failed for execution ${this.executionId} - execution data not persisted` + `[${this.requestId || 'unknown'}] Complete failed for execution ${this.executionId}, attempting fallback`, + { error: errorMsg } ) + await this.completeWithCostOnlyLog({ + traceSpans: params.traceSpans, + endedAt: params.endedAt, + totalDurationMs: params.totalDurationMs, + errorMessage: `Failed to store trace spans: ${errorMsg}`, + isError: false, + }) } } - async safeCompleteWithError(error?: SessionErrorCompleteParams): Promise { + async safeCompleteWithError(params?: SessionErrorCompleteParams): Promise { try { - await this.completeWithError(error) - } catch (enhancedError) { - // Error already logged in completeWithError(), log a summary here + await this.completeWithError(params) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) logger.warn( - `[${this.requestId || 'unknown'}] Error logging completion failed for execution ${this.executionId} - execution data not persisted` + `[${this.requestId || 'unknown'}] CompleteWithError failed for execution ${this.executionId}, attempting fallback`, + { error: errorMsg } + ) + await this.completeWithCostOnlyLog({ + traceSpans: params?.traceSpans, + endedAt: params?.endedAt, + totalDurationMs: params?.totalDurationMs, + errorMessage: + params?.error?.message || `Execution failed to store trace spans: ${errorMsg}`, + isError: true, + }) + } + } + + private async completeWithCostOnlyLog(params: { + traceSpans?: TraceSpan[] + endedAt?: string + totalDurationMs?: number + errorMessage: string + isError: boolean + }): Promise { + logger.warn( + `[${this.requestId || 'unknown'}] Logging completion failed for execution ${this.executionId} - attempting cost-only fallback` + ) + + try { + const costSummary = params.traceSpans?.length + ? calculateCostSummary(params.traceSpans) + : { + totalCost: BASE_EXECUTION_CHARGE, + totalInputCost: 0, + totalOutputCost: 0, + totalTokens: 0, + totalPromptTokens: 0, + totalCompletionTokens: 0, + baseExecutionCharge: BASE_EXECUTION_CHARGE, + modelCost: 0, + models: {}, + } + + await executionLogger.completeWorkflowExecution({ + executionId: this.executionId, + endedAt: params.endedAt || new Date().toISOString(), + totalDurationMs: params.totalDurationMs || 0, + costSummary, + finalOutput: { _fallback: true, error: params.errorMessage }, + traceSpans: [], + isResume: this.isResume, + level: params.isError ? 'error' : 'info', + }) + + logger.info( + `[${this.requestId || 'unknown'}] Cost-only fallback succeeded for execution ${this.executionId}` + ) + } catch (fallbackError) { + logger.error( + `[${this.requestId || 'unknown'}] Cost-only fallback also failed for execution ${this.executionId}:`, + { error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError) } ) } } diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 179df6e0b..9db3098e9 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -479,8 +479,16 @@ export async function transformBlockTool( const llmSchema = await createLLMToolSchema(toolConfig, userProvidedParams) + // Create unique tool ID by appending resource ID for multi-instance tools + let uniqueToolId = toolConfig.id + if (toolId === 'workflow_executor' && userProvidedParams.workflowId) { + uniqueToolId = `${toolConfig.id}_${userProvidedParams.workflowId}` + } else if (toolId.startsWith('knowledge_') && userProvidedParams.knowledgeBaseId) { + uniqueToolId = `${toolConfig.id}_${userProvidedParams.knowledgeBaseId}` + } + return { - id: toolConfig.id, + id: uniqueToolId, name: toolConfig.name, description: toolConfig.description, params: userProvidedParams, diff --git a/apps/sim/stores/knowledge/store.ts b/apps/sim/stores/knowledge/store.ts index 3bc0492e4..528b33142 100644 --- a/apps/sim/stores/knowledge/store.ts +++ b/apps/sim/stores/knowledge/store.ts @@ -44,7 +44,7 @@ export interface DocumentData { processingError?: string | null enabled: boolean uploadedAt: string - // Document tags + // Text tags tag1?: string | null tag2?: string | null tag3?: string | null @@ -52,6 +52,19 @@ export interface DocumentData { tag5?: string | null tag6?: string | null tag7?: string | null + // Number tags (5 slots) + number1?: number | null + number2?: number | null + number3?: number | null + number4?: number | null + number5?: number | null + // Date tags (2 slots) + date1?: string | null + date2?: string | null + // Boolean tags (3 slots) + boolean1?: boolean | null + boolean2?: boolean | null + boolean3?: boolean | null } export interface ChunkData { @@ -63,6 +76,7 @@ export interface ChunkData { enabled: boolean startOffset: number endOffset: number + // Text tags tag1?: string | null tag2?: string | null tag3?: string | null @@ -70,6 +84,19 @@ export interface ChunkData { tag5?: string | null tag6?: string | null tag7?: string | null + // Number tags (5 slots) + number1?: number | null + number2?: number | null + number3?: number | null + number4?: number | null + number5?: number | null + // Date tags (2 slots) + date1?: string | null + date2?: string | null + // Boolean tags (3 slots) + boolean1?: boolean | null + boolean2?: boolean | null + boolean3?: boolean | null createdAt: string updatedAt: string } diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index 355741c42..ec437d796 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -80,7 +80,7 @@ export const useTerminalConsoleStore = create()( return { entries: state.entries } } - // Redact API keys from output + // Redact API keys from output and input const redactedEntry = { ...entry } if ( !isStreamingOutput(entry.output) && @@ -89,6 +89,9 @@ export const useTerminalConsoleStore = create()( ) { redactedEntry.output = redactApiKeys(redactedEntry.output) } + if (redactedEntry.input && typeof redactedEntry.input === 'object') { + redactedEntry.input = redactApiKeys(redactedEntry.input) + } // Create new entry with ID and timestamp const newEntry: ConsoleEntry = { @@ -275,12 +278,17 @@ export const useTerminalConsoleStore = create()( } if (update.replaceOutput !== undefined) { - updatedEntry.output = update.replaceOutput + updatedEntry.output = + typeof update.replaceOutput === 'object' && update.replaceOutput !== null + ? redactApiKeys(update.replaceOutput) + : update.replaceOutput } else if (update.output !== undefined) { - updatedEntry.output = { + const mergedOutput = { ...(entry.output || {}), ...update.output, } + updatedEntry.output = + typeof mergedOutput === 'object' ? redactApiKeys(mergedOutput) : mergedOutput } if (update.error !== undefined) { @@ -304,7 +312,10 @@ export const useTerminalConsoleStore = create()( } if (update.input !== undefined) { - updatedEntry.input = update.input + updatedEntry.input = + typeof update.input === 'object' && update.input !== null + ? redactApiKeys(update.input) + : update.input } return updatedEntry diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 34c242b3d..b4898a686 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -16,6 +16,26 @@ import { const logger = createLogger('Tools') +/** + * Normalizes a tool ID by stripping resource ID suffix (UUID). + * Workflow tools: 'workflow_executor_' -> 'workflow_executor' + * Knowledge tools: 'knowledge_search_' -> 'knowledge_search' + */ +function normalizeToolId(toolId: string): string { + // Check for workflow_executor_ pattern + if (toolId.startsWith('workflow_executor_') && toolId.length > 'workflow_executor_'.length) { + return 'workflow_executor' + } + // Check for knowledge__ pattern + const knowledgeOps = ['knowledge_search', 'knowledge_upload_chunk', 'knowledge_create_document'] + for (const op of knowledgeOps) { + if (toolId.startsWith(`${op}_`) && toolId.length > op.length + 1) { + return op + } + } + return toolId +} + /** * Maximum request body size in bytes before we warn/error about size limits. * Next.js 16 has a default middleware/proxy body limit of 10MB. @@ -186,20 +206,29 @@ export async function executeTool( try { let tool: ToolConfig | undefined + // Normalize tool ID to strip resource suffixes (e.g., workflow_executor_ -> workflow_executor) + const normalizedToolId = normalizeToolId(toolId) + // If it's a custom tool, use the async version with workflowId - if (toolId.startsWith('custom_')) { + if (normalizedToolId.startsWith('custom_')) { const workflowId = params._context?.workflowId - tool = await getToolAsync(toolId, workflowId) + tool = await getToolAsync(normalizedToolId, workflowId) if (!tool) { - logger.error(`[${requestId}] Custom tool not found: ${toolId}`) + logger.error(`[${requestId}] Custom tool not found: ${normalizedToolId}`) } - } else if (toolId.startsWith('mcp-')) { - return await executeMcpTool(toolId, params, executionContext, requestId, startTimeISO) + } else if (normalizedToolId.startsWith('mcp-')) { + return await executeMcpTool( + normalizedToolId, + params, + executionContext, + requestId, + startTimeISO + ) } else { // For built-in tools, use the synchronous version - tool = getTool(toolId) + tool = getTool(normalizedToolId) if (!tool) { - logger.error(`[${requestId}] Built-in tool not found: ${toolId}`) + logger.error(`[${requestId}] Built-in tool not found: ${normalizedToolId}`) } } diff --git a/apps/sim/tools/knowledge/search.ts b/apps/sim/tools/knowledge/search.ts index a4bd4d892..aee78f764 100644 --- a/apps/sim/tools/knowledge/search.ts +++ b/apps/sim/tools/knowledge/search.ts @@ -1,3 +1,4 @@ +import type { StructuredFilter } from '@/lib/knowledge/types' import type { KnowledgeSearchResponse } from '@/tools/knowledge/types' import type { ToolConfig } from '@/tools/types' @@ -53,8 +54,8 @@ export const knowledgeSearchTool: ToolConfig = { // Use single knowledge base ID const knowledgeBaseIds = [params.knowledgeBaseId] - // Parse dynamic tag filters and send display names to API - const filters: Record = {} + // Parse dynamic tag filters + let structuredFilters: StructuredFilter[] = [] if (params.tagFilters) { let tagFilters = params.tagFilters @@ -62,27 +63,29 @@ export const knowledgeSearchTool: ToolConfig = { if (typeof tagFilters === 'string') { try { tagFilters = JSON.parse(tagFilters) - } catch (error) { + } catch { tagFilters = [] } } if (Array.isArray(tagFilters)) { - // Group filters by tag name for OR logic within same tag - const groupedFilters: Record = {} - tagFilters.forEach((filter: any) => { - if (filter.tagName && filter.tagValue && filter.tagValue.trim().length > 0) { - if (!groupedFilters[filter.tagName]) { - groupedFilters[filter.tagName] = [] + // Send full filter objects with operator support + structuredFilters = tagFilters + .filter((filter: Record) => { + // For boolean, any value is valid; for others, check for non-empty string + if (filter.fieldType === 'boolean') { + return filter.tagName && filter.tagValue !== undefined } - groupedFilters[filter.tagName].push(filter.tagValue) - } - }) - - // Convert to filters format - for now, join multiple values with OR separator - Object.entries(groupedFilters).forEach(([tagName, values]) => { - filters[tagName] = values.join('|OR|') // Use special separator for OR logic - }) + return filter.tagName && filter.tagValue && String(filter.tagValue).trim().length > 0 + }) + .map((filter: Record) => ({ + tagName: filter.tagName as string, + tagSlot: (filter.tagSlot as string) || '', // Will be resolved by API from tagName + fieldType: (filter.fieldType as string) || 'text', + operator: (filter.operator as string) || 'eq', + value: filter.tagValue as string | number | boolean, + valueTo: filter.valueTo as string | number | undefined, + })) } } @@ -90,7 +93,7 @@ export const knowledgeSearchTool: ToolConfig = { knowledgeBaseIds, query: params.query, topK: params.topK ? Math.max(1, Math.min(100, Number(params.topK))) : 10, - ...(Object.keys(filters).length > 0 && { filters }), + ...(structuredFilters.length > 0 && { tagFilters: structuredFilters }), ...(workflowId && { workflowId }), } diff --git a/bun.lock b/bun.lock index c41820447..e5af8d82d 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ "drizzle-kit": "^0.31.4", "husky": "9.1.7", "lint-staged": "16.0.0", - "turbo": "2.6.3", + "turbo": "2.7.0", }, }, "apps/docs": { @@ -3303,19 +3303,19 @@ "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - "turbo": ["turbo@2.6.3", "", { "optionalDependencies": { "turbo-darwin-64": "2.6.3", "turbo-darwin-arm64": "2.6.3", "turbo-linux-64": "2.6.3", "turbo-linux-arm64": "2.6.3", "turbo-windows-64": "2.6.3", "turbo-windows-arm64": "2.6.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bf6YKUv11l5Xfcmg76PyWoy/e2vbkkxFNBGJSnfdSXQC33ZiUfutYh6IXidc5MhsnrFkWfdNNLyaRk+kHMLlwA=="], + "turbo": ["turbo@2.7.0", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.0", "turbo-darwin-arm64": "2.7.0", "turbo-linux-64": "2.7.0", "turbo-linux-arm64": "2.7.0", "turbo-windows-64": "2.7.0", "turbo-windows-arm64": "2.7.0" }, "bin": { "turbo": "bin/turbo" } }, "sha512-1dUGwi6cSSVZts1BwJa/Gh7w5dPNNGsNWZEAuRKxXWME44hTKWpQZrgiPnqMc5jJJOovzPK5N6tL+PHYRYL5Wg=="], - "turbo-darwin-64": ["turbo-darwin-64@2.6.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlJJDc1CQ7SK5Y5qnl7AzpkvKSnpkfPmnA+HeU/sgny3oHZckPV2776ebO2M33CYDSor7+8HQwaodY++IINhYg=="], + "turbo-darwin-64": ["turbo-darwin-64@2.7.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-gwqL7cJOSYrV/jNmhXM8a2uzSFn7GcUASOuen6OgmUsafUj9SSWcgXZ/q0w9hRoL917hpidkdI//UpbxbZbwwg=="], - "turbo-darwin-arm64": ["turbo-darwin-arm64@2.6.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MwVt7rBKiOK7zdYerenfCRTypefw4kZCue35IJga9CH1+S50+KTiCkT6LBqo0hHeoH2iKuI0ldTF2a0aB72z3w=="], + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-f3F5DYOnfE6lR6v/rSld7QGZgartKsnlIYY7jcF/AA7Wz27za9XjxMHzb+3i4pvRhAkryFgf2TNq7eCFrzyTpg=="], - "turbo-linux-64": ["turbo-linux-64@2.6.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cqpcw+dXxbnPtNnzeeSyWprjmuFVpHJqKcs7Jym5oXlu/ZcovEASUIUZVN3OGEM6Y/OTyyw0z09tOHNt5yBAVg=="], + "turbo-linux-64": ["turbo-linux-64@2.7.0", "", { "os": "linux", "cpu": "x64" }, "sha512-KsC+UuKlhjCL+lom10/IYoxUsdhJOsuEki72YSr7WGYUSRihcdJQnaUyIDTlm0nPOb+gVihVNBuVP4KsNg1UnA=="], - "turbo-linux-arm64": ["turbo-linux-arm64@2.6.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-MterpZQmjXyr4uM7zOgFSFL3oRdNKeflY7nsjxJb2TklsYqiu3Z9pQ4zRVFFH8n0mLGna7MbQMZuKoWqqHb45w=="], + "turbo-linux-arm64": ["turbo-linux-arm64@2.7.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1tjIYULeJtpmE/ovoI9qPBFJCtUEM7mYfeIMOIs4bXR6t/8u+rHPwr3j+vRHcXanIc42V1n3Pz52VqmJtIAviw=="], - "turbo-windows-64": ["turbo-windows-64@2.6.3", "", { "os": "win32", "cpu": "x64" }, "sha512-biDU70v9dLwnBdLf+daoDlNJVvqOOP8YEjqNipBHzgclbQlXbsi6Gqqelp5er81Qo3BiRgmTNx79oaZQTPb07Q=="], + "turbo-windows-64": ["turbo-windows-64@2.7.0", "", { "os": "win32", "cpu": "x64" }, "sha512-KThkAeax46XiH+qICCQm7R8V2pPdeTTP7ArCSRrSLqnlO75ftNm8Ljx4VAllwIZkILrq/GDM8PlyhZdPeUdDxQ=="], - "turbo-windows-arm64": ["turbo-windows-arm64@2.6.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-dDHVKpSeukah3VsI/xMEKeTnV9V9cjlpFSUs4bmsUiLu3Yv2ENlgVEZv65wxbeE0bh0jjpmElDT+P1KaCxArQQ=="], + "turbo-windows-arm64": ["turbo-windows-arm64@2.7.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-kzI6rsQ3Ejs+CkM9HEEP3Z4h5YMCRxwIlQXFQmgXSG3BIgorCkRF2Xr7iQ2i9AGwY/6jbiAYeJbvi3yCp+noFw=="], "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], diff --git a/package.json b/package.json index ff8685c5a..34d8de099 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "drizzle-kit": "^0.31.4", "husky": "9.1.7", "lint-staged": "16.0.0", - "turbo": "2.6.3" + "turbo": "2.7.0" }, "lint-staged": { "*.{js,jsx,ts,tsx,json,css,scss}": [ diff --git a/packages/db/constants.ts b/packages/db/constants.ts index 39bd76a57..536ae1d71 100644 --- a/packages/db/constants.ts +++ b/packages/db/constants.ts @@ -18,11 +18,56 @@ export const DEFAULT_TEAM_STORAGE_LIMIT_GB = 500 export const DEFAULT_ENTERPRISE_STORAGE_LIMIT_GB = 500 /** - * Tag slots available for knowledge base documents and embeddings + * Text tag slots for knowledge base documents and embeddings */ -export const TAG_SLOTS = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const +export const TEXT_TAG_SLOTS = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const /** - * Type for tag slot names + * Number tag slots for knowledge base documents and embeddings (5 slots) + */ +export const NUMBER_TAG_SLOTS = ['number1', 'number2', 'number3', 'number4', 'number5'] as const + +/** + * Date tag slots for knowledge base documents and embeddings (2 slots) + */ +export const DATE_TAG_SLOTS = ['date1', 'date2'] as const + +/** + * Boolean tag slots for knowledge base documents and embeddings (3 slots) + */ +export const BOOLEAN_TAG_SLOTS = ['boolean1', 'boolean2', 'boolean3'] as const + +/** + * All tag slots combined (for backwards compatibility) + */ +export const TAG_SLOTS = [ + ...TEXT_TAG_SLOTS, + ...NUMBER_TAG_SLOTS, + ...DATE_TAG_SLOTS, + ...BOOLEAN_TAG_SLOTS, +] as const + +/** + * Type for all tag slot names */ export type TagSlot = (typeof TAG_SLOTS)[number] + +/** + * Type for text tag slot names + */ +export type TextTagSlot = (typeof TEXT_TAG_SLOTS)[number] + +/** + * Type for number tag slot names + */ +export type NumberTagSlot = (typeof NUMBER_TAG_SLOTS)[number] + +/** + * Type for date tag slot names + */ +export type DateTagSlot = (typeof DATE_TAG_SLOTS)[number] + +/** + * Type for boolean tag slot names + */ +export type BooleanTagSlot = (typeof BOOLEAN_TAG_SLOTS)[number] diff --git a/packages/db/migrations/0126_dapper_midnight.sql b/packages/db/migrations/0126_dapper_midnight.sql new file mode 100644 index 000000000..64a38c8b4 --- /dev/null +++ b/packages/db/migrations/0126_dapper_midnight.sql @@ -0,0 +1,40 @@ +ALTER TABLE "document" ADD COLUMN "number1" double precision;--> statement-breakpoint +ALTER TABLE "document" ADD COLUMN "number2" double precision;--> statement-breakpoint +ALTER TABLE "document" ADD COLUMN "number3" double precision;--> statement-breakpoint +ALTER TABLE "document" ADD COLUMN "number4" double precision;--> statement-breakpoint +ALTER TABLE "document" ADD COLUMN "number5" double precision;--> statement-breakpoint +ALTER TABLE "document" ADD COLUMN "date1" timestamp;--> statement-breakpoint +ALTER TABLE "document" ADD COLUMN "date2" timestamp;--> statement-breakpoint +ALTER TABLE "document" ADD COLUMN "boolean1" boolean;--> statement-breakpoint +ALTER TABLE "document" ADD COLUMN "boolean2" boolean;--> statement-breakpoint +ALTER TABLE "document" ADD COLUMN "boolean3" boolean;--> statement-breakpoint +ALTER TABLE "embedding" ADD COLUMN "number1" double precision;--> statement-breakpoint +ALTER TABLE "embedding" ADD COLUMN "number2" double precision;--> statement-breakpoint +ALTER TABLE "embedding" ADD COLUMN "number3" double precision;--> statement-breakpoint +ALTER TABLE "embedding" ADD COLUMN "number4" double precision;--> statement-breakpoint +ALTER TABLE "embedding" ADD COLUMN "number5" double precision;--> statement-breakpoint +ALTER TABLE "embedding" ADD COLUMN "date1" timestamp;--> statement-breakpoint +ALTER TABLE "embedding" ADD COLUMN "date2" timestamp;--> statement-breakpoint +ALTER TABLE "embedding" ADD COLUMN "boolean1" boolean;--> statement-breakpoint +ALTER TABLE "embedding" ADD COLUMN "boolean2" boolean;--> statement-breakpoint +ALTER TABLE "embedding" ADD COLUMN "boolean3" boolean;--> statement-breakpoint +CREATE INDEX "doc_number1_idx" ON "document" USING btree ("number1");--> statement-breakpoint +CREATE INDEX "doc_number2_idx" ON "document" USING btree ("number2");--> statement-breakpoint +CREATE INDEX "doc_number3_idx" ON "document" USING btree ("number3");--> statement-breakpoint +CREATE INDEX "doc_number4_idx" ON "document" USING btree ("number4");--> statement-breakpoint +CREATE INDEX "doc_number5_idx" ON "document" USING btree ("number5");--> statement-breakpoint +CREATE INDEX "doc_date1_idx" ON "document" USING btree ("date1");--> statement-breakpoint +CREATE INDEX "doc_date2_idx" ON "document" USING btree ("date2");--> statement-breakpoint +CREATE INDEX "doc_boolean1_idx" ON "document" USING btree ("boolean1");--> statement-breakpoint +CREATE INDEX "doc_boolean2_idx" ON "document" USING btree ("boolean2");--> statement-breakpoint +CREATE INDEX "doc_boolean3_idx" ON "document" USING btree ("boolean3");--> statement-breakpoint +CREATE INDEX "emb_number1_idx" ON "embedding" USING btree ("number1");--> statement-breakpoint +CREATE INDEX "emb_number2_idx" ON "embedding" USING btree ("number2");--> statement-breakpoint +CREATE INDEX "emb_number3_idx" ON "embedding" USING btree ("number3");--> statement-breakpoint +CREATE INDEX "emb_number4_idx" ON "embedding" USING btree ("number4");--> statement-breakpoint +CREATE INDEX "emb_number5_idx" ON "embedding" USING btree ("number5");--> statement-breakpoint +CREATE INDEX "emb_date1_idx" ON "embedding" USING btree ("date1");--> statement-breakpoint +CREATE INDEX "emb_date2_idx" ON "embedding" USING btree ("date2");--> statement-breakpoint +CREATE INDEX "emb_boolean1_idx" ON "embedding" USING btree ("boolean1");--> statement-breakpoint +CREATE INDEX "emb_boolean2_idx" ON "embedding" USING btree ("boolean2");--> statement-breakpoint +CREATE INDEX "emb_boolean3_idx" ON "embedding" USING btree ("boolean3"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0126_snapshot.json b/packages/db/migrations/meta/0126_snapshot.json new file mode 100644 index 000000000..27102ff8b --- /dev/null +++ b/packages/db/migrations/meta/0126_snapshot.json @@ -0,0 +1,8221 @@ +{ + "id": "28e1455e-42b7-4d72-b40c-ce264247bc67", + "prevId": "3764bc2d-e560-47d6-a827-c07936980298", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_user_provider_account_unique": { + "name": "account_user_provider_account_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_namespace_unique": { + "name": "idempotency_key_namespace_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idempotency_key_namespace_idx": { + "name": "idempotency_key_namespace_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_idx": { + "name": "mcp_servers_workspace_deleted_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_workflow_idx": { + "name": "memory_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workflow_key_idx": { + "name": "memory_workflow_key_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workflow_id_workflow_id_fk": { + "name": "memory_workflow_id_workflow_id_fk", + "tableFrom": "memory", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.template_creators": { + "name": "template_creators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reference_type": { + "name": "reference_type", + "type": "template_creator_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_creators_reference_idx": { + "name": "template_creators_reference_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_reference_id_idx": { + "name": "template_creators_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_created_by_idx": { + "name": "template_creators_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_creators_created_by_user_id_fk": { + "name": "template_creators_created_by_user_id_fk", + "tableFrom": "template_creators", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "template_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "required_credentials": { + "name": "required_credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "og_image_url": { + "name": "og_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "templates_status_idx": { + "name": "templates_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_creator_id_idx": { + "name": "templates_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_views_idx": { + "name": "templates_status_views_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_stars_idx": { + "name": "templates_status_stars_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "templates_creator_id_template_creators_id_fk": { + "name": "templates_creator_id_template_creators_id_fk", + "tableFrom": "templates", + "tableTo": "template_creators", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_super_user": { + "name": "is_super_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'10'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_idx": { + "name": "path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id": { + "name": "idx_webhook_on_workflow_id_block_id", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_block_id_workflow_blocks_id_fk": { + "name": "webhook_block_id_workflow_blocks_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_blocks", + "columnsFrom": ["block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "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": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_unique": { + "name": "workflow_schedule_workflow_block_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_block_id_workflow_blocks_id_fk": { + "name": "workflow_schedule_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_blocks", + "columnsFrom": ["block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_files_key_unique": { + "name": "workspace_files_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitation": { + "name": "workspace_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "workspace_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "org_invitation_id": { + "name": "org_invitation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invitation_workspace_id_workspace_id_fk": { + "name": "workspace_invitation_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitation_inviter_id_user_id_fk": { + "name": "workspace_invitation_inviter_id_user_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitation_token_unique": { + "name": "workspace_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.template_creator_type": { + "name": "template_creator_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.template_status": { + "name": "template_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.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 798e59204..b3dd13337 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -876,6 +876,13 @@ "when": 1766133598113, "tag": "0125_eager_lily_hollister", "breakpoints": true + }, + { + "idx": 126, + "version": "7", + "when": 1766203036010, + "tag": "0126_dapper_midnight", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 4c41490ed..724269fca 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -5,6 +5,7 @@ import { check, customType, decimal, + doublePrecision, index, integer, json, @@ -1047,6 +1048,7 @@ export const document = pgTable( deletedAt: timestamp('deleted_at'), // Soft delete // Document tags for filtering (inherited by all chunks) + // Text tags (7 slots) tag1: text('tag1'), tag2: text('tag2'), tag3: text('tag3'), @@ -1054,6 +1056,19 @@ export const document = pgTable( tag5: text('tag5'), tag6: text('tag6'), tag7: text('tag7'), + // Number tags (5 slots) + number1: doublePrecision('number1'), + number2: doublePrecision('number2'), + number3: doublePrecision('number3'), + number4: doublePrecision('number4'), + number5: doublePrecision('number5'), + // Date tags (2 slots) + date1: timestamp('date1'), + date2: timestamp('date2'), + // Boolean tags (3 slots) + boolean1: boolean('boolean1'), + boolean2: boolean('boolean2'), + boolean3: boolean('boolean3'), // Timestamps uploadedAt: timestamp('uploaded_at').notNull().defaultNow(), @@ -1068,7 +1083,7 @@ export const document = pgTable( table.knowledgeBaseId, table.processingStatus ), - // Tag indexes for filtering + // Text tag indexes tag1Idx: index('doc_tag1_idx').on(table.tag1), tag2Idx: index('doc_tag2_idx').on(table.tag2), tag3Idx: index('doc_tag3_idx').on(table.tag3), @@ -1076,6 +1091,19 @@ export const document = pgTable( tag5Idx: index('doc_tag5_idx').on(table.tag5), tag6Idx: index('doc_tag6_idx').on(table.tag6), tag7Idx: index('doc_tag7_idx').on(table.tag7), + // Number tag indexes (5 slots) + number1Idx: index('doc_number1_idx').on(table.number1), + number2Idx: index('doc_number2_idx').on(table.number2), + number3Idx: index('doc_number3_idx').on(table.number3), + number4Idx: index('doc_number4_idx').on(table.number4), + number5Idx: index('doc_number5_idx').on(table.number5), + // Date tag indexes (2 slots) + date1Idx: index('doc_date1_idx').on(table.date1), + date2Idx: index('doc_date2_idx').on(table.date2), + // Boolean tag indexes (3 slots) + boolean1Idx: index('doc_boolean1_idx').on(table.boolean1), + boolean2Idx: index('doc_boolean2_idx').on(table.boolean2), + boolean3Idx: index('doc_boolean3_idx').on(table.boolean3), }) ) @@ -1137,6 +1165,7 @@ export const embedding = pgTable( endOffset: integer('end_offset').notNull(), // Tag columns inherited from document for efficient filtering + // Text tags (7 slots) tag1: text('tag1'), tag2: text('tag2'), tag3: text('tag3'), @@ -1144,6 +1173,19 @@ export const embedding = pgTable( tag5: text('tag5'), tag6: text('tag6'), tag7: text('tag7'), + // Number tags (5 slots) + number1: doublePrecision('number1'), + number2: doublePrecision('number2'), + number3: doublePrecision('number3'), + number4: doublePrecision('number4'), + number5: doublePrecision('number5'), + // Date tags (2 slots) + date1: timestamp('date1'), + date2: timestamp('date2'), + // Boolean tags (3 slots) + boolean1: boolean('boolean1'), + boolean2: boolean('boolean2'), + boolean3: boolean('boolean3'), // Chunk state - enable/disable from knowledge base enabled: boolean('enabled').notNull().default(true), @@ -1182,7 +1224,7 @@ export const embedding = pgTable( ef_construction: 64, }), - // Tag indexes for efficient filtering + // Text tag indexes tag1Idx: index('emb_tag1_idx').on(table.tag1), tag2Idx: index('emb_tag2_idx').on(table.tag2), tag3Idx: index('emb_tag3_idx').on(table.tag3), @@ -1190,6 +1232,19 @@ export const embedding = pgTable( tag5Idx: index('emb_tag5_idx').on(table.tag5), tag6Idx: index('emb_tag6_idx').on(table.tag6), tag7Idx: index('emb_tag7_idx').on(table.tag7), + // Number tag indexes (5 slots) + number1Idx: index('emb_number1_idx').on(table.number1), + number2Idx: index('emb_number2_idx').on(table.number2), + number3Idx: index('emb_number3_idx').on(table.number3), + number4Idx: index('emb_number4_idx').on(table.number4), + number5Idx: index('emb_number5_idx').on(table.number5), + // Date tag indexes (2 slots) + date1Idx: index('emb_date1_idx').on(table.date1), + date2Idx: index('emb_date2_idx').on(table.date2), + // Boolean tag indexes (3 slots) + boolean1Idx: index('emb_boolean1_idx').on(table.boolean1), + boolean2Idx: index('emb_boolean2_idx').on(table.boolean2), + boolean3Idx: index('emb_boolean3_idx').on(table.boolean3), // Full-text search index contentFtsIdx: index('emb_content_fts_idx').using('gin', table.contentTsv),