v0.3.32: loop block max increase, url-encoded API calls, subflow logs, new supabase tools

This commit is contained in:
Waleed Latif
2025-08-20 00:36:46 -07:00
committed by GitHub
69 changed files with 1326 additions and 617 deletions

View File

@@ -142,7 +142,7 @@ Get a single row from a Supabase table based on filter criteria
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `results` | object | The row data if found, null if not found |
| `results` | array | Array containing the row data if found, empty array if not found |
### `supabase_update`
@@ -185,6 +185,26 @@ Delete rows from a Supabase table based on filter criteria
| `message` | string | Operation status message |
| `results` | array | Array of deleted records |
### `supabase_upsert`
Insert or update data in a Supabase table (upsert operation)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) |
| `table` | string | Yes | The name of the Supabase table to upsert data into |
| `data` | any | Yes | The data to upsert \(insert or update\) |
| `apiKey` | string | Yes | Your Supabase service role secret key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `results` | array | Array of upserted records |
## Notes

View File

@@ -45,7 +45,7 @@ export async function GET(request: NextRequest) {
// Fetch the file from Google Drive API
logger.info(`[${requestId}] Fetching file ${fileId} from Google Drive API`)
const response = await fetch(
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks`,
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks,shortcutDetails&supportsAllDrives=true`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
@@ -77,6 +77,34 @@ export async function GET(request: NextRequest) {
'application/vnd.google-apps.presentation': 'application/pdf', // Google Slides to PDF
}
// Resolve shortcuts transparently for UI stability
if (
file.mimeType === 'application/vnd.google-apps.shortcut' &&
file.shortcutDetails?.targetId
) {
const targetId = file.shortcutDetails.targetId
const shortcutResp = await fetch(
`https://www.googleapis.com/drive/v3/files/${targetId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks&supportsAllDrives=true`,
{
headers: { Authorization: `Bearer ${accessToken}` },
}
)
if (shortcutResp.ok) {
const targetFile = await shortcutResp.json()
file.id = targetFile.id
file.name = targetFile.name
file.mimeType = targetFile.mimeType
file.iconLink = targetFile.iconLink
file.webViewLink = targetFile.webViewLink
file.thumbnailLink = targetFile.thumbnailLink
file.createdTime = targetFile.createdTime
file.modifiedTime = targetFile.modifiedTime
file.size = targetFile.size
file.owners = targetFile.owners
file.exportLinks = targetFile.exportLinks
}
}
// If the file is a Google Docs, Sheets, or Slides file, we need to provide the export link
if (file.mimeType.startsWith('application/vnd.google-apps.')) {
const format = exportFormats[file.mimeType] || 'application/pdf'

View File

@@ -1,10 +1,8 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -32,64 +30,48 @@ export async function GET(request: NextRequest) {
const credentialId = searchParams.get('credentialId')
const mimeType = searchParams.get('mimeType')
const query = searchParams.get('query') || ''
const folderId = searchParams.get('folderId') || searchParams.get('parentId') || ''
const workflowId = searchParams.get('workflowId') || undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credential ID`)
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Check if the credential belongs to the user
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
// Authorize use of the credential (supports collaborator credentials via workflow)
const authz = await authorizeCredentialUse(request, { credentialId: credentialId!, workflowId })
if (!authz.ok || !authz.credentialOwnerUserId) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, authz)
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
credentialId!,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Build the query parameters for Google Drive API
let queryParams = 'trashed=false'
// Add mimeType filter if provided
// Build Drive 'q' expression safely
const qParts: string[] = ['trashed = false']
if (folderId) {
qParts.push(`'${folderId.replace(/'/g, "\\'")}' in parents`)
}
if (mimeType) {
// For Google Drive API, we need to use 'q' parameter for mimeType filtering
// Instead of using the mimeType parameter directly, we'll add it to the query
if (queryParams.includes('q=')) {
queryParams += ` and mimeType='${mimeType}'`
} else {
queryParams += `&q=mimeType='${mimeType}'`
}
qParts.push(`mimeType = '${mimeType.replace(/'/g, "\\'")}'`)
}
// Add search query if provided
if (query) {
if (queryParams.includes('q=')) {
queryParams += ` and name contains '${query}'`
} else {
queryParams += `&q=name contains '${query}'`
}
qParts.push(`name contains '${query.replace(/'/g, "\\'")}'`)
}
const q = encodeURIComponent(qParts.join(' and '))
// Fetch files from Google Drive API
// Fetch files from Google Drive API with shared drives support
const response = await fetch(
`https://www.googleapis.com/drive/v3/files?${queryParams}&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners)`,
`https://www.googleapis.com/drive/v3/files?q=${q}&supportsAllDrives=true&includeItemsFromAllDrives=true&spaces=drive&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,parents)`,
{
headers: {
Authorization: `Bearer ${accessToken}`,

View File

@@ -329,7 +329,7 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
try {
const { configureGmailPolling } = await import('@/lib/webhooks/utils')
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
const success = await configureGmailPolling(workflowRecord.userId, savedWebhook, requestId)
if (!success) {
@@ -364,7 +364,7 @@ export async function POST(request: NextRequest) {
)
try {
const { configureOutlookPolling } = await import('@/lib/webhooks/utils')
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
const success = await configureOutlookPolling(
workflowRecord.userId,
savedWebhook,

View File

@@ -82,14 +82,21 @@ function transformBlockData(data: any, blockType: string, isInput: boolean) {
interface CollapsibleInputOutputProps {
span: TraceSpan
spanId: string
depth: number
}
function CollapsibleInputOutput({ span, spanId }: CollapsibleInputOutputProps) {
function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInputOutputProps) {
const [inputExpanded, setInputExpanded] = useState(false)
const [outputExpanded, setOutputExpanded] = useState(false)
// Calculate the left margin based on depth to match the parent span's indentation
const leftMargin = depth * 16 + 8 + 24 // Base depth indentation + icon width + extra padding
return (
<div className='mt-2 mr-4 mb-4 ml-8 space-y-3 overflow-hidden'>
<div
className='mt-2 mr-4 mb-4 space-y-3 overflow-hidden'
style={{ marginLeft: `${leftMargin}px` }}
>
{/* Input Data - Collapsible */}
{span.input && (
<div>
@@ -162,26 +169,30 @@ function BlockDataDisplay({
if (value === undefined) return <span className='text-muted-foreground italic'>undefined</span>
if (typeof value === 'string') {
return <span className='break-all text-green-700 dark:text-green-400'>"{value}"</span>
return <span className='break-all text-emerald-700 dark:text-emerald-400'>"{value}"</span>
}
if (typeof value === 'number') {
return <span className='text-blue-700 dark:text-blue-400'>{value}</span>
return <span className='font-mono text-blue-700 dark:text-blue-400'>{value}</span>
}
if (typeof value === 'boolean') {
return <span className='text-purple-700 dark:text-purple-400'>{value.toString()}</span>
return (
<span className='font-mono text-amber-700 dark:text-amber-400'>{value.toString()}</span>
)
}
if (Array.isArray(value)) {
if (value.length === 0) return <span className='text-muted-foreground'>[]</span>
return (
<div className='space-y-1'>
<div className='space-y-0.5'>
<span className='text-muted-foreground'>[</span>
<div className='ml-4 space-y-1'>
<div className='ml-2 space-y-0.5'>
{value.map((item, index) => (
<div key={index} className='flex min-w-0 gap-2'>
<span className='flex-shrink-0 text-muted-foreground text-xs'>{index}:</span>
<div key={index} className='flex min-w-0 gap-1.5'>
<span className='flex-shrink-0 font-mono text-slate-600 text-xs dark:text-slate-400'>
{index}:
</span>
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(item)}</div>
</div>
))}
@@ -196,10 +207,10 @@ function BlockDataDisplay({
if (entries.length === 0) return <span className='text-muted-foreground'>{'{}'}</span>
return (
<div className='space-y-1'>
<div className='space-y-0.5'>
{entries.map(([objKey, objValue]) => (
<div key={objKey} className='flex min-w-0 gap-2'>
<span className='flex-shrink-0 font-medium text-orange-700 dark:text-orange-400'>
<div key={objKey} className='flex min-w-0 gap-1.5'>
<span className='flex-shrink-0 font-medium text-indigo-700 dark:text-indigo-400'>
{objKey}:
</span>
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(objValue, objKey)}</div>
@@ -227,12 +238,12 @@ function BlockDataDisplay({
{transformedData &&
Object.keys(transformedData).filter((key) => key !== 'error' && key !== 'success')
.length > 0 && (
<div className='space-y-1'>
<div className='space-y-0.5'>
{Object.entries(transformedData)
.filter(([key]) => key !== 'error' && key !== 'success')
.map(([key, value]) => (
<div key={key} className='flex gap-2'>
<span className='font-medium text-orange-700 dark:text-orange-400'>{key}:</span>
<div key={key} className='flex gap-1.5'>
<span className='font-medium text-indigo-700 dark:text-indigo-400'>{key}:</span>
{renderValue(value, key)}
</div>
))}
@@ -592,7 +603,9 @@ function TraceSpanItem({
{expanded && (
<div>
{/* Block Input/Output Data - Collapsible */}
{(span.input || span.output) && <CollapsibleInputOutput span={span} spanId={spanId} />}
{(span.input || span.output) && (
<CollapsibleInputOutput span={span} spanId={spanId} depth={depth} />
)}
{/* Children and tool calls */}
{/* Render child spans */}

View File

@@ -6,9 +6,9 @@ import {
type SlackChannelInfo,
SlackChannelSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
interface ChannelSelectorInputProps {
blockId: string
@@ -29,8 +29,6 @@ export function ChannelSelectorInput({
isPreview = false,
previewValue,
}: ChannelSelectorInputProps) {
const { getValue } = useSubBlockStore()
// Use the proper hook to get the current value and setter (same as file-selector)
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
// Reactive upstream fields
@@ -43,6 +41,8 @@ export function ChannelSelectorInput({
// Get provider-specific values
const provider = subBlock.provider || 'slack'
const isSlack = provider === 'slack'
// Central dependsOn gating
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
// Get the credential for the provider - use provided credential or fall back to reactive values
let credential: string
@@ -89,15 +89,10 @@ export function ChannelSelectorInput({
}}
credential={credential}
label={subBlock.placeholder || 'Select Slack channel'}
disabled={disabled || !credential}
disabled={finalDisabled}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select a Slack account or enter a bot token first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)

View File

@@ -26,7 +26,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
const logger = createLogger('CredentialSelector')
@@ -217,17 +216,6 @@ export function CredentialSelector({
setSelectedId(credentialId)
if (!isPreview) {
setStoreValue(credentialId)
// If credential changed, clear other sub-block fields for a clean state
if (previousId && previousId !== credentialId) {
const wfId = (activeWorkflowId as string) || ''
const workflowValues = useSubBlockStore.getState().workflowValues[wfId] || {}
const blockValues = workflowValues[blockId] || {}
Object.keys(blockValues).forEach((key) => {
if (key !== subBlock.id) {
collaborativeSetSubblockValue(blockId, key, '')
}
})
}
}
setOpen(false)
}

View File

@@ -12,6 +12,7 @@ import {
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
@@ -65,6 +66,9 @@ export function DocumentSelector({
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
const isDisabled = finalDisabled
// Fetch documents for the selected knowledge base
const fetchDocuments = useCallback(async () => {
if (!knowledgeBaseId) {
@@ -103,6 +107,7 @@ export function DocumentSelector({
// Handle dropdown open/close - fetch documents when opening
const handleOpenChange = (isOpen: boolean) => {
if (isPreview) return
if (isDisabled) return
setOpen(isOpen)
@@ -124,13 +129,14 @@ export function DocumentSelector({
// Sync selected document with value prop
useEffect(() => {
if (isDisabled) return
if (value && documents.length > 0) {
const docInfo = documents.find((doc) => doc.id === value)
setSelectedDocument(docInfo || null)
} else {
setSelectedDocument(null)
}
}, [value, documents])
}, [value, documents, isDisabled])
// Reset documents when knowledge base changes
useEffect(() => {
@@ -141,10 +147,10 @@ export function DocumentSelector({
// Fetch documents when knowledge base is available
useEffect(() => {
if (knowledgeBaseId && !isPreview) {
if (knowledgeBaseId && !isPreview && !isDisabled) {
fetchDocuments()
}
}, [knowledgeBaseId, isPreview, fetchDocuments])
}, [knowledgeBaseId, isPreview, isDisabled, fetchDocuments])
const formatDocumentName = (document: DocumentData) => {
return document.filename
@@ -166,9 +172,6 @@ export function DocumentSelector({
const label = subBlock.placeholder || 'Select document'
// Show disabled state if no knowledge base is selected
const isDisabled = disabled || isPreview || !knowledgeBaseId
return (
<div className='w-full'>
<Popover open={open} onOpenChange={handleOpenChange}>

View File

@@ -376,6 +376,14 @@ export function ConfluenceFileSelector({
}
}, [value])
// Clear preview when value is cleared (e.g., collaborator cleared or domain change cascade)
useEffect(() => {
if (!value) {
setSelectedFile(null)
onFileInfoChange?.(null)
}
}, [value, onFileInfoChange])
// Handle file selection
const handleSelectFile = (file: ConfluenceFileInfo) => {
setSelectedFileId(file.id)
@@ -547,7 +555,7 @@ export function ConfluenceFileSelector({
</Popover>
{/* File preview */}
{showPreview && selectedFile && (
{showPreview && selectedFile && selectedFileId && selectedFile.id === selectedFileId && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button

View File

@@ -414,6 +414,13 @@ export function GoogleDrivePicker({
return <FileIcon className={`${iconSize} text-muted-foreground`} />
}
const canShowPreview = !!(
showPreview &&
selectedFile &&
selectedFileId &&
selectedFile.id === selectedFileId
)
return (
<>
<div className='space-y-2'>
@@ -440,7 +447,7 @@ export function GoogleDrivePicker({
}}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedFile ? (
{canShowPreview ? (
<>
{getFileIcon(selectedFile, 'sm')}
<span className='truncate font-normal'>{selectedFile.name}</span>
@@ -460,7 +467,7 @@ export function GoogleDrivePicker({
</Button>
{/* File preview */}
{showPreview && selectedFile && (
{canShowPreview && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button

View File

@@ -727,6 +727,13 @@ export function MicrosoftFileSelector({
})
: availableFiles
const canShowPreview = !!(
showPreview &&
selectedFile &&
selectedFileId &&
selectedFile.id === selectedFileId
)
return (
<>
<div className='space-y-2'>
@@ -750,7 +757,7 @@ export function MicrosoftFileSelector({
}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedFile ? (
{canShowPreview ? (
<>
{getFileIcon(selectedFile, 'sm')}
<span className='truncate font-normal'>{selectedFile.name}</span>
@@ -925,7 +932,7 @@ export function MicrosoftFileSelector({
</Popover>
{/* File preview */}
{showPreview && selectedFile && (
{canShowPreview && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button

View File

@@ -1,33 +1,24 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { getEnv } from '@/lib/env'
import {
type ConfluenceFileInfo,
ConfluenceFileSelector,
type DiscordChannelInfo,
DiscordChannelSelector,
type FileInfo,
type GoogleCalendarInfo,
GoogleCalendarSelector,
GoogleDrivePicker,
type JiraIssueInfo,
JiraIssueSelector,
type MicrosoftFileInfo,
MicrosoftFileSelector,
type TeamsMessageInfo,
TeamsMessageSelector,
WealthboxFileSelector,
type WealthboxItemInfo,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
interface FileSelectorInputProps {
blockId: string
@@ -46,11 +37,12 @@ export function FileSelectorInput({
previewValue,
previewContextValues,
}: FileSelectorInputProps) {
const { getValue } = useSubBlockStore()
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry()
const params = useParams()
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
// Central dependsOn gating for this selector instance
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
// Helper to coerce various preview value shapes into a string ID
const coerceToIdString = (val: unknown): string => {
@@ -72,18 +64,13 @@ export function FileSelectorInput({
// Use the proper hook to get the current value and setter
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
const [selectedFileId, setSelectedFileId] = useState<string>('')
const [_fileInfo, setFileInfo] = useState<FileInfo | ConfluenceFileInfo | null>(null)
const [selectedIssueId, setSelectedIssueId] = useState<string>('')
const [_issueInfo, setIssueInfo] = useState<JiraIssueInfo | null>(null)
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
const [channelInfo, setChannelInfo] = useState<DiscordChannelInfo | null>(null)
const [selectedMessageId, setSelectedMessageId] = useState<string>('')
const [messageInfo, setMessageInfo] = useState<TeamsMessageInfo | null>(null)
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('')
const [calendarInfo, setCalendarInfo] = useState<GoogleCalendarInfo | null>(null)
const [selectedWealthboxItemId, setSelectedWealthboxItemId] = useState<string>('')
const [wealthboxItemInfo, setWealthboxItemInfo] = useState<WealthboxItemInfo | null>(null)
const [domainValue] = useSubBlockValue(blockId, 'domain')
const [projectIdValue] = useSubBlockValue(blockId, 'projectId')
const [planIdValue] = useSubBlockValue(blockId, 'planId')
const [teamIdValue] = useSubBlockValue(blockId, 'teamId')
const [operationValue] = useSubBlockValue(blockId, 'operation')
const [serverIdValue] = useSubBlockValue(blockId, 'serverId')
const [botTokenValue] = useSubBlockValue(blockId, 'botToken')
// Determine if the persisted credential belongs to the current viewer
const { isForeignCredential } = useForeignCredential(
@@ -104,128 +91,29 @@ export function FileSelectorInput({
const isWealthbox = provider === 'wealthbox'
const isMicrosoftSharePoint = provider === 'microsoft' && subBlock.serviceId === 'sharepoint'
const isMicrosoftPlanner = provider === 'microsoft-planner'
// For Confluence and Jira, we need the domain and credentials
const domain =
isConfluence || isJira
? (isPreview && previewContextValues?.domain?.value) ||
(getValue(blockId, 'domain') as string) ||
''
? (isPreview && previewContextValues?.domain?.value) || (domainValue as string) || ''
: ''
const jiraCredential = isJira
? (isPreview && previewContextValues?.credential?.value) ||
(getValue(blockId, 'credential') as string) ||
(connectedCredential as string) ||
''
: ''
// For Discord, we need the bot token and server ID
const botToken = isDiscord
? (isPreview && previewContextValues?.botToken?.value) ||
(getValue(blockId, 'botToken') as string) ||
''
? (isPreview && previewContextValues?.botToken?.value) || (botTokenValue as string) || ''
: ''
const serverId = isDiscord
? (isPreview && previewContextValues?.serverId?.value) ||
(getValue(blockId, 'serverId') as string) ||
''
? (isPreview && previewContextValues?.serverId?.value) || (serverIdValue as string) || ''
: ''
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
// Keep local selection in sync with store value (and preview)
useEffect(() => {
const raw = isPreview && previewValue !== undefined ? previewValue : storeValue
const effective = coerceToIdString(raw)
if (effective) {
if (isJira) {
setSelectedIssueId(effective)
} else if (isDiscord) {
setSelectedChannelId(effective)
} else if (isMicrosoftTeams) {
setSelectedMessageId(effective)
} else if (isGoogleCalendar) {
setSelectedCalendarId(effective)
} else if (isWealthbox) {
setSelectedWealthboxItemId(effective)
} else if (isMicrosoftSharePoint) {
setSelectedFileId(effective)
} else {
setSelectedFileId(effective)
}
} else {
// Clear when value becomes empty
if (isJira) {
setSelectedIssueId('')
} else if (isDiscord) {
setSelectedChannelId('')
} else if (isMicrosoftTeams) {
setSelectedMessageId('')
} else if (isGoogleCalendar) {
setSelectedCalendarId('')
} else if (isWealthbox) {
setSelectedWealthboxItemId('')
} else if (isMicrosoftSharePoint) {
setSelectedFileId('')
} else {
setSelectedFileId('')
}
}
}, [
isPreview,
previewValue,
storeValue,
isJira,
isDiscord,
isMicrosoftTeams,
isGoogleCalendar,
isWealthbox,
isMicrosoftSharePoint,
])
// Handle file selection
const handleFileChange = (fileId: string, info?: any) => {
setSelectedFileId(fileId)
setFileInfo(info || null)
setStoreValue(fileId)
}
// Handle issue selection
const handleIssueChange = (issueKey: string, info?: JiraIssueInfo) => {
setSelectedIssueId(issueKey)
setIssueInfo(info || null)
setStoreValue(issueKey)
// Clear the fields when a new issue is selected
if (isJira) {
collaborativeSetSubblockValue(blockId, 'summary', '')
collaborativeSetSubblockValue(blockId, 'description', '')
if (!issueKey) {
// Also clear the manual issue key when cleared
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
}
}
}
// Handle channel selection
const handleChannelChange = (channelId: string, info?: DiscordChannelInfo) => {
setSelectedChannelId(channelId)
setChannelInfo(info || null)
setStoreValue(channelId)
}
// Handle calendar selection
const handleCalendarChange = (calendarId: string, info?: GoogleCalendarInfo) => {
setSelectedCalendarId(calendarId)
setCalendarInfo(info || null)
setStoreValue(calendarId)
}
// Handle Wealthbox item selection
const handleWealthboxItemChange = (itemId: string, info?: WealthboxItemInfo) => {
setSelectedWealthboxItemId(itemId)
setWealthboxItemInfo(info || null)
setStoreValue(itemId)
}
// For Google Drive
const clientId = getEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID') || ''
const apiKey = getEnv('NEXT_PUBLIC_GOOGLE_API_KEY') || ''
@@ -245,25 +133,17 @@ export function FileSelectorInput({
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedCalendarId(val)
setCalendarInfo(info || null)
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
label={subBlock.placeholder || 'Select Google Calendar'}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
onCalendarInfoChange={setCalendarInfo}
credentialId={credential}
workflowId={workflowIdFromUrl}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select Google Calendar credentials first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
@@ -277,21 +157,18 @@ export function FileSelectorInput({
<TooltipTrigger asChild>
<div className='w-full'>
<DiscordChannelSelector
value={selectedChannelId}
onChange={handleChannelChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(channelId) => setStoreValue(channelId)}
botToken={botToken}
serverId={serverId}
label={subBlock.placeholder || 'Select Discord channel'}
disabled={disabled || !botToken || !serverId}
disabled={finalDisabled}
showPreview={true}
/>
</div>
</TooltipTrigger>
{(!botToken || !serverId) && (
<TooltipContent side='top'>
<p>{!botToken ? 'Please enter a Bot Token first' : 'Please select a Server first'}</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
@@ -311,9 +188,7 @@ export function FileSelectorInput({
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedFileId(val)
setFileInfo(info || null)
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
domain={domain}
@@ -321,20 +196,14 @@ export function FileSelectorInput({
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Confluence page'}
disabled={disabled || !domain}
disabled={finalDisabled}
showPreview={true}
onFileInfoChange={setFileInfo as (info: ConfluenceFileInfo | null) => void}
credentialId={credential}
workflowId={workflowIdFromUrl}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
{!domain && (
<TooltipContent side='top'>
<p>Please enter a Confluence domain first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
@@ -353,168 +222,139 @@ export function FileSelectorInput({
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedIssueId(val)
setIssueInfo(info || null)
collaborativeSetSubblockValue(blockId, subBlock.id, val)
onChange={(issueKey) => {
collaborativeSetSubblockValue(blockId, subBlock.id, issueKey)
// Clear related fields when a new issue is selected
collaborativeSetSubblockValue(blockId, 'summary', '')
collaborativeSetSubblockValue(blockId, 'description', '')
if (!issueKey) {
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
}
}}
domain={domain}
provider='jira'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Jira issue'}
disabled={
disabled || !domain || !credential || !(getValue(blockId, 'projectId') as string)
}
disabled={finalDisabled}
showPreview={true}
onIssueInfoChange={setIssueInfo as (info: JiraIssueInfo | null) => void}
credentialId={credential}
projectId={(getValue(blockId, 'projectId') as string) || ''}
projectId={(projectIdValue as string) || ''}
isForeignCredential={isForeignCredential}
workflowId={activeWorkflowId || ''}
/>
</div>
</TooltipTrigger>
{!domain ? (
<TooltipContent side='top'>
<p>Please enter a Jira domain first</p>
</TooltipContent>
) : !credential ? (
<TooltipContent side='top'>
<p>Please select Jira credentials first</p>
</TooltipContent>
) : !(getValue(blockId, 'projectId') as string) ? (
<TooltipContent side='top'>
<p>Please select a Jira project first</p>
</TooltipContent>
) : null}
</Tooltip>
</TooltipProvider>
)
}
if (isMicrosoftExcel) {
// Get credential reactively
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(selectedFileId)}
onChange={handleFileChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft-excel'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Microsoft Excel file'}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select Microsoft Excel credentials first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
// Handle Microsoft Word selector
// Microsoft Word selector
if (isMicrosoftWord) {
// Get credential reactively
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(selectedFileId)}
onChange={handleFileChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft-word'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Microsoft Word document'}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select Microsoft Word credentials first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
// Handle Microsoft OneDrive selector
// Microsoft OneDrive selector
if (isMicrosoftOneDrive) {
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(selectedFileId)}
onChange={handleFileChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select OneDrive folder'}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select Microsoft credentials first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
// Handle Microsoft SharePoint selector
// Microsoft SharePoint selector
if (isMicrosoftSharePoint) {
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(selectedFileId)}
onChange={handleFileChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select SharePoint site'}
disabled={disabled || !credential}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
@@ -531,26 +371,26 @@ export function FileSelectorInput({
)
}
// Handle Microsoft Planner task selector
// Microsoft Planner task selector
if (isMicrosoftPlanner) {
const credential = (connectedCredential as string) || ''
const planId = (getValue(blockId, 'planId') as string) || ''
const planId = (planIdValue as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={selectedFileId}
onChange={handleFileChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft-planner'
requiredScopes={subBlock.requiredScopes || []}
serviceId='microsoft-planner'
label={subBlock.placeholder || 'Select task'}
disabled={disabled || !credential || !planId}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
planId={planId}
workflowId={activeWorkflowId || ''}
credentialId={credential}
@@ -572,32 +412,22 @@ export function FileSelectorInput({
)
}
// Handle Microsoft Teams selector
// Microsoft Teams selector
if (isMicrosoftTeams) {
// Get credential using the same pattern as other tools
const credential = (connectedCredential as string) || ''
// Determine the selector type based on the subBlock ID
// Determine the selector type based on the subBlock ID / operation
let selectionType: 'team' | 'channel' | 'chat' = 'team'
if (subBlock.id === 'teamId') {
selectionType = 'team'
} else if (subBlock.id === 'channelId') {
selectionType = 'channel'
} else if (subBlock.id === 'chatId') {
selectionType = 'chat'
} else {
// Fallback: look at the operation to determine the selection type
const operation = (getValue(blockId, 'operation') as string) || ''
if (operation.includes('chat')) {
selectionType = 'chat'
} else if (operation.includes('channel')) {
selectionType = 'channel'
}
if (subBlock.id === 'teamId') selectionType = 'team'
else if (subBlock.id === 'channelId') selectionType = 'channel'
else if (subBlock.id === 'chatId') selectionType = 'chat'
else {
const operation = (operationValue as string) || ''
if (operation.includes('chat')) selectionType = 'chat'
else if (operation.includes('channel')) selectionType = 'channel'
}
// Get the teamId from workflow parameters for channel selector
const selectedTeamId = (getValue(blockId, 'teamId') as string) || ''
const selectedTeamId = (teamIdValue as string) || ''
return (
<TooltipProvider>
@@ -610,10 +440,8 @@ export function FileSelectorInput({
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(value, info) => {
setSelectedMessageId(value)
setMessageInfo(info || null)
collaborativeSetSubblockValue(blockId, subBlock.id, value)
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider='microsoft-teams'
requiredScopes={subBlock.requiredScopes || []}
@@ -621,7 +449,6 @@ export function FileSelectorInput({
label={subBlock.placeholder || 'Select Teams message location'}
disabled={disabled || !credential}
showPreview={true}
onMessageInfoChange={setMessageInfo}
credential={credential}
selectionType={selectionType}
initialTeamId={selectedTeamId}
@@ -640,15 +467,11 @@ export function FileSelectorInput({
)
}
// Render Wealthbox selector
// Wealthbox selector
if (isWealthbox) {
// Get credential reactively
const credential = (connectedCredential as string) || ''
// Only handle contacts now - both notes and tasks use short-input
if (subBlock.id === 'contactId') {
const itemType = 'contact'
return (
<TooltipProvider>
<Tooltip>
@@ -660,9 +483,7 @@ export function FileSelectorInput({
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedWealthboxItemId(val)
setWealthboxItemInfo(info || null)
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider='wealthbox'
@@ -671,7 +492,6 @@ export function FileSelectorInput({
label={subBlock.placeholder || `Select ${itemType}`}
disabled={disabled || !credential}
showPreview={true}
onFileInfoChange={setWealthboxItemInfo}
credentialId={credential}
itemType={itemType}
/>
@@ -686,7 +506,7 @@ export function FileSelectorInput({
</TooltipProvider>
)
}
// If it's noteId or taskId, we should not render the file selector since they now use short-input
// noteId or taskId now use short-input
return null
}
@@ -705,9 +525,7 @@ export function FileSelectorInput({
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(val, info) => {
setSelectedFileId(val)
setFileInfo(info || null)
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider={provider}
@@ -717,7 +535,6 @@ export function FileSelectorInput({
serviceId={subBlock.serviceId}
mimeTypeFilter={subBlock.mimeType}
showPreview={true}
onFileInfoChange={setFileInfo}
clientId={clientId}
apiKey={apiKey}
credentialId={credential}

View File

@@ -5,6 +5,7 @@ import {
type FolderInfo,
FolderSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
@@ -37,8 +38,13 @@ export function FolderSelectorInput({
(connectedCredential as string) || ''
)
// Central dependsOn gating
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
// Get the current value from the store or prop value if in preview mode
useEffect(() => {
// When gated/disabled, do not set defaults or write to store
if (finalDisabled) return
if (isPreview && previewValue !== undefined) {
setSelectedFolderId(previewValue)
return
@@ -54,7 +60,15 @@ export function FolderSelectorInput({
if (!isPreview) {
collaborativeSetSubblockValue(blockId, subBlock.id, defaultValue)
}
}, [blockId, subBlock.id, storeValue, collaborativeSetSubblockValue, isPreview, previewValue])
}, [
blockId,
subBlock.id,
storeValue,
collaborativeSetSubblockValue,
isPreview,
previewValue,
finalDisabled,
])
// Handle folder selection
const handleFolderChange = (folderId: string, info?: FolderInfo) => {
@@ -72,7 +86,7 @@ export function FolderSelectorInput({
provider={subBlock.provider || 'google-email'}
requiredScopes={subBlock.requiredScopes || []}
label={subBlock.placeholder || 'Select folder'}
disabled={disabled}
disabled={finalDisabled}
serviceId={subBlock.serviceId}
onFolderInfoChange={setFolderInfo}
credentialId={(connectedCredential as string) || ''}

View File

@@ -279,29 +279,33 @@ export function FolderSelector({
// Fetch credentials on initial mount
useEffect(() => {
if (disabled) return
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
}, [fetchCredentials, disabled])
// Fetch folders when credential is selected
useEffect(() => {
if (disabled) return
if (selectedCredentialId) {
fetchFolders()
}
}, [selectedCredentialId, fetchFolders])
}, [selectedCredentialId, fetchFolders, disabled])
// Keep internal selectedFolderId in sync with the value prop
useEffect(() => {
if (disabled) return
const currentValue = isPreview ? previewValue : value
if (currentValue !== selectedFolderId) {
setSelectedFolderId(currentValue || '')
}
}, [value, isPreview, previewValue])
}, [value, isPreview, previewValue, disabled])
// Fetch the selected folder metadata once credentials are ready or value changes
useEffect(() => {
if (disabled) return
const currentValue = isPreview ? (previewValue as string) : (value as string)
if (
currentValue &&
@@ -310,7 +314,15 @@ export function FolderSelector({
) {
fetchFolderById(currentValue)
}
}, [value, selectedCredentialId, selectedFolder, fetchFolderById, isPreview, previewValue])
}, [
value,
selectedCredentialId,
selectedFolder,
fetchFolderById,
isPreview,
previewValue,
disabled,
])
// Handle folder selection
const handleSelectFolder = (folder: FolderInfo) => {

View File

@@ -353,6 +353,14 @@ export function JiraProjectSelector({
}
}, [value])
// Clear local preview when value is cleared remotely or via collaborator
useEffect(() => {
if (!value) {
setSelectedProject(null)
onProjectInfoChange?.(null)
}
}, [value, onProjectInfoChange])
// Handle open change
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
@@ -387,6 +395,8 @@ export function JiraProjectSelector({
onProjectInfoChange?.(null)
}
const canShowPreview = !!(showPreview && selectedProject && value && selectedProject.id === value)
return (
<>
<div className='space-y-2'>
@@ -399,7 +409,7 @@ export function JiraProjectSelector({
className='w-full justify-between'
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
>
{selectedProject ? (
{canShowPreview ? (
<div className='flex items-center gap-2 overflow-hidden'>
<JiraIcon className='h-4 w-4' />
<span className='truncate font-normal'>{selectedProject.name}</span>
@@ -546,7 +556,7 @@ export function JiraProjectSelector({
</Popover>
{/* Project preview */}
{showPreview && selectedProject && (
{canShowPreview && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button

View File

@@ -18,6 +18,7 @@ import {
type LinearTeamInfo,
LinearTeamSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
@@ -51,16 +52,11 @@ export function ProjectSelectorInput({
subBlock.provider || subBlock.serviceId || 'jira',
(connectedCredential as string) || ''
)
// Local setters for related Jira fields to ensure immediate UI clearing
const [_issueKeyValue, setIssueKeyValue] = useSubBlockValue<string>(blockId, 'issueKey')
const [_manualIssueKeyValue, setManualIssueKeyValue] = useSubBlockValue<string>(
blockId,
'manualIssueKey'
)
// Reactive dependencies from store for Linear
const [linearCredential] = useSubBlockValue(blockId, 'credential')
const [linearTeamId] = useSubBlockValue(blockId, 'teamId')
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
// Get provider-specific values
const provider = subBlock.provider || 'jira'
@@ -95,27 +91,6 @@ export function ProjectSelectorInput({
setProjectInfo(info || null)
setStoreValue(projectId)
// Clear the issue-related fields when a new project is selected
if (provider === 'jira') {
collaborativeSetSubblockValue(blockId, 'summary', '')
collaborativeSetSubblockValue(blockId, 'description', '')
// Clear both the basic and advanced issue key fields to ensure UI resets
collaborativeSetSubblockValue(blockId, 'issueKey', '')
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
// Also clear locally for immediate UI feedback on this client
setIssueKeyValue('')
setManualIssueKeyValue('')
} else if (provider === 'discord') {
collaborativeSetSubblockValue(blockId, 'channelId', '')
} else if (provider === 'linear') {
if (subBlock.id === 'teamId') {
collaborativeSetSubblockValue(blockId, 'teamId', projectId)
collaborativeSetSubblockValue(blockId, 'projectId', '')
} else if (subBlock.id === 'projectId') {
collaborativeSetSubblockValue(blockId, 'projectId', projectId)
}
}
onProjectSelect?.(projectId)
}
@@ -163,7 +138,7 @@ export function ProjectSelectorInput({
}}
credential={(linearCredential as string) || ''}
label={subBlock.placeholder || 'Select Linear team'}
disabled={disabled || !(linearCredential as string)}
disabled={finalDisabled}
showPreview={true}
workflowId={activeWorkflowId || ''}
/>
@@ -171,7 +146,7 @@ export function ProjectSelectorInput({
(() => {
const credential = (linearCredential as string) || ''
const teamId = (linearTeamId as string) || ''
const isDisabled = disabled || !credential || !teamId
const isDisabled = finalDisabled
return (
<LinearProjectSelector
value={selectedProjectId}
@@ -213,7 +188,7 @@ export function ProjectSelectorInput({
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Jira project'}
disabled={disabled || !domain || !(jiraCredential as string)}
disabled={finalDisabled}
showPreview={true}
onProjectInfoChange={setProjectInfo}
credentialId={(jiraCredential as string) || ''}
@@ -222,15 +197,6 @@ export function ProjectSelectorInput({
/>
</div>
</TooltipTrigger>
{!domain ? (
<TooltipContent side='top'>
<p>Please enter a Jira domain first</p>
</TooltipContent>
) : !(jiraCredential as string) ? (
<TooltipContent side='top'>
<p>Please select a Jira account first</p>
</TooltipContent>
) : null}
</Tooltip>
</TooltipProvider>
)

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -46,18 +46,51 @@ export function TriggerModal({
const [config, setConfig] = useState<Record<string, any>>(initialConfig)
const [isSaving, setIsSaving] = useState(false)
// Track if config has changed from initial values
// Snapshot initial values at open for stable dirty-checking across collaborators
const initialConfigRef = useRef<Record<string, any>>(initialConfig)
const initialCredentialRef = useRef<string | null>(null)
// Capture initial credential on first detect
useEffect(() => {
if (initialCredentialRef.current !== null) return
const subBlockStore = useSubBlockStore.getState()
const cred = (subBlockStore.getValue(blockId, 'triggerCredentials') as string | null) || null
initialCredentialRef.current = cred
}, [blockId])
// Track if config has changed from initial snapshot
const hasConfigChanged = useMemo(() => {
return JSON.stringify(config) !== JSON.stringify(initialConfig)
}, [config, initialConfig])
return JSON.stringify(config) !== JSON.stringify(initialConfigRef.current)
}, [config])
// Track if credential has changed from initial snapshot (computed later once selectedCredentialId is declared)
let hasCredentialChanged = false
const [isDeleting, setIsDeleting] = useState(false)
const [webhookUrl, setWebhookUrl] = useState('')
const [generatedPath, setGeneratedPath] = useState('')
const [hasCredentials, setHasCredentials] = useState(false)
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
hasCredentialChanged = selectedCredentialId !== initialCredentialRef.current
const [dynamicOptions, setDynamicOptions] = useState<
Record<string, Array<{ id: string; name: string }>>
>({})
const lastCredentialIdRef = useRef<string | null>(null)
// Reset provider-dependent config fields when credentials change
const resetFieldsForCredentialChange = () => {
setConfig((prev) => {
const next = { ...prev }
if (triggerDef.provider === 'gmail') {
if (Array.isArray(next.labelIds)) next.labelIds = []
} else if (triggerDef.provider === 'outlook') {
if (Array.isArray(next.folderIds)) next.folderIds = []
} else if (triggerDef.provider === 'airtable') {
if (typeof next.baseId === 'string') next.baseId = ''
if (typeof next.tableId === 'string') next.tableId = ''
}
return next
})
}
// Initialize config with default values from trigger definition
useEffect(() => {
@@ -79,35 +112,71 @@ export function TriggerModal({
}
}, [triggerDef.configFields, initialConfig])
// Monitor credential selection
// Monitor credential selection across collaborators; clear options on change/clear
useEffect(() => {
if (triggerDef.requiresCredentials && triggerDef.credentialProvider) {
// Check if credentials are selected by monitoring the sub-block store
const checkCredentials = () => {
const subBlockStore = useSubBlockStore.getState()
const credentialValue = subBlockStore.getValue(blockId, 'triggerCredentials')
const hasCredential = Boolean(credentialValue)
const credentialValue = subBlockStore.getValue(blockId, 'triggerCredentials') as
| string
| null
const currentCredentialId = credentialValue || null
const hasCredential = Boolean(currentCredentialId)
setHasCredentials(hasCredential)
// If credential changed and it's a Gmail trigger, load labels
if (hasCredential && credentialValue !== selectedCredentialId) {
setSelectedCredentialId(credentialValue)
// If credential was cleared by another user, reset local state and dynamic options
if (!hasCredential) {
if (selectedCredentialId !== null) {
setSelectedCredentialId(null)
}
// Clear provider-specific dynamic options
setDynamicOptions({})
// Per requirements: only clear dependent selections on actual credential CHANGE,
// not when it becomes empty. So we do NOT reset fields here.
lastCredentialIdRef.current = null
return
}
// If credential changed, clear options immediately and load for new cred
const previousCredentialId = lastCredentialIdRef.current
// First detection (prev null → current non-null): do not clear selections
if (previousCredentialId === null) {
setSelectedCredentialId(currentCredentialId)
lastCredentialIdRef.current = currentCredentialId
if (typeof currentCredentialId === 'string') {
if (triggerDef.provider === 'gmail') {
void loadGmailLabels(currentCredentialId)
} else if (triggerDef.provider === 'outlook') {
void loadOutlookFolders(currentCredentialId)
}
}
return
}
// Real change (prev non-null → different non-null): clear dependent selections
if (
typeof currentCredentialId === 'string' &&
currentCredentialId !== previousCredentialId
) {
setSelectedCredentialId(currentCredentialId)
lastCredentialIdRef.current = currentCredentialId
// Clear stale options before loading new ones
setDynamicOptions({})
// Clear any selected values that depend on the credential
resetFieldsForCredentialChange()
if (triggerDef.provider === 'gmail') {
loadGmailLabels(credentialValue)
void loadGmailLabels(currentCredentialId)
} else if (triggerDef.provider === 'outlook') {
loadOutlookFolders(credentialValue)
void loadOutlookFolders(currentCredentialId)
}
}
}
checkCredentials()
// Set up a subscription to monitor changes
const unsubscribe = useSubBlockStore.subscribe(checkCredentials)
return unsubscribe
}
// If credentials aren't required, set to true
setHasCredentials(true)
}, [
blockId,
@@ -367,10 +436,14 @@ export function TriggerModal({
</Button>
<Button
onClick={handleSave}
disabled={isSaving || !isConfigValid() || (!hasConfigChanged && !!triggerId)}
disabled={
isSaving ||
!isConfigValid() ||
(!(hasConfigChanged || hasCredentialChanged) && !!triggerId)
}
className={cn(
'h-10',
isConfigValid() && (hasConfigChanged || !triggerId)
isConfigValid() && (hasConfigChanged || hasCredentialChanged || !triggerId)
? 'bg-primary hover:bg-primary/90'
: '',
isSaving &&

View File

@@ -172,6 +172,11 @@ export function TriggerConfig({
// Map trigger ID to webhook provider name
const webhookProvider = effectiveTriggerId.replace(/_webhook|_poller$/, '') // e.g., 'slack_webhook' -> 'slack', 'gmail_poller' -> 'gmail'
// Include selected credential from the modal (if any)
const selectedCredentialId =
(useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as string | null) ||
null
// For credential-based triggers (like Gmail), create webhook entry for polling service but no webhook URL
if (triggerDef.requiresCredentials && !triggerDef.webhook) {
// Gmail polling service requires a webhook database entry to find the configuration
@@ -185,7 +190,10 @@ export function TriggerConfig({
blockId,
path: '', // Empty path - API will generate dummy path for Gmail
provider: webhookProvider,
providerConfig: config,
providerConfig: {
...config,
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
},
}),
})
@@ -225,7 +233,10 @@ export function TriggerConfig({
blockId,
path,
provider: webhookProvider,
providerConfig: config,
providerConfig: {
...config,
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
},
}),
})

View File

@@ -0,0 +1,54 @@
'use client'
import { useMemo } from 'react'
import type { SubBlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
/**
* Centralized dependsOn gating for sub-block components.
* - Computes dependency values from the active workflow/block
* - Returns a stable disabled flag to pass to inputs and to guard effects
*/
export function useDependsOnGate(
blockId: string,
subBlock: SubBlockConfig,
opts?: { disabled?: boolean; isPreview?: boolean }
) {
const disabledProp = opts?.disabled ?? false
const isPreview = opts?.isPreview ?? false
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
// Use only explicit dependsOn from block config. No inference.
const dependsOn: string[] = (subBlock.dependsOn as string[] | undefined) || []
const dependencyValues = useSubBlockStore((state) => {
if (dependsOn.length === 0) return [] as any[]
if (!activeWorkflowId) return dependsOn.map(() => null)
const workflowValues = state.workflowValues[activeWorkflowId] || {}
const blockValues = (workflowValues as any)[blockId] || {}
return dependsOn.map((depKey) => (blockValues as any)[depKey] ?? null)
}) as any[]
const depsSatisfied = useMemo(() => {
if (dependsOn.length === 0) return true
return dependencyValues.every((v) =>
typeof v === 'string' ? v.trim().length > 0 : v !== null && v !== undefined && v !== ''
)
}, [dependencyValues, dependsOn])
// Block everything except the credential field itself until dependencies are set
const blocked =
!isPreview && dependsOn.length > 0 && !depsSatisfied && subBlock.type !== 'oauth-input'
const finalDisabled = disabledProp || isPreview || blocked
return {
dependsOn,
dependencyValues,
depsSatisfied,
blocked,
finalDisabled,
}
}

View File

@@ -37,6 +37,10 @@ export function useSubBlockValue<T = any>(
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
// Subscribe to active workflow id to avoid races where the workflow id is set after mount.
// This ensures our selector recomputes when the active workflow changes.
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const blockType = useWorkflowStore(
useCallback((state) => state.blocks?.[blockId]?.type, [blockId])
)
@@ -56,9 +60,16 @@ export function useSubBlockValue<T = any>(
const streamingValueRef = useRef<T | null>(null)
const wasStreamingRef = useRef<boolean>(false)
// Get value from subblock store - always call this hook unconditionally
// Get value from subblock store, keyed by active workflow id
// We intentionally depend on activeWorkflowId so this recomputes when it changes.
const storeValue = useSubBlockStore(
useCallback((state) => state.getValue(blockId, subBlockId), [blockId, subBlockId])
useCallback(
(state) => {
if (!activeWorkflowId) return null
return state.workflowValues[activeWorkflowId]?.[blockId]?.[subBlockId] ?? null
},
[activeWorkflowId, blockId, subBlockId]
)
)
// Check if we're in diff mode and get diff value if available
@@ -123,12 +134,10 @@ export function useSubBlockValue<T = any>(
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[useWorkflowRegistry.getState().activeWorkflowId || '']: {
...state.workflowValues[useWorkflowRegistry.getState().activeWorkflowId || ''],
[activeWorkflowId || '']: {
...state.workflowValues[activeWorkflowId || ''],
[blockId]: {
...state.workflowValues[useWorkflowRegistry.getState().activeWorkflowId || '']?.[
blockId
],
...state.workflowValues[activeWorkflowId || '']?.[blockId],
[subBlockId]: newValue,
},
},
@@ -190,6 +199,7 @@ export function useSubBlockValue<T = any>(
isStreaming,
emitValue,
isShowingDiff,
activeWorkflowId,
]
)

View File

@@ -807,7 +807,7 @@ export function useWorkflowExecution() {
// Continue execution until there are no more pending blocks
let iterationCount = 0
const maxIterations = 100 // Safety to prevent infinite loops
const maxIterations = 500 // Safety to prevent infinite loops
while (currentPendingBlocks.length > 0 && iterationCount < maxIterations) {
logger.info(

View File

@@ -720,21 +720,47 @@ export function Sidebar() {
`[data-workflow-id="${workflowId}"]`
) as HTMLElement
if (activeWorkflow) {
activeWorkflow.scrollIntoView({
block: 'start',
})
// Check if this is a newly created workflow (created within the last 5 seconds)
const currentWorkflow = workflows[workflowId]
const isNewlyCreated =
currentWorkflow &&
currentWorkflow.lastModified instanceof Date &&
Date.now() - currentWorkflow.lastModified.getTime() < 5000 // 5 seconds
// Adjust scroll position to eliminate the small gap at the top
const scrollViewport = scrollContainer.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLElement
if (scrollViewport && scrollViewport.scrollTop > 0) {
scrollViewport.scrollTop = Math.max(0, scrollViewport.scrollTop - 8)
if (isNewlyCreated) {
// For newly created workflows, use the original behavior - scroll to top
activeWorkflow.scrollIntoView({
block: 'start',
})
// Adjust scroll position to eliminate the small gap at the top
const scrollViewport = scrollContainer.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLElement
if (scrollViewport && scrollViewport.scrollTop > 0) {
scrollViewport.scrollTop = Math.max(0, scrollViewport.scrollTop - 8)
}
} else {
// For existing workflows, check if already visible and scroll minimally
const containerRect = scrollContainer.getBoundingClientRect()
const workflowRect = activeWorkflow.getBoundingClientRect()
// Only scroll if the workflow is not fully visible
const isFullyVisible =
workflowRect.top >= containerRect.top && workflowRect.bottom <= containerRect.bottom
if (!isFullyVisible) {
// Use 'nearest' to scroll minimally - only bring into view, don't force to top
activeWorkflow.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
})
}
}
}
}
}
}, [workflowId, isLoading])
}, [workflowId, isLoading, workflows])
const [showSettings, setShowSettings] = useState(false)
const [showHelp, setShowHelp] = useState(false)

View File

@@ -45,6 +45,7 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter your base ID (e.g., appXXXXXXXXXXXXXX)',
dependsOn: ['credential'],
required: true,
},
{
@@ -53,6 +54,7 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter table ID (e.g., tblXXXXXXXXXXXXXX)',
dependsOn: ['credential', 'baseId'],
required: true,
},
{

View File

@@ -58,6 +58,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
provider: 'confluence',
serviceId: 'confluence',
placeholder: 'Select Confluence page',
dependsOn: ['credential', 'domain'],
mode: 'basic',
},
// Manual page ID input (advanced mode)

View File

@@ -43,6 +43,7 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
provider: 'discord',
serviceId: 'discord',
placeholder: 'Select Discord server',
dependsOn: ['botToken'],
mode: 'basic',
condition: {
field: 'operation',
@@ -71,6 +72,7 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
provider: 'discord',
serviceId: 'discord',
placeholder: 'Select Discord channel',
dependsOn: ['botToken', 'serverId'],
mode: 'basic',
condition: { field: 'operation', value: ['discord_send_message', 'discord_get_messages'] },
},

View File

@@ -106,6 +106,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
'https://www.googleapis.com/auth/gmail.labels',
],
placeholder: 'Select Gmail label/folder',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: 'read_gmail' },
},

View File

@@ -48,6 +48,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
serviceId: 'google-calendar',
requiredScopes: ['https://www.googleapis.com/auth/calendar'],
placeholder: 'Select calendar',
dependsOn: ['credential'],
mode: 'basic',
},
// Manual calendar ID input (advanced mode)

View File

@@ -49,6 +49,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
requiredScopes: [],
mimeType: 'application/vnd.google-apps.document',
placeholder: 'Select a document',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: ['read', 'write'] },
},
@@ -59,6 +60,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter document ID',
dependsOn: ['credential'],
mode: 'advanced',
condition: { field: 'operation', value: ['read', 'write'] },
},
@@ -83,6 +85,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
requiredScopes: [],
mimeType: 'application/vnd.google-apps.folder',
placeholder: 'Select a parent folder',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: 'create' },
},
@@ -93,6 +96,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter parent folder ID (leave empty for root folder)',
dependsOn: ['credential'],
mode: 'advanced',
condition: { field: 'operation', value: 'create' },
},

View File

@@ -82,6 +82,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
mimeType: 'application/vnd.google-apps.folder',
placeholder: 'Select a parent folder',
mode: 'basic',
dependsOn: ['credential'],
condition: { field: 'operation', value: 'upload' },
},
{
@@ -155,6 +156,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
mimeType: 'application/vnd.google-apps.folder',
placeholder: 'Select a parent folder',
mode: 'basic',
dependsOn: ['credential'],
condition: { field: 'operation', value: 'create_folder' },
},
// Manual Folder ID input (advanced mode)
@@ -179,6 +181,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
mimeType: 'application/vnd.google-apps.folder',
placeholder: 'Select a folder to list files from',
mode: 'basic',
dependsOn: ['credential'],
condition: { field: 'operation', value: 'list' },
},
// Manual Folder ID input (advanced mode)

View File

@@ -50,6 +50,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
requiredScopes: [],
mimeType: 'application/vnd.google-apps.spreadsheet',
placeholder: 'Select a spreadsheet',
dependsOn: ['credential'],
mode: 'basic',
},
// Manual Spreadsheet ID (advanced mode)
@@ -59,6 +60,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'ID of the spreadsheet (from URL)',
dependsOn: ['credential'],
mode: 'advanced',
},
// Range

View File

@@ -62,6 +62,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
provider: 'jira',
serviceId: 'jira',
placeholder: 'Select Jira project',
dependsOn: ['credential', 'domain'],
mode: 'basic',
},
// Manual project ID input (advanced mode)
@@ -71,6 +72,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter Jira project ID',
dependsOn: ['credential', 'domain'],
mode: 'advanced',
},
// Issue selector (basic mode)
@@ -82,6 +84,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
provider: 'jira',
serviceId: 'jira',
placeholder: 'Select Jira issue',
dependsOn: ['credential', 'domain', 'projectId'],
condition: { field: 'operation', value: ['read', 'update'] },
mode: 'basic',
},
@@ -92,6 +95,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter Jira issue key',
dependsOn: ['credential', 'domain', 'projectId'],
condition: { field: 'operation', value: ['read', 'update'] },
mode: 'advanced',
},

View File

@@ -66,6 +66,7 @@ export const KnowledgeBlock: BlockConfig = {
type: 'document-selector',
layout: 'full',
placeholder: 'Select document',
dependsOn: ['knowledgeBaseId'],
required: true,
condition: { field: 'operation', value: 'upload_chunk' },
},

View File

@@ -42,6 +42,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
provider: 'linear',
serviceId: 'linear',
placeholder: 'Select a team',
dependsOn: ['credential'],
mode: 'basic',
},
{
@@ -52,6 +53,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
provider: 'linear',
serviceId: 'linear',
placeholder: 'Select a project',
dependsOn: ['credential', 'teamId'],
mode: 'basic',
},
// Manual team ID input (advanced mode)

View File

@@ -46,6 +46,7 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
requiredScopes: [],
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
placeholder: 'Select a spreadsheet',
dependsOn: ['credential'],
mode: 'basic',
},
{
@@ -54,6 +55,7 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter spreadsheet ID',
dependsOn: ['credential'],
mode: 'advanced',
},
{

View File

@@ -61,6 +61,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
layout: 'full',
placeholder: 'Enter the plan ID',
condition: { field: 'operation', value: ['create_task', 'read_task'] },
dependsOn: ['credential'],
},
{
id: 'taskId',
@@ -70,6 +71,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
placeholder: 'Select a task',
provider: 'microsoft-planner',
condition: { field: 'operation', value: ['read_task'] },
dependsOn: ['credential', 'planId'],
mode: 'basic',
},
@@ -81,6 +83,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
layout: 'full',
placeholder: 'Enter the task ID',
condition: { field: 'operation', value: ['read_task'] },
dependsOn: ['credential', 'planId'],
mode: 'advanced',
},

View File

@@ -61,6 +61,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
serviceId: 'microsoft-teams',
requiredScopes: [],
placeholder: 'Select a team',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: ['read_channel', 'write_channel'] },
},
@@ -82,6 +83,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
serviceId: 'microsoft-teams',
requiredScopes: [],
placeholder: 'Select a chat',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: ['read_chat', 'write_chat'] },
},
@@ -103,6 +105,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
serviceId: 'microsoft-teams',
requiredScopes: [],
placeholder: 'Select a channel',
dependsOn: ['credential', 'teamId'],
mode: 'basic',
condition: { field: 'operation', value: ['read_channel', 'write_channel'] },
},

View File

@@ -78,6 +78,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
],
mimeType: 'application/vnd.microsoft.graph.folder',
placeholder: 'Select a parent folder',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: 'upload' },
},
@@ -87,6 +88,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter parent folder ID (leave empty for root folder)',
dependsOn: ['credential'],
mode: 'advanced',
condition: { field: 'operation', value: 'upload' },
},
@@ -115,6 +117,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
],
mimeType: 'application/vnd.microsoft.graph.folder',
placeholder: 'Select a parent folder',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: 'create_folder' },
},
@@ -125,6 +128,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter parent folder ID (leave empty for root folder)',
dependsOn: ['credential'],
mode: 'advanced',
condition: { field: 'operation', value: 'create_folder' },
},
@@ -146,6 +150,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
],
mimeType: 'application/vnd.microsoft.graph.folder',
placeholder: 'Select a folder to list files from',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: 'list' },
},
@@ -156,6 +161,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter folder ID (leave empty for root folder)',
dependsOn: ['credential'],
mode: 'advanced',
condition: { field: 'operation', value: 'list' },
},

View File

@@ -124,6 +124,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
serviceId: 'outlook',
requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'],
placeholder: 'Select Outlook folder',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: 'read_outlook' },
},

View File

@@ -61,6 +61,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
],
mimeType: 'application/vnd.microsoft.graph.folder',
placeholder: 'Select a site',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: ['create_page', 'read_page', 'list_sites'] },
},
@@ -99,6 +100,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter site ID (leave empty for root site)',
dependsOn: ['credential'],
mode: 'advanced',
condition: { field: 'operation', value: 'create_page' },
},

View File

@@ -27,6 +27,7 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
{ label: 'Create a Row', id: 'insert' },
{ label: 'Update a Row', id: 'update' },
{ label: 'Delete a Row', id: 'delete' },
{ label: 'Upsert a Row', id: 'upsert' },
],
value: () => 'query',
},
@@ -75,6 +76,15 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
condition: { field: 'operation', value: 'update' },
required: true,
},
{
id: 'data',
title: 'Data',
type: 'code',
layout: 'full',
placeholder: '{\n "column1": "value1",\n "column2": "value2"\n}',
condition: { field: 'operation', value: 'upsert' },
required: true,
},
// Filter for get_row, update, delete operations (required)
{
id: 'filter',
@@ -138,6 +148,7 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
'supabase_get_row',
'supabase_update',
'supabase_delete',
'supabase_upsert',
],
config: {
tool: (params) => {
@@ -152,6 +163,8 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
return 'supabase_update'
case 'delete':
return 'supabase_delete'
case 'upsert':
return 'supabase_upsert'
default:
throw new Error(`Invalid Supabase operation: ${params.operation}`)
}
@@ -164,8 +177,12 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
if (data && typeof data === 'string' && data.trim()) {
try {
parsedData = JSON.parse(data)
} catch (_e) {
throw new Error('Invalid JSON data format')
} catch (parseError) {
// Provide more detailed error information
const errorMsg = parseError instanceof Error ? parseError.message : 'Unknown JSON error'
throw new Error(
`Invalid JSON data format: ${errorMsg}. Please check your JSON syntax (e.g., strings must be quoted like "value").`
)
}
} else if (data && typeof data === 'object') {
parsedData = data

View File

@@ -171,6 +171,9 @@ export interface SubBlockConfig {
// Trigger-specific configuration
availableTriggers?: string[] // List of trigger IDs available for this subblock
triggerProvider?: string // Which provider's triggers to show
// Declarative dependency hints for cross-field clearing or invalidation
// Example: dependsOn: ['credential'] means this field should be cleared when credential changes
dependsOn?: string[]
}
// Main block definition

View File

@@ -209,6 +209,7 @@ describe('WorkflowBlockHandler', () => {
success: true,
childWorkflowName: 'Child Workflow',
result: { data: 'test result' },
childTraceSpans: [],
})
})
@@ -248,6 +249,7 @@ describe('WorkflowBlockHandler', () => {
success: true,
childWorkflowName: 'Child Workflow',
result: { nested: 'data' },
childTraceSpans: [],
})
})
})

View File

@@ -1,5 +1,6 @@
import { generateInternalToken } from '@/lib/auth/internal'
import { createLogger } from '@/lib/logs/console/logger'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { getBaseUrl } from '@/lib/urls/utils'
import type { BlockOutput } from '@/blocks/types'
import { Executor } from '@/executor'
@@ -104,18 +105,17 @@ export class WorkflowBlockHandler implements BlockHandler {
// Remove current execution from stack after completion
WorkflowBlockHandler.executionStack.delete(executionId)
// Log execution completion
logger.info(`Child workflow ${childWorkflowName} completed in ${Math.round(duration)}ms`)
// Map child workflow output to parent block output
const childTraceSpans = this.captureChildWorkflowLogs(result, childWorkflowName, context)
const mappedResult = this.mapChildOutputToParent(
result,
workflowId,
childWorkflowName,
duration
duration,
childTraceSpans
)
// If the child workflow failed, throw an error to trigger proper error handling in the parent
if ((mappedResult as any).success === false) {
const childError = (mappedResult as any).error || 'Unknown error'
throw new Error(`Error in child workflow "${childWorkflowName}": ${childError}`)
@@ -125,19 +125,13 @@ export class WorkflowBlockHandler implements BlockHandler {
} catch (error: any) {
logger.error(`Error executing child workflow ${workflowId}:`, error)
// Clean up execution stack in case of error
const executionId = `${context.workflowId}_sub_${workflowId}_${block.id}`
WorkflowBlockHandler.executionStack.delete(executionId)
// Get workflow name for error reporting
const { workflows } = useWorkflowRegistry.getState()
const workflowMetadata = workflows[workflowId]
const childWorkflowName = workflowMetadata?.name || workflowId
// Enhance error message with child workflow context
const originalError = error.message || 'Unknown error'
// Check if error message already has child workflow context to avoid duplication
if (originalError.startsWith('Error in child workflow')) {
throw error // Re-throw as-is to avoid duplication
}
@@ -151,12 +145,9 @@ export class WorkflowBlockHandler implements BlockHandler {
*/
private async loadChildWorkflow(workflowId: string) {
try {
// Fetch workflow from API with internal authentication header
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
// Add internal auth header for server-side calls
if (typeof window === 'undefined') {
const token = await generateInternalToken()
headers.Authorization = `Bearer ${token}`
@@ -182,16 +173,12 @@ export class WorkflowBlockHandler implements BlockHandler {
}
logger.info(`Loaded child workflow: ${workflowData.name} (${workflowId})`)
// Extract the workflow state (API returns normalized data in state field)
const workflowState = workflowData.state
if (!workflowState || !workflowState.blocks) {
logger.error(`Child workflow ${workflowId} has invalid state`)
return null
}
// Use blocks directly since API returns data from normalized tables
const serializedWorkflow = this.serializer.serializeWorkflow(
workflowState.blocks,
workflowState.edges || [],
@@ -222,17 +209,101 @@ export class WorkflowBlockHandler implements BlockHandler {
}
/**
* Maps child workflow output to parent block output format
* Captures and transforms child workflow logs into trace spans
*/
private captureChildWorkflowLogs(
childResult: any,
childWorkflowName: string,
parentContext: ExecutionContext
): any[] {
try {
if (!childResult.logs || !Array.isArray(childResult.logs)) {
return []
}
const { traceSpans } = buildTraceSpans(childResult)
if (!traceSpans || traceSpans.length === 0) {
return []
}
const transformedSpans = traceSpans.map((span: any) => {
return this.transformSpanForChildWorkflow(span, childWorkflowName)
})
return transformedSpans
} catch (error) {
logger.error(`Error capturing child workflow logs for ${childWorkflowName}:`, error)
return []
}
}
/**
* Transforms trace span for child workflow context
*/
private transformSpanForChildWorkflow(span: any, childWorkflowName: string): any {
const transformedSpan = {
...span,
name: this.cleanChildSpanName(span.name, childWorkflowName),
metadata: {
...span.metadata,
isFromChildWorkflow: true,
childWorkflowName,
},
}
if (span.children && Array.isArray(span.children)) {
transformedSpan.children = span.children.map((childSpan: any) =>
this.transformSpanForChildWorkflow(childSpan, childWorkflowName)
)
}
if (span.output?.childTraceSpans) {
transformedSpan.output = {
...transformedSpan.output,
childTraceSpans: span.output.childTraceSpans,
}
}
return transformedSpan
}
/**
* Cleans up child span names for readability
*/
private cleanChildSpanName(spanName: string, childWorkflowName: string): string {
if (spanName.includes(`${childWorkflowName}:`)) {
const cleanName = spanName.replace(`${childWorkflowName}:`, '').trim()
if (cleanName === 'Workflow Execution') {
return `${childWorkflowName} workflow`
}
if (cleanName.startsWith('Agent ')) {
return `${cleanName}`
}
return `${cleanName}`
}
if (spanName === 'Workflow Execution') {
return `${childWorkflowName} workflow`
}
return `${spanName}`
}
/**
* Maps child workflow output to parent block output
*/
private mapChildOutputToParent(
childResult: any,
childWorkflowId: string,
childWorkflowName: string,
duration: number
duration: number,
childTraceSpans?: any[]
): BlockOutput {
const success = childResult.success !== false
// If child workflow failed, return minimal output
if (!success) {
logger.warn(`Child workflow ${childWorkflowName} failed`)
return {
@@ -241,18 +312,15 @@ export class WorkflowBlockHandler implements BlockHandler {
error: childResult.error || 'Child workflow execution failed',
} as Record<string, any>
}
// Extract the actual result content from the flattened structure
let result = childResult
if (childResult?.output) {
result = childResult.output
}
// Return a properly structured response with all required fields
return {
success: true,
childWorkflowName,
result,
childTraceSpans: childTraceSpans || [],
} as Record<string, any>
}
}

View File

@@ -1,5 +1,6 @@
import { BlockPathCalculator } from '@/lib/block-path-calculator'
import { createLogger } from '@/lib/logs/console/logger'
import type { TraceSpan } from '@/lib/logs/types'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/consts'
import {
@@ -225,7 +226,7 @@ export class Executor {
let hasMoreLayers = true
let iteration = 0
const maxIterations = 100 // Safety limit for infinite loops
const maxIterations = 500 // Safety limit for infinite loops
while (hasMoreLayers && iteration < maxIterations && !this.isCancelled) {
const nextLayer = this.getNextExecutionLayer(context)
@@ -1510,6 +1511,9 @@ export class Executor {
blockLog.durationMs = Math.round(executionTime)
blockLog.endedAt = new Date().toISOString()
// Handle child workflow logs integration
this.integrateChildWorkflowLogs(block, output)
context.blockLogs.push(blockLog)
// Skip console logging for infrastructure blocks like loops and parallels
@@ -1617,6 +1621,9 @@ export class Executor {
blockLog.durationMs = Math.round(executionTime)
blockLog.endedAt = new Date().toISOString()
// Handle child workflow logs integration
this.integrateChildWorkflowLogs(block, output)
context.blockLogs.push(blockLog)
// Skip console logging for infrastructure blocks like loops and parallels
@@ -2003,4 +2010,22 @@ export class Executor {
context.blockLogs.push(starterBlockLog)
}
}
/**
* Preserves child workflow trace spans for proper nesting
*/
private integrateChildWorkflowLogs(block: SerializedBlock, output: NormalizedBlockOutput): void {
if (block.metadata?.id !== BlockType.WORKFLOW) {
return
}
if (!output || typeof output !== 'object' || !output.childTraceSpans) {
return
}
const childTraceSpans = output.childTraceSpans as TraceSpan[]
if (!Array.isArray(childTraceSpans) || childTraceSpans.length === 0) {
return
}
}
}

View File

@@ -804,7 +804,7 @@ export function useCollaborativeWorkflow() {
)
const collaborativeSetSubblockValue = useCallback(
(blockId: string, subblockId: string, value: any) => {
(blockId: string, subblockId: string, value: any, options?: { _visited?: Set<string> }) => {
if (isApplyingRemoteChange.current) return
// Skip socket operations when in diff mode
@@ -840,6 +840,28 @@ export function useCollaborativeWorkflow() {
// Apply locally first (immediate UI feedback)
subBlockStore.setValue(blockId, subblockId, value)
// Declarative clearing: clear sub-blocks that depend on this subblockId
try {
const visited = options?._visited || new Set<string>()
if (visited.has(subblockId)) return
visited.add(subblockId)
const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type
const blockConfig = blockType ? getBlock(blockType) : null
if (blockConfig?.subBlocks && Array.isArray(blockConfig.subBlocks)) {
const dependents = blockConfig.subBlocks.filter(
(sb: any) => Array.isArray(sb.dependsOn) && sb.dependsOn.includes(subblockId)
)
for (const dep of dependents) {
// Skip clearing if the dependent is the same field
if (!dep?.id || dep.id === subblockId) continue
// Cascade using the same collaborative path so it emits and further cascades
collaborativeSetSubblockValue(blockId, dep.id, '', { _visited: visited })
}
}
} catch {
// Best-effort; do not block on clearing
}
},
[
subBlockStore,

View File

@@ -1296,6 +1296,26 @@ export const auth = betterAuth({
if (user.length > 0) {
const currentUser = user[0]
// Store the original user ID before we change the referenceId
const originalUserId = subscription.referenceId
// First, sync usage limits for the purchasing user with their new plan
try {
const { syncUsageLimitsFromSubscription } = await import('@/lib/billing')
await syncUsageLimitsFromSubscription(originalUserId)
logger.info(
'Usage limits synced for purchasing user before organization creation',
{
userId: originalUserId,
}
)
} catch (error) {
logger.error('Failed to sync usage limits for purchasing user', {
userId: originalUserId,
error,
})
}
// Create organization for the team
const orgId = `org_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
const orgSlug = `${currentUser.name?.toLowerCase().replace(/\s+/g, '-') || 'team'}-${Date.now()}`
@@ -1376,17 +1396,21 @@ export const auth = betterAuth({
}
}
// Sync usage limits and initialize billing period for the user/organization
// Initialize billing period for the user/organization
try {
const { syncUsageLimitsFromSubscription } = await import('@/lib/billing')
const { initializeBillingPeriod } = await import(
'@/lib/billing/core/billing-periods'
)
await syncUsageLimitsFromSubscription(subscription.referenceId)
logger.info('Usage limits synced after subscription creation', {
referenceId: subscription.referenceId,
})
// Note: Usage limits are already synced above for team plan users
// For non-team plans, sync usage limits using the referenceId (which is the user ID)
if (subscription.plan !== 'team') {
const { syncUsageLimitsFromSubscription } = await import('@/lib/billing')
await syncUsageLimitsFromSubscription(subscription.referenceId)
logger.info('Usage limits synced after subscription creation', {
referenceId: subscription.referenceId,
})
}
// Initialize billing period for new subscription using Stripe dates
if (subscription.plan !== 'free') {

View File

@@ -90,29 +90,44 @@ export function buildTraceSpans(result: ExecutionResult): {
// Always add cost, token, and model information if available (regardless of provider timing)
if (log.output?.cost) {
;(span as any).cost = log.output.cost
logger.debug(`Added cost to span ${span.id}`, {
blockId: log.blockId,
blockType: log.blockType,
cost: log.output.cost,
})
}
if (log.output?.tokens) {
;(span as any).tokens = log.output.tokens
logger.debug(`Added tokens to span ${span.id}`, {
blockId: log.blockId,
blockType: log.blockType,
tokens: log.output.tokens,
})
}
if (log.output?.model) {
;(span as any).model = log.output.model
logger.debug(`Added model to span ${span.id}`, {
blockId: log.blockId,
blockType: log.blockType,
model: log.output.model,
}
// Handle child workflow spans for workflow blocks
if (
log.blockType === 'workflow' &&
log.output?.childTraceSpans &&
Array.isArray(log.output.childTraceSpans)
) {
// Convert child trace spans to be direct children of this workflow block span
const childTraceSpans = log.output.childTraceSpans as TraceSpan[]
// Process child workflow spans and add them as children
const flatChildSpans: TraceSpan[] = []
childTraceSpans.forEach((childSpan) => {
// Skip the synthetic workflow span wrapper - we only want the actual block executions
if (childSpan.type === 'workflow' && childSpan.name === 'Workflow Execution') {
// Add its children directly, skipping the synthetic wrapper
if (childSpan.children && Array.isArray(childSpan.children)) {
flatChildSpans.push(...childSpan.children)
}
} else {
// This is a regular span, add it directly
// But first, ensure nested workflow blocks in this span are also processed
const processedSpan = ensureNestedWorkflowsProcessed(childSpan)
flatChildSpans.push(processedSpan)
}
})
// Add the child spans as children of this workflow block
span.children = flatChildSpans
}
// Enhanced approach: Use timeSegments for sequential flow if available
@@ -163,20 +178,6 @@ export function buildTraceSpans(result: ExecutionResult): {
status: 'success',
}
})
logger.debug(
`Created ${span.children?.length || 0} sequential segments for span ${span.id}`,
{
blockId: log.blockId,
blockType: log.blockType,
segments:
span.children?.map((child) => ({
name: child.name,
type: child.type,
duration: child.duration,
})) || [],
}
)
} else {
// Fallback: Extract tool calls using the original approach for backwards compatibility
// Tool calls handling for different formats:
@@ -237,12 +238,6 @@ export function buildTraceSpans(result: ExecutionResult): {
}
})
.filter(Boolean) // Remove any null entries from failed processing
logger.debug(`Added ${span.toolCalls?.length || 0} tool calls to span ${span.id}`, {
blockId: log.blockId,
blockType: log.blockType,
toolCallNames: span.toolCalls?.map((tc) => tc.name) || [],
})
}
}
@@ -384,6 +379,45 @@ export function buildTraceSpans(result: ExecutionResult): {
return { traceSpans: rootSpans, totalDuration }
}
// Helper function to recursively process nested workflow blocks in trace spans
function ensureNestedWorkflowsProcessed(span: TraceSpan): TraceSpan {
// Create a copy to avoid mutating the original
const processedSpan = { ...span }
// If this is a workflow block and it has childTraceSpans in its output, process them
if (
span.type === 'workflow' &&
span.output?.childTraceSpans &&
Array.isArray(span.output.childTraceSpans)
) {
const childTraceSpans = span.output.childTraceSpans as TraceSpan[]
const nestedChildren: TraceSpan[] = []
childTraceSpans.forEach((childSpan) => {
// Skip synthetic workflow wrappers and get the actual blocks
if (childSpan.type === 'workflow' && childSpan.name === 'Workflow Execution') {
if (childSpan.children && Array.isArray(childSpan.children)) {
// Recursively process each child to handle deeper nesting
childSpan.children.forEach((grandchildSpan) => {
nestedChildren.push(ensureNestedWorkflowsProcessed(grandchildSpan))
})
}
} else {
// Regular span, recursively process it for potential deeper nesting
nestedChildren.push(ensureNestedWorkflowsProcessed(childSpan))
}
})
// Set the processed children on this workflow block
processedSpan.children = nestedChildren
} else if (span.children && Array.isArray(span.children)) {
// Recursively process regular children too
processedSpan.children = span.children.map((child) => ensureNestedWorkflowsProcessed(child))
}
return processedSpan
}
export function stripCustomToolPrefix(name: string) {
return name.startsWith('custom_') ? name.replace('custom_', '') : name
}

View File

@@ -3,9 +3,9 @@ import { nanoid } from 'nanoid'
import { Logger } from '@/lib/logs/console/logger'
import { hasProcessedMessage, markMessageAsProcessed } from '@/lib/redis'
import { getBaseUrl } from '@/lib/urls/utils'
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { webhook } from '@/db/schema'
import { account, webhook } from '@/db/schema'
const logger = new Logger('GmailPollingService')
@@ -81,20 +81,37 @@ export async function pollGmailWebhooks() {
const requestId = nanoid()
try {
// Extract user ID from webhook metadata if available
// Extract metadata
const metadata = webhookData.providerConfig as any
const userId = metadata?.userId
const credentialId: string | undefined = metadata?.credentialId
const userId: string | undefined = metadata?.userId
if (!userId) {
logger.error(`[${requestId}] No user ID found for webhook ${webhookId}`)
return { success: false, webhookId, error: 'No user ID' }
if (!credentialId && !userId) {
logger.error(`[${requestId}] Missing credentialId and userId for webhook ${webhookId}`)
return { success: false, webhookId, error: 'Missing credentialId and userId' }
}
// Get OAuth token for Gmail API
const accessToken = await getOAuthToken(userId, 'google-email')
// Resolve owner and token
let accessToken: string | null = null
if (credentialId) {
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (rows.length === 0) {
logger.error(
`[${requestId}] Credential ${credentialId} not found for webhook ${webhookId}`
)
return { success: false, webhookId, error: 'Credential not found' }
}
const ownerUserId = rows[0].userId
accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
} else if (userId) {
// Backward-compat fallback to workflow owner token
accessToken = await getOAuthToken(userId, 'google-email')
}
if (!accessToken) {
logger.error(`[${requestId}] Failed to get Gmail access token for webhook ${webhookId}`)
logger.error(
`[${requestId}] Failed to get Gmail access token for webhook ${webhookId} (cred or fallback)`
)
return { success: false, webhookId, error: 'No access token' }
}

View File

@@ -3,9 +3,9 @@ import { nanoid } from 'nanoid'
import { Logger } from '@/lib/logs/console/logger'
import { hasProcessedMessage, markMessageAsProcessed } from '@/lib/redis'
import { getBaseUrl } from '@/lib/urls/utils'
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { webhook } from '@/db/schema'
import { account, webhook } from '@/db/schema'
const logger = new Logger('OutlookPollingService')
@@ -108,28 +108,37 @@ export async function pollOutlookWebhooks() {
try {
logger.info(`[${requestId}] Processing Outlook webhook: ${webhookId}`)
// Extract user ID from webhook metadata if available
// Extract credentialId and/or userId
const metadata = webhookData.providerConfig as any
const userId = metadata?.userId
const credentialId: string | undefined = metadata?.credentialId
const userId: string | undefined = metadata?.userId
// Debug: Webhook metadata extraction
logger.debug(
`[${requestId}] Webhook ${webhookId} providerConfig:`,
JSON.stringify(metadata, null, 2)
)
logger.debug(`[${requestId}] Extracted userId:`, userId)
if (!userId) {
logger.error(`[${requestId}] No user ID found for webhook ${webhookId}`)
logger.debug(`[${requestId}] No userId found in providerConfig for webhook ${webhookId}`)
return { success: false, webhookId, error: 'No user ID' }
if (!credentialId && !userId) {
logger.error(`[${requestId}] Missing credentialId and userId for webhook ${webhookId}`)
return { success: false, webhookId, error: 'Missing credentialId and userId' }
}
// Get OAuth token for Outlook API
const accessToken = await getOAuthToken(userId, 'outlook')
// Resolve access token
let accessToken: string | null = null
if (credentialId) {
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!rows.length) {
logger.error(
`[${requestId}] Credential ${credentialId} not found for webhook ${webhookId}`
)
return { success: false, webhookId, error: 'Credential not found' }
}
const ownerUserId = rows[0].userId
accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
} else if (userId) {
// Backward-compat fallback to workflow owner token
accessToken = await getOAuthToken(userId, 'outlook')
}
if (!accessToken) {
logger.error(`[${requestId}] Failed to get Outlook access token for webhook ${webhookId}`)
logger.error(
`[${requestId}] Failed to get Outlook access token for webhook ${webhookId} (cred or fallback)`
)
return { success: false, webhookId, error: 'No access token' }
}

View File

@@ -1,9 +1,9 @@
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { webhook } from '@/db/schema'
import { account, webhook } from '@/db/schema'
const logger = createLogger('WebhookUtils')
@@ -860,6 +860,31 @@ export async function fetchAndProcessAirtablePayloads(
return // Exit early
}
// Require credentialId
const credentialId: string | undefined = localProviderConfig.credentialId
if (!credentialId) {
logger.error(
`[${requestId}] Missing credentialId in providerConfig for Airtable webhook ${webhookData.id}.`
)
return
}
// Resolve owner and access token strictly via credentialId (no fallback)
let ownerUserId: string | null = null
try {
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
ownerUserId = rows.length ? rows[0].userId : null
} catch (_e) {
ownerUserId = null
}
if (!ownerUserId) {
logger.error(
`[${requestId}] Could not resolve owner for Airtable credential ${credentialId} on webhook ${webhookData.id}`
)
return
}
// --- Retrieve Stored Cursor from localProviderConfig ---
const storedCursor = localProviderConfig.externalWebhookCursor
@@ -908,14 +933,13 @@ export async function fetchAndProcessAirtablePayloads(
)
}
// --- Get OAuth Token ---
// --- Get OAuth Token (strict via credentialId) ---
let accessToken: string | null = null
try {
accessToken = await getOAuthToken(workflowData.userId, 'airtable')
accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
if (!accessToken) {
logger.error(
`[${requestId}] Failed to obtain valid Airtable access token. Cannot proceed.`,
{ userId: workflowData.userId }
`[${requestId}] Failed to obtain valid Airtable access token via credential ${credentialId}.`
)
throw new Error('Airtable access token not found.')
}
@@ -923,11 +947,11 @@ export async function fetchAndProcessAirtablePayloads(
logger.info(`[${requestId}] Successfully obtained Airtable access token`)
} catch (tokenError: any) {
logger.error(
`[${requestId}] Failed to get Airtable OAuth token for user ${workflowData.userId}`,
`[${requestId}] Failed to get Airtable OAuth token for credential ${credentialId}`,
{
error: tokenError.message,
stack: tokenError.stack,
userId: workflowData.userId,
credentialId,
}
)
// Error logging handled by logging session
@@ -1102,7 +1126,7 @@ export async function fetchAndProcessAirtablePayloads(
// DEBUG: Log totals for this batch
logger.debug(
`[${requestId}] TRACE: Processed ${changeCount} changes in API call ${apiCallCount}`,
`[${requestId}] TRACE: Processed ${changeCount} changes in API call ${apiCallCount})`,
{
currentMapSize: consolidatedChangesMap.size,
}
@@ -1257,14 +1281,47 @@ export async function configureGmailPolling(
logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`)
try {
const accessToken = await getOAuthToken(userId, 'google-email')
if (!accessToken) {
logger.error(`[${requestId}] Failed to retrieve Gmail access token for user ${userId}`)
return false
}
const providerConfig = (webhookData.providerConfig as Record<string, any>) || {}
const credentialId: string | undefined = providerConfig.credentialId
let effectiveUserId: string | null = null
let accessToken: string | null = null
if (credentialId) {
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (rows.length === 0) {
logger.error(
`[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}`
)
return false
}
effectiveUserId = rows[0].userId
accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
if (!accessToken) {
logger.error(
`[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}`
)
return false
}
} else {
// Backward-compat: fall back to workflow owner
if (!userId) {
logger.error(
`[${requestId}] Missing credentialId and userId for Gmail webhook ${webhookData.id}`
)
return false
}
effectiveUserId = userId
accessToken = await getOAuthToken(effectiveUserId, 'google-email')
if (!accessToken) {
logger.error(
`[${requestId}] Failed to obtain Gmail token for user ${effectiveUserId} (fallback)`
)
return false
}
}
const maxEmailsPerPoll =
typeof providerConfig.maxEmailsPerPoll === 'string'
? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25
@@ -1282,7 +1339,8 @@ export async function configureGmailPolling(
.set({
providerConfig: {
...providerConfig,
userId, // Store user ID for OAuth access during polling
userId: effectiveUserId,
...(credentialId ? { credentialId } : {}),
maxEmailsPerPoll,
pollingInterval,
markAsRead: providerConfig.markAsRead || false,
@@ -1323,23 +1381,48 @@ export async function configureOutlookPolling(
logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`)
try {
const accessToken = await getOAuthToken(userId, 'outlook')
if (!accessToken) {
logger.error(`[${requestId}] Failed to retrieve Outlook access token for user ${userId}`)
return false
}
const providerConfig = (webhookData.providerConfig as Record<string, any>) || {}
const maxEmailsPerPoll =
typeof providerConfig.maxEmailsPerPoll === 'string'
? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25
: providerConfig.maxEmailsPerPoll || 25
const credentialId: string | undefined = providerConfig.credentialId
const pollingInterval =
typeof providerConfig.pollingInterval === 'string'
? Number.parseInt(providerConfig.pollingInterval, 10) || 5
: providerConfig.pollingInterval || 5
let effectiveUserId: string | null = null
let accessToken: string | null = null
if (credentialId) {
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (rows.length === 0) {
logger.error(
`[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}`
)
return false
}
effectiveUserId = rows[0].userId
accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
if (!accessToken) {
logger.error(
`[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}`
)
return false
}
} else {
// Backward-compat: fall back to workflow owner
if (!userId) {
logger.error(
`[${requestId}] Missing credentialId and userId for Outlook webhook ${webhookData.id}`
)
return false
}
effectiveUserId = userId
accessToken = await getOAuthToken(effectiveUserId, 'outlook')
if (!accessToken) {
logger.error(
`[${requestId}] Failed to obtain Outlook token for user ${effectiveUserId} (fallback)`
)
return false
}
}
const providerCfg = (webhookData.providerConfig as Record<string, any>) || {}
const now = new Date()
@@ -1347,14 +1430,21 @@ export async function configureOutlookPolling(
.update(webhook)
.set({
providerConfig: {
...providerConfig,
userId, // Store user ID for OAuth access during polling
maxEmailsPerPoll,
pollingInterval,
markAsRead: providerConfig.markAsRead || false,
includeRawEmail: providerConfig.includeRawEmail || false,
folderIds: providerConfig.folderIds || ['inbox'],
folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE',
...providerCfg,
userId: effectiveUserId,
...(credentialId ? { credentialId } : {}),
maxEmailsPerPoll:
typeof providerCfg.maxEmailsPerPoll === 'string'
? Number.parseInt(providerCfg.maxEmailsPerPoll, 10) || 25
: providerCfg.maxEmailsPerPoll || 25,
pollingInterval:
typeof providerCfg.pollingInterval === 'string'
? Number.parseInt(providerCfg.pollingInterval, 10) || 5
: providerCfg.pollingInterval || 5,
markAsRead: providerCfg.markAsRead || false,
includeRawEmail: providerCfg.includeRawEmail || false,
folderIds: providerCfg.folderIds || ['inbox'],
folderFilterBehavior: providerCfg.folderFilterBehavior || 'INCLUDE',
lastCheckedTimestamp: now.toISOString(),
setupCompleted: true,
},

View File

@@ -4,7 +4,7 @@ import { redactApiKeys } from '@/lib/utils'
import type { NormalizedBlockOutput } from '@/executor/types'
import type { ConsoleEntry, ConsoleStore } from '@/stores/panel/console/types'
const MAX_ENTRIES = 50 // MAX across all workflows
const MAX_ENTRIES = 500 // MAX across all workflows - allows for 100 loop iterations + other workflow logs
const MAX_IMAGE_DATA_SIZE = 1000 // Maximum size of image data to store (in characters)
const MAX_ANY_DATA_SIZE = 5000 // Maximum size of any data to store (in characters)
const MAX_TOTAL_ENTRY_SIZE = 50000 // Maximum size of entire entry to prevent localStorage overflow

View File

@@ -109,7 +109,7 @@ describe('workflow store', () => {
expect(state.loops.loop1.forEachItems).toEqual(['item1', 'item2', 'item3'])
})
it('should clamp loop count between 1 and 50', () => {
it('should clamp loop count between 1 and 100', () => {
const { addBlock, updateLoopCount } = useWorkflowStore.getState()
// Add a loop block
@@ -199,7 +199,7 @@ describe('workflow store', () => {
expect(parsedDistribution).toHaveLength(3)
})
it('should clamp parallel count between 1 and 50', () => {
it('should clamp parallel count between 1 and 20', () => {
const { addBlock, updateParallelCount } = useWorkflowStore.getState()
// Add a parallel block

View File

@@ -192,7 +192,18 @@ export class ToolTester<P = any, R = any> {
const response = await this.mockFetch(url, {
method: method,
headers: this.tool.request.headers(params),
body: this.tool.request.body ? JSON.stringify(this.tool.request.body(params)) : undefined,
body: this.tool.request.body
? (() => {
const bodyResult = this.tool.request.body(params)
const headers = this.tool.request.headers(params)
const isPreformattedContent =
headers['Content-Type'] === 'application/x-ndjson' ||
headers['Content-Type'] === 'application/x-www-form-urlencoded'
return isPreformattedContent && typeof bodyResult === 'string'
? bodyResult
: JSON.stringify(bodyResult)
})()
: undefined,
})
if (!response.ok) {

View File

@@ -41,7 +41,7 @@ export const createFolderTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUplo
},
request: {
url: 'https://www.googleapis.com/drive/v3/files',
url: 'https://www.googleapis.com/drive/v3/files?supportsAllDrives=true',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,

View File

@@ -59,6 +59,10 @@ export const listTool: ToolConfig<GoogleDriveToolParams, GoogleDriveListResponse
'fields',
'files(id,name,mimeType,webViewLink,webContentLink,size,createdTime,modifiedTime,parents),nextPageToken'
)
// Ensure shared drives support
url.searchParams.append('supportsAllDrives', 'true')
url.searchParams.append('includeItemsFromAllDrives', 'true')
url.searchParams.append('spaces', 'drive')
// Build the query conditions
const conditions = ['trashed = false'] // Always exclude trashed files

View File

@@ -57,7 +57,7 @@ export const uploadTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUploadResp
},
request: {
url: 'https://www.googleapis.com/drive/v3/files',
url: 'https://www.googleapis.com/drive/v3/files?supportsAllDrives=true',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
@@ -115,7 +115,7 @@ export const uploadTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUploadResp
})
const uploadResponse = await fetch(
`https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=media`,
`https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=media&supportsAllDrives=true`,
{
method: 'PATCH',
headers: {
@@ -144,7 +144,7 @@ export const uploadTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUploadResp
})
const updateNameResponse = await fetch(
`https://www.googleapis.com/drive/v3/files/${fileId}`,
`https://www.googleapis.com/drive/v3/files/${fileId}?supportsAllDrives=true`,
{
method: 'PATCH',
headers: {
@@ -167,7 +167,7 @@ export const uploadTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUploadResp
// Get the final file data
const finalFileResponse = await fetch(
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,webViewLink,webContentLink,size,createdTime,modifiedTime,parents`,
`https://www.googleapis.com/drive/v3/files/${fileId}?supportsAllDrives=true&fields=id,name,mimeType,webViewLink,webContentLink,size,createdTime,modifiedTime,parents`,
{
headers: {
Authorization: authHeader,

View File

@@ -109,6 +109,26 @@ describe('HTTP Request Tool', () => {
})
})
it.concurrent('should respect custom Content-Type headers', () => {
// Custom Content-Type should not be overridden
const headers = tester.getRequestHeaders({
url: 'https://api.example.com',
method: 'POST',
body: { key: 'value' },
headers: [{ Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' }],
})
expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded')
// Case-insensitive Content-Type should not be overridden
const headers2 = tester.getRequestHeaders({
url: 'https://api.example.com',
method: 'POST',
body: { key: 'value' },
headers: [{ Key: 'content-type', Value: 'text/plain' }],
})
expect(headers2['content-type']).toBe('text/plain')
})
it('should set dynamic Referer header correctly', async () => {
const originalWindow = global.window
Object.defineProperty(global, 'window', {
@@ -164,6 +184,30 @@ describe('HTTP Request Tool', () => {
})
})
describe('Body Construction', () => {
it.concurrent('should handle JSON bodies correctly', () => {
const body = { username: 'test', password: 'secret' }
expect(
tester.getRequestBody({
url: 'https://api.example.com',
body,
})
).toEqual(body)
})
it.concurrent('should handle FormData correctly', () => {
const formData = { file: 'test.txt', content: 'file content' }
const result = tester.getRequestBody({
url: 'https://api.example.com',
formData,
})
expect(result).toBeInstanceOf(FormData)
})
})
describe('Request Execution', () => {
it('should apply default and dynamic headers to requests', async () => {
// Setup mock response
@@ -253,6 +297,59 @@ describe('HTTP Request Tool', () => {
expect(bodyArg).toEqual(body)
})
it('should handle POST requests with URL-encoded form data', async () => {
// Setup mock response
tester.setup({ result: 'success' })
// Create test body
const body = { username: 'testuser123', password: 'testpass456', email: 'test@example.com' }
// Execute the tool with form-urlencoded content type
await tester.execute({
url: 'https://api.example.com/oauth/token',
method: 'POST',
body,
headers: [{ cells: { Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' } }],
})
// Verify the request was made with correct headers
const fetchCall = (global.fetch as any).mock.calls[0]
expect(fetchCall[0]).toBe('https://api.example.com/oauth/token')
expect(fetchCall[1].method).toBe('POST')
expect(fetchCall[1].headers['Content-Type']).toBe('application/x-www-form-urlencoded')
// Verify the body is URL-encoded (should not be JSON stringified)
expect(fetchCall[1].body).toBe(
'username=testuser123&password=testpass456&email=test%40example.com'
)
})
it('should handle OAuth client credentials requests', async () => {
// Setup mock response for OAuth token endpoint
tester.setup({ access_token: 'token123', token_type: 'Bearer' })
// Execute OAuth client credentials request
await tester.execute({
url: 'https://oauth.example.com/token',
method: 'POST',
body: { grant_type: 'client_credentials', scope: 'read write' },
headers: [
{ cells: { Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' } },
{ cells: { Key: 'Authorization', Value: 'Basic Y2xpZW50OnNlY3JldA==' } },
],
})
// Verify the OAuth request was properly formatted
const fetchCall = (global.fetch as any).mock.calls[0]
expect(fetchCall[0]).toBe('https://oauth.example.com/token')
expect(fetchCall[1].method).toBe('POST')
expect(fetchCall[1].headers['Content-Type']).toBe('application/x-www-form-urlencoded')
expect(fetchCall[1].headers.Authorization).toBe('Basic Y2xpZW50OnNlY3JldA==')
// Verify the body is URL-encoded
expect(fetchCall[1].body).toBe('grant_type=client_credentials&scope=read+write')
})
it('should handle errors correctly', async () => {
// Setup error response
tester.setup(mockHttpResponses.error, { ok: false, status: 400 })

View File

@@ -67,12 +67,12 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
const processedUrl = processUrl(params.url, params.pathParams, params.params)
const allHeaders = getDefaultHeaders(headers, processedUrl)
// Set appropriate Content-Type
// Set appropriate Content-Type only if not already specified by user
if (params.formData) {
// Don't set Content-Type for FormData, browser will set it with boundary
return allHeaders
}
if (params.body) {
if (params.body && !allHeaders['Content-Type'] && !allHeaders['content-type']) {
allHeaders['Content-Type'] = 'application/json'
}
@@ -89,6 +89,24 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
}
if (params.body) {
// Check if user wants URL-encoded form data
const headers = transformTable(params.headers || null)
const contentType = headers['Content-Type'] || headers['content-type']
if (
contentType === 'application/x-www-form-urlencoded' &&
typeof params.body === 'object'
) {
// Convert JSON object to URL-encoded string
const urlencoded = new URLSearchParams()
Object.entries(params.body).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
urlencoded.append(key, String(value))
}
})
return urlencoded.toString()
}
return params.body
}

View File

@@ -128,6 +128,7 @@ import {
supabaseInsertTool,
supabaseQueryTool,
supabaseUpdateTool,
supabaseUpsertTool,
} from '@/tools/supabase'
import { tavilyExtractTool, tavilySearchTool } from '@/tools/tavily'
import { telegramMessageTool } from '@/tools/telegram'
@@ -190,6 +191,7 @@ export const tools: Record<string, ToolConfig> = {
supabase_get_row: supabaseGetRowTool,
supabase_update: supabaseUpdateTool,
supabase_delete: supabaseDeleteTool,
supabase_upsert: supabaseUpsertTool,
typeform_responses: typeformResponsesTool,
typeform_files: typeformFilesTool,
typeform_insights: typeformInsightsTool,

View File

@@ -59,28 +59,36 @@ export const deleteTool: ToolConfig<SupabaseDeleteParams, SupabaseDeleteResponse
},
transformResponse: async (response: Response) => {
// Handle empty response from delete operations
const text = await response.text()
let data
if (text?.trim()) {
try {
data = JSON.parse(text)
} catch (e) {
// If we can't parse it, just use the text
data = text
} catch (parseError) {
throw new Error(`Failed to parse Supabase response: ${parseError}`)
}
} else {
// Empty response means successful deletion
data = []
}
const deletedCount = Array.isArray(data) ? data.length : text ? 1 : 0
const deletedCount = Array.isArray(data) ? data.length : 0
if (deletedCount === 0) {
return {
success: true,
output: {
message: 'No rows were deleted (no matching records found)',
results: data,
},
error: undefined,
}
}
return {
success: true,
output: {
message: `Successfully deleted ${deletedCount === 0 ? 'row(s)' : `${deletedCount} row(s)`}`,
message: `Successfully deleted ${deletedCount} row${deletedCount === 1 ? '' : 's'}`,
results: data,
},
error: undefined,

View File

@@ -57,14 +57,21 @@ export const getRowTool: ToolConfig<SupabaseGetRowParams, SupabaseGetRowResponse
},
transformResponse: async (response: Response) => {
const data = await response.json()
const row = data.length > 0 ? data[0] : null
let data
try {
data = await response.json()
} catch (parseError) {
throw new Error(`Failed to parse Supabase response: ${parseError}`)
}
const rowFound = data.length > 0
const results = rowFound ? [data[0]] : []
return {
success: true,
output: {
message: row ? 'Successfully found row' : 'No row found matching the criteria',
results: row,
message: rowFound ? 'Successfully found 1 row' : 'No row found matching the criteria',
results: results,
},
error: undefined,
}
@@ -72,6 +79,9 @@ export const getRowTool: ToolConfig<SupabaseGetRowParams, SupabaseGetRowResponse
outputs: {
message: { type: 'string', description: 'Operation status message' },
results: { type: 'object', description: 'The row data if found, null if not found' },
results: {
type: 'array',
description: 'Array containing the row data if found, empty array if not found',
},
},
}

View File

@@ -3,9 +3,11 @@ import { getRowTool } from '@/tools/supabase/get_row'
import { insertTool } from '@/tools/supabase/insert'
import { queryTool } from '@/tools/supabase/query'
import { updateTool } from '@/tools/supabase/update'
import { upsertTool } from '@/tools/supabase/upsert'
export const supabaseQueryTool = queryTool
export const supabaseInsertTool = insertTool
export const supabaseGetRowTool = getRowTool
export const supabaseUpdateTool = updateTool
export const supabaseDeleteTool = deleteTool
export const supabaseUpsertTool = upsertTool

View File

@@ -53,8 +53,8 @@ export const insertTool: ToolConfig<SupabaseInsertParams, SupabaseInsertResponse
},
transformResponse: async (response: Response) => {
// Handle empty response case
const text = await response.text()
if (!text || text.trim() === '') {
return {
success: true,
@@ -66,12 +66,34 @@ export const insertTool: ToolConfig<SupabaseInsertParams, SupabaseInsertResponse
}
}
const data = JSON.parse(text)
let data
try {
data = JSON.parse(text)
} catch (parseError) {
throw new Error(`Failed to parse Supabase response: ${parseError}`)
}
// Check if results array is empty and provide better feedback
const resultsArray = Array.isArray(data) ? data : [data]
const isEmpty = resultsArray.length === 0 || (resultsArray.length === 1 && !resultsArray[0])
if (isEmpty) {
return {
success: false,
output: {
message: 'No data was inserted into Supabase',
results: data,
},
error:
'No data was inserted into Supabase. This usually indicates invalid data format or schema mismatch. Please check that your JSON is valid and matches your table schema.',
}
}
const insertedCount = resultsArray.length
return {
success: true,
output: {
message: 'Successfully inserted data into Supabase',
message: `Successfully inserted ${insertedCount} row${insertedCount === 1 ? '' : 's'} into Supabase`,
results: data,
},
error: undefined,

View File

@@ -79,12 +79,30 @@ export const queryTool: ToolConfig<SupabaseQueryParams, SupabaseQueryResponse> =
},
transformResponse: async (response: Response) => {
const data = await response.json()
let data
try {
data = await response.json()
} catch (parseError) {
throw new Error(`Failed to parse Supabase response: ${parseError}`)
}
const rowCount = Array.isArray(data) ? data.length : 0
if (rowCount === 0) {
return {
success: true,
output: {
message: 'No rows found matching the query criteria',
results: data,
},
error: undefined,
}
}
return {
success: true,
output: {
message: 'Successfully queried data from Supabase',
message: `Successfully queried ${rowCount} row${rowCount === 1 ? '' : 's'} from Supabase`,
results: data,
},
error: undefined,

View File

@@ -38,6 +38,13 @@ export interface SupabaseDeleteParams {
filter: string
}
export interface SupabaseUpsertParams {
apiKey: string
projectId: string
table: string
data: any
}
export interface SupabaseBaseResponse extends ToolResponse {
output: {
message: string
@@ -56,4 +63,6 @@ export interface SupabaseUpdateResponse extends SupabaseBaseResponse {}
export interface SupabaseDeleteResponse extends SupabaseBaseResponse {}
export interface SupabaseUpsertResponse extends SupabaseBaseResponse {}
export interface SupabaseResponse extends SupabaseBaseResponse {}

View File

@@ -63,28 +63,36 @@ export const updateTool: ToolConfig<SupabaseUpdateParams, SupabaseUpdateResponse
},
transformResponse: async (response: Response) => {
// Handle potentially empty response from update operations
const text = await response.text()
let data
if (text?.trim()) {
try {
data = JSON.parse(text)
} catch (e) {
// If we can't parse it, just use the text
data = text
} catch (parseError) {
throw new Error(`Failed to parse Supabase response: ${parseError}`)
}
} else {
// Empty response means successful update
data = []
}
const updatedCount = Array.isArray(data) ? data.length : text ? 1 : 0
const updatedCount = Array.isArray(data) ? data.length : 0
if (updatedCount === 0) {
return {
success: true,
output: {
message: 'No rows were updated (no matching records found)',
results: data,
},
error: undefined,
}
}
return {
success: true,
output: {
message: `Successfully updated ${updatedCount === 0 ? 'row(s)' : `${updatedCount} row(s)`}`,
message: `Successfully updated ${updatedCount} row${updatedCount === 1 ? '' : 's'}`,
results: data,
},
error: undefined,

View File

@@ -0,0 +1,107 @@
import type { SupabaseUpsertParams, SupabaseUpsertResponse } from '@/tools/supabase/types'
import type { ToolConfig } from '@/tools/types'
export const upsertTool: ToolConfig<SupabaseUpsertParams, SupabaseUpsertResponse> = {
id: 'supabase_upsert',
name: 'Supabase Upsert',
description: 'Insert or update data in a Supabase table (upsert operation)',
version: '1.0',
params: {
projectId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
},
table: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'The name of the Supabase table to upsert data into',
},
data: {
type: 'any',
required: true,
visibility: 'user-or-llm',
description: 'The data to upsert (insert or update)',
},
apiKey: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Your Supabase service role secret key',
},
},
request: {
url: (params) => `https://${params.projectId}.supabase.co/rest/v1/${params.table}?select=*`,
method: 'POST',
headers: (params) => ({
apikey: params.apiKey,
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
Prefer: 'return=representation,resolution=merge-duplicates',
}),
body: (params) => {
// Prepare the data - if it's an object but not an array, wrap it in an array
const dataToSend =
typeof params.data === 'object' && !Array.isArray(params.data) ? [params.data] : params.data
return dataToSend
},
},
transformResponse: async (response: Response) => {
const text = await response.text()
if (!text || text.trim() === '') {
return {
success: true,
output: {
message: 'Successfully upserted data into Supabase (no data returned)',
results: [],
},
error: undefined,
}
}
let data
try {
data = JSON.parse(text)
} catch (parseError) {
throw new Error(`Failed to parse Supabase response: ${parseError}`)
}
// Check if results array is empty and provide better feedback
const resultsArray = Array.isArray(data) ? data : [data]
const isEmpty = resultsArray.length === 0 || (resultsArray.length === 1 && !resultsArray[0])
if (isEmpty) {
return {
success: false,
output: {
message: 'No data was upserted into Supabase',
results: data,
},
error:
'No data was upserted into Supabase. This usually indicates invalid data format or schema mismatch. Please check that your JSON is valid and matches your table schema.',
}
}
const upsertedCount = resultsArray.length
return {
success: true,
output: {
message: `Successfully upserted ${upsertedCount} row${upsertedCount === 1 ? '' : 's'} into Supabase`,
results: data,
},
error: undefined,
}
},
outputs: {
message: { type: 'string', description: 'Operation status message' },
results: { type: 'array', description: 'Array of upserted records' },
},
}

View File

@@ -164,7 +164,7 @@ describe('formatRequestParams', () => {
})
// Return a preformatted body
mockTool.request.body = vi.fn().mockReturnValue({ body: 'key1=value1&key2=value2' })
mockTool.request.body = vi.fn().mockReturnValue('key1=value1&key2=value2')
const params = { method: 'POST' }
const result = formatRequestParams(mockTool, params)
@@ -179,9 +179,7 @@ describe('formatRequestParams', () => {
})
// Return a preformatted body for NDJSON
mockTool.request.body = vi.fn().mockReturnValue({
body: '{"prompt": "Hello"}\n{"prompt": "World"}',
})
mockTool.request.body = vi.fn().mockReturnValue('{"prompt": "Hello"}\n{"prompt": "World"}')
const params = { method: 'POST' }
const result = formatRequestParams(mockTool, params)

View File

@@ -63,8 +63,8 @@ export function formatRequestParams(tool: ToolConfig, params: Record<string, any
headers['Content-Type'] === 'application/x-ndjson' ||
headers['Content-Type'] === 'application/x-www-form-urlencoded'
const body = hasBody
? isPreformattedContent && bodyResult
? bodyResult.body
? isPreformattedContent && typeof bodyResult === 'string'
? bodyResult
: JSON.stringify(bodyResult)
: undefined