feat(timeout): add API block timeout configuration (#3053)

* feat(timeout): add timeout subblock to the api block

* fix(timeout): honor timeout config for internal routes and fix type coercion

- Add AbortController support for internal routes (/api/*) to honor timeout
- Fix type coercion: convert string timeout from short-input to number
- Handle NaN gracefully by falling back to undefined (default timeout)

Fixes #2786
Fixes #2242

* fix: remove redundant clearTimeout in catch block

* fix: validate timeout is positive number

Negative timeout values would cause immediate request abort since
JavaScript treats negative setTimeout delays as 0.

* update docs image, update search modal performance

* removed unused keywords type

* ack comments

* cleanup

* fix: add default timeout for internal routes and validate finite timeout

- Internal routes now use same 5-minute default as external routes
- Added Number.isFinite() check to reject Infinity values

* fix: enforce max timeout and improve error message consistency

- Clamp timeout to max 600000ms (10 minutes) as documented
- External routes now report timeout value in error message

* remove unused code
This commit is contained in:
Waleed
2026-01-28 17:14:26 -08:00
committed by GitHub
parent 1bc476f10b
commit 06d7ce7667
11 changed files with 139 additions and 129 deletions

View File

@@ -180,20 +180,6 @@ function resolveCustomToolFromReference(
return null
}
/**
* Checks if a stored custom tool uses the reference-only format.
*
* @remarks
* Reference-only format means the tool has a customToolId but no inline code/schema,
* requiring resolution from the database at runtime.
*
* @param storedTool - The stored tool to check
* @returns `true` if the tool is a reference-only custom tool, `false` otherwise
*/
function isCustomToolReference(storedTool: StoredTool): boolean {
return storedTool.type === 'custom-tool' && !!storedTool.customToolId && !storedTool.code
}
/**
* Generic sync wrapper that synchronizes store values with local component state.
*
@@ -1155,21 +1141,6 @@ export const ToolInput = memo(function ToolInput({
return filterBlocks(allToolBlocks)
}, [filterBlocks])
const customFilter = useCallback((value: string, search: string) => {
if (!search.trim()) return 1
const normalizedValue = value.toLowerCase()
const normalizedSearch = search.toLowerCase()
if (normalizedValue === normalizedSearch) return 1
if (normalizedValue.startsWith(normalizedSearch)) return 0.8
if (normalizedValue.includes(normalizedSearch)) return 0.6
return 0
}, [])
const hasBackfilledRef = useRef(false)
useEffect(() => {
if (

View File

@@ -6,10 +6,10 @@ import { Database, HelpCircle, Layout, Settings } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { createPortal } from 'react-dom'
import { Library } from '@/components/emcn'
import { useBrandConfig } from '@/lib/branding/branding'
import { cn } from '@/lib/core/utils/cn'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSearchModalStore } from '@/stores/modals/search/store'
import type {
SearchBlockItem,
@@ -18,6 +18,23 @@ import type {
} from '@/stores/modals/search/types'
import { useSettingsModalStore } from '@/stores/modals/settings/store'
function customFilter(value: string, search: string): number {
const searchLower = search.toLowerCase()
const valueLower = value.toLowerCase()
if (valueLower === searchLower) return 1
if (valueLower.startsWith(searchLower)) return 0.9
if (valueLower.includes(searchLower)) return 0.7
const searchWords = searchLower.split(/\s+/).filter(Boolean)
if (searchWords.length > 1) {
const allWordsMatch = searchWords.every((word) => valueLower.includes(word))
if (allWordsMatch) return 0.5
}
return 0
}
interface SearchModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -48,6 +65,7 @@ interface PageItem {
href?: string
onClick?: () => void
shortcut?: string
hidden?: boolean
}
export function SearchModal({
@@ -60,11 +78,10 @@ export function SearchModal({
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const brand = useBrandConfig()
const inputRef = useRef<HTMLInputElement>(null)
const [search, setSearch] = useState('')
const [mounted, setMounted] = useState(false)
const openSettingsModal = useSettingsModalStore((state) => state.openModal)
const { config: permissionConfig } = usePermissionConfig()
useEffect(() => {
setMounted(true)
@@ -79,54 +96,66 @@ export function SearchModal({
}, [])
const pages = useMemo(
(): PageItem[] => [
{
id: 'logs',
name: 'Logs',
icon: Library,
href: `/workspace/${workspaceId}/logs`,
shortcut: '⌘⇧L',
},
{
id: 'templates',
name: 'Templates',
icon: Layout,
href: `/workspace/${workspaceId}/templates`,
},
{
id: 'knowledge-base',
name: 'Knowledge Base',
icon: Database,
href: `/workspace/${workspaceId}/knowledge`,
},
{
id: 'help',
name: 'Help',
icon: HelpCircle,
onClick: openHelpModal,
},
{
id: 'settings',
name: 'Settings',
icon: Settings,
onClick: openSettingsModal,
shortcut: '⌘,',
},
],
[workspaceId, openHelpModal, openSettingsModal]
(): PageItem[] =>
[
{
id: 'logs',
name: 'Logs',
icon: Library,
href: `/workspace/${workspaceId}/logs`,
shortcut: '⌘⇧L',
},
{
id: 'templates',
name: 'Templates',
icon: Layout,
href: `/workspace/${workspaceId}/templates`,
hidden: permissionConfig.hideTemplates,
},
{
id: 'knowledge-base',
name: 'Knowledge Base',
icon: Database,
href: `/workspace/${workspaceId}/knowledge`,
hidden: permissionConfig.hideKnowledgeBaseTab,
},
{
id: 'help',
name: 'Help',
icon: HelpCircle,
onClick: openHelpModal,
},
{
id: 'settings',
name: 'Settings',
icon: Settings,
onClick: openSettingsModal,
},
].filter((page) => !page.hidden),
[
workspaceId,
openHelpModal,
openSettingsModal,
permissionConfig.hideTemplates,
permissionConfig.hideKnowledgeBaseTab,
]
)
useEffect(() => {
if (open) {
setSearch('')
requestAnimationFrame(() => {
inputRef.current?.focus()
})
if (open && inputRef.current) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
)?.set
if (nativeInputValueSetter) {
nativeInputValueSetter.call(inputRef.current, '')
inputRef.current.dispatchEvent(new Event('input', { bubbles: true }))
}
inputRef.current.focus()
}
}, [open])
const handleSearchChange = useCallback((value: string) => {
setSearch(value)
const handleSearchChange = useCallback(() => {
requestAnimationFrame(() => {
const list = document.querySelector('[cmdk-list]')
if (list) {
@@ -228,28 +257,6 @@ export function SearchModal({
const showToolOperations = isOnWorkflowPage && toolOperations.length > 0
const showDocs = isOnWorkflowPage && docs.length > 0
const customFilter = useCallback((value: string, search: string, keywords?: string[]) => {
const searchLower = search.toLowerCase()
const valueLower = value.toLowerCase()
if (valueLower === searchLower) return 1
if (valueLower.startsWith(searchLower)) return 0.8
if (valueLower.includes(searchLower)) return 0.6
const searchWords = searchLower.split(/\s+/).filter(Boolean)
const allWordsMatch = searchWords.every((word) => valueLower.includes(word))
if (allWordsMatch && searchWords.length > 0) return 0.4
if (keywords?.length) {
const keywordsLower = keywords.join(' ').toLowerCase()
if (keywordsLower.includes(searchLower)) return 0.3
const keywordWordsMatch = searchWords.every((word) => keywordsLower.includes(word))
if (keywordWordsMatch && searchWords.length > 0) return 0.2
}
return 0
}, [])
if (!mounted) return null
return createPortal(
@@ -278,7 +285,6 @@ export function SearchModal({
<Command label='Search' filter={customFilter}>
<Command.Input
ref={inputRef}
value={search}
autoFocus
onValueChange={handleSearchChange}
placeholder='Search anything...'
@@ -295,7 +301,6 @@ export function SearchModal({
<CommandItem
key={block.id}
value={`${block.name} block-${block.id}`}
keywords={[block.description]}
onSelect={() => handleBlockSelect(block, 'block')}
icon={block.icon}
bgColor={block.bgColor}
@@ -313,7 +318,6 @@ export function SearchModal({
<CommandItem
key={tool.id}
value={`${tool.name} tool-${tool.id}`}
keywords={[tool.description]}
onSelect={() => handleBlockSelect(tool, 'tool')}
icon={tool.icon}
bgColor={tool.bgColor}
@@ -331,7 +335,6 @@ export function SearchModal({
<CommandItem
key={trigger.id}
value={`${trigger.name} trigger-${trigger.id}`}
keywords={[trigger.description]}
onSelect={() => handleBlockSelect(trigger, 'trigger')}
icon={trigger.icon}
bgColor={trigger.bgColor}
@@ -371,7 +374,6 @@ export function SearchModal({
<CommandItem
key={op.id}
value={`${op.searchValue} operation-${op.id}`}
keywords={op.keywords}
onSelect={() => handleToolOperationSelect(op)}
icon={op.icon}
bgColor={op.bgColor}
@@ -458,7 +460,6 @@ const groupHeadingClassName =
interface CommandItemProps {
value: string
keywords?: string[]
onSelect: () => void
icon: React.ComponentType<{ className?: string }>
bgColor: string
@@ -468,7 +469,6 @@ interface CommandItemProps {
function CommandItem({
value,
keywords,
onSelect,
icon: Icon,
bgColor,
@@ -478,7 +478,6 @@ function CommandItem({
return (
<Command.Item
value={value}
keywords={keywords}
onSelect={onSelect}
className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50'
>