mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-22 21:38:05 -05:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9c4251c1c | ||
|
|
748793e07d | ||
|
|
91da7e183a | ||
|
|
ab09a5ad23 | ||
|
|
fcd0240db6 | ||
|
|
4e4149792a | ||
|
|
9a8b591257 | ||
|
|
f3ae3f8442 | ||
|
|
66dfe2c6b2 | ||
|
|
cc2be33d6b | ||
|
|
376f7cb571 | ||
|
|
42159c23b9 | ||
|
|
2f0f246002 | ||
|
|
900d3ef9ea | ||
|
|
f3fcc28f89 | ||
|
|
7cfdf46724 | ||
|
|
d681451297 | ||
|
|
5987a6d060 | ||
|
|
e2ccefb2f4 | ||
|
|
103b31a569 | ||
|
|
004e058353 | ||
|
|
5157f0bbb2 | ||
|
|
8bbcf31b83 | ||
|
|
9e814315dd | ||
|
|
0ea0256623 | ||
|
|
fb8868c854 | ||
|
|
ea4964052d | ||
|
|
268e2f114f | ||
|
|
45371e521e | ||
|
|
5988d0e46f | ||
|
|
145db9d8c3 |
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -22,8 +21,10 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
|||||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
|
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||||
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||||
|
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||||
|
|
||||||
const logger = createLogger('LoginForm')
|
const logger = createLogger('LoginForm')
|
||||||
|
|
||||||
@@ -105,8 +106,7 @@ export default function LoginPage({
|
|||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||||
const [showValidationError, setShowValidationError] = useState(false)
|
const [showValidationError, setShowValidationError] = useState(false)
|
||||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
const buttonClass = useBrandedButtonClass()
|
||||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
|
||||||
|
|
||||||
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
||||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||||
@@ -114,7 +114,6 @@ export default function LoginPage({
|
|||||||
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
|
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
|
||||||
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
|
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
|
||||||
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
|
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
|
||||||
const [isResetButtonHovered, setIsResetButtonHovered] = useState(false)
|
|
||||||
const [resetStatus, setResetStatus] = useState<{
|
const [resetStatus, setResetStatus] = useState<{
|
||||||
type: 'success' | 'error' | null
|
type: 'success' | 'error' | null
|
||||||
message: string
|
message: string
|
||||||
@@ -123,6 +122,7 @@ export default function LoginPage({
|
|||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||||
|
const [resetSuccessMessage, setResetSuccessMessage] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
@@ -139,32 +139,12 @@ export default function LoginPage({
|
|||||||
|
|
||||||
const inviteFlow = searchParams.get('invite_flow') === 'true'
|
const inviteFlow = searchParams.get('invite_flow') === 'true'
|
||||||
setIsInviteFlow(inviteFlow)
|
setIsInviteFlow(inviteFlow)
|
||||||
}
|
|
||||||
|
|
||||||
const checkCustomBrand = () => {
|
const resetSuccess = searchParams.get('resetSuccess') === 'true'
|
||||||
const computedStyle = getComputedStyle(document.documentElement)
|
if (resetSuccess) {
|
||||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
setResetSuccessMessage('Password reset successful. Please sign in with your new password.')
|
||||||
|
|
||||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
|
||||||
setButtonClass('branded-button-custom')
|
|
||||||
} else {
|
|
||||||
setButtonClass('branded-button-gradient')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checkCustomBrand()
|
|
||||||
|
|
||||||
window.addEventListener('resize', checkCustomBrand)
|
|
||||||
const observer = new MutationObserver(checkCustomBrand)
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['style', 'class'],
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', checkCustomBrand)
|
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -202,6 +182,13 @@ export default function LoginPage({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
|
const redirectToVerify = (emailToVerify: string) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
sessionStorage.setItem('verificationEmail', emailToVerify)
|
||||||
|
}
|
||||||
|
router.push('/verify')
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData(e.currentTarget)
|
const formData = new FormData(e.currentTarget)
|
||||||
const emailRaw = formData.get('email') as string
|
const emailRaw = formData.get('email') as string
|
||||||
const email = emailRaw.trim().toLowerCase()
|
const email = emailRaw.trim().toLowerCase()
|
||||||
@@ -221,6 +208,7 @@ export default function LoginPage({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace'
|
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace'
|
||||||
|
let errorHandled = false
|
||||||
|
|
||||||
const result = await client.signIn.email(
|
const result = await client.signIn.email(
|
||||||
{
|
{
|
||||||
@@ -231,11 +219,16 @@ export default function LoginPage({
|
|||||||
{
|
{
|
||||||
onError: (ctx) => {
|
onError: (ctx) => {
|
||||||
logger.error('Login error:', ctx.error)
|
logger.error('Login error:', ctx.error)
|
||||||
const errorMessage: string[] = ['Invalid email or password']
|
|
||||||
|
|
||||||
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
|
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||||
|
errorHandled = true
|
||||||
|
redirectToVerify(email)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
errorHandled = true
|
||||||
|
const errorMessage: string[] = ['Invalid email or password']
|
||||||
|
|
||||||
if (
|
if (
|
||||||
ctx.error.code?.includes('BAD_REQUEST') ||
|
ctx.error.code?.includes('BAD_REQUEST') ||
|
||||||
ctx.error.message?.includes('Email and password sign in is not enabled')
|
ctx.error.message?.includes('Email and password sign in is not enabled')
|
||||||
@@ -271,6 +264,7 @@ export default function LoginPage({
|
|||||||
errorMessage.push('Too many requests. Please wait a moment before trying again.')
|
errorMessage.push('Too many requests. Please wait a moment before trying again.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setResetSuccessMessage(null)
|
||||||
setPasswordErrors(errorMessage)
|
setPasswordErrors(errorMessage)
|
||||||
setShowValidationError(true)
|
setShowValidationError(true)
|
||||||
},
|
},
|
||||||
@@ -278,15 +272,25 @@ export default function LoginPage({
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (!result || result.error) {
|
if (!result || result.error) {
|
||||||
|
// Show error if not already handled by onError callback
|
||||||
|
if (!errorHandled) {
|
||||||
|
setResetSuccessMessage(null)
|
||||||
|
const errorMessage = result?.error?.message || 'Login failed. Please try again.'
|
||||||
|
setPasswordErrors([errorMessage])
|
||||||
|
setShowValidationError(true)
|
||||||
|
}
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear reset success message on successful login
|
||||||
|
setResetSuccessMessage(null)
|
||||||
|
|
||||||
|
// Explicit redirect fallback if better-auth doesn't redirect
|
||||||
|
router.push(safeCallbackUrl)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
|
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||||
if (typeof window !== 'undefined') {
|
redirectToVerify(email)
|
||||||
sessionStorage.setItem('verificationEmail', email)
|
|
||||||
}
|
|
||||||
router.push('/verify')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,6 +404,13 @@ export default function LoginPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Password reset success message */}
|
||||||
|
{resetSuccessMessage && (
|
||||||
|
<div className={`${inter.className} mt-1 space-y-1 text-[#4CAF50] text-xs`}>
|
||||||
|
<p>{resetSuccessMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Email/Password Form - show unless explicitly disabled */}
|
{/* Email/Password Form - show unless explicitly disabled */}
|
||||||
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
|
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
|
||||||
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
|
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
|
||||||
@@ -482,24 +493,14 @@ export default function LoginPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<BrandedButton
|
||||||
type='submit'
|
type='submit'
|
||||||
onMouseEnter={() => setIsButtonHovered(true)}
|
|
||||||
onMouseLeave={() => setIsButtonHovered(false)}
|
|
||||||
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
loadingText='Signing in'
|
||||||
>
|
>
|
||||||
<span className='flex items-center gap-1'>
|
Sign in
|
||||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
</BrandedButton>
|
||||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
|
||||||
{isButtonHovered ? (
|
|
||||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -610,25 +611,15 @@ export default function LoginPage({
|
|||||||
<p>{resetStatus.message}</p>
|
<p>{resetStatus.message}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<BrandedButton
|
||||||
type='button'
|
type='button'
|
||||||
onClick={handleForgotPassword}
|
onClick={handleForgotPassword}
|
||||||
onMouseEnter={() => setIsResetButtonHovered(true)}
|
|
||||||
onMouseLeave={() => setIsResetButtonHovered(false)}
|
|
||||||
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
|
||||||
disabled={isSubmittingReset}
|
disabled={isSubmittingReset}
|
||||||
|
loading={isSubmittingReset}
|
||||||
|
loadingText='Sending'
|
||||||
>
|
>
|
||||||
<span className='flex items-center gap-1'>
|
Send Reset Link
|
||||||
{isSubmittingReset ? 'Sending...' : 'Send Reset Link'}
|
</BrandedButton>
|
||||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
|
||||||
{isResetButtonHovered ? (
|
|
||||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||||
|
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||||
|
|
||||||
interface RequestResetFormProps {
|
interface RequestResetFormProps {
|
||||||
email: string
|
email: string
|
||||||
@@ -27,36 +27,6 @@ export function RequestResetForm({
|
|||||||
statusMessage,
|
statusMessage,
|
||||||
className,
|
className,
|
||||||
}: RequestResetFormProps) {
|
}: RequestResetFormProps) {
|
||||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
|
||||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkCustomBrand = () => {
|
|
||||||
const computedStyle = getComputedStyle(document.documentElement)
|
|
||||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
|
||||||
|
|
||||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
|
||||||
setButtonClass('branded-button-custom')
|
|
||||||
} else {
|
|
||||||
setButtonClass('branded-button-gradient')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkCustomBrand()
|
|
||||||
|
|
||||||
window.addEventListener('resize', checkCustomBrand)
|
|
||||||
const observer = new MutationObserver(checkCustomBrand)
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['style', 'class'],
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', checkCustomBrand)
|
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onSubmit(email)
|
onSubmit(email)
|
||||||
@@ -94,24 +64,14 @@ export function RequestResetForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<BrandedButton
|
||||||
type='submit'
|
type='submit'
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onMouseEnter={() => setIsButtonHovered(true)}
|
loading={isSubmitting}
|
||||||
onMouseLeave={() => setIsButtonHovered(false)}
|
loadingText='Sending'
|
||||||
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
|
||||||
>
|
>
|
||||||
<span className='flex items-center gap-1'>
|
Send Reset Link
|
||||||
{isSubmitting ? 'Sending...' : 'Send Reset Link'}
|
</BrandedButton>
|
||||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
|
||||||
{isButtonHovered ? (
|
|
||||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -138,35 +98,6 @@ export function SetNewPasswordForm({
|
|||||||
const [validationMessage, setValidationMessage] = useState('')
|
const [validationMessage, setValidationMessage] = useState('')
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
|
||||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkCustomBrand = () => {
|
|
||||||
const computedStyle = getComputedStyle(document.documentElement)
|
|
||||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
|
||||||
|
|
||||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
|
||||||
setButtonClass('branded-button-custom')
|
|
||||||
} else {
|
|
||||||
setButtonClass('branded-button-gradient')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkCustomBrand()
|
|
||||||
|
|
||||||
window.addEventListener('resize', checkCustomBrand)
|
|
||||||
const observer = new MutationObserver(checkCustomBrand)
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['style', 'class'],
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', checkCustomBrand)
|
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -296,24 +227,14 @@ export function SetNewPasswordForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<BrandedButton
|
||||||
disabled={isSubmitting || !token}
|
|
||||||
type='submit'
|
type='submit'
|
||||||
onMouseEnter={() => setIsButtonHovered(true)}
|
disabled={isSubmitting || !token}
|
||||||
onMouseLeave={() => setIsButtonHovered(false)}
|
loading={isSubmitting}
|
||||||
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
loadingText='Resetting'
|
||||||
>
|
>
|
||||||
<span className='flex items-center gap-1'>
|
Reset Password
|
||||||
{isSubmitting ? 'Resetting...' : 'Reset Password'}
|
</BrandedButton>
|
||||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
|
||||||
{isButtonHovered ? (
|
|
||||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
import { Suspense, useEffect, useState } from 'react'
|
import { Suspense, useEffect, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { client, useSession } from '@/lib/auth/auth-client'
|
import { client, useSession } from '@/lib/auth/auth-client'
|
||||||
@@ -14,8 +13,10 @@ import { cn } from '@/lib/core/utils/cn'
|
|||||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
|
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||||
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||||
|
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||||
|
|
||||||
const logger = createLogger('SignupForm')
|
const logger = createLogger('SignupForm')
|
||||||
|
|
||||||
@@ -95,8 +96,7 @@ function SignupFormContent({
|
|||||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||||
const [redirectUrl, setRedirectUrl] = useState('')
|
const [redirectUrl, setRedirectUrl] = useState('')
|
||||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
const buttonClass = useBrandedButtonClass()
|
||||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [nameErrors, setNameErrors] = useState<string[]>([])
|
const [nameErrors, setNameErrors] = useState<string[]>([])
|
||||||
@@ -126,31 +126,6 @@ function SignupFormContent({
|
|||||||
if (inviteFlowParam === 'true') {
|
if (inviteFlowParam === 'true') {
|
||||||
setIsInviteFlow(true)
|
setIsInviteFlow(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkCustomBrand = () => {
|
|
||||||
const computedStyle = getComputedStyle(document.documentElement)
|
|
||||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
|
||||||
|
|
||||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
|
||||||
setButtonClass('branded-button-custom')
|
|
||||||
} else {
|
|
||||||
setButtonClass('branded-button-gradient')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkCustomBrand()
|
|
||||||
|
|
||||||
window.addEventListener('resize', checkCustomBrand)
|
|
||||||
const observer = new MutationObserver(checkCustomBrand)
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['style', 'class'],
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', checkCustomBrand)
|
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
const validatePassword = (passwordValue: string): string[] => {
|
const validatePassword = (passwordValue: string): string[] => {
|
||||||
@@ -500,24 +475,14 @@ function SignupFormContent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<BrandedButton
|
||||||
type='submit'
|
type='submit'
|
||||||
onMouseEnter={() => setIsButtonHovered(true)}
|
|
||||||
onMouseLeave={() => setIsButtonHovered(false)}
|
|
||||||
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
loadingText='Creating account'
|
||||||
>
|
>
|
||||||
<span className='flex items-center gap-1'>
|
Create account
|
||||||
{isLoading ? 'Creating account' : 'Create account'}
|
</BrandedButton>
|
||||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
|
||||||
{isButtonHovered ? (
|
|
||||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { cn } from '@/lib/core/utils/cn'
|
|||||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
|
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||||
|
|
||||||
const logger = createLogger('SSOForm')
|
const logger = createLogger('SSOForm')
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ export default function SSOForm() {
|
|||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
const buttonClass = useBrandedButtonClass()
|
||||||
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,31 +91,6 @@ export default function SSOForm() {
|
|||||||
setShowEmailValidationError(true)
|
setShowEmailValidationError(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkCustomBrand = () => {
|
|
||||||
const computedStyle = getComputedStyle(document.documentElement)
|
|
||||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
|
||||||
|
|
||||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
|
||||||
setButtonClass('branded-button-custom')
|
|
||||||
} else {
|
|
||||||
setButtonClass('branded-button-gradient')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkCustomBrand()
|
|
||||||
|
|
||||||
window.addEventListener('resize', checkCustomBrand)
|
|
||||||
const observer = new MutationObserver(checkCustomBrand)
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['style', 'class'],
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', checkCustomBrand)
|
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/core/utils/cn'
|
|||||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
import { useVerification } from '@/app/(auth)/verify/use-verification'
|
import { useVerification } from '@/app/(auth)/verify/use-verification'
|
||||||
|
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||||
|
|
||||||
interface VerifyContentProps {
|
interface VerifyContentProps {
|
||||||
hasEmailService: boolean
|
hasEmailService: boolean
|
||||||
@@ -58,34 +59,7 @@ function VerificationForm({
|
|||||||
setCountdown(30)
|
setCountdown(30)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
const buttonClass = useBrandedButtonClass()
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkCustomBrand = () => {
|
|
||||||
const computedStyle = getComputedStyle(document.documentElement)
|
|
||||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
|
||||||
|
|
||||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
|
||||||
setButtonClass('branded-button-custom')
|
|
||||||
} else {
|
|
||||||
setButtonClass('branded-button-gradient')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkCustomBrand()
|
|
||||||
|
|
||||||
window.addEventListener('resize', checkCustomBrand)
|
|
||||||
const observer = new MutationObserver(checkCustomBrand)
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['style', 'class'],
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', checkCustomBrand)
|
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useRef, useState } from 'react'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { Textarea } from '@/components/emcn'
|
import { Textarea } from '@/components/emcn'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +17,7 @@ import { isHosted } from '@/lib/core/config/feature-flags'
|
|||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
|
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||||
import Footer from '@/app/(landing)/components/footer/footer'
|
import Footer from '@/app/(landing)/components/footer/footer'
|
||||||
import Nav from '@/app/(landing)/components/nav/nav'
|
import Nav from '@/app/(landing)/components/nav/nav'
|
||||||
|
|
||||||
@@ -493,18 +493,17 @@ export default function CareersPage() {
|
|||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<div className='flex justify-end pt-2'>
|
<div className='flex justify-end pt-2'>
|
||||||
<Button
|
<BrandedButton
|
||||||
type='submit'
|
type='submit'
|
||||||
disabled={isSubmitting || submitStatus === 'success'}
|
disabled={isSubmitting || submitStatus === 'success'}
|
||||||
className='min-w-[200px] rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all duration-300 hover:opacity-90 disabled:opacity-50'
|
loading={isSubmitting}
|
||||||
size='lg'
|
loadingText='Submitting'
|
||||||
|
showArrow={false}
|
||||||
|
fullWidth={false}
|
||||||
|
className='min-w-[200px]'
|
||||||
>
|
>
|
||||||
{isSubmitting
|
{submitStatus === 'success' ? 'Submitted' : 'Submit Application'}
|
||||||
? 'Submitting...'
|
</BrandedButton>
|
||||||
: submitStatus === 'success'
|
|
||||||
? 'Submitted'
|
|
||||||
: 'Submit Application'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useBrandConfig } from '@/lib/branding/branding'
|
|||||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||||
|
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||||
|
|
||||||
const logger = createLogger('nav')
|
const logger = createLogger('nav')
|
||||||
|
|
||||||
@@ -20,11 +21,12 @@ interface NavProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
|
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
|
||||||
const [githubStars, setGithubStars] = useState('25.1k')
|
const [githubStars, setGithubStars] = useState('25.8k')
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
const [isLoginHovered, setIsLoginHovered] = useState(false)
|
const [isLoginHovered, setIsLoginHovered] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const brand = useBrandConfig()
|
const brand = useBrandConfig()
|
||||||
|
const buttonClass = useBrandedButtonClass()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (variant !== 'landing') return
|
if (variant !== 'landing') return
|
||||||
@@ -183,7 +185,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
|
|||||||
href='/signup'
|
href='/signup'
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
className='group inline-flex items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[14px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all sm:text-[16px]'
|
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
|
||||||
aria-label='Get started with Sim - Sign up for free'
|
aria-label='Get started with Sim - Sign up for free'
|
||||||
prefetch={true}
|
prefetch={true}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, desc, eq, inArray } from 'drizzle-orm'
|
import { and, desc, eq, inArray } from 'drizzle-orm'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { refreshOAuthToken } from '@/lib/oauth'
|
import { refreshOAuthToken } from '@/lib/oauth'
|
||||||
|
import {
|
||||||
|
getMicrosoftRefreshTokenExpiry,
|
||||||
|
isMicrosoftProvider,
|
||||||
|
PROACTIVE_REFRESH_THRESHOLD_DAYS,
|
||||||
|
} from '@/lib/oauth/microsoft'
|
||||||
|
|
||||||
const logger = createLogger('OAuthUtilsAPI')
|
const logger = createLogger('OAuthUtilsAPI')
|
||||||
|
|
||||||
@@ -205,15 +210,32 @@ export async function refreshAccessTokenIfNeeded(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decide if we should refresh: token missing OR expired
|
// Decide if we should refresh: token missing OR expired
|
||||||
const expiresAt = credential.accessTokenExpiresAt
|
const accessTokenExpiresAt = credential.accessTokenExpiresAt
|
||||||
|
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const shouldRefresh =
|
|
||||||
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
|
// Check if access token needs refresh (missing or expired)
|
||||||
|
const accessTokenNeedsRefresh =
|
||||||
|
!!credential.refreshToken &&
|
||||||
|
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
|
||||||
|
|
||||||
|
// Check if we should proactively refresh to prevent refresh token expiry
|
||||||
|
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
|
||||||
|
const proactiveRefreshThreshold = new Date(
|
||||||
|
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
|
||||||
|
)
|
||||||
|
const refreshTokenNeedsProactiveRefresh =
|
||||||
|
!!credential.refreshToken &&
|
||||||
|
isMicrosoftProvider(credential.providerId) &&
|
||||||
|
refreshTokenExpiresAt &&
|
||||||
|
refreshTokenExpiresAt <= proactiveRefreshThreshold
|
||||||
|
|
||||||
|
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
|
||||||
|
|
||||||
const accessToken = credential.accessToken
|
const accessToken = credential.accessToken
|
||||||
|
|
||||||
if (shouldRefresh) {
|
if (shouldRefresh) {
|
||||||
logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
|
logger.info(`[${requestId}] Refreshing token for credential`)
|
||||||
try {
|
try {
|
||||||
const refreshedToken = await refreshOAuthToken(
|
const refreshedToken = await refreshOAuthToken(
|
||||||
credential.providerId,
|
credential.providerId,
|
||||||
@@ -227,11 +249,15 @@ export async function refreshAccessTokenIfNeeded(
|
|||||||
userId: credential.userId,
|
userId: credential.userId,
|
||||||
hasRefreshToken: !!credential.refreshToken,
|
hasRefreshToken: !!credential.refreshToken,
|
||||||
})
|
})
|
||||||
|
if (!accessTokenNeedsRefresh && accessToken) {
|
||||||
|
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
|
||||||
|
return accessToken
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare update data
|
// Prepare update data
|
||||||
const updateData: any = {
|
const updateData: Record<string, unknown> = {
|
||||||
accessToken: refreshedToken.accessToken,
|
accessToken: refreshedToken.accessToken,
|
||||||
accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000),
|
accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -243,6 +269,10 @@ export async function refreshAccessTokenIfNeeded(
|
|||||||
updateData.refreshToken = refreshedToken.refreshToken
|
updateData.refreshToken = refreshedToken.refreshToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMicrosoftProvider(credential.providerId)) {
|
||||||
|
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
||||||
|
}
|
||||||
|
|
||||||
// Update the token in the database
|
// Update the token in the database
|
||||||
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
||||||
|
|
||||||
@@ -256,6 +286,10 @@ export async function refreshAccessTokenIfNeeded(
|
|||||||
credentialId,
|
credentialId,
|
||||||
userId: credential.userId,
|
userId: credential.userId,
|
||||||
})
|
})
|
||||||
|
if (!accessTokenNeedsRefresh && accessToken) {
|
||||||
|
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
|
||||||
|
return accessToken
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
} else if (!accessToken) {
|
} else if (!accessToken) {
|
||||||
@@ -277,10 +311,27 @@ export async function refreshTokenIfNeeded(
|
|||||||
credentialId: string
|
credentialId: string
|
||||||
): Promise<{ accessToken: string; refreshed: boolean }> {
|
): Promise<{ accessToken: string; refreshed: boolean }> {
|
||||||
// Decide if we should refresh: token missing OR expired
|
// Decide if we should refresh: token missing OR expired
|
||||||
const expiresAt = credential.accessTokenExpiresAt
|
const accessTokenExpiresAt = credential.accessTokenExpiresAt
|
||||||
|
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const shouldRefresh =
|
|
||||||
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
|
// Check if access token needs refresh (missing or expired)
|
||||||
|
const accessTokenNeedsRefresh =
|
||||||
|
!!credential.refreshToken &&
|
||||||
|
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
|
||||||
|
|
||||||
|
// Check if we should proactively refresh to prevent refresh token expiry
|
||||||
|
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
|
||||||
|
const proactiveRefreshThreshold = new Date(
|
||||||
|
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
|
||||||
|
)
|
||||||
|
const refreshTokenNeedsProactiveRefresh =
|
||||||
|
!!credential.refreshToken &&
|
||||||
|
isMicrosoftProvider(credential.providerId) &&
|
||||||
|
refreshTokenExpiresAt &&
|
||||||
|
refreshTokenExpiresAt <= proactiveRefreshThreshold
|
||||||
|
|
||||||
|
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
|
||||||
|
|
||||||
// If token appears valid and present, return it directly
|
// If token appears valid and present, return it directly
|
||||||
if (!shouldRefresh) {
|
if (!shouldRefresh) {
|
||||||
@@ -293,13 +344,17 @@ export async function refreshTokenIfNeeded(
|
|||||||
|
|
||||||
if (!refreshResult) {
|
if (!refreshResult) {
|
||||||
logger.error(`[${requestId}] Failed to refresh token for credential`)
|
logger.error(`[${requestId}] Failed to refresh token for credential`)
|
||||||
|
if (!accessTokenNeedsRefresh && credential.accessToken) {
|
||||||
|
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
|
||||||
|
return { accessToken: credential.accessToken, refreshed: false }
|
||||||
|
}
|
||||||
throw new Error('Failed to refresh token')
|
throw new Error('Failed to refresh token')
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult
|
const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult
|
||||||
|
|
||||||
// Prepare update data
|
// Prepare update data
|
||||||
const updateData: any = {
|
const updateData: Record<string, unknown> = {
|
||||||
accessToken: refreshedToken,
|
accessToken: refreshedToken,
|
||||||
accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry
|
accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -311,6 +366,10 @@ export async function refreshTokenIfNeeded(
|
|||||||
updateData.refreshToken = newRefreshToken
|
updateData.refreshToken = newRefreshToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMicrosoftProvider(credential.providerId)) {
|
||||||
|
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
||||||
|
}
|
||||||
|
|
||||||
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
||||||
|
|
||||||
logger.info(`[${requestId}] Successfully refreshed access token`)
|
logger.info(`[${requestId}] Successfully refreshed access token`)
|
||||||
@@ -331,6 +390,11 @@ export async function refreshTokenIfNeeded(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!accessTokenNeedsRefresh && credential.accessToken) {
|
||||||
|
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
|
||||||
|
return { accessToken: credential.accessToken, refreshed: false }
|
||||||
|
}
|
||||||
|
|
||||||
logger.error(`[${requestId}] Refresh failed and no valid token found in DB`, error)
|
logger.error(`[${requestId}] Refresh failed and no valid token found in DB`, error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ const resetPasswordSchema = z.object({
|
|||||||
.max(100, 'Password must not exceed 100 characters')
|
.max(100, 'Password must not exceed 100 characters')
|
||||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||||
.regex(/[0-9]/, 'Password must contain at least one number'),
|
.regex(/[0-9]/, 'Password must contain at least one number')
|
||||||
|
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { preprocessExecution } from '@/lib/execution/preprocessing'
|
|||||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||||
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
|
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
|
||||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||||
import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils'
|
import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils'
|
||||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ async function getWorkflowInputSchema(workflowId: string): Promise<any[]> {
|
|||||||
.from(workflowBlocks)
|
.from(workflowBlocks)
|
||||||
.where(eq(workflowBlocks.workflowId, workflowId))
|
.where(eq(workflowBlocks.workflowId, workflowId))
|
||||||
|
|
||||||
const startBlock = blocks.find((block) => isValidStartBlockType(block.type))
|
const startBlock = blocks.find((block) => isInputDefinitionTrigger(block.type))
|
||||||
|
|
||||||
if (!startBlock) {
|
if (!startBlock) {
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -276,8 +276,11 @@ describe('Function Execute API Route', () => {
|
|||||||
it.concurrent('should resolve tag variables with <tag_name> syntax', async () => {
|
it.concurrent('should resolve tag variables with <tag_name> syntax', async () => {
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
code: 'return <email>',
|
code: 'return <email>',
|
||||||
params: {
|
blockData: {
|
||||||
email: { id: '123', subject: 'Test Email' },
|
'block-123': { id: '123', subject: 'Test Email' },
|
||||||
|
},
|
||||||
|
blockNameMapping: {
|
||||||
|
email: 'block-123',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -305,9 +308,13 @@ describe('Function Execute API Route', () => {
|
|||||||
it.concurrent('should only match valid variable names in angle brackets', async () => {
|
it.concurrent('should only match valid variable names in angle brackets', async () => {
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
code: 'return <validVar> + "<invalid@email.com>" + <another_valid>',
|
code: 'return <validVar> + "<invalid@email.com>" + <another_valid>',
|
||||||
params: {
|
blockData: {
|
||||||
validVar: 'hello',
|
'block-1': 'hello',
|
||||||
another_valid: 'world',
|
'block-2': 'world',
|
||||||
|
},
|
||||||
|
blockNameMapping: {
|
||||||
|
validvar: 'block-1',
|
||||||
|
another_valid: 'block-2',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -321,28 +328,22 @@ describe('Function Execute API Route', () => {
|
|||||||
it.concurrent(
|
it.concurrent(
|
||||||
'should handle Gmail webhook data with email addresses containing angle brackets',
|
'should handle Gmail webhook data with email addresses containing angle brackets',
|
||||||
async () => {
|
async () => {
|
||||||
const gmailData = {
|
const emailData = {
|
||||||
email: {
|
id: '123',
|
||||||
id: '123',
|
from: 'Waleed Latif <waleed@sim.ai>',
|
||||||
from: 'Waleed Latif <waleed@sim.ai>',
|
to: 'User <user@example.com>',
|
||||||
to: 'User <user@example.com>',
|
subject: 'Test Email',
|
||||||
subject: 'Test Email',
|
bodyText: 'Hello world',
|
||||||
bodyText: 'Hello world',
|
|
||||||
},
|
|
||||||
rawEmail: {
|
|
||||||
id: '123',
|
|
||||||
payload: {
|
|
||||||
headers: [
|
|
||||||
{ name: 'From', value: 'Waleed Latif <waleed@sim.ai>' },
|
|
||||||
{ name: 'To', value: 'User <user@example.com>' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
code: 'return <email>',
|
code: 'return <email>',
|
||||||
params: gmailData,
|
blockData: {
|
||||||
|
'block-email': emailData,
|
||||||
|
},
|
||||||
|
blockNameMapping: {
|
||||||
|
email: 'block-email',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await POST(req)
|
const response = await POST(req)
|
||||||
@@ -356,17 +357,20 @@ describe('Function Execute API Route', () => {
|
|||||||
it.concurrent(
|
it.concurrent(
|
||||||
'should properly serialize complex email objects with special characters',
|
'should properly serialize complex email objects with special characters',
|
||||||
async () => {
|
async () => {
|
||||||
const complexEmailData = {
|
const emailData = {
|
||||||
email: {
|
from: 'Test User <test@example.com>',
|
||||||
from: 'Test User <test@example.com>',
|
bodyHtml: '<div>HTML content with "quotes" and \'apostrophes\'</div>',
|
||||||
bodyHtml: '<div>HTML content with "quotes" and \'apostrophes\'</div>',
|
bodyText: 'Text with\nnewlines\tand\ttabs',
|
||||||
bodyText: 'Text with\nnewlines\tand\ttabs',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
code: 'return <email>',
|
code: 'return <email>',
|
||||||
params: complexEmailData,
|
blockData: {
|
||||||
|
'block-email': emailData,
|
||||||
|
},
|
||||||
|
blockNameMapping: {
|
||||||
|
email: 'block-email',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await POST(req)
|
const response = await POST(req)
|
||||||
@@ -519,18 +523,23 @@ describe('Function Execute API Route', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.concurrent('should handle JSON serialization edge cases', async () => {
|
it.concurrent('should handle JSON serialization edge cases', async () => {
|
||||||
|
const complexData = {
|
||||||
|
special: 'chars"with\'quotes',
|
||||||
|
unicode: '🎉 Unicode content',
|
||||||
|
nested: {
|
||||||
|
deep: {
|
||||||
|
value: 'test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
code: 'return <complexData>',
|
code: 'return <complexData>',
|
||||||
params: {
|
blockData: {
|
||||||
complexData: {
|
'block-complex': complexData,
|
||||||
special: 'chars"with\'quotes',
|
},
|
||||||
unicode: '🎉 Unicode content',
|
blockNameMapping: {
|
||||||
nested: {
|
complexdata: 'block-complex',
|
||||||
deep: {
|
|
||||||
value: 'test',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { executeInE2B } from '@/lib/execution/e2b'
|
|||||||
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
|
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
|
||||||
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
|
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
|
||||||
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
|
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
|
||||||
|
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
|
||||||
import {
|
import {
|
||||||
createEnvVarPattern,
|
createEnvVarPattern,
|
||||||
createWorkflowVariablePattern,
|
createWorkflowVariablePattern,
|
||||||
resolveEnvVarReferences,
|
|
||||||
} from '@/executor/utils/reference-validation'
|
} from '@/executor/utils/reference-validation'
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
@@ -18,8 +18,8 @@ export const MAX_DURATION = 210
|
|||||||
|
|
||||||
const logger = createLogger('FunctionExecuteAPI')
|
const logger = createLogger('FunctionExecuteAPI')
|
||||||
|
|
||||||
const E2B_JS_WRAPPER_LINES = 3 // Lines before user code: ';(async () => {', ' try {', ' const __sim_result = await (async () => {'
|
const E2B_JS_WRAPPER_LINES = 3
|
||||||
const E2B_PYTHON_WRAPPER_LINES = 1 // Lines before user code: 'def __sim_main__():'
|
const E2B_PYTHON_WRAPPER_LINES = 1
|
||||||
|
|
||||||
type TypeScriptModule = typeof import('typescript')
|
type TypeScriptModule = typeof import('typescript')
|
||||||
|
|
||||||
@@ -134,33 +134,21 @@ function extractEnhancedError(
|
|||||||
if (error.stack) {
|
if (error.stack) {
|
||||||
enhanced.stack = error.stack
|
enhanced.stack = error.stack
|
||||||
|
|
||||||
// Parse stack trace to extract line and column information
|
|
||||||
// Handle both compilation errors and runtime errors
|
|
||||||
const stackLines: string[] = error.stack.split('\n')
|
const stackLines: string[] = error.stack.split('\n')
|
||||||
|
|
||||||
for (const line of stackLines) {
|
for (const line of stackLines) {
|
||||||
// Pattern 1: Compilation errors - "user-function.js:6"
|
|
||||||
let match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
let match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
||||||
|
|
||||||
// Pattern 2: Runtime errors - "at user-function.js:5:12"
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
match = line.match(/at\s+user-function\.js:(\d+):(\d+)/)
|
match = line.match(/at\s+user-function\.js:(\d+):(\d+)/)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pattern 3: Generic patterns for any line containing our filename
|
|
||||||
if (!match) {
|
|
||||||
match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
const stackLine = Number.parseInt(match[1], 10)
|
const stackLine = Number.parseInt(match[1], 10)
|
||||||
const stackColumn = match[2] ? Number.parseInt(match[2], 10) : undefined
|
const stackColumn = match[2] ? Number.parseInt(match[2], 10) : undefined
|
||||||
|
|
||||||
// Adjust line number to account for wrapper code
|
|
||||||
// The user code starts at a specific line in our wrapper
|
|
||||||
const adjustedLine = stackLine - userCodeStartLine + 1
|
const adjustedLine = stackLine - userCodeStartLine + 1
|
||||||
|
|
||||||
// Check if this is a syntax error in wrapper code caused by incomplete user code
|
|
||||||
const isWrapperSyntaxError =
|
const isWrapperSyntaxError =
|
||||||
stackLine > userCodeStartLine &&
|
stackLine > userCodeStartLine &&
|
||||||
error.name === 'SyntaxError' &&
|
error.name === 'SyntaxError' &&
|
||||||
@@ -168,7 +156,6 @@ function extractEnhancedError(
|
|||||||
error.message.includes('Unexpected end of input'))
|
error.message.includes('Unexpected end of input'))
|
||||||
|
|
||||||
if (isWrapperSyntaxError && userCode) {
|
if (isWrapperSyntaxError && userCode) {
|
||||||
// Map wrapper syntax errors to the last line of user code
|
|
||||||
const codeLines = userCode.split('\n')
|
const codeLines = userCode.split('\n')
|
||||||
const lastUserLine = codeLines.length
|
const lastUserLine = codeLines.length
|
||||||
enhanced.line = lastUserLine
|
enhanced.line = lastUserLine
|
||||||
@@ -181,7 +168,6 @@ function extractEnhancedError(
|
|||||||
enhanced.line = adjustedLine
|
enhanced.line = adjustedLine
|
||||||
enhanced.column = stackColumn
|
enhanced.column = stackColumn
|
||||||
|
|
||||||
// Extract the actual line content from user code
|
|
||||||
if (userCode) {
|
if (userCode) {
|
||||||
const codeLines = userCode.split('\n')
|
const codeLines = userCode.split('\n')
|
||||||
if (adjustedLine <= codeLines.length) {
|
if (adjustedLine <= codeLines.length) {
|
||||||
@@ -192,7 +178,6 @@ function extractEnhancedError(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stackLine <= userCodeStartLine) {
|
if (stackLine <= userCodeStartLine) {
|
||||||
// Error is in wrapper code itself
|
|
||||||
enhanced.line = stackLine
|
enhanced.line = stackLine
|
||||||
enhanced.column = stackColumn
|
enhanced.column = stackColumn
|
||||||
break
|
break
|
||||||
@@ -200,7 +185,6 @@ function extractEnhancedError(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up stack trace to show user-relevant information
|
|
||||||
const cleanedStackLines: string[] = stackLines
|
const cleanedStackLines: string[] = stackLines
|
||||||
.filter(
|
.filter(
|
||||||
(line: string) =>
|
(line: string) =>
|
||||||
@@ -214,9 +198,6 @@ function extractEnhancedError(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep original message without adding error type prefix
|
|
||||||
// The error type will be added later in createUserFriendlyErrorMessage
|
|
||||||
|
|
||||||
return enhanced
|
return enhanced
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +212,6 @@ function formatE2BError(
|
|||||||
userCode: string,
|
userCode: string,
|
||||||
prologueLineCount: number
|
prologueLineCount: number
|
||||||
): { formattedError: string; cleanedOutput: string } {
|
): { formattedError: string; cleanedOutput: string } {
|
||||||
// Calculate line offset based on language and prologue
|
|
||||||
const wrapperLines =
|
const wrapperLines =
|
||||||
language === CodeLanguage.Python ? E2B_PYTHON_WRAPPER_LINES : E2B_JS_WRAPPER_LINES
|
language === CodeLanguage.Python ? E2B_PYTHON_WRAPPER_LINES : E2B_JS_WRAPPER_LINES
|
||||||
const totalOffset = prologueLineCount + wrapperLines
|
const totalOffset = prologueLineCount + wrapperLines
|
||||||
@@ -241,27 +221,20 @@ function formatE2BError(
|
|||||||
let cleanErrorMsg = ''
|
let cleanErrorMsg = ''
|
||||||
|
|
||||||
if (language === CodeLanguage.Python) {
|
if (language === CodeLanguage.Python) {
|
||||||
// Python error format: "Cell In[X], line Y" followed by error details
|
|
||||||
// Extract line number from the Cell reference
|
|
||||||
const cellMatch = errorOutput.match(/Cell In\[\d+\], line (\d+)/)
|
const cellMatch = errorOutput.match(/Cell In\[\d+\], line (\d+)/)
|
||||||
if (cellMatch) {
|
if (cellMatch) {
|
||||||
const originalLine = Number.parseInt(cellMatch[1], 10)
|
const originalLine = Number.parseInt(cellMatch[1], 10)
|
||||||
userLine = originalLine - totalOffset
|
userLine = originalLine - totalOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract clean error message from the error string
|
|
||||||
// Remove file references like "(detected at line X) (file.py, line Y)"
|
|
||||||
cleanErrorMsg = errorMessage
|
cleanErrorMsg = errorMessage
|
||||||
.replace(/\s*\(detected at line \d+\)/g, '')
|
.replace(/\s*\(detected at line \d+\)/g, '')
|
||||||
.replace(/\s*\([^)]+\.py, line \d+\)/g, '')
|
.replace(/\s*\([^)]+\.py, line \d+\)/g, '')
|
||||||
.trim()
|
.trim()
|
||||||
} else if (language === CodeLanguage.JavaScript) {
|
} else if (language === CodeLanguage.JavaScript) {
|
||||||
// JavaScript error format from E2B: "SyntaxError: /path/file.ts: Message. (line:col)\n\n 9 | ..."
|
|
||||||
// First, extract the error type and message from the first line
|
|
||||||
const firstLineEnd = errorMessage.indexOf('\n')
|
const firstLineEnd = errorMessage.indexOf('\n')
|
||||||
const firstLine = firstLineEnd > 0 ? errorMessage.substring(0, firstLineEnd) : errorMessage
|
const firstLine = firstLineEnd > 0 ? errorMessage.substring(0, firstLineEnd) : errorMessage
|
||||||
|
|
||||||
// Parse: "SyntaxError: /home/user/index.ts: Missing semicolon. (11:9)"
|
|
||||||
const jsErrorMatch = firstLine.match(/^(\w+Error):\s*[^:]+:\s*([^(]+)\.\s*\((\d+):(\d+)\)/)
|
const jsErrorMatch = firstLine.match(/^(\w+Error):\s*[^:]+:\s*([^(]+)\.\s*\((\d+):(\d+)\)/)
|
||||||
if (jsErrorMatch) {
|
if (jsErrorMatch) {
|
||||||
cleanErrorType = jsErrorMatch[1]
|
cleanErrorType = jsErrorMatch[1]
|
||||||
@@ -269,13 +242,11 @@ function formatE2BError(
|
|||||||
const originalLine = Number.parseInt(jsErrorMatch[3], 10)
|
const originalLine = Number.parseInt(jsErrorMatch[3], 10)
|
||||||
userLine = originalLine - totalOffset
|
userLine = originalLine - totalOffset
|
||||||
} else {
|
} else {
|
||||||
// Fallback: look for line number in the arrow pointer line (> 11 |)
|
|
||||||
const arrowMatch = errorMessage.match(/^>\s*(\d+)\s*\|/m)
|
const arrowMatch = errorMessage.match(/^>\s*(\d+)\s*\|/m)
|
||||||
if (arrowMatch) {
|
if (arrowMatch) {
|
||||||
const originalLine = Number.parseInt(arrowMatch[1], 10)
|
const originalLine = Number.parseInt(arrowMatch[1], 10)
|
||||||
userLine = originalLine - totalOffset
|
userLine = originalLine - totalOffset
|
||||||
}
|
}
|
||||||
// Try to extract error type and message
|
|
||||||
const errorMatch = firstLine.match(/^(\w+Error):\s*(.+)/)
|
const errorMatch = firstLine.match(/^(\w+Error):\s*(.+)/)
|
||||||
if (errorMatch) {
|
if (errorMatch) {
|
||||||
cleanErrorType = errorMatch[1]
|
cleanErrorType = errorMatch[1]
|
||||||
@@ -289,13 +260,11 @@ function formatE2BError(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the final clean error message
|
|
||||||
const finalErrorMsg =
|
const finalErrorMsg =
|
||||||
cleanErrorType && cleanErrorMsg
|
cleanErrorType && cleanErrorMsg
|
||||||
? `${cleanErrorType}: ${cleanErrorMsg}`
|
? `${cleanErrorType}: ${cleanErrorMsg}`
|
||||||
: cleanErrorMsg || errorMessage
|
: cleanErrorMsg || errorMessage
|
||||||
|
|
||||||
// Format with line number if available
|
|
||||||
let formattedError = finalErrorMsg
|
let formattedError = finalErrorMsg
|
||||||
if (userLine && userLine > 0) {
|
if (userLine && userLine > 0) {
|
||||||
const codeLines = userCode.split('\n')
|
const codeLines = userCode.split('\n')
|
||||||
@@ -311,7 +280,6 @@ function formatE2BError(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For stdout, just return the clean error message without the full traceback
|
|
||||||
const cleanedOutput = finalErrorMsg
|
const cleanedOutput = finalErrorMsg
|
||||||
|
|
||||||
return { formattedError, cleanedOutput }
|
return { formattedError, cleanedOutput }
|
||||||
@@ -327,7 +295,6 @@ function createUserFriendlyErrorMessage(
|
|||||||
): string {
|
): string {
|
||||||
let errorMessage = enhanced.message
|
let errorMessage = enhanced.message
|
||||||
|
|
||||||
// Add line information if available
|
|
||||||
if (enhanced.line !== undefined) {
|
if (enhanced.line !== undefined) {
|
||||||
let lineInfo = `Line ${enhanced.line}`
|
let lineInfo = `Line ${enhanced.line}`
|
||||||
|
|
||||||
@@ -338,18 +305,14 @@ function createUserFriendlyErrorMessage(
|
|||||||
|
|
||||||
errorMessage = `${lineInfo} - ${errorMessage}`
|
errorMessage = `${lineInfo} - ${errorMessage}`
|
||||||
} else {
|
} else {
|
||||||
// If no line number, try to extract it from stack trace for display
|
|
||||||
if (enhanced.stack) {
|
if (enhanced.stack) {
|
||||||
const stackMatch = enhanced.stack.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
const stackMatch = enhanced.stack.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
||||||
if (stackMatch) {
|
if (stackMatch) {
|
||||||
const line = Number.parseInt(stackMatch[1], 10)
|
const line = Number.parseInt(stackMatch[1], 10)
|
||||||
let lineInfo = `Line ${line}`
|
let lineInfo = `Line ${line}`
|
||||||
|
|
||||||
// Try to get line content if we have userCode
|
|
||||||
if (userCode) {
|
if (userCode) {
|
||||||
const codeLines = userCode.split('\n')
|
const codeLines = userCode.split('\n')
|
||||||
// Note: stackMatch gives us VM line number, need to adjust
|
|
||||||
// This is a fallback case, so we might not have perfect line mapping
|
|
||||||
if (line <= codeLines.length) {
|
if (line <= codeLines.length) {
|
||||||
const lineContent = codeLines[line - 1]?.trim()
|
const lineContent = codeLines[line - 1]?.trim()
|
||||||
if (lineContent) {
|
if (lineContent) {
|
||||||
@@ -363,7 +326,6 @@ function createUserFriendlyErrorMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add error type prefix with consistent naming
|
|
||||||
if (enhanced.name !== 'Error') {
|
if (enhanced.name !== 'Error') {
|
||||||
const errorTypePrefix =
|
const errorTypePrefix =
|
||||||
enhanced.name === 'SyntaxError'
|
enhanced.name === 'SyntaxError'
|
||||||
@@ -374,7 +336,6 @@ function createUserFriendlyErrorMessage(
|
|||||||
? 'Reference Error'
|
? 'Reference Error'
|
||||||
: enhanced.name
|
: enhanced.name
|
||||||
|
|
||||||
// Only add prefix if not already present
|
|
||||||
if (!errorMessage.toLowerCase().includes(errorTypePrefix.toLowerCase())) {
|
if (!errorMessage.toLowerCase().includes(errorTypePrefix.toLowerCase())) {
|
||||||
errorMessage = `${errorTypePrefix}: ${errorMessage}`
|
errorMessage = `${errorTypePrefix}: ${errorMessage}`
|
||||||
}
|
}
|
||||||
@@ -383,9 +344,6 @@ function createUserFriendlyErrorMessage(
|
|||||||
return errorMessage
|
return errorMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves workflow variables with <variable.name> syntax
|
|
||||||
*/
|
|
||||||
function resolveWorkflowVariables(
|
function resolveWorkflowVariables(
|
||||||
code: string,
|
code: string,
|
||||||
workflowVariables: Record<string, any>,
|
workflowVariables: Record<string, any>,
|
||||||
@@ -405,39 +363,35 @@ function resolveWorkflowVariables(
|
|||||||
while ((match = regex.exec(code)) !== null) {
|
while ((match = regex.exec(code)) !== null) {
|
||||||
const variableName = match[1].trim()
|
const variableName = match[1].trim()
|
||||||
|
|
||||||
// Find the variable by name (workflowVariables is indexed by ID, values are variable objects)
|
|
||||||
const foundVariable = Object.entries(workflowVariables).find(
|
const foundVariable = Object.entries(workflowVariables).find(
|
||||||
([_, variable]) => normalizeName(variable.name || '') === variableName
|
([_, variable]) => normalizeName(variable.name || '') === variableName
|
||||||
)
|
)
|
||||||
|
|
||||||
let variableValue: unknown = ''
|
if (!foundVariable) {
|
||||||
if (foundVariable) {
|
const availableVars = Object.values(workflowVariables)
|
||||||
const variable = foundVariable[1]
|
.map((v) => v.name)
|
||||||
variableValue = variable.value
|
.filter(Boolean)
|
||||||
|
throw new Error(
|
||||||
|
`Variable "${variableName}" doesn't exist.` +
|
||||||
|
(availableVars.length > 0 ? ` Available: ${availableVars.join(', ')}` : '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (variable.value !== undefined && variable.value !== null) {
|
const variable = foundVariable[1]
|
||||||
|
let variableValue: unknown = variable.value
|
||||||
|
|
||||||
|
if (variable.value !== undefined && variable.value !== null) {
|
||||||
|
const type = variable.type === 'string' ? 'plain' : variable.type
|
||||||
|
|
||||||
|
if (type === 'number') {
|
||||||
|
variableValue = Number(variableValue)
|
||||||
|
} else if (type === 'boolean') {
|
||||||
|
variableValue = variableValue === 'true' || variableValue === true
|
||||||
|
} else if (type === 'json' && typeof variableValue === 'string') {
|
||||||
try {
|
try {
|
||||||
// Handle 'string' type the same as 'plain' for backward compatibility
|
variableValue = JSON.parse(variableValue)
|
||||||
const type = variable.type === 'string' ? 'plain' : variable.type
|
|
||||||
|
|
||||||
// For plain text, use exactly what's entered without modifications
|
|
||||||
if (type === 'plain' && typeof variableValue === 'string') {
|
|
||||||
// Use as-is for plain text
|
|
||||||
} else if (type === 'number') {
|
|
||||||
variableValue = Number(variableValue)
|
|
||||||
} else if (type === 'boolean') {
|
|
||||||
variableValue = variableValue === 'true' || variableValue === true
|
|
||||||
} else if (type === 'json') {
|
|
||||||
try {
|
|
||||||
variableValue =
|
|
||||||
typeof variableValue === 'string' ? JSON.parse(variableValue) : variableValue
|
|
||||||
} catch {
|
|
||||||
// Keep original value if JSON parsing fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback to original value on error
|
// Keep as-is
|
||||||
variableValue = variable.value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,11 +404,9 @@ function resolveWorkflowVariables(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process replacements in reverse order to maintain correct indices
|
|
||||||
for (let i = replacements.length - 1; i >= 0; i--) {
|
for (let i = replacements.length - 1; i >= 0; i--) {
|
||||||
const { match: matchStr, index, variableName, variableValue } = replacements[i]
|
const { match: matchStr, index, variableName, variableValue } = replacements[i]
|
||||||
|
|
||||||
// Use variable reference approach
|
|
||||||
const safeVarName = `__variable_${variableName.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
const safeVarName = `__variable_${variableName.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||||
contextVariables[safeVarName] = variableValue
|
contextVariables[safeVarName] = variableValue
|
||||||
resolvedCode =
|
resolvedCode =
|
||||||
@@ -464,9 +416,6 @@ function resolveWorkflowVariables(
|
|||||||
return resolvedCode
|
return resolvedCode
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves environment variables with {{var_name}} syntax
|
|
||||||
*/
|
|
||||||
function resolveEnvironmentVariables(
|
function resolveEnvironmentVariables(
|
||||||
code: string,
|
code: string,
|
||||||
params: Record<string, any>,
|
params: Record<string, any>,
|
||||||
@@ -482,32 +431,28 @@ function resolveEnvironmentVariables(
|
|||||||
|
|
||||||
const resolverVars: Record<string, string> = {}
|
const resolverVars: Record<string, string> = {}
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
if (value) {
|
if (value !== undefined && value !== null) {
|
||||||
resolverVars[key] = String(value)
|
resolverVars[key] = String(value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
Object.entries(envVars).forEach(([key, value]) => {
|
Object.entries(envVars).forEach(([key, value]) => {
|
||||||
if (value) {
|
if (value !== undefined && value !== null) {
|
||||||
resolverVars[key] = value
|
resolverVars[key] = value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
while ((match = regex.exec(code)) !== null) {
|
while ((match = regex.exec(code)) !== null) {
|
||||||
const varName = match[1].trim()
|
const varName = match[1].trim()
|
||||||
const resolved = resolveEnvVarReferences(match[0], resolverVars, {
|
|
||||||
allowEmbedded: true,
|
if (!(varName in resolverVars)) {
|
||||||
resolveExactMatch: true,
|
continue
|
||||||
trimKeys: true,
|
}
|
||||||
onMissing: 'empty',
|
|
||||||
deep: false,
|
|
||||||
})
|
|
||||||
const varValue =
|
|
||||||
typeof resolved === 'string' ? resolved : resolved == null ? '' : String(resolved)
|
|
||||||
replacements.push({
|
replacements.push({
|
||||||
match: match[0],
|
match: match[0],
|
||||||
index: match.index,
|
index: match.index,
|
||||||
varName,
|
varName,
|
||||||
varValue: String(varValue),
|
varValue: resolverVars[varName],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,64 +468,59 @@ function resolveEnvironmentVariables(
|
|||||||
return resolvedCode
|
return resolvedCode
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves tags with <tag_name> syntax (including nested paths like <block.response.data>)
|
|
||||||
*/
|
|
||||||
function resolveTagVariables(
|
function resolveTagVariables(
|
||||||
code: string,
|
code: string,
|
||||||
params: Record<string, any>,
|
blockData: Record<string, unknown>,
|
||||||
blockData: Record<string, any>,
|
|
||||||
blockNameMapping: Record<string, string>,
|
blockNameMapping: Record<string, string>,
|
||||||
contextVariables: Record<string, any>
|
blockOutputSchemas: Record<string, OutputSchema>,
|
||||||
|
contextVariables: Record<string, unknown>,
|
||||||
|
language = 'javascript'
|
||||||
): string {
|
): string {
|
||||||
let resolvedCode = code
|
let resolvedCode = code
|
||||||
|
const undefinedLiteral = language === 'python' ? 'None' : 'undefined'
|
||||||
|
|
||||||
const tagPattern = new RegExp(
|
const tagPattern = new RegExp(
|
||||||
`${REFERENCE.START}([a-zA-Z_][a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])${REFERENCE.END}`,
|
`${REFERENCE.START}([a-zA-Z_](?:[a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])?)${REFERENCE.END}`,
|
||||||
'g'
|
'g'
|
||||||
)
|
)
|
||||||
const tagMatches = resolvedCode.match(tagPattern) || []
|
const tagMatches = resolvedCode.match(tagPattern) || []
|
||||||
|
|
||||||
for (const match of tagMatches) {
|
for (const match of tagMatches) {
|
||||||
const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim()
|
const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim()
|
||||||
|
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
|
||||||
|
const blockName = pathParts[0]
|
||||||
|
const fieldPath = pathParts.slice(1)
|
||||||
|
|
||||||
// Handle nested paths like "getrecord.response.data" or "function1.response.result"
|
const result = resolveBlockReference(blockName, fieldPath, {
|
||||||
// First try params, then blockData directly, then try with block name mapping
|
blockNameMapping,
|
||||||
let tagValue = getNestedValue(params, tagName) || getNestedValue(blockData, tagName) || ''
|
blockData,
|
||||||
|
blockOutputSchemas,
|
||||||
|
})
|
||||||
|
|
||||||
// If not found and the path starts with a block name, try mapping the block name to ID
|
if (!result) {
|
||||||
if (!tagValue && tagName.includes(REFERENCE.PATH_DELIMITER)) {
|
continue
|
||||||
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
|
}
|
||||||
const normalizedBlockName = pathParts[0] // This should already be normalized like "function1"
|
|
||||||
|
|
||||||
// Direct lookup using normalized block name
|
let tagValue = result.value
|
||||||
const blockId = blockNameMapping[normalizedBlockName] ?? null
|
|
||||||
|
|
||||||
if (blockId) {
|
if (tagValue === undefined) {
|
||||||
const remainingPath = pathParts.slice(1).join('.')
|
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), undefinedLiteral)
|
||||||
const fullPath = `${blockId}.${remainingPath}`
|
continue
|
||||||
tagValue = getNestedValue(blockData, fullPath) || ''
|
}
|
||||||
|
|
||||||
|
if (typeof tagValue === 'string') {
|
||||||
|
const trimmed = tagValue.trimStart()
|
||||||
|
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
tagValue = JSON.parse(tagValue)
|
||||||
|
} catch {
|
||||||
|
// Keep as string if not valid JSON
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the value is a stringified JSON, parse it back to object
|
const safeVarName = `__tag_${tagName.replace(/_/g, '_1').replace(/\./g, '_0')}`
|
||||||
if (
|
|
||||||
typeof tagValue === 'string' &&
|
|
||||||
tagValue.length > 100 &&
|
|
||||||
(tagValue.startsWith('{') || tagValue.startsWith('['))
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
tagValue = JSON.parse(tagValue)
|
|
||||||
} catch (e) {
|
|
||||||
// Keep as string if parsing fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instead of injecting large JSON directly, create a variable reference
|
|
||||||
const safeVarName = `__tag_${tagName.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
|
||||||
contextVariables[safeVarName] = tagValue
|
contextVariables[safeVarName] = tagValue
|
||||||
|
|
||||||
// Replace the template with a variable reference
|
|
||||||
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
|
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,44 +536,31 @@ function resolveTagVariables(
|
|||||||
*/
|
*/
|
||||||
function resolveCodeVariables(
|
function resolveCodeVariables(
|
||||||
code: string,
|
code: string,
|
||||||
params: Record<string, any>,
|
params: Record<string, unknown>,
|
||||||
envVars: Record<string, string> = {},
|
envVars: Record<string, string> = {},
|
||||||
blockData: Record<string, any> = {},
|
blockData: Record<string, unknown> = {},
|
||||||
blockNameMapping: Record<string, string> = {},
|
blockNameMapping: Record<string, string> = {},
|
||||||
workflowVariables: Record<string, any> = {}
|
blockOutputSchemas: Record<string, OutputSchema> = {},
|
||||||
): { resolvedCode: string; contextVariables: Record<string, any> } {
|
workflowVariables: Record<string, unknown> = {},
|
||||||
|
language = 'javascript'
|
||||||
|
): { resolvedCode: string; contextVariables: Record<string, unknown> } {
|
||||||
let resolvedCode = code
|
let resolvedCode = code
|
||||||
const contextVariables: Record<string, any> = {}
|
const contextVariables: Record<string, unknown> = {}
|
||||||
|
|
||||||
// Resolve workflow variables with <variable.name> syntax first
|
|
||||||
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
|
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
|
||||||
|
|
||||||
// Resolve environment variables with {{var_name}} syntax
|
|
||||||
resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
|
resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
|
||||||
|
|
||||||
// Resolve tags with <tag_name> syntax (including nested paths like <block.response.data>)
|
|
||||||
resolvedCode = resolveTagVariables(
|
resolvedCode = resolveTagVariables(
|
||||||
resolvedCode,
|
resolvedCode,
|
||||||
params,
|
|
||||||
blockData,
|
blockData,
|
||||||
blockNameMapping,
|
blockNameMapping,
|
||||||
contextVariables
|
blockOutputSchemas,
|
||||||
|
contextVariables,
|
||||||
|
language
|
||||||
)
|
)
|
||||||
|
|
||||||
return { resolvedCode, contextVariables }
|
return { resolvedCode, contextVariables }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get nested value from object using dot notation path
|
|
||||||
*/
|
|
||||||
function getNestedValue(obj: any, path: string): any {
|
|
||||||
if (!obj || !path) return undefined
|
|
||||||
|
|
||||||
return path.split('.').reduce((current, key) => {
|
|
||||||
return current && typeof current === 'object' ? current[key] : undefined
|
|
||||||
}, obj)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove one trailing newline from stdout
|
* Remove one trailing newline from stdout
|
||||||
* This handles the common case where print() or console.log() adds a trailing \n
|
* This handles the common case where print() or console.log() adds a trailing \n
|
||||||
@@ -666,12 +593,12 @@ export async function POST(req: NextRequest) {
|
|||||||
envVars = {},
|
envVars = {},
|
||||||
blockData = {},
|
blockData = {},
|
||||||
blockNameMapping = {},
|
blockNameMapping = {},
|
||||||
|
blockOutputSchemas = {},
|
||||||
workflowVariables = {},
|
workflowVariables = {},
|
||||||
workflowId,
|
workflowId,
|
||||||
isCustomTool = false,
|
isCustomTool = false,
|
||||||
} = body
|
} = body
|
||||||
|
|
||||||
// Extract internal parameters that shouldn't be passed to the execution context
|
|
||||||
const executionParams = { ...params }
|
const executionParams = { ...params }
|
||||||
executionParams._context = undefined
|
executionParams._context = undefined
|
||||||
|
|
||||||
@@ -683,21 +610,21 @@ export async function POST(req: NextRequest) {
|
|||||||
isCustomTool,
|
isCustomTool,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Resolve variables in the code with workflow environment variables
|
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
|
||||||
|
|
||||||
const codeResolution = resolveCodeVariables(
|
const codeResolution = resolveCodeVariables(
|
||||||
code,
|
code,
|
||||||
executionParams,
|
executionParams,
|
||||||
envVars,
|
envVars,
|
||||||
blockData,
|
blockData,
|
||||||
blockNameMapping,
|
blockNameMapping,
|
||||||
workflowVariables
|
blockOutputSchemas,
|
||||||
|
workflowVariables,
|
||||||
|
lang
|
||||||
)
|
)
|
||||||
resolvedCode = codeResolution.resolvedCode
|
resolvedCode = codeResolution.resolvedCode
|
||||||
const contextVariables = codeResolution.contextVariables
|
const contextVariables = codeResolution.contextVariables
|
||||||
|
|
||||||
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
|
|
||||||
|
|
||||||
// Extract imports once for JavaScript code (reuse later to avoid double extraction)
|
|
||||||
let jsImports = ''
|
let jsImports = ''
|
||||||
let jsRemainingCode = resolvedCode
|
let jsRemainingCode = resolvedCode
|
||||||
let hasImports = false
|
let hasImports = false
|
||||||
@@ -707,31 +634,22 @@ export async function POST(req: NextRequest) {
|
|||||||
jsImports = extractionResult.imports
|
jsImports = extractionResult.imports
|
||||||
jsRemainingCode = extractionResult.remainingCode
|
jsRemainingCode = extractionResult.remainingCode
|
||||||
|
|
||||||
// Check for ES6 imports or CommonJS require statements
|
|
||||||
// ES6 imports are extracted by the TypeScript parser
|
|
||||||
// Also check for require() calls which indicate external dependencies
|
|
||||||
const hasRequireStatements = /require\s*\(\s*['"`]/.test(resolvedCode)
|
const hasRequireStatements = /require\s*\(\s*['"`]/.test(resolvedCode)
|
||||||
hasImports = jsImports.trim().length > 0 || hasRequireStatements
|
hasImports = jsImports.trim().length > 0 || hasRequireStatements
|
||||||
}
|
}
|
||||||
|
|
||||||
// Python always requires E2B
|
|
||||||
if (lang === CodeLanguage.Python && !isE2bEnabled) {
|
if (lang === CodeLanguage.Python && !isE2bEnabled) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.'
|
'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// JavaScript with imports requires E2B
|
|
||||||
if (lang === CodeLanguage.JavaScript && hasImports && !isE2bEnabled) {
|
if (lang === CodeLanguage.JavaScript && hasImports && !isE2bEnabled) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'JavaScript code with import statements requires E2B to be enabled. Please remove the import statements, or contact your administrator to enable E2B.'
|
'JavaScript code with import statements requires E2B to be enabled. Please remove the import statements, or contact your administrator to enable E2B.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use E2B if:
|
|
||||||
// - E2B is enabled AND
|
|
||||||
// - Not a custom tool AND
|
|
||||||
// - (Python OR JavaScript with imports)
|
|
||||||
const useE2B =
|
const useE2B =
|
||||||
isE2bEnabled &&
|
isE2bEnabled &&
|
||||||
!isCustomTool &&
|
!isCustomTool &&
|
||||||
@@ -744,13 +662,10 @@ export async function POST(req: NextRequest) {
|
|||||||
language: lang,
|
language: lang,
|
||||||
})
|
})
|
||||||
let prologue = ''
|
let prologue = ''
|
||||||
const epilogue = ''
|
|
||||||
|
|
||||||
if (lang === CodeLanguage.JavaScript) {
|
if (lang === CodeLanguage.JavaScript) {
|
||||||
// Track prologue lines for error adjustment
|
|
||||||
let prologueLineCount = 0
|
let prologueLineCount = 0
|
||||||
|
|
||||||
// Reuse the imports we already extracted earlier
|
|
||||||
const imports = jsImports
|
const imports = jsImports
|
||||||
const remainingCode = jsRemainingCode
|
const remainingCode = jsRemainingCode
|
||||||
|
|
||||||
@@ -765,7 +680,11 @@ export async function POST(req: NextRequest) {
|
|||||||
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
|
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
|
||||||
prologueLineCount++
|
prologueLineCount++
|
||||||
for (const [k, v] of Object.entries(contextVariables)) {
|
for (const [k, v] of Object.entries(contextVariables)) {
|
||||||
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
|
if (v === undefined) {
|
||||||
|
prologue += `const ${k} = undefined;\n`
|
||||||
|
} else {
|
||||||
|
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
|
||||||
|
}
|
||||||
prologueLineCount++
|
prologueLineCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,7 +701,7 @@ export async function POST(req: NextRequest) {
|
|||||||
' }',
|
' }',
|
||||||
'})();',
|
'})();',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
const codeForE2B = importSection + prologue + wrapped + epilogue
|
const codeForE2B = importSection + prologue + wrapped
|
||||||
|
|
||||||
const execStart = Date.now()
|
const execStart = Date.now()
|
||||||
const {
|
const {
|
||||||
@@ -804,7 +723,6 @@ export async function POST(req: NextRequest) {
|
|||||||
error: e2bError,
|
error: e2bError,
|
||||||
})
|
})
|
||||||
|
|
||||||
// If there was an execution error, format it properly
|
|
||||||
if (e2bError) {
|
if (e2bError) {
|
||||||
const { formattedError, cleanedOutput } = formatE2BError(
|
const { formattedError, cleanedOutput } = formatE2BError(
|
||||||
e2bError,
|
e2bError,
|
||||||
@@ -828,7 +746,7 @@ export async function POST(req: NextRequest) {
|
|||||||
output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime },
|
output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Track prologue lines for error adjustment
|
|
||||||
let prologueLineCount = 0
|
let prologueLineCount = 0
|
||||||
prologue += 'import json\n'
|
prologue += 'import json\n'
|
||||||
prologueLineCount++
|
prologueLineCount++
|
||||||
@@ -837,7 +755,11 @@ export async function POST(req: NextRequest) {
|
|||||||
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
|
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
|
||||||
prologueLineCount++
|
prologueLineCount++
|
||||||
for (const [k, v] of Object.entries(contextVariables)) {
|
for (const [k, v] of Object.entries(contextVariables)) {
|
||||||
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
|
if (v === undefined) {
|
||||||
|
prologue += `${k} = None\n`
|
||||||
|
} else {
|
||||||
|
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
|
||||||
|
}
|
||||||
prologueLineCount++
|
prologueLineCount++
|
||||||
}
|
}
|
||||||
const wrapped = [
|
const wrapped = [
|
||||||
@@ -846,7 +768,7 @@ export async function POST(req: NextRequest) {
|
|||||||
'__sim_result__ = __sim_main__()',
|
'__sim_result__ = __sim_main__()',
|
||||||
"print('__SIM_RESULT__=' + json.dumps(__sim_result__))",
|
"print('__SIM_RESULT__=' + json.dumps(__sim_result__))",
|
||||||
].join('\n')
|
].join('\n')
|
||||||
const codeForE2B = prologue + wrapped + epilogue
|
const codeForE2B = prologue + wrapped
|
||||||
|
|
||||||
const execStart = Date.now()
|
const execStart = Date.now()
|
||||||
const {
|
const {
|
||||||
@@ -868,7 +790,6 @@ export async function POST(req: NextRequest) {
|
|||||||
error: e2bError,
|
error: e2bError,
|
||||||
})
|
})
|
||||||
|
|
||||||
// If there was an execution error, format it properly
|
|
||||||
if (e2bError) {
|
if (e2bError) {
|
||||||
const { formattedError, cleanedOutput } = formatE2BError(
|
const { formattedError, cleanedOutput } = formatE2BError(
|
||||||
e2bError,
|
e2bError,
|
||||||
@@ -897,7 +818,6 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const wrapperLines = ['(async () => {', ' try {']
|
const wrapperLines = ['(async () => {', ' try {']
|
||||||
if (isCustomTool) {
|
if (isCustomTool) {
|
||||||
wrapperLines.push(' // For custom tools, make parameters directly accessible')
|
|
||||||
Object.keys(executionParams).forEach((key) => {
|
Object.keys(executionParams).forEach((key) => {
|
||||||
wrapperLines.push(` const ${key} = params.${key};`)
|
wrapperLines.push(` const ${key} = params.${key};`)
|
||||||
})
|
})
|
||||||
@@ -931,12 +851,10 @@ export async function POST(req: NextRequest) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const ivmError = isolatedResult.error
|
const ivmError = isolatedResult.error
|
||||||
// Adjust line number for prepended param destructuring in custom tools
|
|
||||||
let adjustedLine = ivmError.line
|
let adjustedLine = ivmError.line
|
||||||
let adjustedLineContent = ivmError.lineContent
|
let adjustedLineContent = ivmError.lineContent
|
||||||
if (prependedLineCount > 0 && ivmError.line !== undefined) {
|
if (prependedLineCount > 0 && ivmError.line !== undefined) {
|
||||||
adjustedLine = Math.max(1, ivmError.line - prependedLineCount)
|
adjustedLine = Math.max(1, ivmError.line - prependedLineCount)
|
||||||
// Get line content from original user code, not the prepended code
|
|
||||||
const codeLines = resolvedCode.split('\n')
|
const codeLines = resolvedCode.split('\n')
|
||||||
if (adjustedLine <= codeLines.length) {
|
if (adjustedLine <= codeLines.length) {
|
||||||
adjustedLineContent = codeLines[adjustedLine - 1]?.trim()
|
adjustedLineContent = codeLines[adjustedLine - 1]?.trim()
|
||||||
|
|||||||
27
apps/sim/app/changelog/components/branded-link.tsx
Normal file
27
apps/sim/app/changelog/components/branded-link.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||||
|
|
||||||
|
interface BrandedLinkProps {
|
||||||
|
href: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
target?: string
|
||||||
|
rel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrandedLink({ href, children, className = '', target, rel }: BrandedLinkProps) {
|
||||||
|
const buttonClass = useBrandedButtonClass()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
target={target}
|
||||||
|
rel={rel}
|
||||||
|
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { BookOpen, Github, Rss } from 'lucide-react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
|
import { BrandedLink } from '@/app/changelog/components/branded-link'
|
||||||
import ChangelogList from '@/app/changelog/components/timeline-list'
|
import ChangelogList from '@/app/changelog/components/timeline-list'
|
||||||
|
|
||||||
export interface ChangelogEntry {
|
export interface ChangelogEntry {
|
||||||
@@ -66,25 +67,24 @@ export default async function ChangelogContent() {
|
|||||||
<hr className='mt-6 border-border' />
|
<hr className='mt-6 border-border' />
|
||||||
|
|
||||||
<div className='mt-6 flex flex-wrap items-center gap-3 text-sm'>
|
<div className='mt-6 flex flex-wrap items-center gap-3 text-sm'>
|
||||||
<Link
|
<BrandedLink
|
||||||
href='https://github.com/simstudioai/sim/releases'
|
href='https://github.com/simstudioai/sim/releases'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
className='group inline-flex items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[14px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all sm:text-[16px]'
|
|
||||||
>
|
>
|
||||||
<Github className='h-4 w-4' />
|
<Github className='h-4 w-4' />
|
||||||
View on GitHub
|
View on GitHub
|
||||||
</Link>
|
</BrandedLink>
|
||||||
<Link
|
<Link
|
||||||
href='https://docs.sim.ai'
|
href='https://docs.sim.ai'
|
||||||
className='inline-flex items-center gap-2 rounded-md border border-border px-3 py-1.5 hover:bg-muted'
|
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted'
|
||||||
>
|
>
|
||||||
<BookOpen className='h-4 w-4' />
|
<BookOpen className='h-4 w-4' />
|
||||||
Documentation
|
Documentation
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href='/changelog.xml'
|
href='/changelog.xml'
|
||||||
className='inline-flex items-center gap-2 rounded-md border border-border px-3 py-1.5 hover:bg-muted'
|
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted'
|
||||||
>
|
>
|
||||||
<Rss className='h-4 w-4' />
|
<Rss className='h-4 w-4' />
|
||||||
RSS Feed
|
RSS Feed
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const [starCount, setStarCount] = useState('25.1k')
|
const [starCount, setStarCount] = useState('25.8k')
|
||||||
const [conversationId, setConversationId] = useState('')
|
const [conversationId, setConversationId] = useState('')
|
||||||
|
|
||||||
const [showScrollButton, setShowScrollButton] = useState(false)
|
const [showScrollButton, setShowScrollButton] = useState(false)
|
||||||
|
|||||||
@@ -207,7 +207,6 @@ function TemplateCardInner({
|
|||||||
isPannable={false}
|
isPannable={false}
|
||||||
defaultZoom={0.8}
|
defaultZoom={0.8}
|
||||||
fitPadding={0.2}
|
fitPadding={0.2}
|
||||||
lightweight
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className='h-full w-full bg-[var(--surface-4)]' />
|
<div className='h-full w-full bg-[var(--surface-4)]' />
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import {
|
import {
|
||||||
BlockDetailsSidebar,
|
|
||||||
getLeftmostBlockId,
|
getLeftmostBlockId,
|
||||||
|
PreviewEditor,
|
||||||
WorkflowPreview,
|
WorkflowPreview,
|
||||||
} from '@/app/workspace/[workspaceId]/w/components/preview'
|
} from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||||
import { useExecutionSnapshot } from '@/hooks/queries/logs'
|
import { useExecutionSnapshot } from '@/hooks/queries/logs'
|
||||||
@@ -248,11 +248,10 @@ export function ExecutionSnapshot({
|
|||||||
cursorStyle='pointer'
|
cursorStyle='pointer'
|
||||||
executedBlocks={blockExecutions}
|
executedBlocks={blockExecutions}
|
||||||
selectedBlockId={pinnedBlockId}
|
selectedBlockId={pinnedBlockId}
|
||||||
lightweight
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
|
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
|
||||||
<BlockDetailsSidebar
|
<PreviewEditor
|
||||||
block={workflowState.blocks[pinnedBlockId]}
|
block={workflowState.blocks[pinnedBlockId]}
|
||||||
executionData={blockExecutions[pinnedBlockId]}
|
executionData={blockExecutions[pinnedBlockId]}
|
||||||
allBlockExecutions={blockExecutions}
|
allBlockExecutions={blockExecutions}
|
||||||
|
|||||||
@@ -213,7 +213,6 @@ function TemplateCardInner({
|
|||||||
isPannable={false}
|
isPannable={false}
|
||||||
defaultZoom={0.8}
|
defaultZoom={0.8}
|
||||||
fitPadding={0.2}
|
fitPadding={0.2}
|
||||||
lightweight
|
|
||||||
cursorStyle='pointer'
|
cursorStyle='pointer'
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { memo, useCallback } from 'react'
|
|||||||
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
|
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
|
||||||
import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
|
import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||||
|
import { validateTriggerPaste } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||||
|
import { useNotificationStore } from '@/stores/notifications'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
|
||||||
import { getUniqueBlockName, prepareDuplicateBlockState } from '@/stores/workflows/utils'
|
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
|
|
||||||
const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 }
|
const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 }
|
||||||
@@ -48,29 +48,38 @@ export const ActionBar = memo(
|
|||||||
collaborativeBatchToggleBlockEnabled,
|
collaborativeBatchToggleBlockEnabled,
|
||||||
collaborativeBatchToggleBlockHandles,
|
collaborativeBatchToggleBlockHandles,
|
||||||
} = useCollaborativeWorkflow()
|
} = useCollaborativeWorkflow()
|
||||||
const { activeWorkflowId, setPendingSelection } = useWorkflowRegistry()
|
const { setPendingSelection } = useWorkflowRegistry()
|
||||||
|
|
||||||
|
const addNotification = useNotificationStore((s) => s.addNotification)
|
||||||
|
|
||||||
const handleDuplicateBlock = useCallback(() => {
|
const handleDuplicateBlock = useCallback(() => {
|
||||||
const blocks = useWorkflowStore.getState().blocks
|
const { copyBlocks, preparePasteData, activeWorkflowId } = useWorkflowRegistry.getState()
|
||||||
const sourceBlock = blocks[blockId]
|
const existingBlocks = useWorkflowStore.getState().blocks
|
||||||
if (!sourceBlock) return
|
copyBlocks([blockId])
|
||||||
|
|
||||||
const newId = crypto.randomUUID()
|
const pasteData = preparePasteData(DEFAULT_DUPLICATE_OFFSET)
|
||||||
const newName = getUniqueBlockName(sourceBlock.name, blocks)
|
if (!pasteData) return
|
||||||
const subBlockValues =
|
|
||||||
useSubBlockStore.getState().workflowValues[activeWorkflowId || '']?.[blockId] || {}
|
|
||||||
|
|
||||||
const { block, subBlockValues: filteredValues } = prepareDuplicateBlockState({
|
const blocks = Object.values(pasteData.blocks)
|
||||||
sourceBlock,
|
const validation = validateTriggerPaste(blocks, existingBlocks, 'duplicate')
|
||||||
newId,
|
if (!validation.isValid) {
|
||||||
newName,
|
addNotification({
|
||||||
positionOffset: DEFAULT_DUPLICATE_OFFSET,
|
level: 'error',
|
||||||
subBlockValues,
|
message: validation.message!,
|
||||||
})
|
workflowId: activeWorkflowId || undefined,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setPendingSelection([newId])
|
setPendingSelection(blocks.map((b) => b.id))
|
||||||
collaborativeBatchAddBlocks([block], [], {}, {}, { [newId]: filteredValues })
|
collaborativeBatchAddBlocks(
|
||||||
}, [blockId, activeWorkflowId, collaborativeBatchAddBlocks, setPendingSelection])
|
blocks,
|
||||||
|
pasteData.edges,
|
||||||
|
pasteData.loops,
|
||||||
|
pasteData.parallels,
|
||||||
|
pasteData.subBlockValues
|
||||||
|
)
|
||||||
|
}, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection])
|
||||||
|
|
||||||
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
|
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
|
||||||
useCallback(
|
useCallback(
|
||||||
@@ -90,7 +99,7 @@ export const ActionBar = memo(
|
|||||||
|
|
||||||
const userPermissions = useUserPermissionsContext()
|
const userPermissions = useUserPermissionsContext()
|
||||||
|
|
||||||
const isStartBlock = isValidStartBlockType(blockType)
|
const isStartBlock = isInputDefinitionTrigger(blockType)
|
||||||
const isResponseBlock = blockType === 'response'
|
const isResponseBlock = blockType === 'response'
|
||||||
const isNoteBlock = blockType === 'note'
|
const isNoteBlock = blockType === 'note'
|
||||||
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
|
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
|
||||||
@@ -142,6 +151,29 @@ export const ActionBar = memo(
|
|||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isSubflowBlock && (
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!disabled) {
|
||||||
|
collaborativeBatchToggleBlockEnabled([blockId])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={ACTION_BUTTON_STYLES}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
|
||||||
|
</Button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content side='top'>
|
||||||
|
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isStartBlock && !isResponseBlock && (
|
{!isStartBlock && !isResponseBlock && (
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
@@ -213,29 +245,6 @@ export const ActionBar = memo(
|
|||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isSubflowBlock && (
|
|
||||||
<Tooltip.Root>
|
|
||||||
<Tooltip.Trigger asChild>
|
|
||||||
<Button
|
|
||||||
variant='ghost'
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (!disabled) {
|
|
||||||
collaborativeBatchToggleBlockEnabled([blockId])
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={ACTION_BUTTON_STYLES}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
|
|
||||||
</Button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Content side='top'>
|
|
||||||
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
PopoverDivider,
|
PopoverDivider,
|
||||||
PopoverItem,
|
PopoverItem,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block information for context menu actions
|
* Block information for context menu actions
|
||||||
@@ -74,12 +74,16 @@ export function BlockMenu({
|
|||||||
const allEnabled = selectedBlocks.every((b) => b.enabled)
|
const allEnabled = selectedBlocks.every((b) => b.enabled)
|
||||||
const allDisabled = selectedBlocks.every((b) => !b.enabled)
|
const allDisabled = selectedBlocks.every((b) => !b.enabled)
|
||||||
|
|
||||||
const hasStarterBlock = selectedBlocks.some((b) => isValidStartBlockType(b.type))
|
const hasSingletonBlock = selectedBlocks.some(
|
||||||
|
(b) =>
|
||||||
|
TriggerUtils.requiresSingleInstance(b.type) || TriggerUtils.isSingleInstanceBlockType(b.type)
|
||||||
|
)
|
||||||
|
const hasTriggerBlock = selectedBlocks.some((b) => TriggerUtils.isTriggerBlock(b))
|
||||||
const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note')
|
const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note')
|
||||||
const isSubflow =
|
const isSubflow =
|
||||||
isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel')
|
isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel')
|
||||||
|
|
||||||
const canRemoveFromSubflow = showRemoveFromSubflow && !hasStarterBlock
|
const canRemoveFromSubflow = showRemoveFromSubflow && !hasTriggerBlock
|
||||||
|
|
||||||
const getToggleEnabledLabel = () => {
|
const getToggleEnabledLabel = () => {
|
||||||
if (allEnabled) return 'Disable'
|
if (allEnabled) return 'Disable'
|
||||||
@@ -127,7 +131,7 @@ export function BlockMenu({
|
|||||||
<span>Paste</span>
|
<span>Paste</span>
|
||||||
<span className='ml-auto opacity-70 group-hover:opacity-100'>⌘V</span>
|
<span className='ml-auto opacity-70 group-hover:opacity-100'>⌘V</span>
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
{!hasStarterBlock && (
|
{!hasSingletonBlock && (
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
disabled={disableEdit}
|
disabled={disableEdit}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -138,18 +138,24 @@ export const Notifications = memo(function Notifications() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className='flex h-full flex-col justify-between px-[8px] pt-[6px] pb-[8px]'>
|
<div className='flex h-full flex-col justify-between px-[8px] pt-[6px] pb-[8px]'>
|
||||||
<div
|
<div className='flex items-start gap-[8px]'>
|
||||||
className={`font-medium text-[12px] leading-[16px] ${
|
<div
|
||||||
hasAction ? 'line-clamp-2' : 'line-clamp-4'
|
className={`min-w-0 flex-1 font-medium text-[12px] leading-[16px] ${
|
||||||
}`}
|
hasAction ? 'line-clamp-2' : 'line-clamp-4'
|
||||||
>
|
}`}
|
||||||
|
>
|
||||||
|
{notification.level === 'error' && (
|
||||||
|
<span className='mr-[6px] mb-[2.75px] inline-block h-[6px] w-[6px] rounded-[2px] bg-[var(--text-error)] align-middle' />
|
||||||
|
)}
|
||||||
|
{notification.message}
|
||||||
|
</div>
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
onClick={() => removeNotification(notification.id)}
|
onClick={() => removeNotification(notification.id)}
|
||||||
aria-label='Dismiss notification'
|
aria-label='Dismiss notification'
|
||||||
className='!p-1.5 -m-1.5 float-right ml-[16px]'
|
className='!p-1.5 -m-1.5 shrink-0'
|
||||||
>
|
>
|
||||||
<X className='h-3 w-3' />
|
<X className='h-3 w-3' />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -158,10 +164,6 @@ export const Notifications = memo(function Notifications() {
|
|||||||
<Tooltip.Shortcut keys='⌘E'>Clear all</Tooltip.Shortcut>
|
<Tooltip.Shortcut keys='⌘E'>Clear all</Tooltip.Shortcut>
|
||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
{notification.level === 'error' && (
|
|
||||||
<span className='mr-[6px] mb-[2.75px] inline-block h-[6px] w-[6px] rounded-[2px] bg-[var(--text-error)] align-middle' />
|
|
||||||
)}
|
|
||||||
{notification.message}
|
|
||||||
</div>
|
</div>
|
||||||
{hasAction && (
|
{hasAction && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
mode,
|
mode,
|
||||||
setMode,
|
setMode,
|
||||||
isAborting,
|
isAborting,
|
||||||
|
maskCredentialValue,
|
||||||
} = useCopilotStore()
|
} = useCopilotStore()
|
||||||
|
|
||||||
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
|
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
|
||||||
@@ -210,7 +211,10 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
const isLastTextBlock =
|
const isLastTextBlock =
|
||||||
index === message.contentBlocks!.length - 1 && block.type === 'text'
|
index === message.contentBlocks!.length - 1 && block.type === 'text'
|
||||||
const parsed = parseSpecialTags(block.content)
|
const parsed = parseSpecialTags(block.content)
|
||||||
const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
|
// Mask credential IDs in the displayed content
|
||||||
|
const cleanBlockContent = maskCredentialValue(
|
||||||
|
parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
|
||||||
|
)
|
||||||
|
|
||||||
if (!cleanBlockContent.trim()) return null
|
if (!cleanBlockContent.trim()) return null
|
||||||
|
|
||||||
@@ -238,7 +242,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
return (
|
return (
|
||||||
<div key={blockKey} className='w-full'>
|
<div key={blockKey} className='w-full'>
|
||||||
<ThinkingBlock
|
<ThinkingBlock
|
||||||
content={block.content}
|
content={maskCredentialValue(block.content)}
|
||||||
isStreaming={isActivelyStreaming}
|
isStreaming={isActivelyStreaming}
|
||||||
hasFollowingContent={hasFollowingContent}
|
hasFollowingContent={hasFollowingContent}
|
||||||
hasSpecialTags={hasSpecialTags}
|
hasSpecialTags={hasSpecialTags}
|
||||||
@@ -261,7 +265,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage])
|
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage, maskCredentialValue])
|
||||||
|
|
||||||
if (isUser) {
|
if (isUser) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -782,6 +782,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
|||||||
const [isExpanded, setIsExpanded] = useState(true)
|
const [isExpanded, setIsExpanded] = useState(true)
|
||||||
const [duration, setDuration] = useState(0)
|
const [duration, setDuration] = useState(0)
|
||||||
const startTimeRef = useRef<number>(Date.now())
|
const startTimeRef = useRef<number>(Date.now())
|
||||||
|
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
|
||||||
const wasStreamingRef = useRef(false)
|
const wasStreamingRef = useRef(false)
|
||||||
|
|
||||||
// Only show streaming animations for current message
|
// Only show streaming animations for current message
|
||||||
@@ -816,14 +817,16 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
|||||||
currentText += parsed.cleanContent
|
currentText += parsed.cleanContent
|
||||||
} else if (block.type === 'subagent_tool_call' && block.toolCall) {
|
} else if (block.type === 'subagent_tool_call' && block.toolCall) {
|
||||||
if (currentText.trim()) {
|
if (currentText.trim()) {
|
||||||
segments.push({ type: 'text', content: currentText })
|
// Mask any credential IDs in the accumulated text before displaying
|
||||||
|
segments.push({ type: 'text', content: maskCredentialValue(currentText) })
|
||||||
currentText = ''
|
currentText = ''
|
||||||
}
|
}
|
||||||
segments.push({ type: 'tool', block })
|
segments.push({ type: 'tool', block })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentText.trim()) {
|
if (currentText.trim()) {
|
||||||
segments.push({ type: 'text', content: currentText })
|
// Mask any credential IDs in the accumulated text before displaying
|
||||||
|
segments.push({ type: 'text', content: maskCredentialValue(currentText) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const allParsed = parseSpecialTags(allRawText)
|
const allParsed = parseSpecialTags(allRawText)
|
||||||
@@ -952,6 +955,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
toolCall: CopilotToolCall
|
toolCall: CopilotToolCall
|
||||||
}) {
|
}) {
|
||||||
const blocks = useWorkflowStore((s) => s.blocks)
|
const blocks = useWorkflowStore((s) => s.blocks)
|
||||||
|
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
|
||||||
|
|
||||||
const cachedBlockInfoRef = useRef<Record<string, { name: string; type: string }>>({})
|
const cachedBlockInfoRef = useRef<Record<string, { name: string; type: string }>>({})
|
||||||
|
|
||||||
@@ -983,6 +987,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
title: string
|
title: string
|
||||||
value: any
|
value: any
|
||||||
isPassword?: boolean
|
isPassword?: boolean
|
||||||
|
isCredential?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BlockChange {
|
interface BlockChange {
|
||||||
@@ -1091,6 +1096,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
title: subBlockConfig.title ?? subBlockConfig.id,
|
title: subBlockConfig.title ?? subBlockConfig.id,
|
||||||
value,
|
value,
|
||||||
isPassword: subBlockConfig.password === true,
|
isPassword: subBlockConfig.password === true,
|
||||||
|
isCredential: subBlockConfig.type === 'oauth-input',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1172,8 +1178,15 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
{subBlocksToShow && subBlocksToShow.length > 0 && (
|
{subBlocksToShow && subBlocksToShow.length > 0 && (
|
||||||
<div className='border-[var(--border-1)] border-t px-2.5 py-1.5'>
|
<div className='border-[var(--border-1)] border-t px-2.5 py-1.5'>
|
||||||
{subBlocksToShow.map((sb) => {
|
{subBlocksToShow.map((sb) => {
|
||||||
// Mask password fields like the canvas does
|
// Mask password fields and credential IDs
|
||||||
const displayValue = sb.isPassword ? '•••' : getDisplayValue(sb.value)
|
let displayValue: string
|
||||||
|
if (sb.isPassword) {
|
||||||
|
displayValue = '•••'
|
||||||
|
} else {
|
||||||
|
// Get display value first, then mask any credential IDs that might be in it
|
||||||
|
const rawValue = getDisplayValue(sb.value)
|
||||||
|
displayValue = maskCredentialValue(rawValue)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div key={sb.id} className='flex items-start gap-1.5 py-0.5 text-[11px]'>
|
<div key={sb.id} className='flex items-start gap-1.5 py-0.5 text-[11px]'>
|
||||||
<span
|
<span
|
||||||
@@ -1412,10 +1425,13 @@ function RunSkipButtons({
|
|||||||
setIsProcessing(true)
|
setIsProcessing(true)
|
||||||
setButtonsHidden(true)
|
setButtonsHidden(true)
|
||||||
try {
|
try {
|
||||||
// Add to auto-allowed list first
|
// Add to auto-allowed list - this also executes all pending integration tools of this type
|
||||||
await addAutoAllowedTool(toolCall.name)
|
await addAutoAllowedTool(toolCall.name)
|
||||||
// Then execute
|
// For client tools with interrupts (not integration tools), we still need to call handleRun
|
||||||
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
|
// since executeIntegrationTool only works for server-side tools
|
||||||
|
if (!isIntegrationTool(toolCall.name)) {
|
||||||
|
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false)
|
setIsProcessing(false)
|
||||||
actionInProgressRef.current = false
|
actionInProgressRef.current = false
|
||||||
@@ -1438,10 +1454,10 @@ function RunSkipButtons({
|
|||||||
|
|
||||||
if (buttonsHidden) return null
|
if (buttonsHidden) return null
|
||||||
|
|
||||||
// Hide "Always Allow" for integration tools (only show for client tools with interrupts)
|
// Show "Always Allow" for all tools that require confirmation
|
||||||
const showAlwaysAllow = !isIntegrationTool(toolCall.name)
|
const showAlwaysAllow = true
|
||||||
|
|
||||||
// Standardized buttons for all interrupt tools: Allow, (Always Allow for client tools only), Skip
|
// Standardized buttons for all interrupt tools: Allow, Always Allow, Skip
|
||||||
return (
|
return (
|
||||||
<div className='mt-[10px] flex gap-[6px]'>
|
<div className='mt-[10px] flex gap-[6px]'>
|
||||||
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
|
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
|
||||||
|
|||||||
@@ -105,10 +105,10 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
|||||||
isSendingMessage,
|
isSendingMessage,
|
||||||
])
|
])
|
||||||
|
|
||||||
/** Load auto-allowed tools once on mount */
|
/** Load auto-allowed tools once on mount - runs immediately, independent of workflow */
|
||||||
const hasLoadedAutoAllowedToolsRef = useRef(false)
|
const hasLoadedAutoAllowedToolsRef = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasMountedRef.current && !hasLoadedAutoAllowedToolsRef.current) {
|
if (!hasLoadedAutoAllowedToolsRef.current) {
|
||||||
hasLoadedAutoAllowedToolsRef.current = true
|
hasLoadedAutoAllowedToolsRef.current = true
|
||||||
loadAutoAllowedTools().catch((err) => {
|
loadAutoAllowedTools().catch((err) => {
|
||||||
logger.warn('[Copilot] Failed to load auto-allowed tools', err)
|
logger.warn('[Copilot] Failed to load auto-allowed tools', err)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Skeleton } from '@/components/ui'
|
|||||||
import { isDev } from '@/lib/core/config/feature-flags'
|
import { isDev } from '@/lib/core/config/feature-flags'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
|
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
|
||||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||||
import {
|
import {
|
||||||
type FieldConfig,
|
type FieldConfig,
|
||||||
useCreateForm,
|
useCreateForm,
|
||||||
@@ -147,7 +147,7 @@ export function FormDeploy({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const blocks = Object.values(useWorkflowStore.getState().blocks)
|
const blocks = Object.values(useWorkflowStore.getState().blocks)
|
||||||
const startBlock = blocks.find((b) => isValidStartBlockType(b.type))
|
const startBlock = blocks.find((b) => isInputDefinitionTrigger(b.type))
|
||||||
|
|
||||||
if (startBlock) {
|
if (startBlock) {
|
||||||
const inputFormat = useSubBlockStore.getState().getValue(startBlock.id, 'inputFormat')
|
const inputFormat = useSubBlockStore.getState().getValue(startBlock.id, 'inputFormat')
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||||
import type { InputFormatField } from '@/lib/workflows/types'
|
import type { InputFormatField } from '@/lib/workflows/types'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||||
@@ -52,7 +52,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
|
|||||||
for (const [blockId, block] of Object.entries(blocks)) {
|
for (const [blockId, block] of Object.entries(blocks)) {
|
||||||
if (!block || typeof block !== 'object') continue
|
if (!block || typeof block !== 'object') continue
|
||||||
const blockType = (block as { type?: string }).type
|
const blockType = (block as { type?: string }).type
|
||||||
if (blockType && isValidStartBlockType(blockType)) {
|
if (blockType && isInputDefinitionTrigger(blockType)) {
|
||||||
return blockId
|
return blockId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import {
|
|||||||
import { Skeleton } from '@/components/ui'
|
import { Skeleton } from '@/components/ui'
|
||||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||||
import {
|
import {
|
||||||
BlockDetailsSidebar,
|
|
||||||
getLeftmostBlockId,
|
getLeftmostBlockId,
|
||||||
|
PreviewEditor,
|
||||||
WorkflowPreview,
|
WorkflowPreview,
|
||||||
} from '@/app/workspace/[workspaceId]/w/components/preview'
|
} from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||||
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
|
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
|
||||||
@@ -337,7 +337,7 @@ export function GeneralDeploy({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
|
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
|
||||||
<BlockDetailsSidebar
|
<PreviewEditor
|
||||||
block={workflowToShow.blocks[expandedSelectedBlockId]}
|
block={workflowToShow.blocks[expandedSelectedBlockId]}
|
||||||
workflowVariables={workflowToShow.variables}
|
workflowVariables={workflowToShow.variables}
|
||||||
loops={workflowToShow.loops}
|
loops={workflowToShow.loops}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { Skeleton } from '@/components/ui'
|
import { Skeleton } from '@/components/ui'
|
||||||
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||||
import type { InputFormatField } from '@/lib/workflows/types'
|
import type { InputFormatField } from '@/lib/workflows/types'
|
||||||
import {
|
import {
|
||||||
useAddWorkflowMcpTool,
|
useAddWorkflowMcpTool,
|
||||||
@@ -107,7 +107,7 @@ export function McpDeploy({
|
|||||||
for (const [blockId, block] of Object.entries(blocks)) {
|
for (const [blockId, block] of Object.entries(blocks)) {
|
||||||
if (!block || typeof block !== 'object') continue
|
if (!block || typeof block !== 'object') continue
|
||||||
const blockType = (block as { type?: string }).type
|
const blockType = (block as { type?: string }).type
|
||||||
if (blockType && isValidStartBlockType(blockType)) {
|
if (blockType && isInputDefinitionTrigger(blockType)) {
|
||||||
return blockId
|
return blockId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -446,7 +446,6 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
isPannable={false}
|
isPannable={false}
|
||||||
defaultZoom={0.8}
|
defaultZoom={0.8}
|
||||||
fitPadding={0.2}
|
fitPadding={0.2}
|
||||||
lightweight
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp
|
|||||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||||
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
|
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
|
||||||
import type { GenerationType } from '@/blocks/types'
|
import type { GenerationType } from '@/blocks/types'
|
||||||
|
import { normalizeName } from '@/executor/constants'
|
||||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||||
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
|
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
|
||||||
import { normalizeName } from '@/stores/workflows/utils'
|
|
||||||
|
|
||||||
const logger = createLogger('Code')
|
const logger = createLogger('Code')
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { isEqual } from 'lodash'
|
import { isEqual } from 'lodash'
|
||||||
import { useReactFlow } from 'reactflow'
|
import { useReactFlow } from 'reactflow'
|
||||||
|
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||||
@@ -102,7 +103,8 @@ export const ComboBox = memo(function ComboBox({
|
|||||||
[blockConfig?.subBlocks]
|
[blockConfig?.subBlocks]
|
||||||
)
|
)
|
||||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||||
const dependencyValues = useSubBlockStore(
|
const dependencyValues = useStoreWithEqualityFn(
|
||||||
|
useSubBlockStore,
|
||||||
useCallback(
|
useCallback(
|
||||||
(state) => {
|
(state) => {
|
||||||
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
|
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ import {
|
|||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||||
|
import { normalizeName } from '@/executor/constants'
|
||||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||||
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
|
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
|
||||||
import { normalizeName } from '@/stores/workflows/utils'
|
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
|
|
||||||
const logger = createLogger('ConditionInput')
|
const logger = createLogger('ConditionInput')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { isEqual } from 'lodash'
|
import { isEqual } from 'lodash'
|
||||||
|
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||||
import { Badge } from '@/components/emcn'
|
import { Badge } from '@/components/emcn'
|
||||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||||
@@ -100,7 +101,8 @@ export const Dropdown = memo(function Dropdown({
|
|||||||
[blockConfig?.subBlocks]
|
[blockConfig?.subBlocks]
|
||||||
)
|
)
|
||||||
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
const canonicalModeOverrides = blockState?.data?.canonicalModes
|
||||||
const dependencyValues = useSubBlockStore(
|
const dependencyValues = useStoreWithEqualityFn(
|
||||||
|
useSubBlockStore,
|
||||||
useCallback(
|
useCallback(
|
||||||
(state) => {
|
(state) => {
|
||||||
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
|
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { splitReferenceSegment } from '@/lib/workflows/sanitization/references'
|
import { splitReferenceSegment } from '@/lib/workflows/sanitization/references'
|
||||||
import { REFERENCE } from '@/executor/constants'
|
import { normalizeName, REFERENCE } from '@/executor/constants'
|
||||||
import { createCombinedPattern } from '@/executor/utils/reference-validation'
|
import { createCombinedPattern } from '@/executor/utils/reference-validation'
|
||||||
import { normalizeName } from '@/stores/workflows/utils'
|
|
||||||
|
|
||||||
export interface HighlightContext {
|
export interface HighlightContext {
|
||||||
accessiblePrefixes?: Set<string>
|
accessiblePrefixes?: Set<string>
|
||||||
|
|||||||
@@ -34,3 +34,4 @@ export { Text } from './text/text'
|
|||||||
export { TimeInput } from './time-input/time-input'
|
export { TimeInput } from './time-input/time-input'
|
||||||
export { ToolInput } from './tool-input/tool-input'
|
export { ToolInput } from './tool-input/tool-input'
|
||||||
export { VariablesInput } from './variables-input/variables-input'
|
export { VariablesInput } from './variables-input/variables-input'
|
||||||
|
export { WorkflowSelectorInput } from './workflow-selector/workflow-selector-input'
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { useMemo, useRef, useState } from 'react'
|
|||||||
import { Badge, Input } from '@/components/emcn'
|
import { Badge, Input } from '@/components/emcn'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||||
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||||
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
|
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
|
||||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||||
import { useWorkflowInputFields } from '@/hooks/queries/workflows'
|
import { useWorkflowState } from '@/hooks/queries/workflows'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the InputMappingField component
|
* Props for the InputMappingField component
|
||||||
@@ -70,7 +71,11 @@ export function InputMapping({
|
|||||||
const overlayRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
const overlayRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||||
|
|
||||||
const workflowId = typeof selectedWorkflowId === 'string' ? selectedWorkflowId : undefined
|
const workflowId = typeof selectedWorkflowId === 'string' ? selectedWorkflowId : undefined
|
||||||
const { data: childInputFields = [], isLoading } = useWorkflowInputFields(workflowId)
|
const { data: workflowState, isLoading } = useWorkflowState(workflowId)
|
||||||
|
const childInputFields = useMemo(
|
||||||
|
() => (workflowState?.blocks ? extractInputFieldsFromBlocks(workflowState.blocks) : []),
|
||||||
|
[workflowState?.blocks]
|
||||||
|
)
|
||||||
const [collapsedFields, setCollapsedFields] = useState<Record<string, boolean>>({})
|
const [collapsedFields, setCollapsedFields] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
const valueObj: Record<string, string> = useMemo(() => {
|
const valueObj: Record<string, string> = useMemo(() => {
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import { isEqual } from 'lodash'
|
import { isEqual } from 'lodash'
|
||||||
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
|
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
|
||||||
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
||||||
@@ -382,93 +390,138 @@ export function MessagesInput({
|
|||||||
textareaRefs.current[fieldId]?.focus()
|
textareaRefs.current[fieldId]?.focus()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const autoResizeTextarea = useCallback((fieldId: string) => {
|
const syncOverlay = useCallback((fieldId: string) => {
|
||||||
const textarea = textareaRefs.current[fieldId]
|
const textarea = textareaRefs.current[fieldId]
|
||||||
if (!textarea) return
|
|
||||||
const overlay = overlayRefs.current[fieldId]
|
const overlay = overlayRefs.current[fieldId]
|
||||||
|
if (!textarea || !overlay) return
|
||||||
|
|
||||||
// If user has manually resized, respect their chosen height and only sync overlay.
|
overlay.style.width = `${textarea.clientWidth}px`
|
||||||
if (userResizedRef.current[fieldId]) {
|
overlay.scrollTop = textarea.scrollTop
|
||||||
const currentHeight =
|
overlay.scrollLeft = textarea.scrollLeft
|
||||||
textarea.offsetHeight || Number.parseFloat(textarea.style.height) || MIN_TEXTAREA_HEIGHT_PX
|
|
||||||
const clampedHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, currentHeight)
|
|
||||||
textarea.style.height = `${clampedHeight}px`
|
|
||||||
if (overlay) {
|
|
||||||
overlay.style.height = `${clampedHeight}px`
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea.style.height = 'auto'
|
|
||||||
const naturalHeight = textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
|
|
||||||
const nextHeight = Math.min(
|
|
||||||
MAX_TEXTAREA_HEIGHT_PX,
|
|
||||||
Math.max(MIN_TEXTAREA_HEIGHT_PX, naturalHeight)
|
|
||||||
)
|
|
||||||
textarea.style.height = `${nextHeight}px`
|
|
||||||
|
|
||||||
if (overlay) {
|
|
||||||
overlay.style.height = `${nextHeight}px`
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleResizeStart = useCallback((fieldId: string, e: React.MouseEvent<HTMLDivElement>) => {
|
const autoResizeTextarea = useCallback(
|
||||||
e.preventDefault()
|
(fieldId: string) => {
|
||||||
e.stopPropagation()
|
const textarea = textareaRefs.current[fieldId]
|
||||||
|
const overlay = overlayRefs.current[fieldId]
|
||||||
|
if (!textarea) return
|
||||||
|
|
||||||
const textarea = textareaRefs.current[fieldId]
|
if (!textarea.value.trim()) {
|
||||||
if (!textarea) return
|
userResizedRef.current[fieldId] = false
|
||||||
|
|
||||||
const startHeight = textarea.offsetHeight || textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
|
|
||||||
|
|
||||||
isResizingRef.current = true
|
|
||||||
resizeStateRef.current = {
|
|
||||||
fieldId,
|
|
||||||
startY: e.clientY,
|
|
||||||
startHeight,
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
|
||||||
if (!isResizingRef.current || !resizeStateRef.current) return
|
|
||||||
|
|
||||||
const { fieldId: activeFieldId, startY, startHeight } = resizeStateRef.current
|
|
||||||
const deltaY = moveEvent.clientY - startY
|
|
||||||
const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, startHeight + deltaY)
|
|
||||||
|
|
||||||
const activeTextarea = textareaRefs.current[activeFieldId]
|
|
||||||
if (activeTextarea) {
|
|
||||||
activeTextarea.style.height = `${nextHeight}px`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const overlay = overlayRefs.current[activeFieldId]
|
if (userResizedRef.current[fieldId]) {
|
||||||
|
if (overlay) {
|
||||||
|
overlay.style.height = `${textarea.offsetHeight}px`
|
||||||
|
}
|
||||||
|
syncOverlay(fieldId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.style.height = 'auto'
|
||||||
|
const scrollHeight = textarea.scrollHeight
|
||||||
|
const height = Math.min(
|
||||||
|
MAX_TEXTAREA_HEIGHT_PX,
|
||||||
|
Math.max(MIN_TEXTAREA_HEIGHT_PX, scrollHeight)
|
||||||
|
)
|
||||||
|
|
||||||
|
textarea.style.height = `${height}px`
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
overlay.style.height = `${nextHeight}px`
|
overlay.style.height = `${height}px`
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
if (resizeStateRef.current) {
|
|
||||||
const { fieldId: activeFieldId } = resizeStateRef.current
|
|
||||||
userResizedRef.current[activeFieldId] = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isResizingRef.current = false
|
syncOverlay(fieldId)
|
||||||
resizeStateRef.current = null
|
},
|
||||||
document.removeEventListener('mousemove', handleMouseMove)
|
[syncOverlay]
|
||||||
document.removeEventListener('mouseup', handleMouseUp)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove)
|
const handleResizeStart = useCallback(
|
||||||
document.addEventListener('mouseup', handleMouseUp)
|
(fieldId: string, e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
}, [])
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
useEffect(() => {
|
const textarea = textareaRefs.current[fieldId]
|
||||||
|
if (!textarea) return
|
||||||
|
|
||||||
|
const startHeight = textarea.offsetHeight || textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
|
||||||
|
|
||||||
|
isResizingRef.current = true
|
||||||
|
resizeStateRef.current = {
|
||||||
|
fieldId,
|
||||||
|
startY: e.clientY,
|
||||||
|
startHeight,
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
if (!isResizingRef.current || !resizeStateRef.current) return
|
||||||
|
|
||||||
|
const { fieldId: activeFieldId, startY, startHeight } = resizeStateRef.current
|
||||||
|
const deltaY = moveEvent.clientY - startY
|
||||||
|
const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, startHeight + deltaY)
|
||||||
|
|
||||||
|
const activeTextarea = textareaRefs.current[activeFieldId]
|
||||||
|
const overlay = overlayRefs.current[activeFieldId]
|
||||||
|
|
||||||
|
if (activeTextarea) {
|
||||||
|
activeTextarea.style.height = `${nextHeight}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlay) {
|
||||||
|
overlay.style.height = `${nextHeight}px`
|
||||||
|
if (activeTextarea) {
|
||||||
|
overlay.scrollTop = activeTextarea.scrollTop
|
||||||
|
overlay.scrollLeft = activeTextarea.scrollLeft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (resizeStateRef.current) {
|
||||||
|
const { fieldId: activeFieldId } = resizeStateRef.current
|
||||||
|
userResizedRef.current[activeFieldId] = true
|
||||||
|
syncOverlay(activeFieldId)
|
||||||
|
}
|
||||||
|
|
||||||
|
isResizingRef.current = false
|
||||||
|
resizeStateRef.current = null
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
},
|
||||||
|
[syncOverlay]
|
||||||
|
)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
currentMessages.forEach((_, index) => {
|
currentMessages.forEach((_, index) => {
|
||||||
const fieldId = `message-${index}`
|
autoResizeTextarea(`message-${index}`)
|
||||||
autoResizeTextarea(fieldId)
|
|
||||||
})
|
})
|
||||||
}, [currentMessages, autoResizeTextarea])
|
}, [currentMessages, autoResizeTextarea])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observers: ResizeObserver[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < currentMessages.length; i++) {
|
||||||
|
const fieldId = `message-${i}`
|
||||||
|
const textarea = textareaRefs.current[fieldId]
|
||||||
|
const overlay = overlayRefs.current[fieldId]
|
||||||
|
|
||||||
|
if (textarea && overlay) {
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
overlay.style.width = `${textarea.clientWidth}px`
|
||||||
|
})
|
||||||
|
observer.observe(textarea)
|
||||||
|
observers.push(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observers.forEach((observer) => observer.disconnect())
|
||||||
|
}
|
||||||
|
}, [currentMessages.length])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex w-full flex-col gap-[10px]'>
|
<div className='flex w-full flex-col gap-[10px]'>
|
||||||
{currentMessages.map((message, index) => (
|
{currentMessages.map((message, index) => (
|
||||||
@@ -621,19 +674,15 @@ export function MessagesInput({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Input with overlay for variable highlighting */}
|
{/* Content Input with overlay for variable highlighting */}
|
||||||
<div className='relative w-full'>
|
<div className='relative w-full overflow-hidden'>
|
||||||
<textarea
|
<textarea
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
textareaRefs.current[fieldId] = el
|
textareaRefs.current[fieldId] = el
|
||||||
}}
|
}}
|
||||||
className='allow-scroll box-border min-h-[80px] w-full resize-none whitespace-pre-wrap break-words border-none bg-transparent px-[8px] pt-[8px] font-[inherit] font-medium text-sm text-transparent leading-[inherit] caret-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed'
|
className='relative z-[2] m-0 box-border h-auto min-h-[80px] w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm text-transparent leading-[1.5] caret-[var(--text-primary)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden'
|
||||||
rows={3}
|
|
||||||
placeholder='Enter message content...'
|
placeholder='Enter message content...'
|
||||||
value={message.content}
|
value={message.content}
|
||||||
onChange={(e) => {
|
onChange={fieldHandlers.onChange}
|
||||||
fieldHandlers.onChange(e)
|
|
||||||
autoResizeTextarea(fieldId)
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Tab' && !isPreview && !disabled) {
|
if (e.key === 'Tab' && !isPreview && !disabled) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -670,12 +719,13 @@ export function MessagesInput({
|
|||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
overlayRefs.current[fieldId] = el
|
overlayRefs.current[fieldId] = el
|
||||||
}}
|
}}
|
||||||
className='scrollbar-none pointer-events-none absolute top-0 left-0 box-border w-full overflow-auto whitespace-pre-wrap break-words border-none bg-transparent px-[8px] pt-[8px] font-[inherit] font-medium text-[var(--text-primary)] text-sm leading-[inherit]'
|
className='pointer-events-none absolute top-0 left-0 z-[1] m-0 box-border w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.5] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||||
>
|
>
|
||||||
{formatDisplayText(message.content, {
|
{formatDisplayText(message.content, {
|
||||||
accessiblePrefixes,
|
accessiblePrefixes,
|
||||||
highlightAll: !accessiblePrefixes,
|
highlightAll: !accessiblePrefixes,
|
||||||
})}
|
})}
|
||||||
|
{message.content.endsWith('\n') && '\u200B'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Env var dropdown for this message */}
|
{/* Env var dropdown for this message */}
|
||||||
@@ -705,7 +755,7 @@ export function MessagesInput({
|
|||||||
|
|
||||||
{!isPreview && !disabled && (
|
{!isPreview && !disabled && (
|
||||||
<div
|
<div
|
||||||
className='absolute right-1 bottom-1 flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
|
className='absolute right-1 bottom-1 z-[3] flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
|
||||||
onMouseDown={(e) => handleResizeStart(fieldId, e)}
|
onMouseDown={(e) => handleResizeStart(fieldId, e)}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ import type {
|
|||||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||||
import { getBlock } from '@/blocks'
|
import { getBlock } from '@/blocks'
|
||||||
import type { BlockConfig } from '@/blocks/types'
|
import type { BlockConfig } from '@/blocks/types'
|
||||||
|
import { normalizeName } from '@/executor/constants'
|
||||||
import type { Variable } from '@/stores/panel'
|
import type { Variable } from '@/stores/panel'
|
||||||
import { useVariablesStore } from '@/stores/panel'
|
import { useVariablesStore } from '@/stores/panel'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||||
import { normalizeName } from '@/stores/workflows/utils'
|
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
type OAuthProvider,
|
type OAuthProvider,
|
||||||
type OAuthService,
|
type OAuthService,
|
||||||
} from '@/lib/oauth'
|
} from '@/lib/oauth'
|
||||||
|
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||||
import {
|
import {
|
||||||
CheckboxList,
|
CheckboxList,
|
||||||
@@ -65,7 +66,7 @@ import { useForceRefreshMcpTools, useMcpServers, useStoredMcpTools } from '@/hoo
|
|||||||
import {
|
import {
|
||||||
useChildDeploymentStatus,
|
useChildDeploymentStatus,
|
||||||
useDeployChildWorkflow,
|
useDeployChildWorkflow,
|
||||||
useWorkflowInputFields,
|
useWorkflowState,
|
||||||
useWorkflows,
|
useWorkflows,
|
||||||
} from '@/hooks/queries/workflows'
|
} from '@/hooks/queries/workflows'
|
||||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||||
@@ -771,7 +772,11 @@ function WorkflowInputMapperSyncWrapper({
|
|||||||
disabled: boolean
|
disabled: boolean
|
||||||
workflowId: string
|
workflowId: string
|
||||||
}) {
|
}) {
|
||||||
const { data: inputFields = [], isLoading } = useWorkflowInputFields(workflowId)
|
const { data: workflowState, isLoading } = useWorkflowState(workflowId)
|
||||||
|
const inputFields = useMemo(
|
||||||
|
() => (workflowState?.blocks ? extractInputFieldsFromBlocks(workflowState.blocks) : []),
|
||||||
|
[workflowState?.blocks]
|
||||||
|
)
|
||||||
|
|
||||||
const parsedValue = useMemo(() => {
|
const parsedValue = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
|
||||||
|
import type { SubBlockConfig } from '@/blocks/types'
|
||||||
|
import type { SelectorContext } from '@/hooks/selectors/types'
|
||||||
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
|
||||||
|
interface WorkflowSelectorInputProps {
|
||||||
|
blockId: string
|
||||||
|
subBlock: SubBlockConfig
|
||||||
|
disabled?: boolean
|
||||||
|
isPreview?: boolean
|
||||||
|
previewValue?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowSelectorInput({
|
||||||
|
blockId,
|
||||||
|
subBlock,
|
||||||
|
disabled = false,
|
||||||
|
isPreview = false,
|
||||||
|
previewValue,
|
||||||
|
}: WorkflowSelectorInputProps) {
|
||||||
|
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||||
|
|
||||||
|
const context: SelectorContext = useMemo(
|
||||||
|
() => ({
|
||||||
|
excludeWorkflowId: activeWorkflowId ?? undefined,
|
||||||
|
}),
|
||||||
|
[activeWorkflowId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectorCombobox
|
||||||
|
blockId={blockId}
|
||||||
|
subBlock={subBlock}
|
||||||
|
selectorKey='sim.workflows'
|
||||||
|
selectorContext={context}
|
||||||
|
disabled={disabled}
|
||||||
|
isPreview={isPreview}
|
||||||
|
previewValue={previewValue}
|
||||||
|
placeholder={subBlock.placeholder || 'Select workflow...'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { isEqual } from 'lodash'
|
import { isEqual } from 'lodash'
|
||||||
|
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||||
import {
|
import {
|
||||||
buildCanonicalIndex,
|
buildCanonicalIndex,
|
||||||
isNonEmptyValue,
|
isNonEmptyValue,
|
||||||
@@ -151,7 +152,7 @@ export function useDependsOnGate(
|
|||||||
|
|
||||||
// Get values for all dependency fields (both all and any)
|
// Get values for all dependency fields (both all and any)
|
||||||
// Use isEqual to prevent re-renders when dependency values haven't actually changed
|
// Use isEqual to prevent re-renders when dependency values haven't actually changed
|
||||||
const dependencyValuesMap = useSubBlockStore(dependencySelector, isEqual)
|
const dependencyValuesMap = useStoreWithEqualityFn(useSubBlockStore, dependencySelector, isEqual)
|
||||||
|
|
||||||
const depsSatisfied = useMemo(() => {
|
const depsSatisfied = useMemo(() => {
|
||||||
// Check all fields (AND logic) - all must be satisfied
|
// Check all fields (AND logic) - all must be satisfied
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { isEqual } from 'lodash'
|
import { isEqual } from 'lodash'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||||
import { getProviderFromModel } from '@/providers/utils'
|
import { getProviderFromModel } from '@/providers/utils'
|
||||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||||
@@ -58,7 +59,8 @@ export function useSubBlockValue<T = any>(
|
|||||||
const streamingValueRef = useRef<T | null>(null)
|
const streamingValueRef = useRef<T | null>(null)
|
||||||
const wasStreamingRef = useRef<boolean>(false)
|
const wasStreamingRef = useRef<boolean>(false)
|
||||||
|
|
||||||
const storeValue = useSubBlockStore(
|
const storeValue = useStoreWithEqualityFn(
|
||||||
|
useSubBlockStore,
|
||||||
useCallback(
|
useCallback(
|
||||||
(state) => {
|
(state) => {
|
||||||
// If the active workflow ID isn't available yet, return undefined so we can fall back to initialValue
|
// If the active workflow ID isn't available yet, return undefined so we can fall back to initialValue
|
||||||
@@ -92,7 +94,8 @@ export function useSubBlockValue<T = any>(
|
|||||||
|
|
||||||
// Always call this hook unconditionally - don't wrap it in a condition
|
// Always call this hook unconditionally - don't wrap it in a condition
|
||||||
// Optimized: only re-render if model value actually changes
|
// Optimized: only re-render if model value actually changes
|
||||||
const modelSubBlockValue = useSubBlockStore(
|
const modelSubBlockValue = useStoreWithEqualityFn(
|
||||||
|
useSubBlockStore,
|
||||||
useCallback((state) => (blockId ? state.getValue(blockId, 'model') : null), [blockId]),
|
useCallback((state) => (blockId ? state.getValue(blockId, 'model') : null), [blockId]),
|
||||||
(a, b) => a === b
|
(a, b) => a === b
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
TimeInput,
|
TimeInput,
|
||||||
ToolInput,
|
ToolInput,
|
||||||
VariablesInput,
|
VariablesInput,
|
||||||
|
WorkflowSelectorInput,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components'
|
||||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||||
import type { SubBlockConfig } from '@/blocks/types'
|
import type { SubBlockConfig } from '@/blocks/types'
|
||||||
@@ -90,7 +91,6 @@ const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record<string,
|
|||||||
if (!config.required) return false
|
if (!config.required) return false
|
||||||
if (typeof config.required === 'boolean') return config.required
|
if (typeof config.required === 'boolean') return config.required
|
||||||
|
|
||||||
// Helper function to evaluate a condition
|
|
||||||
const evalCond = (
|
const evalCond = (
|
||||||
cond: {
|
cond: {
|
||||||
field: string
|
field: string
|
||||||
@@ -132,7 +132,6 @@ const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record<string,
|
|||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
|
|
||||||
// If required is a condition object or function, evaluate it
|
|
||||||
const condition = typeof config.required === 'function' ? config.required() : config.required
|
const condition = typeof config.required === 'function' ? config.required() : config.required
|
||||||
return evalCond(condition, subBlockValues || {})
|
return evalCond(condition, subBlockValues || {})
|
||||||
}
|
}
|
||||||
@@ -378,7 +377,6 @@ function SubBlockComponent({
|
|||||||
setIsValidJson(isValid)
|
setIsValidJson(isValid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if wand is enabled for this sub-block
|
|
||||||
const isWandEnabled = config.wandConfig?.enabled ?? false
|
const isWandEnabled = config.wandConfig?.enabled ?? false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -438,8 +436,6 @@ function SubBlockComponent({
|
|||||||
| null
|
| null
|
||||||
| undefined
|
| undefined
|
||||||
|
|
||||||
// Use dependsOn gating to compute final disabled state
|
|
||||||
// Only pass previewContextValues when in preview mode to avoid format mismatches
|
|
||||||
const { finalDisabled: gatedDisabled } = useDependsOnGate(blockId, config, {
|
const { finalDisabled: gatedDisabled } = useDependsOnGate(blockId, config, {
|
||||||
disabled,
|
disabled,
|
||||||
isPreview,
|
isPreview,
|
||||||
@@ -869,6 +865,17 @@ function SubBlockComponent({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
case 'workflow-selector':
|
||||||
|
return (
|
||||||
|
<WorkflowSelectorInput
|
||||||
|
blockId={blockId}
|
||||||
|
subBlock={config}
|
||||||
|
disabled={isDisabled}
|
||||||
|
isPreview={isPreview}
|
||||||
|
previewValue={previewValue as string | null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
case 'mcp-server-selector':
|
case 'mcp-server-selector':
|
||||||
return (
|
return (
|
||||||
<McpServerSelector
|
<McpServerSelector
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function SubflowEditor({
|
|||||||
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
|
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
|
||||||
{/* Subflow Editor Section */}
|
{/* Subflow Editor Section */}
|
||||||
<div ref={subBlocksRef} className='subblocks-section flex flex-1 flex-col overflow-hidden'>
|
<div ref={subBlocksRef} className='subblocks-section flex flex-1 flex-col overflow-hidden'>
|
||||||
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[5px] pb-[8px]'>
|
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[9px] pb-[8px]'>
|
||||||
{/* Type Selection */}
|
{/* Type Selection */}
|
||||||
<div>
|
<div>
|
||||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||||
|
|||||||
@@ -2,8 +2,18 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { isEqual } from 'lodash'
|
import { isEqual } from 'lodash'
|
||||||
import { BookOpen, Check, ChevronDown, ChevronUp, Pencil } from 'lucide-react'
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||||
import { Button, Tooltip } from '@/components/emcn'
|
import { Button, Tooltip } from '@/components/emcn'
|
||||||
import {
|
import {
|
||||||
buildCanonicalIndex,
|
buildCanonicalIndex,
|
||||||
@@ -28,8 +38,10 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
|
|||||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||||
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
||||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||||
|
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||||
import { getBlock } from '@/blocks/registry'
|
import { getBlock } from '@/blocks/registry'
|
||||||
import type { SubBlockType } from '@/blocks/types'
|
import type { SubBlockType } from '@/blocks/types'
|
||||||
|
import { useWorkflowState } from '@/hooks/queries/workflows'
|
||||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||||
import { usePanelEditorStore } from '@/stores/panel'
|
import { usePanelEditorStore } from '@/stores/panel'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
@@ -84,6 +96,14 @@ export function Editor() {
|
|||||||
// Get subflow display properties from configs
|
// Get subflow display properties from configs
|
||||||
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
|
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
|
||||||
|
|
||||||
|
// Check if selected block is a workflow block
|
||||||
|
const isWorkflowBlock =
|
||||||
|
currentBlock && (currentBlock.type === 'workflow' || currentBlock.type === 'workflow_input')
|
||||||
|
|
||||||
|
// Get workspace ID from params
|
||||||
|
const params = useParams()
|
||||||
|
const workspaceId = params.workspaceId as string
|
||||||
|
|
||||||
// Refs for resize functionality
|
// Refs for resize functionality
|
||||||
const subBlocksRef = useRef<HTMLDivElement>(null)
|
const subBlocksRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -99,7 +119,8 @@ export function Editor() {
|
|||||||
currentWorkflow.isSnapshotView
|
currentWorkflow.isSnapshotView
|
||||||
)
|
)
|
||||||
|
|
||||||
const blockSubBlockValues = useSubBlockStore(
|
const blockSubBlockValues = useStoreWithEqualityFn(
|
||||||
|
useSubBlockStore,
|
||||||
useCallback(
|
useCallback(
|
||||||
(state) => {
|
(state) => {
|
||||||
if (!activeWorkflowId || !currentBlockId) return EMPTY_SUBBLOCK_VALUES
|
if (!activeWorkflowId || !currentBlockId) return EMPTY_SUBBLOCK_VALUES
|
||||||
@@ -252,11 +273,11 @@ export function Editor() {
|
|||||||
|
|
||||||
// Trigger rename mode when signaled from context menu
|
// Trigger rename mode when signaled from context menu
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shouldFocusRename && currentBlock && !isSubflow) {
|
if (shouldFocusRename && currentBlock) {
|
||||||
handleStartRename()
|
handleStartRename()
|
||||||
setShouldFocusRename(false)
|
setShouldFocusRename(false)
|
||||||
}
|
}
|
||||||
}, [shouldFocusRename, currentBlock, isSubflow, handleStartRename, setShouldFocusRename])
|
}, [shouldFocusRename, currentBlock, handleStartRename, setShouldFocusRename])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles opening documentation link in a new secure tab.
|
* Handles opening documentation link in a new secure tab.
|
||||||
@@ -268,6 +289,22 @@ export function Editor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get child workflow ID for workflow blocks
|
||||||
|
const childWorkflowId = isWorkflowBlock ? blockSubBlockValues?.workflowId : null
|
||||||
|
|
||||||
|
// Fetch child workflow state for preview (only for workflow blocks with a selected workflow)
|
||||||
|
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } =
|
||||||
|
useWorkflowState(childWorkflowId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles opening the child workflow in a new tab.
|
||||||
|
*/
|
||||||
|
const handleOpenChildWorkflow = useCallback(() => {
|
||||||
|
if (childWorkflowId && workspaceId) {
|
||||||
|
window.open(`/workspace/${workspaceId}/w/${childWorkflowId}`, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
}, [childWorkflowId, workspaceId])
|
||||||
|
|
||||||
// Determine if connections are at minimum height (collapsed state)
|
// Determine if connections are at minimum height (collapsed state)
|
||||||
const isConnectionsAtMinHeight = connectionsHeight <= 35
|
const isConnectionsAtMinHeight = connectionsHeight <= 35
|
||||||
|
|
||||||
@@ -320,7 +357,7 @@ export function Editor() {
|
|||||||
</div>
|
</div>
|
||||||
<div className='flex shrink-0 items-center gap-[8px]'>
|
<div className='flex shrink-0 items-center gap-[8px]'>
|
||||||
{/* Rename button */}
|
{/* Rename button */}
|
||||||
{currentBlock && !isSubflow && (
|
{currentBlock && (
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -406,7 +443,66 @@ export function Editor() {
|
|||||||
className='subblocks-section flex flex-1 flex-col overflow-hidden'
|
className='subblocks-section flex flex-1 flex-col overflow-hidden'
|
||||||
>
|
>
|
||||||
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[12px] pb-[8px] [overflow-anchor:none]'>
|
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[12px] pb-[8px] [overflow-anchor:none]'>
|
||||||
{subBlocks.length === 0 ? (
|
{/* Workflow Preview - only for workflow blocks with a selected child workflow */}
|
||||||
|
{isWorkflowBlock && childWorkflowId && (
|
||||||
|
<>
|
||||||
|
<div className='subblock-content flex flex-col gap-[9.5px]'>
|
||||||
|
<div className='pl-[2px] font-medium text-[13px] text-[var(--text-primary)] leading-none'>
|
||||||
|
Workflow Preview
|
||||||
|
</div>
|
||||||
|
<div className='relative h-[160px] overflow-hidden rounded-[4px] border border-[var(--border)]'>
|
||||||
|
{isLoadingChildWorkflow ? (
|
||||||
|
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
|
||||||
|
<Loader2 className='h-5 w-5 animate-spin text-[var(--text-tertiary)]' />
|
||||||
|
</div>
|
||||||
|
) : childWorkflowState ? (
|
||||||
|
<>
|
||||||
|
<div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'>
|
||||||
|
<WorkflowPreview
|
||||||
|
workflowState={childWorkflowState}
|
||||||
|
height={160}
|
||||||
|
width='100%'
|
||||||
|
isPannable={true}
|
||||||
|
defaultZoom={0.6}
|
||||||
|
fitPadding={0.15}
|
||||||
|
cursorStyle='grab'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
onClick={handleOpenChildWorkflow}
|
||||||
|
className='absolute right-[6px] bottom-[6px] z-10 h-[24px] w-[24px] cursor-pointer border border-[var(--border)] bg-[var(--surface-2)] p-0 hover:bg-[var(--surface-4)]'
|
||||||
|
>
|
||||||
|
<ExternalLink className='h-[12px] w-[12px]' />
|
||||||
|
</Button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content side='top'>Open workflow</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
|
||||||
|
<span className='text-[13px] text-[var(--text-tertiary)]'>
|
||||||
|
Unable to load preview
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||||
|
<div
|
||||||
|
className='h-[1.25px]'
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{subBlocks.length === 0 && !isWorkflowBlock ? (
|
||||||
<div className='flex h-full items-center justify-center text-center text-[#8D8D8D] text-[13px]'>
|
<div className='flex h-full items-center justify-center text-center text-[#8D8D8D] text-[13px]'>
|
||||||
This block has no subblocks
|
This block has no subblocks
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback } from 'react'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { shallow } from 'zustand/shallow'
|
|
||||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
|
|
||||||
@@ -13,35 +12,26 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
|||||||
*/
|
*/
|
||||||
export function useEditorBlockProperties(blockId: string | null, isSnapshotView: boolean) {
|
export function useEditorBlockProperties(blockId: string | null, isSnapshotView: boolean) {
|
||||||
const normalBlockProps = useWorkflowStore(
|
const normalBlockProps = useWorkflowStore(
|
||||||
useCallback(
|
useShallow((state) => {
|
||||||
(state) => {
|
if (!blockId) return { advancedMode: false, triggerMode: false }
|
||||||
if (!blockId) return { advancedMode: false, triggerMode: false }
|
const block = state.blocks?.[blockId]
|
||||||
const block = state.blocks?.[blockId]
|
return {
|
||||||
return {
|
advancedMode: block?.advancedMode ?? false,
|
||||||
advancedMode: block?.advancedMode ?? false,
|
triggerMode: block?.triggerMode ?? false,
|
||||||
triggerMode: block?.triggerMode ?? false,
|
}
|
||||||
}
|
})
|
||||||
},
|
|
||||||
[blockId]
|
|
||||||
),
|
|
||||||
shallow
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const baselineBlockProps = useWorkflowDiffStore(
|
const baselineBlockProps = useWorkflowDiffStore(
|
||||||
useCallback(
|
useShallow((state) => {
|
||||||
(state) => {
|
if (!blockId) return { advancedMode: false, triggerMode: false }
|
||||||
if (!blockId) return { advancedMode: false, triggerMode: false }
|
const block = state.baselineWorkflow?.blocks?.[blockId]
|
||||||
const block = state.baselineWorkflow?.blocks?.[blockId]
|
return {
|
||||||
return {
|
advancedMode: block?.advancedMode ?? false,
|
||||||
advancedMode: block?.advancedMode ?? false,
|
triggerMode: block?.triggerMode ?? false,
|
||||||
triggerMode: block?.triggerMode ?? false,
|
}
|
||||||
}
|
})
|
||||||
},
|
|
||||||
[blockId]
|
|
||||||
),
|
|
||||||
shallow
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Use the appropriate props based on view mode
|
|
||||||
return isSnapshotView ? baselineBlockProps : normalBlockProps
|
return isSnapshotView ? baselineBlockProps : normalBlockProps
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { isEqual } from 'lodash'
|
import { isEqual } from 'lodash'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
|
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
|
||||||
|
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||||
import { Badge, Tooltip } from '@/components/emcn'
|
import { Badge, Tooltip } from '@/components/emcn'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
@@ -526,7 +527,8 @@ const SubBlockRow = memo(function SubBlockRow({
|
|||||||
* Subscribe only to variables for this workflow to avoid re-renders from other workflows.
|
* Subscribe only to variables for this workflow to avoid re-renders from other workflows.
|
||||||
* Uses isEqual for deep comparison since Object.fromEntries creates a new object each time.
|
* Uses isEqual for deep comparison since Object.fromEntries creates a new object each time.
|
||||||
*/
|
*/
|
||||||
const workflowVariables = useVariablesStore(
|
const workflowVariables = useStoreWithEqualityFn(
|
||||||
|
useVariablesStore,
|
||||||
useCallback(
|
useCallback(
|
||||||
(state) => {
|
(state) => {
|
||||||
if (!workflowId) return {}
|
if (!workflowId) return {}
|
||||||
@@ -729,7 +731,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
|||||||
const isStarterBlock = type === 'starter'
|
const isStarterBlock = type === 'starter'
|
||||||
const isWebhookTriggerBlock = type === 'webhook' || type === 'generic_webhook'
|
const isWebhookTriggerBlock = type === 'webhook' || type === 'generic_webhook'
|
||||||
|
|
||||||
const blockSubBlockValues = useSubBlockStore(
|
const blockSubBlockValues = useStoreWithEqualityFn(
|
||||||
|
useSubBlockStore,
|
||||||
useCallback(
|
useCallback(
|
||||||
(state) => {
|
(state) => {
|
||||||
if (!activeWorkflowId) return EMPTY_SUBBLOCK_VALUES
|
if (!activeWorkflowId) return EMPTY_SUBBLOCK_VALUES
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react'
|
|||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
|
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
|
||||||
import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/sanitization/references'
|
import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/sanitization/references'
|
||||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||||
import { normalizeName } from '@/executor/constants'
|
import { normalizeName } from '@/executor/constants'
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
|
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||||
@@ -27,7 +27,7 @@ export function useAccessibleReferencePrefixes(blockId?: string | null): Set<str
|
|||||||
const accessibleIds = new Set<string>(ancestorIds)
|
const accessibleIds = new Set<string>(ancestorIds)
|
||||||
accessibleIds.add(blockId)
|
accessibleIds.add(blockId)
|
||||||
|
|
||||||
const starterBlock = Object.values(blocks).find((block) => isValidStartBlockType(block.type))
|
const starterBlock = Object.values(blocks).find((block) => isInputDefinitionTrigger(block.type))
|
||||||
if (starterBlock && ancestorIds.includes(starterBlock.id)) {
|
if (starterBlock && ancestorIds.includes(starterBlock.id)) {
|
||||||
accessibleIds.add(starterBlock.id)
|
accessibleIds.add(starterBlock.id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,6 +180,21 @@ function mapEdgesByNode(edges: Edge[], nodeIds: Set<string>): Map<string, Edge[]
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs the panel editor with the current selection state.
|
||||||
|
* Shows block details when exactly one block is selected, clears otherwise.
|
||||||
|
*/
|
||||||
|
function syncPanelWithSelection(selectedIds: string[]) {
|
||||||
|
const { currentBlockId, clearCurrentBlock, setCurrentBlockId } = usePanelEditorStore.getState()
|
||||||
|
if (selectedIds.length === 1 && selectedIds[0] !== currentBlockId) {
|
||||||
|
setCurrentBlockId(selectedIds[0])
|
||||||
|
} else if (selectedIds.length === 0 && currentBlockId) {
|
||||||
|
clearCurrentBlock()
|
||||||
|
} else if (selectedIds.length > 1 && currentBlockId) {
|
||||||
|
clearCurrentBlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Custom node types for ReactFlow. */
|
/** Custom node types for ReactFlow. */
|
||||||
const nodeTypes: NodeTypes = {
|
const nodeTypes: NodeTypes = {
|
||||||
workflowBlock: WorkflowBlock,
|
workflowBlock: WorkflowBlock,
|
||||||
@@ -2075,7 +2090,10 @@ const WorkflowContent = React.memo(() => {
|
|||||||
...node,
|
...node,
|
||||||
selected: pendingSet.has(node.id),
|
selected: pendingSet.has(node.id),
|
||||||
}))
|
}))
|
||||||
setDisplayNodes(resolveParentChildSelectionConflicts(withSelection, blocks))
|
const resolved = resolveParentChildSelectionConflicts(withSelection, blocks)
|
||||||
|
setDisplayNodes(resolved)
|
||||||
|
const selectedIds = resolved.filter((node) => node.selected).map((node) => node.id)
|
||||||
|
syncPanelWithSelection(selectedIds)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2175,13 +2193,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
})
|
})
|
||||||
const selectedIds = selectedIdsRef.current as string[] | null
|
const selectedIds = selectedIdsRef.current as string[] | null
|
||||||
if (selectedIds !== null) {
|
if (selectedIds !== null) {
|
||||||
const { currentBlockId, clearCurrentBlock, setCurrentBlockId } =
|
syncPanelWithSelection(selectedIds)
|
||||||
usePanelEditorStore.getState()
|
|
||||||
if (selectedIds.length === 1 && selectedIds[0] !== currentBlockId) {
|
|
||||||
setCurrentBlockId(selectedIds[0])
|
|
||||||
} else if (selectedIds.length === 0 && currentBlockId) {
|
|
||||||
clearCurrentBlock()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[blocks]
|
[blocks]
|
||||||
|
|||||||
@@ -21,11 +21,9 @@ interface WorkflowPreviewBlockData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight block component for workflow previews.
|
* Preview block component for workflow visualization.
|
||||||
* Renders block header, dummy subblocks skeleton, and handles.
|
* Renders block header, subblocks skeleton, and handles without
|
||||||
* Respects horizontalHandles and enabled state from workflow.
|
* hooks, store subscriptions, or interactive features.
|
||||||
* No heavy hooks, store subscriptions, or interactive features.
|
|
||||||
* Used in template cards and other preview contexts for performance.
|
|
||||||
*/
|
*/
|
||||||
function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>) {
|
function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>) {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -685,7 +685,7 @@ interface WorkflowVariable {
|
|||||||
value: unknown
|
value: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BlockDetailsSidebarProps {
|
interface PreviewEditorProps {
|
||||||
block: BlockState
|
block: BlockState
|
||||||
executionData?: ExecutionData
|
executionData?: ExecutionData
|
||||||
/** All block execution data for resolving variable references */
|
/** All block execution data for resolving variable references */
|
||||||
@@ -722,7 +722,7 @@ const DEFAULT_CONNECTIONS_HEIGHT = 150
|
|||||||
/**
|
/**
|
||||||
* Readonly sidebar panel showing block configuration using SubBlock components.
|
* Readonly sidebar panel showing block configuration using SubBlock components.
|
||||||
*/
|
*/
|
||||||
function BlockDetailsSidebarContent({
|
function PreviewEditorContent({
|
||||||
block,
|
block,
|
||||||
executionData,
|
executionData,
|
||||||
allBlockExecutions,
|
allBlockExecutions,
|
||||||
@@ -732,7 +732,7 @@ function BlockDetailsSidebarContent({
|
|||||||
parallels,
|
parallels,
|
||||||
isExecutionMode = false,
|
isExecutionMode = false,
|
||||||
onClose,
|
onClose,
|
||||||
}: BlockDetailsSidebarProps) {
|
}: PreviewEditorProps) {
|
||||||
// Convert Record<string, Variable> to Array<Variable> for iteration
|
// Convert Record<string, Variable> to Array<Variable> for iteration
|
||||||
const normalizedWorkflowVariables = useMemo(() => {
|
const normalizedWorkflowVariables = useMemo(() => {
|
||||||
if (!workflowVariables) return []
|
if (!workflowVariables) return []
|
||||||
@@ -998,6 +998,22 @@ function BlockDetailsSidebarContent({
|
|||||||
})
|
})
|
||||||
}, [extractedRefs.envVars])
|
}, [extractedRefs.envVars])
|
||||||
|
|
||||||
|
const rawValues = useMemo(() => {
|
||||||
|
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
|
||||||
|
if (entry && typeof entry === 'object' && 'value' in entry) {
|
||||||
|
acc[key] = (entry as { value: unknown }).value
|
||||||
|
} else {
|
||||||
|
acc[key] = entry
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
}, [subBlockValues])
|
||||||
|
|
||||||
|
const canonicalIndex = useMemo(
|
||||||
|
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||||
|
[blockConfig?.subBlocks]
|
||||||
|
)
|
||||||
|
|
||||||
// Check if this is a subflow block (loop or parallel)
|
// Check if this is a subflow block (loop or parallel)
|
||||||
const isSubflow = block.type === 'loop' || block.type === 'parallel'
|
const isSubflow = block.type === 'loop' || block.type === 'parallel'
|
||||||
const loopConfig = block.type === 'loop' ? loops?.[block.id] : undefined
|
const loopConfig = block.type === 'loop' ? loops?.[block.id] : undefined
|
||||||
@@ -1079,21 +1095,6 @@ function BlockDetailsSidebarContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawValues = useMemo(() => {
|
|
||||||
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
|
|
||||||
if (entry && typeof entry === 'object' && 'value' in entry) {
|
|
||||||
acc[key] = (entry as { value: unknown }).value
|
|
||||||
} else {
|
|
||||||
acc[key] = entry
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
}, [subBlockValues])
|
|
||||||
|
|
||||||
const canonicalIndex = useMemo(
|
|
||||||
() => buildCanonicalIndex(blockConfig.subBlocks),
|
|
||||||
[blockConfig.subBlocks]
|
|
||||||
)
|
|
||||||
const canonicalModeOverrides = block.data?.canonicalModes
|
const canonicalModeOverrides = block.data?.canonicalModes
|
||||||
const effectiveAdvanced =
|
const effectiveAdvanced =
|
||||||
(block.advancedMode ?? false) ||
|
(block.advancedMode ?? false) ||
|
||||||
@@ -1371,12 +1372,12 @@ function BlockDetailsSidebarContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block details sidebar wrapped in ReactFlowProvider for hook compatibility.
|
* Preview editor wrapped in ReactFlowProvider for hook compatibility.
|
||||||
*/
|
*/
|
||||||
export function BlockDetailsSidebar(props: BlockDetailsSidebarProps) {
|
export function PreviewEditor(props: PreviewEditorProps) {
|
||||||
return (
|
return (
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
<BlockDetailsSidebarContent {...props} />
|
<PreviewEditorContent {...props} />
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -15,10 +15,9 @@ interface WorkflowPreviewSubflowData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight subflow component for workflow previews.
|
* Preview subflow component for workflow visualization.
|
||||||
* Matches the styling of the actual SubflowNodeComponent but without
|
* Renders loop/parallel containers without hooks, store subscriptions,
|
||||||
* hooks, store subscriptions, or interactive features.
|
* or interactive features.
|
||||||
* Used in template cards and other preview contexts for performance.
|
|
||||||
*/
|
*/
|
||||||
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
|
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
|
||||||
const { name, width = 500, height = 300, kind, isPreviewSelected = false } = data
|
const { name, width = 500, height = 300, kind, isPreviewSelected = false } = data
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { BlockDetailsSidebar } from './components/block-details-sidebar'
|
export { PreviewEditor } from './components/preview-editor'
|
||||||
export { getLeftmostBlockId, WorkflowPreview } from './preview'
|
export { getLeftmostBlockId, WorkflowPreview } from './preview'
|
||||||
|
|||||||
@@ -15,14 +15,10 @@ import 'reactflow/dist/style.css'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||||
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
|
|
||||||
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
|
||||||
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
|
||||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||||
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||||
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
|
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
|
||||||
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
|
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
|
||||||
import { getBlock } from '@/blocks'
|
|
||||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||||
|
|
||||||
const logger = createLogger('WorkflowPreview')
|
const logger = createLogger('WorkflowPreview')
|
||||||
@@ -134,8 +130,6 @@ interface WorkflowPreviewProps {
|
|||||||
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
|
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
|
||||||
/** Callback when the canvas (empty area) is clicked */
|
/** Callback when the canvas (empty area) is clicked */
|
||||||
onPaneClick?: () => void
|
onPaneClick?: () => void
|
||||||
/** Use lightweight blocks for better performance in template cards */
|
|
||||||
lightweight?: boolean
|
|
||||||
/** Cursor style to show when hovering the canvas */
|
/** Cursor style to show when hovering the canvas */
|
||||||
cursorStyle?: 'default' | 'pointer' | 'grab'
|
cursorStyle?: 'default' | 'pointer' | 'grab'
|
||||||
/** Map of executed block IDs to their status for highlighting the execution path */
|
/** Map of executed block IDs to their status for highlighting the execution path */
|
||||||
@@ -145,19 +139,10 @@ interface WorkflowPreviewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full node types with interactive WorkflowBlock for detailed previews
|
* Preview node types using minimal components without hooks or store subscriptions.
|
||||||
|
* This prevents interaction issues while allowing canvas panning and node clicking.
|
||||||
*/
|
*/
|
||||||
const fullNodeTypes: NodeTypes = {
|
const previewNodeTypes: NodeTypes = {
|
||||||
workflowBlock: WorkflowBlock,
|
|
||||||
noteBlock: NoteBlock,
|
|
||||||
subflowNode: SubflowNodeComponent,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight node types for template cards and other high-volume previews.
|
|
||||||
* Uses minimal components without hooks or store subscriptions.
|
|
||||||
*/
|
|
||||||
const lightweightNodeTypes: NodeTypes = {
|
|
||||||
workflowBlock: WorkflowPreviewBlock,
|
workflowBlock: WorkflowPreviewBlock,
|
||||||
noteBlock: WorkflowPreviewBlock,
|
noteBlock: WorkflowPreviewBlock,
|
||||||
subflowNode: WorkflowPreviewSubflow,
|
subflowNode: WorkflowPreviewSubflow,
|
||||||
@@ -172,17 +157,19 @@ const edgeTypes: EdgeTypes = {
|
|||||||
interface FitViewOnChangeProps {
|
interface FitViewOnChangeProps {
|
||||||
nodeIds: string
|
nodeIds: string
|
||||||
fitPadding: number
|
fitPadding: number
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper component that calls fitView when the set of nodes changes.
|
* Helper component that calls fitView when the set of nodes changes or when the container resizes.
|
||||||
* Only triggers on actual node additions/removals, not on selection changes.
|
* Only triggers on actual node additions/removals, not on selection changes.
|
||||||
* Must be rendered inside ReactFlowProvider.
|
* Must be rendered inside ReactFlowProvider.
|
||||||
*/
|
*/
|
||||||
function FitViewOnChange({ nodeIds, fitPadding }: FitViewOnChangeProps) {
|
function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) {
|
||||||
const { fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
const lastNodeIdsRef = useRef<string | null>(null)
|
const lastNodeIdsRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
// Fit view when nodes change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!nodeIds.length) return
|
if (!nodeIds.length) return
|
||||||
const shouldFit = lastNodeIdsRef.current !== nodeIds
|
const shouldFit = lastNodeIdsRef.current !== nodeIds
|
||||||
@@ -195,6 +182,27 @@ function FitViewOnChange({ nodeIds, fitPadding }: FitViewOnChangeProps) {
|
|||||||
return () => clearTimeout(timeoutId)
|
return () => clearTimeout(timeoutId)
|
||||||
}, [nodeIds, fitPadding, fitView])
|
}, [nodeIds, fitPadding, fitView])
|
||||||
|
|
||||||
|
// Fit view when container resizes (debounced to avoid excessive calls during drag)
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId)
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
fitView({ padding: fitPadding, duration: 150 })
|
||||||
|
}, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
resizeObserver.observe(container)
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId)
|
||||||
|
resizeObserver.disconnect()
|
||||||
|
}
|
||||||
|
}, [containerRef, fitPadding, fitView])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,12 +218,12 @@ export function WorkflowPreview({
|
|||||||
onNodeClick,
|
onNodeClick,
|
||||||
onNodeContextMenu,
|
onNodeContextMenu,
|
||||||
onPaneClick,
|
onPaneClick,
|
||||||
lightweight = false,
|
|
||||||
cursorStyle = 'grab',
|
cursorStyle = 'grab',
|
||||||
executedBlocks,
|
executedBlocks,
|
||||||
selectedBlockId,
|
selectedBlockId,
|
||||||
}: WorkflowPreviewProps) {
|
}: WorkflowPreviewProps) {
|
||||||
const nodeTypes = lightweight ? lightweightNodeTypes : fullNodeTypes
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const nodeTypes = previewNodeTypes
|
||||||
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
|
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
|
||||||
|
|
||||||
const blocksStructure = useMemo(() => {
|
const blocksStructure = useMemo(() => {
|
||||||
@@ -285,119 +293,28 @@ export function WorkflowPreview({
|
|||||||
|
|
||||||
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
|
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
|
||||||
|
|
||||||
if (lightweight) {
|
// Handle loop/parallel containers
|
||||||
if (block.type === 'loop' || block.type === 'parallel') {
|
if (block.type === 'loop' || block.type === 'parallel') {
|
||||||
const isSelected = selectedBlockId === blockId
|
|
||||||
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
|
|
||||||
nodeArray.push({
|
|
||||||
id: blockId,
|
|
||||||
type: 'subflowNode',
|
|
||||||
position: absolutePosition,
|
|
||||||
draggable: false,
|
|
||||||
data: {
|
|
||||||
name: block.name,
|
|
||||||
width: dimensions.width,
|
|
||||||
height: dimensions.height,
|
|
||||||
kind: block.type as 'loop' | 'parallel',
|
|
||||||
isPreviewSelected: isSelected,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSelected = selectedBlockId === blockId
|
|
||||||
|
|
||||||
let lightweightExecutionStatus: ExecutionStatus | undefined
|
|
||||||
if (executedBlocks) {
|
|
||||||
const blockExecution = executedBlocks[blockId]
|
|
||||||
if (blockExecution) {
|
|
||||||
if (blockExecution.status === 'error') {
|
|
||||||
lightweightExecutionStatus = 'error'
|
|
||||||
} else if (blockExecution.status === 'success') {
|
|
||||||
lightweightExecutionStatus = 'success'
|
|
||||||
} else {
|
|
||||||
lightweightExecutionStatus = 'not-executed'
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lightweightExecutionStatus = 'not-executed'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeArray.push({
|
|
||||||
id: blockId,
|
|
||||||
type: 'workflowBlock',
|
|
||||||
position: absolutePosition,
|
|
||||||
draggable: false,
|
|
||||||
// Blocks inside subflows need higher z-index to appear above the container
|
|
||||||
zIndex: block.data?.parentId ? 10 : undefined,
|
|
||||||
data: {
|
|
||||||
type: block.type,
|
|
||||||
name: block.name,
|
|
||||||
isTrigger: block.triggerMode === true,
|
|
||||||
horizontalHandles: block.horizontalHandles ?? false,
|
|
||||||
enabled: block.enabled ?? true,
|
|
||||||
isPreviewSelected: isSelected,
|
|
||||||
executionStatus: lightweightExecutionStatus,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === 'loop') {
|
|
||||||
const isSelected = selectedBlockId === blockId
|
const isSelected = selectedBlockId === blockId
|
||||||
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
|
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
|
||||||
nodeArray.push({
|
nodeArray.push({
|
||||||
id: blockId,
|
id: blockId,
|
||||||
type: 'subflowNode',
|
type: 'subflowNode',
|
||||||
position: absolutePosition,
|
position: absolutePosition,
|
||||||
parentId: block.data?.parentId,
|
|
||||||
extent: block.data?.extent || undefined,
|
|
||||||
draggable: false,
|
draggable: false,
|
||||||
data: {
|
data: {
|
||||||
...block.data,
|
|
||||||
name: block.name,
|
name: block.name,
|
||||||
width: dimensions.width,
|
width: dimensions.width,
|
||||||
height: dimensions.height,
|
height: dimensions.height,
|
||||||
state: 'valid',
|
kind: block.type as 'loop' | 'parallel',
|
||||||
isPreview: true,
|
|
||||||
isPreviewSelected: isSelected,
|
isPreviewSelected: isSelected,
|
||||||
kind: 'loop',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === 'parallel') {
|
// Handle regular blocks
|
||||||
const isSelected = selectedBlockId === blockId
|
const isSelected = selectedBlockId === blockId
|
||||||
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
|
|
||||||
nodeArray.push({
|
|
||||||
id: blockId,
|
|
||||||
type: 'subflowNode',
|
|
||||||
position: absolutePosition,
|
|
||||||
parentId: block.data?.parentId,
|
|
||||||
extent: block.data?.extent || undefined,
|
|
||||||
draggable: false,
|
|
||||||
data: {
|
|
||||||
...block.data,
|
|
||||||
name: block.name,
|
|
||||||
width: dimensions.width,
|
|
||||||
height: dimensions.height,
|
|
||||||
state: 'valid',
|
|
||||||
isPreview: true,
|
|
||||||
isPreviewSelected: isSelected,
|
|
||||||
kind: 'parallel',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockConfig = getBlock(block.type)
|
|
||||||
if (!blockConfig) {
|
|
||||||
logger.error(`No configuration found for block type: ${block.type}`, { blockId })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
|
|
||||||
|
|
||||||
let executionStatus: ExecutionStatus | undefined
|
let executionStatus: ExecutionStatus | undefined
|
||||||
if (executedBlocks) {
|
if (executedBlocks) {
|
||||||
@@ -415,24 +332,20 @@ export function WorkflowPreview({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSelected = selectedBlockId === blockId
|
|
||||||
|
|
||||||
nodeArray.push({
|
nodeArray.push({
|
||||||
id: blockId,
|
id: blockId,
|
||||||
type: nodeType,
|
type: 'workflowBlock',
|
||||||
position: absolutePosition,
|
position: absolutePosition,
|
||||||
draggable: false,
|
draggable: false,
|
||||||
// Blocks inside subflows need higher z-index to appear above the container
|
// Blocks inside subflows need higher z-index to appear above the container
|
||||||
zIndex: block.data?.parentId ? 10 : undefined,
|
zIndex: block.data?.parentId ? 10 : undefined,
|
||||||
data: {
|
data: {
|
||||||
type: block.type,
|
type: block.type,
|
||||||
config: blockConfig,
|
|
||||||
name: block.name,
|
name: block.name,
|
||||||
blockState: block,
|
isTrigger: block.triggerMode === true,
|
||||||
canEdit: false,
|
horizontalHandles: block.horizontalHandles ?? false,
|
||||||
isPreview: true,
|
enabled: block.enabled ?? true,
|
||||||
isPreviewSelected: isSelected,
|
isPreviewSelected: isSelected,
|
||||||
subBlockValues: block.subBlocks ?? {},
|
|
||||||
executionStatus,
|
executionStatus,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -445,7 +358,6 @@ export function WorkflowPreview({
|
|||||||
parallelsStructure,
|
parallelsStructure,
|
||||||
workflowState.blocks,
|
workflowState.blocks,
|
||||||
isValidWorkflowState,
|
isValidWorkflowState,
|
||||||
lightweight,
|
|
||||||
executedBlocks,
|
executedBlocks,
|
||||||
selectedBlockId,
|
selectedBlockId,
|
||||||
])
|
])
|
||||||
@@ -503,6 +415,7 @@ export function WorkflowPreview({
|
|||||||
return (
|
return (
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
<div
|
<div
|
||||||
|
ref={containerRef}
|
||||||
style={{ height, width, backgroundColor: 'var(--bg)' }}
|
style={{ height, width, backgroundColor: 'var(--bg)' }}
|
||||||
className={cn('preview-mode', onNodeClick && 'interactive-nodes', className)}
|
className={cn('preview-mode', onNodeClick && 'interactive-nodes', className)}
|
||||||
>
|
>
|
||||||
@@ -513,6 +426,20 @@ export function WorkflowPreview({
|
|||||||
.preview-mode .react-flow__selectionpane { cursor: ${cursorStyle} !important; }
|
.preview-mode .react-flow__selectionpane { cursor: ${cursorStyle} !important; }
|
||||||
.preview-mode .react-flow__renderer { cursor: ${cursorStyle}; }
|
.preview-mode .react-flow__renderer { cursor: ${cursorStyle}; }
|
||||||
|
|
||||||
|
/* Active/grabbing cursor when dragging */
|
||||||
|
${
|
||||||
|
cursorStyle === 'grab'
|
||||||
|
? `
|
||||||
|
.preview-mode .react-flow:active { cursor: grabbing; }
|
||||||
|
.preview-mode .react-flow__pane:active { cursor: grabbing !important; }
|
||||||
|
.preview-mode .react-flow__selectionpane:active { cursor: grabbing !important; }
|
||||||
|
.preview-mode .react-flow__renderer:active { cursor: grabbing; }
|
||||||
|
.preview-mode .react-flow__node:active { cursor: grabbing !important; }
|
||||||
|
.preview-mode .react-flow__node:active * { cursor: grabbing !important; }
|
||||||
|
`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
/* Node cursor - pointer on nodes when onNodeClick is provided */
|
/* Node cursor - pointer on nodes when onNodeClick is provided */
|
||||||
.preview-mode.interactive-nodes .react-flow__node { cursor: pointer !important; }
|
.preview-mode.interactive-nodes .react-flow__node { cursor: pointer !important; }
|
||||||
.preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; }
|
.preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; }
|
||||||
@@ -560,7 +487,11 @@ export function WorkflowPreview({
|
|||||||
}
|
}
|
||||||
onPaneClick={onPaneClick}
|
onPaneClick={onPaneClick}
|
||||||
/>
|
/>
|
||||||
<FitViewOnChange nodeIds={blocksStructure.ids} fitPadding={fitPadding} />
|
<FitViewOnChange
|
||||||
|
nodeIds={blocksStructure.ids}
|
||||||
|
fitPadding={fitPadding}
|
||||||
|
containerRef={containerRef}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useSidebarStore } from '@/stores/sidebar/store'
|
|||||||
* Avatar display configuration for responsive layout.
|
* Avatar display configuration for responsive layout.
|
||||||
*/
|
*/
|
||||||
const AVATAR_CONFIG = {
|
const AVATAR_CONFIG = {
|
||||||
MIN_COUNT: 3,
|
MIN_COUNT: 4,
|
||||||
MAX_COUNT: 12,
|
MAX_COUNT: 12,
|
||||||
WIDTH_PER_AVATAR: 20,
|
WIDTH_PER_AVATAR: 20,
|
||||||
} as const
|
} as const
|
||||||
@@ -106,7 +106,9 @@ export function Avatars({ workflowId }: AvatarsProps) {
|
|||||||
}, [presenceUsers, currentWorkflowId, workflowId, currentSocketId])
|
}, [presenceUsers, currentWorkflowId, workflowId, currentSocketId])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate visible users and overflow count
|
* Calculate visible users and overflow count.
|
||||||
|
* Shows up to maxVisible avatars, with overflow indicator for any remaining.
|
||||||
|
* Users are reversed so new avatars appear on the left (keeping right side stable).
|
||||||
*/
|
*/
|
||||||
const { visibleUsers, overflowCount } = useMemo(() => {
|
const { visibleUsers, overflowCount } = useMemo(() => {
|
||||||
if (workflowUsers.length === 0) {
|
if (workflowUsers.length === 0) {
|
||||||
@@ -116,7 +118,8 @@ export function Avatars({ workflowId }: AvatarsProps) {
|
|||||||
const visible = workflowUsers.slice(0, maxVisible)
|
const visible = workflowUsers.slice(0, maxVisible)
|
||||||
const overflow = Math.max(0, workflowUsers.length - maxVisible)
|
const overflow = Math.max(0, workflowUsers.length - maxVisible)
|
||||||
|
|
||||||
return { visibleUsers: visible, overflowCount: overflow }
|
// Reverse so rightmost avatars stay stable as new ones are revealed on the left
|
||||||
|
return { visibleUsers: [...visible].reverse(), overflowCount: overflow }
|
||||||
}, [workflowUsers, maxVisible])
|
}, [workflowUsers, maxVisible])
|
||||||
|
|
||||||
if (visibleUsers.length === 0) {
|
if (visibleUsers.length === 0) {
|
||||||
@@ -139,9 +142,8 @@ export function Avatars({ workflowId }: AvatarsProps) {
|
|||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visibleUsers.map((user, index) => (
|
{visibleUsers.map((user, index) => (
|
||||||
<UserAvatar key={user.socketId} user={user} index={overflowCount > 0 ? index + 1 : index} />
|
<UserAvatar key={user.socketId} user={user} index={index} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ export function WorkflowItem({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'min-w-0 flex-1 truncate font-medium',
|
'min-w-0 truncate font-medium',
|
||||||
active
|
active
|
||||||
? 'text-[var(--text-primary)]'
|
? 'text-[var(--text-primary)]'
|
||||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||||
|
|||||||
@@ -242,15 +242,9 @@ Return ONLY the email body - no explanations, no extra text.`,
|
|||||||
id: 'messageId',
|
id: 'messageId',
|
||||||
title: 'Message ID',
|
title: 'Message ID',
|
||||||
type: 'short-input',
|
type: 'short-input',
|
||||||
placeholder: 'Enter message ID to read (optional)',
|
placeholder: 'Read specific email by ID (overrides label/folder)',
|
||||||
condition: {
|
condition: { field: 'operation', value: 'read_gmail' },
|
||||||
field: 'operation',
|
mode: 'advanced',
|
||||||
value: 'read_gmail',
|
|
||||||
and: {
|
|
||||||
field: 'folder',
|
|
||||||
value: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// Search Fields
|
// Search Fields
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -129,12 +129,9 @@ ROUTING RULES:
|
|||||||
3. If the context is even partially related to a route's description, select that route
|
3. If the context is even partially related to a route's description, select that route
|
||||||
4. ONLY output NO_MATCH if the context is completely unrelated to ALL route descriptions
|
4. ONLY output NO_MATCH if the context is completely unrelated to ALL route descriptions
|
||||||
|
|
||||||
OUTPUT FORMAT:
|
Respond with a JSON object containing:
|
||||||
- Output EXACTLY one route ID (copied exactly as shown above) OR "NO_MATCH"
|
- route: EXACTLY one route ID (copied exactly as shown above) OR "NO_MATCH"
|
||||||
- No explanation, no punctuation, no additional text
|
- reasoning: A brief explanation (1-2 sentences) of why you chose this route`
|
||||||
- Just the route ID or NO_MATCH
|
|
||||||
|
|
||||||
Your response:`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -272,6 +269,7 @@ interface RouterV2Response extends ToolResponse {
|
|||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
selectedRoute: string
|
selectedRoute: string
|
||||||
|
reasoning: string
|
||||||
selectedPath: {
|
selectedPath: {
|
||||||
blockId: string
|
blockId: string
|
||||||
blockType: string
|
blockType: string
|
||||||
@@ -355,6 +353,7 @@ export const RouterV2Block: BlockConfig<RouterV2Response> = {
|
|||||||
tokens: { type: 'json', description: 'Token usage' },
|
tokens: { type: 'json', description: 'Token usage' },
|
||||||
cost: { type: 'json', description: 'Cost information' },
|
cost: { type: 'json', description: 'Cost information' },
|
||||||
selectedRoute: { type: 'string', description: 'Selected route ID' },
|
selectedRoute: { type: 'string', description: 'Selected route ID' },
|
||||||
|
reasoning: { type: 'string', description: 'Explanation of why this route was chosen' },
|
||||||
selectedPath: { type: 'json', description: 'Selected routing path' },
|
selectedPath: { type: 'json', description: 'Selected routing path' },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,5 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { WorkflowIcon } from '@/components/icons'
|
import { WorkflowIcon } from '@/components/icons'
|
||||||
import type { BlockConfig } from '@/blocks/types'
|
import type { BlockConfig } from '@/blocks/types'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
|
||||||
|
|
||||||
const logger = createLogger('WorkflowBlock')
|
|
||||||
|
|
||||||
// Helper function to get available workflows for the dropdown
|
|
||||||
const getAvailableWorkflows = (): Array<{ label: string; id: string }> => {
|
|
||||||
try {
|
|
||||||
const { workflows, activeWorkflowId } = useWorkflowRegistry.getState()
|
|
||||||
|
|
||||||
// Filter out the current workflow to prevent recursion
|
|
||||||
const availableWorkflows = Object.entries(workflows)
|
|
||||||
.filter(([id]) => id !== activeWorkflowId)
|
|
||||||
.map(([id, workflow]) => ({
|
|
||||||
label: workflow.name || `Workflow ${id.slice(0, 8)}`,
|
|
||||||
id: id,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.label.localeCompare(b.label))
|
|
||||||
|
|
||||||
return availableWorkflows
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error getting available workflows:', error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WorkflowBlock: BlockConfig = {
|
export const WorkflowBlock: BlockConfig = {
|
||||||
type: 'workflow',
|
type: 'workflow',
|
||||||
@@ -38,8 +13,7 @@ export const WorkflowBlock: BlockConfig = {
|
|||||||
{
|
{
|
||||||
id: 'workflowId',
|
id: 'workflowId',
|
||||||
title: 'Select Workflow',
|
title: 'Select Workflow',
|
||||||
type: 'combobox',
|
type: 'workflow-selector',
|
||||||
options: getAvailableWorkflows,
|
|
||||||
placeholder: 'Search workflows...',
|
placeholder: 'Search workflows...',
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,18 +1,5 @@
|
|||||||
import { WorkflowIcon } from '@/components/icons'
|
import { WorkflowIcon } from '@/components/icons'
|
||||||
import type { BlockConfig } from '@/blocks/types'
|
import type { BlockConfig } from '@/blocks/types'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
|
||||||
|
|
||||||
const getAvailableWorkflows = (): Array<{ label: string; id: string }> => {
|
|
||||||
try {
|
|
||||||
const { workflows, activeWorkflowId } = useWorkflowRegistry.getState()
|
|
||||||
return Object.entries(workflows)
|
|
||||||
.filter(([id]) => id !== activeWorkflowId)
|
|
||||||
.map(([id, w]) => ({ label: w.name || `Workflow ${id.slice(0, 8)}`, id }))
|
|
||||||
.sort((a, b) => a.label.localeCompare(b.label))
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WorkflowInputBlock: BlockConfig = {
|
export const WorkflowInputBlock: BlockConfig = {
|
||||||
type: 'workflow_input',
|
type: 'workflow_input',
|
||||||
@@ -25,21 +12,19 @@ export const WorkflowInputBlock: BlockConfig = {
|
|||||||
`,
|
`,
|
||||||
category: 'blocks',
|
category: 'blocks',
|
||||||
docsLink: 'https://docs.sim.ai/blocks/workflow',
|
docsLink: 'https://docs.sim.ai/blocks/workflow',
|
||||||
bgColor: '#6366F1', // Indigo - modern and professional
|
bgColor: '#6366F1',
|
||||||
icon: WorkflowIcon,
|
icon: WorkflowIcon,
|
||||||
subBlocks: [
|
subBlocks: [
|
||||||
{
|
{
|
||||||
id: 'workflowId',
|
id: 'workflowId',
|
||||||
title: 'Select Workflow',
|
title: 'Select Workflow',
|
||||||
type: 'combobox',
|
type: 'workflow-selector',
|
||||||
options: getAvailableWorkflows,
|
|
||||||
placeholder: 'Search workflows...',
|
placeholder: 'Search workflows...',
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
// Renders dynamic mapping UI based on selected child workflow's Start trigger inputFormat
|
|
||||||
{
|
{
|
||||||
id: 'inputMapping',
|
id: 'inputMapping',
|
||||||
title: 'Input Mapping',
|
title: 'Inputs',
|
||||||
type: 'input-mapping',
|
type: 'input-mapping',
|
||||||
description:
|
description:
|
||||||
"Map fields defined in the child workflow's Start block to variables/values in this workflow.",
|
"Map fields defined in the child workflow's Start block to variables/values in this workflow.",
|
||||||
|
|||||||
@@ -24,6 +24,71 @@ function createBlock(id: string, metadataId: string): SerializedBlock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('DAGBuilder disabled subflow validation', () => {
|
||||||
|
it('skips validation for disabled loops with no blocks inside', () => {
|
||||||
|
const workflow: SerializedWorkflow = {
|
||||||
|
version: '1',
|
||||||
|
blocks: [
|
||||||
|
createBlock('start', BlockType.STARTER),
|
||||||
|
{ ...createBlock('loop-block', BlockType.FUNCTION), enabled: false },
|
||||||
|
],
|
||||||
|
connections: [],
|
||||||
|
loops: {
|
||||||
|
'loop-1': {
|
||||||
|
id: 'loop-1',
|
||||||
|
nodes: [], // Empty loop - would normally throw
|
||||||
|
iterations: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const builder = new DAGBuilder()
|
||||||
|
// Should not throw even though loop has no blocks inside
|
||||||
|
expect(() => builder.build(workflow)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips validation for disabled parallels with no blocks inside', () => {
|
||||||
|
const workflow: SerializedWorkflow = {
|
||||||
|
version: '1',
|
||||||
|
blocks: [createBlock('start', BlockType.STARTER)],
|
||||||
|
connections: [],
|
||||||
|
loops: {},
|
||||||
|
parallels: {
|
||||||
|
'parallel-1': {
|
||||||
|
id: 'parallel-1',
|
||||||
|
nodes: [], // Empty parallel - would normally throw
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const builder = new DAGBuilder()
|
||||||
|
// Should not throw even though parallel has no blocks inside
|
||||||
|
expect(() => builder.build(workflow)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips validation for loops where all inner blocks are disabled', () => {
|
||||||
|
const workflow: SerializedWorkflow = {
|
||||||
|
version: '1',
|
||||||
|
blocks: [
|
||||||
|
createBlock('start', BlockType.STARTER),
|
||||||
|
{ ...createBlock('inner-block', BlockType.FUNCTION), enabled: false },
|
||||||
|
],
|
||||||
|
connections: [],
|
||||||
|
loops: {
|
||||||
|
'loop-1': {
|
||||||
|
id: 'loop-1',
|
||||||
|
nodes: ['inner-block'], // Has node but it's disabled
|
||||||
|
iterations: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const builder = new DAGBuilder()
|
||||||
|
// Should not throw - loop is effectively disabled since all inner blocks are disabled
|
||||||
|
expect(() => builder.build(workflow)).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('DAGBuilder human-in-the-loop transformation', () => {
|
describe('DAGBuilder human-in-the-loop transformation', () => {
|
||||||
it('creates trigger nodes and rewires edges for pause blocks', () => {
|
it('creates trigger nodes and rewires edges for pause blocks', () => {
|
||||||
const workflow: SerializedWorkflow = {
|
const workflow: SerializedWorkflow = {
|
||||||
|
|||||||
@@ -136,17 +136,18 @@ export class DAGBuilder {
|
|||||||
nodes: string[] | undefined,
|
nodes: string[] | undefined,
|
||||||
type: 'Loop' | 'Parallel'
|
type: 'Loop' | 'Parallel'
|
||||||
): void {
|
): void {
|
||||||
|
const sentinelStartId =
|
||||||
|
type === 'Loop' ? buildSentinelStartId(id) : buildParallelSentinelStartId(id)
|
||||||
|
const sentinelStartNode = dag.nodes.get(sentinelStartId)
|
||||||
|
|
||||||
|
if (!sentinelStartNode) return
|
||||||
|
|
||||||
if (!nodes || nodes.length === 0) {
|
if (!nodes || nodes.length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${type} has no blocks inside. Add at least one block to the ${type.toLowerCase()}.`
|
`${type} has no blocks inside. Add at least one block to the ${type.toLowerCase()}.`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sentinelStartId =
|
|
||||||
type === 'Loop' ? buildSentinelStartId(id) : buildParallelSentinelStartId(id)
|
|
||||||
const sentinelStartNode = dag.nodes.get(sentinelStartId)
|
|
||||||
if (!sentinelStartNode) return
|
|
||||||
|
|
||||||
const hasConnections = Array.from(sentinelStartNode.outgoingEdges.values()).some((edge) =>
|
const hasConnections = Array.from(sentinelStartNode.outgoingEdges.values()).some((edge) =>
|
||||||
nodes.includes(extractBaseBlockId(edge.target))
|
nodes.includes(extractBaseBlockId(edge.target))
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,21 +20,13 @@ export class EdgeManager {
|
|||||||
const activatedTargets: string[] = []
|
const activatedTargets: string[] = []
|
||||||
const edgesToDeactivate: Array<{ target: string; handle?: string }> = []
|
const edgesToDeactivate: Array<{ target: string; handle?: string }> = []
|
||||||
|
|
||||||
// First pass: categorize edges as activating or deactivating
|
for (const [, edge] of node.outgoingEdges) {
|
||||||
// Don't modify incomingEdges yet - we need the original state for deactivation checks
|
|
||||||
for (const [edgeId, edge] of node.outgoingEdges) {
|
|
||||||
if (skipBackwardsEdge && this.isBackwardsEdge(edge.sourceHandle)) {
|
if (skipBackwardsEdge && this.isBackwardsEdge(edge.sourceHandle)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldActivate = this.shouldActivateEdge(edge, output)
|
if (!this.shouldActivateEdge(edge, output)) {
|
||||||
if (!shouldActivate) {
|
if (!this.isLoopEdge(edge.sourceHandle)) {
|
||||||
const isLoopEdge =
|
|
||||||
edge.sourceHandle === EDGE.LOOP_CONTINUE ||
|
|
||||||
edge.sourceHandle === EDGE.LOOP_CONTINUE_ALT ||
|
|
||||||
edge.sourceHandle === EDGE.LOOP_EXIT
|
|
||||||
|
|
||||||
if (!isLoopEdge) {
|
|
||||||
edgesToDeactivate.push({ target: edge.target, handle: edge.sourceHandle })
|
edgesToDeactivate.push({ target: edge.target, handle: edge.sourceHandle })
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -43,13 +35,19 @@ export class EdgeManager {
|
|||||||
activatedTargets.push(edge.target)
|
activatedTargets.push(edge.target)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second pass: process deactivations while incomingEdges is still intact
|
const cascadeTargets = new Set<string>()
|
||||||
// This ensures hasActiveIncomingEdges can find all potential sources
|
|
||||||
for (const { target, handle } of edgesToDeactivate) {
|
for (const { target, handle } of edgesToDeactivate) {
|
||||||
this.deactivateEdgeAndDescendants(node.id, target, handle)
|
this.deactivateEdgeAndDescendants(node.id, target, handle, cascadeTargets)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activatedTargets.length === 0) {
|
||||||
|
for (const { target } of edgesToDeactivate) {
|
||||||
|
if (this.isTerminalControlNode(target)) {
|
||||||
|
cascadeTargets.add(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Third pass: update incomingEdges for activated targets
|
|
||||||
for (const targetId of activatedTargets) {
|
for (const targetId of activatedTargets) {
|
||||||
const targetNode = this.dag.nodes.get(targetId)
|
const targetNode = this.dag.nodes.get(targetId)
|
||||||
if (!targetNode) {
|
if (!targetNode) {
|
||||||
@@ -59,28 +57,25 @@ export class EdgeManager {
|
|||||||
targetNode.incomingEdges.delete(node.id)
|
targetNode.incomingEdges.delete(node.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fourth pass: check readiness after all edge processing is complete
|
|
||||||
for (const targetId of activatedTargets) {
|
for (const targetId of activatedTargets) {
|
||||||
const targetNode = this.dag.nodes.get(targetId)
|
if (this.isTargetReady(targetId)) {
|
||||||
if (targetNode && this.isNodeReady(targetNode)) {
|
|
||||||
readyNodes.push(targetId)
|
readyNodes.push(targetId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const targetId of cascadeTargets) {
|
||||||
|
if (!readyNodes.includes(targetId) && !activatedTargets.includes(targetId)) {
|
||||||
|
if (this.isTargetReady(targetId)) {
|
||||||
|
readyNodes.push(targetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return readyNodes
|
return readyNodes
|
||||||
}
|
}
|
||||||
|
|
||||||
isNodeReady(node: DAGNode): boolean {
|
isNodeReady(node: DAGNode): boolean {
|
||||||
if (node.incomingEdges.size === 0) {
|
return node.incomingEdges.size === 0 || this.countActiveIncomingEdges(node) === 0
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeIncomingCount = this.countActiveIncomingEdges(node)
|
|
||||||
if (activeIncomingCount > 0) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreIncomingEdge(targetNodeId: string, sourceNodeId: string): void {
|
restoreIncomingEdge(targetNodeId: string, sourceNodeId: string): void {
|
||||||
@@ -99,13 +94,10 @@ export class EdgeManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear deactivated edges for a set of nodes (used when restoring loop state for next iteration).
|
* Clear deactivated edges for a set of nodes (used when restoring loop state for next iteration).
|
||||||
* This ensures error/success edges can be re-evaluated on each iteration.
|
|
||||||
*/
|
*/
|
||||||
clearDeactivatedEdgesForNodes(nodeIds: Set<string>): void {
|
clearDeactivatedEdgesForNodes(nodeIds: Set<string>): void {
|
||||||
const edgesToRemove: string[] = []
|
const edgesToRemove: string[] = []
|
||||||
for (const edgeKey of this.deactivatedEdges) {
|
for (const edgeKey of this.deactivatedEdges) {
|
||||||
// Edge key format is "sourceId-targetId-handle"
|
|
||||||
// Check if either source or target is in the nodeIds set
|
|
||||||
for (const nodeId of nodeIds) {
|
for (const nodeId of nodeIds) {
|
||||||
if (edgeKey.startsWith(`${nodeId}-`) || edgeKey.includes(`-${nodeId}-`)) {
|
if (edgeKey.startsWith(`${nodeId}-`) || edgeKey.includes(`-${nodeId}-`)) {
|
||||||
edgesToRemove.push(edgeKey)
|
edgesToRemove.push(edgeKey)
|
||||||
@@ -118,6 +110,44 @@ export class EdgeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isTargetReady(targetId: string): boolean {
|
||||||
|
const targetNode = this.dag.nodes.get(targetId)
|
||||||
|
return targetNode ? this.isNodeReady(targetNode) : false
|
||||||
|
}
|
||||||
|
|
||||||
|
private isLoopEdge(handle?: string): boolean {
|
||||||
|
return (
|
||||||
|
handle === EDGE.LOOP_CONTINUE ||
|
||||||
|
handle === EDGE.LOOP_CONTINUE_ALT ||
|
||||||
|
handle === EDGE.LOOP_EXIT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private isControlEdge(handle?: string): boolean {
|
||||||
|
return (
|
||||||
|
handle === EDGE.LOOP_CONTINUE ||
|
||||||
|
handle === EDGE.LOOP_CONTINUE_ALT ||
|
||||||
|
handle === EDGE.LOOP_EXIT ||
|
||||||
|
handle === EDGE.PARALLEL_EXIT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private isBackwardsEdge(sourceHandle?: string): boolean {
|
||||||
|
return sourceHandle === EDGE.LOOP_CONTINUE || sourceHandle === EDGE.LOOP_CONTINUE_ALT
|
||||||
|
}
|
||||||
|
|
||||||
|
private isTerminalControlNode(nodeId: string): boolean {
|
||||||
|
const node = this.dag.nodes.get(nodeId)
|
||||||
|
if (!node || node.outgoingEdges.size === 0) return false
|
||||||
|
|
||||||
|
for (const [, edge] of node.outgoingEdges) {
|
||||||
|
if (!this.isControlEdge(edge.sourceHandle)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private shouldActivateEdge(edge: DAGEdge, output: NormalizedBlockOutput): boolean {
|
private shouldActivateEdge(edge: DAGEdge, output: NormalizedBlockOutput): boolean {
|
||||||
const handle = edge.sourceHandle
|
const handle = edge.sourceHandle
|
||||||
|
|
||||||
@@ -159,14 +189,12 @@ export class EdgeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private isBackwardsEdge(sourceHandle?: string): boolean {
|
|
||||||
return sourceHandle === EDGE.LOOP_CONTINUE || sourceHandle === EDGE.LOOP_CONTINUE_ALT
|
|
||||||
}
|
|
||||||
|
|
||||||
private deactivateEdgeAndDescendants(
|
private deactivateEdgeAndDescendants(
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
targetId: string,
|
targetId: string,
|
||||||
sourceHandle?: string
|
sourceHandle?: string,
|
||||||
|
cascadeTargets?: Set<string>,
|
||||||
|
isCascade = false
|
||||||
): void {
|
): void {
|
||||||
const edgeKey = this.createEdgeKey(sourceId, targetId, sourceHandle)
|
const edgeKey = this.createEdgeKey(sourceId, targetId, sourceHandle)
|
||||||
if (this.deactivatedEdges.has(edgeKey)) {
|
if (this.deactivatedEdges.has(edgeKey)) {
|
||||||
@@ -174,38 +202,46 @@ export class EdgeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.deactivatedEdges.add(edgeKey)
|
this.deactivatedEdges.add(edgeKey)
|
||||||
|
|
||||||
const targetNode = this.dag.nodes.get(targetId)
|
const targetNode = this.dag.nodes.get(targetId)
|
||||||
if (!targetNode) return
|
if (!targetNode) return
|
||||||
|
|
||||||
// Check if target has other active incoming edges
|
if (isCascade && this.isTerminalControlNode(targetId)) {
|
||||||
// Pass the specific edge key being deactivated, not just source ID,
|
cascadeTargets?.add(targetId)
|
||||||
// to handle multiple edges from same source to same target (e.g., condition branches)
|
}
|
||||||
const hasOtherActiveIncoming = this.hasActiveIncomingEdges(targetNode, edgeKey)
|
|
||||||
if (!hasOtherActiveIncoming) {
|
if (this.hasActiveIncomingEdges(targetNode, edgeKey)) {
|
||||||
for (const [_, outgoingEdge] of targetNode.outgoingEdges) {
|
return
|
||||||
this.deactivateEdgeAndDescendants(targetId, outgoingEdge.target, outgoingEdge.sourceHandle)
|
}
|
||||||
|
|
||||||
|
for (const [, outgoingEdge] of targetNode.outgoingEdges) {
|
||||||
|
if (!this.isControlEdge(outgoingEdge.sourceHandle)) {
|
||||||
|
this.deactivateEdgeAndDescendants(
|
||||||
|
targetId,
|
||||||
|
outgoingEdge.target,
|
||||||
|
outgoingEdge.sourceHandle,
|
||||||
|
cascadeTargets,
|
||||||
|
true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a node has any active incoming edges besides the one being excluded.
|
* Checks if a node has any active incoming edges besides the one being excluded.
|
||||||
* This properly handles the case where multiple edges from the same source go to
|
|
||||||
* the same target (e.g., multiple condition branches pointing to one block).
|
|
||||||
*/
|
*/
|
||||||
private hasActiveIncomingEdges(node: DAGNode, excludeEdgeKey: string): boolean {
|
private hasActiveIncomingEdges(node: DAGNode, excludeEdgeKey: string): boolean {
|
||||||
for (const incomingSourceId of node.incomingEdges) {
|
for (const incomingSourceId of node.incomingEdges) {
|
||||||
const incomingNode = this.dag.nodes.get(incomingSourceId)
|
const incomingNode = this.dag.nodes.get(incomingSourceId)
|
||||||
if (!incomingNode) continue
|
if (!incomingNode) continue
|
||||||
|
|
||||||
for (const [_, incomingEdge] of incomingNode.outgoingEdges) {
|
for (const [, incomingEdge] of incomingNode.outgoingEdges) {
|
||||||
if (incomingEdge.target === node.id) {
|
if (incomingEdge.target === node.id) {
|
||||||
const incomingEdgeKey = this.createEdgeKey(
|
const incomingEdgeKey = this.createEdgeKey(
|
||||||
incomingSourceId,
|
incomingSourceId,
|
||||||
node.id,
|
node.id,
|
||||||
incomingEdge.sourceHandle
|
incomingEdge.sourceHandle
|
||||||
)
|
)
|
||||||
// Skip the specific edge being excluded, but check other edges from same source
|
|
||||||
if (incomingEdgeKey === excludeEdgeKey) continue
|
if (incomingEdgeKey === excludeEdgeKey) continue
|
||||||
if (!this.deactivatedEdges.has(incomingEdgeKey)) {
|
if (!this.deactivatedEdges.has(incomingEdgeKey)) {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -554,6 +554,413 @@ describe('ExecutionEngine', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Error handling in execution', () => {
|
||||||
|
it('should fail execution when a single node throws an error', async () => {
|
||||||
|
const startNode = createMockNode('start', 'starter')
|
||||||
|
const errorNode = createMockNode('error-node', 'function')
|
||||||
|
startNode.outgoingEdges.set('edge1', { target: 'error-node' })
|
||||||
|
|
||||||
|
const dag = createMockDAG([startNode, errorNode])
|
||||||
|
const context = createMockContext()
|
||||||
|
const edgeManager = createMockEdgeManager((node) => {
|
||||||
|
if (node.id === 'start') return ['error-node']
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeOrchestrator = {
|
||||||
|
executionCount: 0,
|
||||||
|
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
|
||||||
|
if (nodeId === 'error-node') {
|
||||||
|
throw new Error('Block execution failed')
|
||||||
|
}
|
||||||
|
return { nodeId, output: {}, isFinalOutput: false }
|
||||||
|
}),
|
||||||
|
handleNodeCompletion: vi.fn(),
|
||||||
|
} as unknown as MockNodeOrchestrator
|
||||||
|
|
||||||
|
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
|
||||||
|
|
||||||
|
await expect(engine.run('start')).rejects.toThrow('Block execution failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should stop parallel branches when one branch throws an error', async () => {
|
||||||
|
const startNode = createMockNode('start', 'starter')
|
||||||
|
const parallelNodes = Array.from({ length: 5 }, (_, i) =>
|
||||||
|
createMockNode(`parallel${i}`, 'function')
|
||||||
|
)
|
||||||
|
|
||||||
|
parallelNodes.forEach((_, i) => {
|
||||||
|
startNode.outgoingEdges.set(`edge${i}`, { target: `parallel${i}` })
|
||||||
|
})
|
||||||
|
|
||||||
|
const dag = createMockDAG([startNode, ...parallelNodes])
|
||||||
|
const context = createMockContext()
|
||||||
|
const edgeManager = createMockEdgeManager((node) => {
|
||||||
|
if (node.id === 'start') return parallelNodes.map((_, i) => `parallel${i}`)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const executedNodes: string[] = []
|
||||||
|
const nodeOrchestrator = {
|
||||||
|
executionCount: 0,
|
||||||
|
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
|
||||||
|
executedNodes.push(nodeId)
|
||||||
|
if (nodeId === 'parallel0') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
throw new Error('Parallel branch failed')
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
return { nodeId, output: {}, isFinalOutput: false }
|
||||||
|
}),
|
||||||
|
handleNodeCompletion: vi.fn(),
|
||||||
|
} as unknown as MockNodeOrchestrator
|
||||||
|
|
||||||
|
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
|
||||||
|
|
||||||
|
await expect(engine.run('start')).rejects.toThrow('Parallel branch failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should capture only the first error when multiple parallel branches fail', async () => {
|
||||||
|
const startNode = createMockNode('start', 'starter')
|
||||||
|
const parallelNodes = Array.from({ length: 3 }, (_, i) =>
|
||||||
|
createMockNode(`parallel${i}`, 'function')
|
||||||
|
)
|
||||||
|
|
||||||
|
parallelNodes.forEach((_, i) => {
|
||||||
|
startNode.outgoingEdges.set(`edge${i}`, { target: `parallel${i}` })
|
||||||
|
})
|
||||||
|
|
||||||
|
const dag = createMockDAG([startNode, ...parallelNodes])
|
||||||
|
const context = createMockContext()
|
||||||
|
const edgeManager = createMockEdgeManager((node) => {
|
||||||
|
if (node.id === 'start') return parallelNodes.map((_, i) => `parallel${i}`)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeOrchestrator = {
|
||||||
|
executionCount: 0,
|
||||||
|
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
|
||||||
|
if (nodeId === 'parallel0') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
throw new Error('First error')
|
||||||
|
}
|
||||||
|
if (nodeId === 'parallel1') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||||
|
throw new Error('Second error')
|
||||||
|
}
|
||||||
|
if (nodeId === 'parallel2') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30))
|
||||||
|
throw new Error('Third error')
|
||||||
|
}
|
||||||
|
return { nodeId, output: {}, isFinalOutput: false }
|
||||||
|
}),
|
||||||
|
handleNodeCompletion: vi.fn(),
|
||||||
|
} as unknown as MockNodeOrchestrator
|
||||||
|
|
||||||
|
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
|
||||||
|
|
||||||
|
await expect(engine.run('start')).rejects.toThrow('First error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should wait for ongoing executions to complete before throwing error', async () => {
|
||||||
|
const startNode = createMockNode('start', 'starter')
|
||||||
|
const fastErrorNode = createMockNode('fast-error', 'function')
|
||||||
|
const slowNode = createMockNode('slow', 'function')
|
||||||
|
|
||||||
|
startNode.outgoingEdges.set('edge1', { target: 'fast-error' })
|
||||||
|
startNode.outgoingEdges.set('edge2', { target: 'slow' })
|
||||||
|
|
||||||
|
const dag = createMockDAG([startNode, fastErrorNode, slowNode])
|
||||||
|
const context = createMockContext()
|
||||||
|
const edgeManager = createMockEdgeManager((node) => {
|
||||||
|
if (node.id === 'start') return ['fast-error', 'slow']
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
let slowNodeCompleted = false
|
||||||
|
const nodeOrchestrator = {
|
||||||
|
executionCount: 0,
|
||||||
|
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
|
||||||
|
if (nodeId === 'fast-error') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
throw new Error('Fast error')
|
||||||
|
}
|
||||||
|
if (nodeId === 'slow') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
slowNodeCompleted = true
|
||||||
|
return { nodeId, output: {}, isFinalOutput: false }
|
||||||
|
}
|
||||||
|
return { nodeId, output: {}, isFinalOutput: false }
|
||||||
|
}),
|
||||||
|
handleNodeCompletion: vi.fn(),
|
||||||
|
} as unknown as MockNodeOrchestrator
|
||||||
|
|
||||||
|
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
|
||||||
|
|
||||||
|
await expect(engine.run('start')).rejects.toThrow('Fast error')
|
||||||
|
|
||||||
|
expect(slowNodeCompleted).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not queue new nodes after an error occurs', async () => {
|
||||||
|
const startNode = createMockNode('start', 'starter')
|
||||||
|
const errorNode = createMockNode('error-node', 'function')
|
||||||
|
const afterErrorNode = createMockNode('after-error', 'function')
|
||||||
|
|
||||||
|
startNode.outgoingEdges.set('edge1', { target: 'error-node' })
|
||||||
|
errorNode.outgoingEdges.set('edge2', { target: 'after-error' })
|
||||||
|
|
||||||
|
const dag = createMockDAG([startNode, errorNode, afterErrorNode])
|
||||||
|
const context = createMockContext()
|
||||||
|
|
||||||
|
const queuedNodes: string[] = []
|
||||||
|
const edgeManager = createMockEdgeManager((node) => {
|
||||||
|
if (node.id === 'start') {
|
||||||
|
queuedNodes.push('error-node')
|
||||||
|
return ['error-node']
|
||||||
|
}
|
||||||
|
if (node.id === 'error-node') {
|
||||||
|
queuedNodes.push('after-error')
|
||||||
|
return ['after-error']
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const executedNodes: string[] = []
|
||||||
|
const nodeOrchestrator = {
|
||||||
|
executionCount: 0,
|
||||||
|
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
|
||||||
|
executedNodes.push(nodeId)
|
||||||
|
if (nodeId === 'error-node') {
|
||||||
|
throw new Error('Node error')
|
||||||
|
}
|
||||||
|
return { nodeId, output: {}, isFinalOutput: false }
|
||||||
|
}),
|
||||||
|
handleNodeCompletion: vi.fn(),
|
||||||
|
} as unknown as MockNodeOrchestrator
|
||||||
|
|
||||||
|
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
|
||||||
|
|
||||||
|
await expect(engine.run('start')).rejects.toThrow('Node error')
|
||||||
|
|
||||||
|
expect(executedNodes).not.toContain('after-error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should populate error result with metadata when execution fails', async () => {
|
||||||
|
const startNode = createMockNode('start', 'starter')
|
||||||
|
const errorNode = createMockNode('error-node', 'function')
|
||||||
|
startNode.outgoingEdges.set('edge1', { target: 'error-node' })
|
||||||
|
|
||||||
|
const dag = createMockDAG([startNode, errorNode])
|
||||||
|
const context = createMockContext()
|
||||||
|
context.blockLogs.push({
|
||||||
|
blockId: 'start',
|
||||||
|
blockName: 'Start',
|
||||||
|
blockType: 'starter',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
durationMs: 10,
|
||||||
|
success: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const edgeManager = createMockEdgeManager((node) => {
|
||||||
|
if (node.id === 'start') return ['error-node']
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeOrchestrator = {
|
||||||
|
executionCount: 0,
|
||||||
|
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
|
||||||
|
if (nodeId === 'error-node') {
|
||||||
|
const error = new Error('Execution failed') as any
|
||||||
|
error.executionResult = {
|
||||||
|
success: false,
|
||||||
|
output: { partial: 'data' },
|
||||||
|
logs: context.blockLogs,
|
||||||
|
metadata: context.metadata,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return { nodeId, output: {}, isFinalOutput: false }
|
||||||
|
}),
|
||||||
|
handleNodeCompletion: vi.fn(),
|
||||||
|
} as unknown as MockNodeOrchestrator
|
||||||
|
|
||||||
|
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await engine.run('start')
|
||||||
|
expect.fail('Should have thrown')
|
||||||
|
} catch (error: any) {
|
||||||
|
expect(error.executionResult).toBeDefined()
|
||||||
|
expect(error.executionResult.metadata.endTime).toBeDefined()
|
||||||
|
expect(error.executionResult.metadata.duration).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prefer cancellation status over error when both occur', async () => {
|
||||||
|
const abortController = new AbortController()
|
||||||
|
|
||||||
|
const startNode = createMockNode('start', 'starter')
|
||||||
|
const errorNode = createMockNode('error-node', 'function')
|
||||||
|
startNode.outgoingEdges.set('edge1', { target: 'error-node' })
|
||||||
|
|
||||||
|
const dag = createMockDAG([startNode, errorNode])
|
||||||
|
const context = createMockContext({ abortSignal: abortController.signal })
|
||||||
|
const edgeManager = createMockEdgeManager((node) => {
|
||||||
|
if (node.id === 'start') return ['error-node']
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeOrchestrator = {
|
||||||
|
executionCount: 0,
|
||||||
|
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
|
||||||
|
if (nodeId === 'error-node') {
|
||||||
|
abortController.abort()
|
||||||
|
throw new Error('Node error')
|
||||||
|
}
|
||||||
|
return { nodeId, output: {}, isFinalOutput: false }
|
||||||
|
}),
|
||||||
|
handleNodeCompletion: vi.fn(),
|
||||||
|
} as unknown as MockNodeOrchestrator
|
||||||
|
|
||||||
|
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
|
||||||
|
const result = await engine.run('start')
|
||||||
|
|
||||||
|
expect(result.status).toBe('cancelled')
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should stop loop iteration when error occurs in loop body', async () => {
|
||||||
|
const loopStartNode = createMockNode('loop-start', 'loop_sentinel')
|
||||||
|
loopStartNode.metadata = { isSentinel: true, sentinelType: 'start', loopId: 'loop1' }
|
||||||
|
|
||||||
|
const loopBodyNode = createMockNode('loop-body', 'function')
|
||||||
|
loopBodyNode.metadata = { isLoopNode: true, loopId: 'loop1' }
|
||||||
|
|
||||||
|
const loopEndNode = createMockNode('loop-end', 'loop_sentinel')
|
||||||
|
loopEndNode.metadata = { isSentinel: true, sentinelType: 'end', loopId: 'loop1' }
|
||||||
|
|
||||||
|
const afterLoopNode = createMockNode('after-loop', 'function')
|
||||||
|
|
||||||
|
loopStartNode.outgoingEdges.set('edge1', { target: 'loop-body' })
|
||||||
|
loopBodyNode.outgoingEdges.set('edge2', { target: 'loop-end' })
|
||||||
|
loopEndNode.outgoingEdges.set('loop_continue', {
|
||||||
|
target: 'loop-start',
|
||||||
|
sourceHandle: 'loop_continue',
|
||||||
|
})
|
||||||
|
loopEndNode.outgoingEdges.set('loop_complete', {
|
||||||
|
target: 'after-loop',
|
||||||
|
sourceHandle: 'loop_complete',
|
||||||
|
})
|
||||||
|
|
||||||
|
const dag = createMockDAG([loopStartNode, loopBodyNode, loopEndNode, afterLoopNode])
|
||||||
|
const context = createMockContext()
|
||||||
|
|
||||||
|
let iterationCount = 0
|
||||||
|
const edgeManager = createMockEdgeManager((node) => {
|
||||||
|
if (node.id === 'loop-start') return ['loop-body']
|
||||||
|
if (node.id === 'loop-body') return ['loop-end']
|
||||||
|
if (node.id === 'loop-end') {
|
||||||
|
iterationCount++
|
||||||
|
if (iterationCount < 5) return ['loop-start']
|
||||||
|
return ['after-loop']
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeOrchestrator = {
|
||||||
|
executionCount: 0,
|
||||||
|
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
|
||||||
|
if (nodeId === 'loop-body' && iterationCount >= 2) {
|
||||||
|
throw new Error('Loop body error on iteration 3')
|
||||||
|
}
|
||||||
|
return { nodeId, output: {}, isFinalOutput: false }
|
||||||
|
}),
|
||||||
|
handleNodeCompletion: vi.fn(),
|
||||||
|
} as unknown as MockNodeOrchestrator
|
||||||
|
|
||||||
|
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
|
||||||
|
|
||||||
|
await expect(engine.run('loop-start')).rejects.toThrow('Loop body error on iteration 3')
|
||||||
|
|
||||||
|
expect(iterationCount).toBeLessThanOrEqual(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle error that is not an Error instance', async () => {
|
||||||
|
const startNode = createMockNode('start', 'starter')
|
||||||
|
const errorNode = createMockNode('error-node', 'function')
|
||||||
|
startNode.outgoingEdges.set('edge1', { target: 'error-node' })
|
||||||
|
|
||||||
|
const dag = createMockDAG([startNode, errorNode])
|
||||||
|
const context = createMockContext()
|
||||||
|
const edgeManager = createMockEdgeManager((node) => {
|
||||||
|
if (node.id === 'start') return ['error-node']
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeOrchestrator = {
|
||||||
|
executionCount: 0,
|
||||||
|
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
|
||||||
|
if (nodeId === 'error-node') {
|
||||||
|
throw 'String error message'
|
||||||
|
}
|
||||||
|
return { nodeId, output: {}, isFinalOutput: false }
|
||||||
|
}),
|
||||||
|
handleNodeCompletion: vi.fn(),
|
||||||
|
} as unknown as MockNodeOrchestrator
|
||||||
|
|
||||||
|
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
|
||||||
|
|
||||||
|
await expect(engine.run('start')).rejects.toThrow('String error message')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should preserve partial output when error occurs after some blocks complete', async () => {
|
||||||
|
const startNode = createMockNode('start', 'starter')
|
||||||
|
const successNode = createMockNode('success', 'function')
|
||||||
|
const errorNode = createMockNode('error-node', 'function')
|
||||||
|
|
||||||
|
startNode.outgoingEdges.set('edge1', { target: 'success' })
|
||||||
|
successNode.outgoingEdges.set('edge2', { target: 'error-node' })
|
||||||
|
|
||||||
|
const dag = createMockDAG([startNode, successNode, errorNode])
|
||||||
|
const context = createMockContext()
|
||||||
|
const edgeManager = createMockEdgeManager((node) => {
|
||||||
|
if (node.id === 'start') return ['success']
|
||||||
|
if (node.id === 'success') return ['error-node']
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeOrchestrator = {
|
||||||
|
executionCount: 0,
|
||||||
|
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
|
||||||
|
if (nodeId === 'success') {
|
||||||
|
return { nodeId, output: { successData: 'preserved' }, isFinalOutput: false }
|
||||||
|
}
|
||||||
|
if (nodeId === 'error-node') {
|
||||||
|
throw new Error('Late error')
|
||||||
|
}
|
||||||
|
return { nodeId, output: {}, isFinalOutput: false }
|
||||||
|
}),
|
||||||
|
handleNodeCompletion: vi.fn(),
|
||||||
|
} as unknown as MockNodeOrchestrator
|
||||||
|
|
||||||
|
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await engine.run('start')
|
||||||
|
expect.fail('Should have thrown')
|
||||||
|
} catch (error: any) {
|
||||||
|
// Verify the error was thrown
|
||||||
|
expect(error.message).toBe('Late error')
|
||||||
|
// The partial output should be available in executionResult if attached
|
||||||
|
if (error.executionResult) {
|
||||||
|
expect(error.executionResult.output).toBeDefined()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Cancellation flag behavior', () => {
|
describe('Cancellation flag behavior', () => {
|
||||||
it('should set cancelledFlag when abort signal fires', async () => {
|
it('should set cancelledFlag when abort signal fires', async () => {
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export class ExecutionEngine {
|
|||||||
private pausedBlocks: Map<string, PauseMetadata> = new Map()
|
private pausedBlocks: Map<string, PauseMetadata> = new Map()
|
||||||
private allowResumeTriggers: boolean
|
private allowResumeTriggers: boolean
|
||||||
private cancelledFlag = false
|
private cancelledFlag = false
|
||||||
|
private errorFlag = false
|
||||||
|
private executionError: Error | null = null
|
||||||
private lastCancellationCheck = 0
|
private lastCancellationCheck = 0
|
||||||
private readonly useRedisCancellation: boolean
|
private readonly useRedisCancellation: boolean
|
||||||
private readonly CANCELLATION_CHECK_INTERVAL_MS = 500
|
private readonly CANCELLATION_CHECK_INTERVAL_MS = 500
|
||||||
@@ -103,7 +105,7 @@ export class ExecutionEngine {
|
|||||||
this.initializeQueue(triggerBlockId)
|
this.initializeQueue(triggerBlockId)
|
||||||
|
|
||||||
while (this.hasWork()) {
|
while (this.hasWork()) {
|
||||||
if (await this.checkCancellation()) {
|
if ((await this.checkCancellation()) || this.errorFlag) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
await this.processQueue()
|
await this.processQueue()
|
||||||
@@ -113,6 +115,11 @@ export class ExecutionEngine {
|
|||||||
await this.waitForAllExecutions()
|
await this.waitForAllExecutions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rethrow the captured error so it's handled by the catch block
|
||||||
|
if (this.errorFlag && this.executionError) {
|
||||||
|
throw this.executionError
|
||||||
|
}
|
||||||
|
|
||||||
if (this.pausedBlocks.size > 0) {
|
if (this.pausedBlocks.size > 0) {
|
||||||
return this.buildPausedResult(startTime)
|
return this.buildPausedResult(startTime)
|
||||||
}
|
}
|
||||||
@@ -196,11 +203,17 @@ export class ExecutionEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private trackExecution(promise: Promise<void>): void {
|
private trackExecution(promise: Promise<void>): void {
|
||||||
this.executing.add(promise)
|
const trackedPromise = promise
|
||||||
promise.catch(() => {})
|
.catch((error) => {
|
||||||
promise.finally(() => {
|
if (!this.errorFlag) {
|
||||||
this.executing.delete(promise)
|
this.errorFlag = true
|
||||||
})
|
this.executionError = error instanceof Error ? error : new Error(String(error))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.executing.delete(trackedPromise)
|
||||||
|
})
|
||||||
|
this.executing.add(trackedPromise)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForAnyExecution(): Promise<void> {
|
private async waitForAnyExecution(): Promise<void> {
|
||||||
@@ -315,7 +328,7 @@ export class ExecutionEngine {
|
|||||||
|
|
||||||
private async processQueue(): Promise<void> {
|
private async processQueue(): Promise<void> {
|
||||||
while (this.readyQueue.length > 0) {
|
while (this.readyQueue.length > 0) {
|
||||||
if (await this.checkCancellation()) {
|
if ((await this.checkCancellation()) || this.errorFlag) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
const nodeId = this.dequeue()
|
const nodeId = this.dequeue()
|
||||||
@@ -324,7 +337,7 @@ export class ExecutionEngine {
|
|||||||
this.trackExecution(promise)
|
this.trackExecution(promise)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.executing.size > 0 && !this.cancelledFlag) {
|
if (this.executing.size > 0 && !this.cancelledFlag && !this.errorFlag) {
|
||||||
await this.waitForAnyExecution()
|
await this.waitForAnyExecution()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ export class AgentBlockHandler implements BlockHandler {
|
|||||||
base.executeFunction = async (callParams: Record<string, any>) => {
|
base.executeFunction = async (callParams: Record<string, any>) => {
|
||||||
const mergedParams = mergeToolParameters(userProvidedParams, callParams)
|
const mergedParams = mergeToolParameters(userProvidedParams, callParams)
|
||||||
|
|
||||||
const { blockData, blockNameMapping } = collectBlockData(ctx)
|
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
|
||||||
|
|
||||||
const result = await executeTool(
|
const result = await executeTool(
|
||||||
'function_execute',
|
'function_execute',
|
||||||
@@ -317,6 +317,7 @@ export class AgentBlockHandler implements BlockHandler {
|
|||||||
workflowVariables: ctx.workflowVariables || {},
|
workflowVariables: ctx.workflowVariables || {},
|
||||||
blockData,
|
blockData,
|
||||||
blockNameMapping,
|
blockNameMapping,
|
||||||
|
blockOutputSchemas,
|
||||||
isCustomTool: true,
|
isCustomTool: true,
|
||||||
_context: {
|
_context: {
|
||||||
workflowId: ctx.workflowId,
|
workflowId: ctx.workflowId,
|
||||||
@@ -347,8 +348,8 @@ export class AgentBlockHandler implements BlockHandler {
|
|||||||
): Promise<{ schema: any; code: string; title: string } | null> {
|
): Promise<{ schema: any; code: string; title: string } | null> {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
const { useCustomToolsStore } = await import('@/stores/custom-tools')
|
const { getCustomTool } = await import('@/hooks/queries/custom-tools')
|
||||||
const tool = useCustomToolsStore.getState().getTool(customToolId)
|
const tool = getCustomTool(customToolId, ctx.workspaceId)
|
||||||
if (tool) {
|
if (tool) {
|
||||||
return {
|
return {
|
||||||
schema: tool.schema,
|
schema: tool.schema,
|
||||||
@@ -356,9 +357,9 @@ export class AgentBlockHandler implements BlockHandler {
|
|||||||
title: tool.title,
|
title: tool.title,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.warn(`Custom tool not found in store: ${customToolId}`)
|
logger.warn(`Custom tool not found in cache: ${customToolId}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error accessing custom tools store:', { error })
|
logger.error('Error accessing custom tools cache:', { error })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function evaluateConditionExpression(
|
|||||||
const contextSetup = `const context = ${JSON.stringify(evalContext)};`
|
const contextSetup = `const context = ${JSON.stringify(evalContext)};`
|
||||||
const code = `${contextSetup}\nreturn Boolean(${conditionExpression})`
|
const code = `${contextSetup}\nreturn Boolean(${conditionExpression})`
|
||||||
|
|
||||||
const { blockData, blockNameMapping } = collectBlockData(ctx)
|
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
|
||||||
|
|
||||||
const result = await executeTool(
|
const result = await executeTool(
|
||||||
'function_execute',
|
'function_execute',
|
||||||
@@ -37,6 +37,7 @@ export async function evaluateConditionExpression(
|
|||||||
workflowVariables: ctx.workflowVariables || {},
|
workflowVariables: ctx.workflowVariables || {},
|
||||||
blockData,
|
blockData,
|
||||||
blockNameMapping,
|
blockNameMapping,
|
||||||
|
blockOutputSchemas,
|
||||||
_context: {
|
_context: {
|
||||||
workflowId: ctx.workflowId,
|
workflowId: ctx.workflowId,
|
||||||
workspaceId: ctx.workspaceId,
|
workspaceId: ctx.workspaceId,
|
||||||
|
|||||||
@@ -75,7 +75,12 @@ describe('FunctionBlockHandler', () => {
|
|||||||
workflowVariables: {},
|
workflowVariables: {},
|
||||||
blockData: {},
|
blockData: {},
|
||||||
blockNameMapping: {},
|
blockNameMapping: {},
|
||||||
_context: { workflowId: mockContext.workflowId, workspaceId: mockContext.workspaceId },
|
blockOutputSchemas: {},
|
||||||
|
_context: {
|
||||||
|
workflowId: mockContext.workflowId,
|
||||||
|
workspaceId: mockContext.workspaceId,
|
||||||
|
isDeployedContext: mockContext.isDeployedContext,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
const expectedOutput: any = { result: 'Success' }
|
const expectedOutput: any = { result: 'Success' }
|
||||||
|
|
||||||
@@ -84,8 +89,8 @@ describe('FunctionBlockHandler', () => {
|
|||||||
expect(mockExecuteTool).toHaveBeenCalledWith(
|
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||||
'function_execute',
|
'function_execute',
|
||||||
expectedToolParams,
|
expectedToolParams,
|
||||||
false, // skipPostProcess
|
false,
|
||||||
mockContext // execution context
|
mockContext
|
||||||
)
|
)
|
||||||
expect(result).toEqual(expectedOutput)
|
expect(result).toEqual(expectedOutput)
|
||||||
})
|
})
|
||||||
@@ -107,7 +112,12 @@ describe('FunctionBlockHandler', () => {
|
|||||||
workflowVariables: {},
|
workflowVariables: {},
|
||||||
blockData: {},
|
blockData: {},
|
||||||
blockNameMapping: {},
|
blockNameMapping: {},
|
||||||
_context: { workflowId: mockContext.workflowId, workspaceId: mockContext.workspaceId },
|
blockOutputSchemas: {},
|
||||||
|
_context: {
|
||||||
|
workflowId: mockContext.workflowId,
|
||||||
|
workspaceId: mockContext.workspaceId,
|
||||||
|
isDeployedContext: mockContext.isDeployedContext,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
const expectedOutput: any = { result: 'Success' }
|
const expectedOutput: any = { result: 'Success' }
|
||||||
|
|
||||||
@@ -116,8 +126,8 @@ describe('FunctionBlockHandler', () => {
|
|||||||
expect(mockExecuteTool).toHaveBeenCalledWith(
|
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||||
'function_execute',
|
'function_execute',
|
||||||
expectedToolParams,
|
expectedToolParams,
|
||||||
false, // skipPostProcess
|
false,
|
||||||
mockContext // execution context
|
mockContext
|
||||||
)
|
)
|
||||||
expect(result).toEqual(expectedOutput)
|
expect(result).toEqual(expectedOutput)
|
||||||
})
|
})
|
||||||
@@ -132,7 +142,12 @@ describe('FunctionBlockHandler', () => {
|
|||||||
workflowVariables: {},
|
workflowVariables: {},
|
||||||
blockData: {},
|
blockData: {},
|
||||||
blockNameMapping: {},
|
blockNameMapping: {},
|
||||||
_context: { workflowId: mockContext.workflowId, workspaceId: mockContext.workspaceId },
|
blockOutputSchemas: {},
|
||||||
|
_context: {
|
||||||
|
workflowId: mockContext.workflowId,
|
||||||
|
workspaceId: mockContext.workspaceId,
|
||||||
|
isDeployedContext: mockContext.isDeployedContext,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
await handler.execute(mockContext, mockBlock, inputs)
|
await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class FunctionBlockHandler implements BlockHandler {
|
|||||||
? inputs.code.map((c: { content: string }) => c.content).join('\n')
|
? inputs.code.map((c: { content: string }) => c.content).join('\n')
|
||||||
: inputs.code
|
: inputs.code
|
||||||
|
|
||||||
const { blockData, blockNameMapping } = collectBlockData(ctx)
|
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
|
||||||
|
|
||||||
const result = await executeTool(
|
const result = await executeTool(
|
||||||
'function_execute',
|
'function_execute',
|
||||||
@@ -35,6 +35,7 @@ export class FunctionBlockHandler implements BlockHandler {
|
|||||||
workflowVariables: ctx.workflowVariables || {},
|
workflowVariables: ctx.workflowVariables || {},
|
||||||
blockData,
|
blockData,
|
||||||
blockNameMapping,
|
blockNameMapping,
|
||||||
|
blockOutputSchemas,
|
||||||
_context: {
|
_context: {
|
||||||
workflowId: ctx.workflowId,
|
workflowId: ctx.workflowId,
|
||||||
workspaceId: ctx.workspaceId,
|
workspaceId: ctx.workspaceId,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '@/executor/human-in-the-loop/utils'
|
} from '@/executor/human-in-the-loop/utils'
|
||||||
import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types'
|
import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types'
|
||||||
import { collectBlockData } from '@/executor/utils/block-data'
|
import { collectBlockData } from '@/executor/utils/block-data'
|
||||||
|
import { parseObjectStrings } from '@/executor/utils/json'
|
||||||
import type { SerializedBlock } from '@/serializer/types'
|
import type { SerializedBlock } from '@/serializer/types'
|
||||||
import { executeTool } from '@/tools'
|
import { executeTool } from '@/tools'
|
||||||
|
|
||||||
@@ -265,7 +266,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
|
|||||||
|
|
||||||
if (dataMode === 'structured' && inputs.builderData) {
|
if (dataMode === 'structured' && inputs.builderData) {
|
||||||
const convertedData = this.convertBuilderDataToJson(inputs.builderData)
|
const convertedData = this.convertBuilderDataToJson(inputs.builderData)
|
||||||
return this.parseObjectStrings(convertedData)
|
return parseObjectStrings(convertedData)
|
||||||
}
|
}
|
||||||
|
|
||||||
return inputs.data || {}
|
return inputs.data || {}
|
||||||
@@ -485,29 +486,6 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseObjectStrings(data: any): any {
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data)
|
|
||||||
if (typeof parsed === 'object' && parsed !== null) {
|
|
||||||
return this.parseObjectStrings(parsed)
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
} catch {
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(data)) {
|
|
||||||
return data.map((item) => this.parseObjectStrings(item))
|
|
||||||
} else if (typeof data === 'object' && data !== null) {
|
|
||||||
const result: any = {}
|
|
||||||
for (const [key, value] of Object.entries(data)) {
|
|
||||||
result[key] = this.parseObjectStrings(value)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseStatus(status?: string): number {
|
private parseStatus(status?: string): number {
|
||||||
if (!status) return HTTP.STATUS.OK
|
if (!status) return HTTP.STATUS.OK
|
||||||
const parsed = Number(status)
|
const parsed = Number(status)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { BlockType, HTTP, REFERENCE } from '@/executor/constants'
|
import { BlockType, HTTP, REFERENCE } from '@/executor/constants'
|
||||||
import type { BlockHandler, ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
|
import type { BlockHandler, ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
|
||||||
|
import { parseObjectStrings } from '@/executor/utils/json'
|
||||||
import type { SerializedBlock } from '@/serializer/types'
|
import type { SerializedBlock } from '@/serializer/types'
|
||||||
|
|
||||||
const logger = createLogger('ResponseBlockHandler')
|
const logger = createLogger('ResponseBlockHandler')
|
||||||
@@ -73,7 +74,7 @@ export class ResponseBlockHandler implements BlockHandler {
|
|||||||
|
|
||||||
if (dataMode === 'structured' && inputs.builderData) {
|
if (dataMode === 'structured' && inputs.builderData) {
|
||||||
const convertedData = this.convertBuilderDataToJson(inputs.builderData)
|
const convertedData = this.convertBuilderDataToJson(inputs.builderData)
|
||||||
return this.parseObjectStrings(convertedData)
|
return parseObjectStrings(convertedData)
|
||||||
}
|
}
|
||||||
|
|
||||||
return inputs.data || {}
|
return inputs.data || {}
|
||||||
@@ -222,29 +223,6 @@ export class ResponseBlockHandler implements BlockHandler {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseObjectStrings(data: any): any {
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data)
|
|
||||||
if (typeof parsed === 'object' && parsed !== null) {
|
|
||||||
return this.parseObjectStrings(parsed)
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
} catch {
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(data)) {
|
|
||||||
return data.map((item) => this.parseObjectStrings(item))
|
|
||||||
} else if (typeof data === 'object' && data !== null) {
|
|
||||||
const result: any = {}
|
|
||||||
for (const [key, value] of Object.entries(data)) {
|
|
||||||
result[key] = this.parseObjectStrings(value)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseStatus(status?: string): number {
|
private parseStatus(status?: string): number {
|
||||||
if (!status) return HTTP.STATUS.OK
|
if (!status) return HTTP.STATUS.OK
|
||||||
const parsed = Number(status)
|
const parsed = Number(status)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import '@sim/testing/mocks/executor'
|
import '@sim/testing/mocks/executor'
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||||
import { generateRouterPrompt } from '@/blocks/blocks/router'
|
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
|
||||||
import { BlockType } from '@/executor/constants'
|
import { BlockType } from '@/executor/constants'
|
||||||
import { RouterBlockHandler } from '@/executor/handlers/router/router-handler'
|
import { RouterBlockHandler } from '@/executor/handlers/router/router-handler'
|
||||||
import type { ExecutionContext } from '@/executor/types'
|
import type { ExecutionContext } from '@/executor/types'
|
||||||
@@ -9,6 +9,7 @@ import { getProviderFromModel } from '@/providers/utils'
|
|||||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||||
|
|
||||||
const mockGenerateRouterPrompt = generateRouterPrompt as Mock
|
const mockGenerateRouterPrompt = generateRouterPrompt as Mock
|
||||||
|
const mockGenerateRouterV2Prompt = generateRouterV2Prompt as Mock
|
||||||
const mockGetProviderFromModel = getProviderFromModel as Mock
|
const mockGetProviderFromModel = getProviderFromModel as Mock
|
||||||
const mockFetch = global.fetch as unknown as Mock
|
const mockFetch = global.fetch as unknown as Mock
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ describe('RouterBlockHandler', () => {
|
|||||||
metadata: { id: BlockType.ROUTER, name: 'Test Router' },
|
metadata: { id: BlockType.ROUTER, name: 'Test Router' },
|
||||||
position: { x: 50, y: 50 },
|
position: { x: 50, y: 50 },
|
||||||
config: { tool: BlockType.ROUTER, params: {} },
|
config: { tool: BlockType.ROUTER, params: {} },
|
||||||
inputs: { prompt: 'string', model: 'string' }, // Using ParamType strings
|
inputs: { prompt: 'string', model: 'string' },
|
||||||
outputs: {},
|
outputs: {},
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}
|
}
|
||||||
@@ -72,14 +73,11 @@ describe('RouterBlockHandler', () => {
|
|||||||
workflow: mockWorkflow as SerializedWorkflow,
|
workflow: mockWorkflow as SerializedWorkflow,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset mocks using vi
|
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
||||||
// Default mock implementations
|
|
||||||
mockGetProviderFromModel.mockReturnValue('openai')
|
mockGetProviderFromModel.mockReturnValue('openai')
|
||||||
mockGenerateRouterPrompt.mockReturnValue('Generated System Prompt')
|
mockGenerateRouterPrompt.mockReturnValue('Generated System Prompt')
|
||||||
|
|
||||||
// Set up fetch mock to return a successful response
|
|
||||||
mockFetch.mockImplementation(() => {
|
mockFetch.mockImplementation(() => {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -147,7 +145,6 @@ describe('RouterBlockHandler', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// Verify the request body contains the expected data
|
|
||||||
const fetchCallArgs = mockFetch.mock.calls[0]
|
const fetchCallArgs = mockFetch.mock.calls[0]
|
||||||
const requestBody = JSON.parse(fetchCallArgs[1].body)
|
const requestBody = JSON.parse(fetchCallArgs[1].body)
|
||||||
expect(requestBody).toMatchObject({
|
expect(requestBody).toMatchObject({
|
||||||
@@ -180,7 +177,6 @@ describe('RouterBlockHandler', () => {
|
|||||||
const inputs = { prompt: 'Test' }
|
const inputs = { prompt: 'Test' }
|
||||||
mockContext.workflow!.blocks = [mockBlock, mockTargetBlock2]
|
mockContext.workflow!.blocks = [mockBlock, mockTargetBlock2]
|
||||||
|
|
||||||
// Expect execute to throw because getTargetBlocks (called internally) will throw
|
|
||||||
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
||||||
'Target block target-block-1 not found'
|
'Target block target-block-1 not found'
|
||||||
)
|
)
|
||||||
@@ -190,7 +186,6 @@ describe('RouterBlockHandler', () => {
|
|||||||
it('should throw error if LLM response is not a valid target block ID', async () => {
|
it('should throw error if LLM response is not a valid target block ID', async () => {
|
||||||
const inputs = { prompt: 'Test', apiKey: 'test-api-key' }
|
const inputs = { prompt: 'Test', apiKey: 'test-api-key' }
|
||||||
|
|
||||||
// Override fetch mock to return an invalid block ID
|
|
||||||
mockFetch.mockImplementationOnce(() => {
|
mockFetch.mockImplementationOnce(() => {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -228,7 +223,6 @@ describe('RouterBlockHandler', () => {
|
|||||||
it('should handle server error responses', async () => {
|
it('should handle server error responses', async () => {
|
||||||
const inputs = { prompt: 'Test error handling.', apiKey: 'test-api-key' }
|
const inputs = { prompt: 'Test error handling.', apiKey: 'test-api-key' }
|
||||||
|
|
||||||
// Override fetch mock to return an error
|
|
||||||
mockFetch.mockImplementationOnce(() => {
|
mockFetch.mockImplementationOnce(() => {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -276,13 +270,12 @@ describe('RouterBlockHandler', () => {
|
|||||||
|
|
||||||
mockGetProviderFromModel.mockReturnValue('vertex')
|
mockGetProviderFromModel.mockReturnValue('vertex')
|
||||||
|
|
||||||
// Mock the database query for Vertex credential
|
|
||||||
const mockDb = await import('@sim/db')
|
const mockDb = await import('@sim/db')
|
||||||
const mockAccount = {
|
const mockAccount = {
|
||||||
id: 'test-vertex-credential-id',
|
id: 'test-vertex-credential-id',
|
||||||
accessToken: 'mock-access-token',
|
accessToken: 'mock-access-token',
|
||||||
refreshToken: 'mock-refresh-token',
|
refreshToken: 'mock-refresh-token',
|
||||||
expiresAt: new Date(Date.now() + 3600000), // 1 hour from now
|
expiresAt: new Date(Date.now() + 3600000),
|
||||||
}
|
}
|
||||||
vi.spyOn(mockDb.db.query.account, 'findFirst').mockResolvedValue(mockAccount as any)
|
vi.spyOn(mockDb.db.query.account, 'findFirst').mockResolvedValue(mockAccount as any)
|
||||||
|
|
||||||
@@ -300,3 +293,287 @@ describe('RouterBlockHandler', () => {
|
|||||||
expect(requestBody.apiKey).toBe('mock-access-token')
|
expect(requestBody.apiKey).toBe('mock-access-token')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('RouterBlockHandler V2', () => {
|
||||||
|
let handler: RouterBlockHandler
|
||||||
|
let mockRouterV2Block: SerializedBlock
|
||||||
|
let mockContext: ExecutionContext
|
||||||
|
let mockWorkflow: Partial<SerializedWorkflow>
|
||||||
|
let mockTargetBlock1: SerializedBlock
|
||||||
|
let mockTargetBlock2: SerializedBlock
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockTargetBlock1 = {
|
||||||
|
id: 'target-block-1',
|
||||||
|
metadata: { id: 'agent', name: 'Support Agent' },
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
config: { tool: 'agent', params: {} },
|
||||||
|
inputs: {},
|
||||||
|
outputs: {},
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
mockTargetBlock2 = {
|
||||||
|
id: 'target-block-2',
|
||||||
|
metadata: { id: 'agent', name: 'Sales Agent' },
|
||||||
|
position: { x: 100, y: 150 },
|
||||||
|
config: { tool: 'agent', params: {} },
|
||||||
|
inputs: {},
|
||||||
|
outputs: {},
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
mockRouterV2Block = {
|
||||||
|
id: 'router-v2-block-1',
|
||||||
|
metadata: { id: BlockType.ROUTER_V2, name: 'Test Router V2' },
|
||||||
|
position: { x: 50, y: 50 },
|
||||||
|
config: { tool: BlockType.ROUTER_V2, params: {} },
|
||||||
|
inputs: {},
|
||||||
|
outputs: {},
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
mockWorkflow = {
|
||||||
|
blocks: [mockRouterV2Block, mockTargetBlock1, mockTargetBlock2],
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
source: mockRouterV2Block.id,
|
||||||
|
target: mockTargetBlock1.id,
|
||||||
|
sourceHandle: 'router-route-support',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: mockRouterV2Block.id,
|
||||||
|
target: mockTargetBlock2.id,
|
||||||
|
sourceHandle: 'router-route-sales',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
handler = new RouterBlockHandler({})
|
||||||
|
|
||||||
|
mockContext = {
|
||||||
|
workflowId: 'test-workflow-id',
|
||||||
|
blockStates: new Map(),
|
||||||
|
blockLogs: [],
|
||||||
|
metadata: { duration: 0 },
|
||||||
|
environmentVariables: {},
|
||||||
|
decisions: { router: new Map(), condition: new Map() },
|
||||||
|
loopExecutions: new Map(),
|
||||||
|
completedLoops: new Set(),
|
||||||
|
executedBlocks: new Set(),
|
||||||
|
activeExecutionPath: new Set(),
|
||||||
|
workflow: mockWorkflow as SerializedWorkflow,
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
mockGetProviderFromModel.mockReturnValue('openai')
|
||||||
|
mockGenerateRouterV2Prompt.mockReturnValue('Generated V2 System Prompt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle router_v2 blocks', () => {
|
||||||
|
expect(handler.canHandle(mockRouterV2Block)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute router V2 and return reasoning', async () => {
|
||||||
|
const inputs = {
|
||||||
|
context: 'I need help with a billing issue',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
routes: JSON.stringify([
|
||||||
|
{ id: 'route-support', title: 'Support', value: 'Customer support inquiries' },
|
||||||
|
{ id: 'route-sales', title: 'Sales', value: 'Sales and pricing questions' },
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockImplementationOnce(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
content: JSON.stringify({
|
||||||
|
route: 'route-support',
|
||||||
|
reasoning: 'The user mentioned a billing issue which is a customer support matter.',
|
||||||
|
}),
|
||||||
|
model: 'gpt-4o',
|
||||||
|
tokens: { input: 150, output: 25, total: 175 },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await handler.execute(mockContext, mockRouterV2Block, inputs)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
context: 'I need help with a billing issue',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
selectedRoute: 'route-support',
|
||||||
|
reasoning: 'The user mentioned a billing issue which is a customer support matter.',
|
||||||
|
selectedPath: {
|
||||||
|
blockId: 'target-block-1',
|
||||||
|
blockType: 'agent',
|
||||||
|
blockTitle: 'Support Agent',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include responseFormat in provider request', async () => {
|
||||||
|
const inputs = {
|
||||||
|
context: 'Test context',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Description 1' }]),
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockImplementationOnce(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
content: JSON.stringify({ route: 'route-1', reasoning: 'Test reasoning' }),
|
||||||
|
model: 'gpt-4o',
|
||||||
|
tokens: { input: 100, output: 20, total: 120 },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await handler.execute(mockContext, mockRouterV2Block, inputs)
|
||||||
|
|
||||||
|
const fetchCallArgs = mockFetch.mock.calls[0]
|
||||||
|
const requestBody = JSON.parse(fetchCallArgs[1].body)
|
||||||
|
|
||||||
|
expect(requestBody.responseFormat).toEqual({
|
||||||
|
name: 'router_response',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
route: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The selected route ID or NO_MATCH',
|
||||||
|
},
|
||||||
|
reasoning: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Brief explanation of why this route was chosen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['route', 'reasoning'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle NO_MATCH response with reasoning', async () => {
|
||||||
|
const inputs = {
|
||||||
|
context: 'Random unrelated query',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Specific topic' }]),
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockImplementationOnce(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
content: JSON.stringify({
|
||||||
|
route: 'NO_MATCH',
|
||||||
|
reasoning: 'The query does not relate to any available route.',
|
||||||
|
}),
|
||||||
|
model: 'gpt-4o',
|
||||||
|
tokens: { input: 100, output: 20, total: 120 },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow(
|
||||||
|
'Router could not determine a matching route: The query does not relate to any available route.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error for invalid route ID in response', async () => {
|
||||||
|
const inputs = {
|
||||||
|
context: 'Test context',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Description' }]),
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockImplementationOnce(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
content: JSON.stringify({ route: 'invalid-route', reasoning: 'Some reasoning' }),
|
||||||
|
model: 'gpt-4o',
|
||||||
|
tokens: { input: 100, output: 20, total: 120 },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow(
|
||||||
|
/Router could not determine a valid route/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle routes passed as array instead of JSON string', async () => {
|
||||||
|
const inputs = {
|
||||||
|
context: 'Test context',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
routes: [{ id: 'route-1', title: 'Route 1', value: 'Description' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockImplementationOnce(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
content: JSON.stringify({ route: 'route-1', reasoning: 'Matched route 1' }),
|
||||||
|
model: 'gpt-4o',
|
||||||
|
tokens: { input: 100, output: 20, total: 120 },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await handler.execute(mockContext, mockRouterV2Block, inputs)
|
||||||
|
|
||||||
|
expect(result.selectedRoute).toBe('route-1')
|
||||||
|
expect(result.reasoning).toBe('Matched route 1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error when no routes are defined', async () => {
|
||||||
|
const inputs = {
|
||||||
|
context: 'Test context',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
routes: '[]',
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow(
|
||||||
|
'No routes defined for router'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle fallback when JSON parsing fails', async () => {
|
||||||
|
const inputs = {
|
||||||
|
context: 'Test context',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Description' }]),
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockImplementationOnce(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
content: 'route-1',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
tokens: { input: 100, output: 5, total: 105 },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await handler.execute(mockContext, mockRouterV2Block, inputs)
|
||||||
|
|
||||||
|
expect(result.selectedRoute).toBe('route-1')
|
||||||
|
expect(result.reasoning).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -238,6 +238,25 @@ export class RouterBlockHandler implements BlockHandler {
|
|||||||
apiKey: finalApiKey,
|
apiKey: finalApiKey,
|
||||||
workflowId: ctx.workflowId,
|
workflowId: ctx.workflowId,
|
||||||
workspaceId: ctx.workspaceId,
|
workspaceId: ctx.workspaceId,
|
||||||
|
responseFormat: {
|
||||||
|
name: 'router_response',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
route: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The selected route ID or NO_MATCH',
|
||||||
|
},
|
||||||
|
reasoning: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Brief explanation of why this route was chosen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['route', 'reasoning'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if (providerId === 'vertex') {
|
if (providerId === 'vertex') {
|
||||||
@@ -277,16 +296,31 @@ export class RouterBlockHandler implements BlockHandler {
|
|||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
|
|
||||||
const chosenRouteId = result.content.trim()
|
let chosenRouteId: string
|
||||||
|
let reasoning = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedResponse = JSON.parse(result.content)
|
||||||
|
chosenRouteId = parsedResponse.route?.trim() || ''
|
||||||
|
reasoning = parsedResponse.reasoning || ''
|
||||||
|
} catch (_parseError) {
|
||||||
|
logger.error('Router response was not valid JSON despite responseFormat', {
|
||||||
|
content: result.content,
|
||||||
|
})
|
||||||
|
chosenRouteId = result.content.trim()
|
||||||
|
}
|
||||||
|
|
||||||
if (chosenRouteId === 'NO_MATCH' || chosenRouteId.toUpperCase() === 'NO_MATCH') {
|
if (chosenRouteId === 'NO_MATCH' || chosenRouteId.toUpperCase() === 'NO_MATCH') {
|
||||||
logger.info('Router determined no route matches the context, routing to error path')
|
logger.info('Router determined no route matches the context, routing to error path')
|
||||||
throw new Error('Router could not determine a matching route for the given context')
|
throw new Error(
|
||||||
|
reasoning
|
||||||
|
? `Router could not determine a matching route: ${reasoning}`
|
||||||
|
: 'Router could not determine a matching route for the given context'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const chosenRoute = routes.find((r) => r.id === chosenRouteId)
|
const chosenRoute = routes.find((r) => r.id === chosenRouteId)
|
||||||
|
|
||||||
// Throw error if LLM returns invalid route ID - this routes through error path
|
|
||||||
if (!chosenRoute) {
|
if (!chosenRoute) {
|
||||||
const availableRoutes = routes.map((r) => ({ id: r.id, title: r.title }))
|
const availableRoutes = routes.map((r) => ({ id: r.id, title: r.title }))
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -298,7 +332,6 @@ export class RouterBlockHandler implements BlockHandler {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the target block connected to this route's handle
|
|
||||||
const connection = ctx.workflow?.connections.find(
|
const connection = ctx.workflow?.connections.find(
|
||||||
(conn) => conn.source === block.id && conn.sourceHandle === `router-${chosenRoute.id}`
|
(conn) => conn.source === block.id && conn.sourceHandle === `router-${chosenRoute.id}`
|
||||||
)
|
)
|
||||||
@@ -334,6 +367,7 @@ export class RouterBlockHandler implements BlockHandler {
|
|||||||
total: cost.total,
|
total: cost.total,
|
||||||
},
|
},
|
||||||
selectedRoute: chosenRoute.id,
|
selectedRoute: chosenRoute.id,
|
||||||
|
reasoning,
|
||||||
selectedPath: targetBlock
|
selectedPath: targetBlock
|
||||||
? {
|
? {
|
||||||
blockId: targetBlock.id,
|
blockId: targetBlock.id,
|
||||||
@@ -353,7 +387,7 @@ export class RouterBlockHandler implements BlockHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse routes from input (can be JSON string or array).
|
* Parse routes from input (can be JSON string or array)
|
||||||
*/
|
*/
|
||||||
private parseRoutes(input: any): RouteDefinition[] {
|
private parseRoutes(input: any): RouteDefinition[] {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -204,26 +204,21 @@ describe('WorkflowBlockHandler', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should map failed child output correctly', () => {
|
it('should throw error for failed child output so BlockExecutor can check error port', () => {
|
||||||
const childResult = {
|
const childResult = {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Child workflow failed',
|
error: 'Child workflow failed',
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = (handler as any).mapChildOutputToParent(
|
expect(() =>
|
||||||
childResult,
|
(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
|
||||||
'child-id',
|
).toThrow('Error in child workflow "Child Workflow": Child workflow failed')
|
||||||
'Child Workflow',
|
|
||||||
100
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
try {
|
||||||
success: false,
|
;(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
|
||||||
childWorkflowName: 'Child Workflow',
|
} catch (error: any) {
|
||||||
result: {},
|
expect(error.childTraceSpans).toEqual([])
|
||||||
error: 'Child workflow failed',
|
}
|
||||||
childTraceSpans: [],
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle nested response structures', () => {
|
it('should handle nested response structures', () => {
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ export class WorkflowBlockHandler implements BlockHandler {
|
|||||||
const workflowMetadata = workflows[workflowId]
|
const workflowMetadata = workflows[workflowId]
|
||||||
const childWorkflowName = workflowMetadata?.name || workflowId
|
const childWorkflowName = workflowMetadata?.name || workflowId
|
||||||
|
|
||||||
|
const originalError = error.message || 'Unknown error'
|
||||||
|
const wrappedError = new Error(
|
||||||
|
`Error in child workflow "${childWorkflowName}": ${originalError}`
|
||||||
|
)
|
||||||
|
|
||||||
if (error.executionResult?.logs) {
|
if (error.executionResult?.logs) {
|
||||||
const executionResult = error.executionResult as ExecutionResult
|
const executionResult = error.executionResult as ExecutionResult
|
||||||
|
|
||||||
@@ -159,28 +164,12 @@ export class WorkflowBlockHandler implements BlockHandler {
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(`Captured ${childTraceSpans.length} child trace spans from failed execution`)
|
logger.info(`Captured ${childTraceSpans.length} child trace spans from failed execution`)
|
||||||
|
;(wrappedError as any).childTraceSpans = childTraceSpans
|
||||||
return {
|
} else if (error.childTraceSpans && Array.isArray(error.childTraceSpans)) {
|
||||||
success: false,
|
;(wrappedError as any).childTraceSpans = error.childTraceSpans
|
||||||
childWorkflowName,
|
|
||||||
result: {},
|
|
||||||
error: error.message || 'Child workflow execution failed',
|
|
||||||
childTraceSpans: childTraceSpans,
|
|
||||||
} as Record<string, any>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.childTraceSpans && Array.isArray(error.childTraceSpans)) {
|
throw wrappedError
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
childWorkflowName,
|
|
||||||
result: {},
|
|
||||||
error: error.message || 'Child workflow execution failed',
|
|
||||||
childTraceSpans: error.childTraceSpans,
|
|
||||||
} as Record<string, any>
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalError = error.message || 'Unknown error'
|
|
||||||
throw new Error(`Error in child workflow "${childWorkflowName}": ${originalError}`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,17 +441,13 @@ export class WorkflowBlockHandler implements BlockHandler {
|
|||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
logger.warn(`Child workflow ${childWorkflowName} failed`)
|
logger.warn(`Child workflow ${childWorkflowName} failed`)
|
||||||
// Return failure with child trace spans so they can be displayed
|
const error = new Error(
|
||||||
return {
|
`Error in child workflow "${childWorkflowName}": ${childResult.error || 'Child workflow execution failed'}`
|
||||||
success: false,
|
)
|
||||||
childWorkflowName,
|
;(error as any).childTraceSpans = childTraceSpans || []
|
||||||
result,
|
throw error
|
||||||
error: childResult.error || 'Child workflow execution failed',
|
|
||||||
childTraceSpans: childTraceSpans || [],
|
|
||||||
} as Record<string, any>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success case
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
childWorkflowName,
|
childWorkflowName,
|
||||||
|
|||||||
@@ -1,24 +1,43 @@
|
|||||||
|
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||||
import { normalizeName } from '@/executor/constants'
|
import { normalizeName } from '@/executor/constants'
|
||||||
import type { ExecutionContext } from '@/executor/types'
|
import type { ExecutionContext } from '@/executor/types'
|
||||||
|
import type { OutputSchema } from '@/executor/utils/block-reference'
|
||||||
|
|
||||||
export interface BlockDataCollection {
|
export interface BlockDataCollection {
|
||||||
blockData: Record<string, any>
|
blockData: Record<string, unknown>
|
||||||
blockNameMapping: Record<string, string>
|
blockNameMapping: Record<string, string>
|
||||||
|
blockOutputSchemas: Record<string, OutputSchema>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectBlockData(ctx: ExecutionContext): BlockDataCollection {
|
export function collectBlockData(ctx: ExecutionContext): BlockDataCollection {
|
||||||
const blockData: Record<string, any> = {}
|
const blockData: Record<string, unknown> = {}
|
||||||
const blockNameMapping: Record<string, string> = {}
|
const blockNameMapping: Record<string, string> = {}
|
||||||
|
const blockOutputSchemas: Record<string, OutputSchema> = {}
|
||||||
|
|
||||||
for (const [id, state] of ctx.blockStates.entries()) {
|
for (const [id, state] of ctx.blockStates.entries()) {
|
||||||
if (state.output !== undefined) {
|
if (state.output !== undefined) {
|
||||||
blockData[id] = state.output
|
blockData[id] = state.output
|
||||||
const workflowBlock = ctx.workflow?.blocks?.find((b) => b.id === id)
|
}
|
||||||
if (workflowBlock?.metadata?.name) {
|
|
||||||
blockNameMapping[normalizeName(workflowBlock.metadata.name)] = id
|
const workflowBlock = ctx.workflow?.blocks?.find((b) => b.id === id)
|
||||||
|
if (!workflowBlock) continue
|
||||||
|
|
||||||
|
if (workflowBlock.metadata?.name) {
|
||||||
|
blockNameMapping[normalizeName(workflowBlock.metadata.name)] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockType = workflowBlock.metadata?.id
|
||||||
|
if (blockType) {
|
||||||
|
const params = workflowBlock.config?.params as Record<string, unknown> | undefined
|
||||||
|
const subBlocks = params
|
||||||
|
? Object.fromEntries(Object.entries(params).map(([k, v]) => [k, { value: v }]))
|
||||||
|
: undefined
|
||||||
|
const schema = getBlockOutputs(blockType, subBlocks)
|
||||||
|
if (schema && Object.keys(schema).length > 0) {
|
||||||
|
blockOutputSchemas[id] = schema
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { blockData, blockNameMapping }
|
return { blockData, blockNameMapping, blockOutputSchemas }
|
||||||
}
|
}
|
||||||
|
|||||||
255
apps/sim/executor/utils/block-reference.test.ts
Normal file
255
apps/sim/executor/utils/block-reference.test.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment node
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
type BlockReferenceContext,
|
||||||
|
InvalidFieldError,
|
||||||
|
resolveBlockReference,
|
||||||
|
} from './block-reference'
|
||||||
|
|
||||||
|
describe('resolveBlockReference', () => {
|
||||||
|
const createContext = (
|
||||||
|
overrides: Partial<BlockReferenceContext> = {}
|
||||||
|
): BlockReferenceContext => ({
|
||||||
|
blockNameMapping: { start: 'block-1', agent: 'block-2' },
|
||||||
|
blockData: {},
|
||||||
|
blockOutputSchemas: {},
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('block name resolution', () => {
|
||||||
|
it('should return undefined when block name does not exist', () => {
|
||||||
|
const ctx = createContext()
|
||||||
|
const result = resolveBlockReference('unknown', ['field'], ctx)
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should normalize block name before lookup', () => {
|
||||||
|
const ctx = createContext({
|
||||||
|
blockNameMapping: { myblock: 'block-1' },
|
||||||
|
blockData: { 'block-1': { value: 'test' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = resolveBlockReference('MyBlock', ['value'], ctx)
|
||||||
|
expect(result).toEqual({ value: 'test', blockId: 'block-1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle block names with spaces', () => {
|
||||||
|
const ctx = createContext({
|
||||||
|
blockNameMapping: { myblock: 'block-1' },
|
||||||
|
blockData: { 'block-1': { value: 'test' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = resolveBlockReference('My Block', ['value'], ctx)
|
||||||
|
expect(result).toEqual({ value: 'test', blockId: 'block-1' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('field resolution', () => {
|
||||||
|
it('should return entire block output when no path specified', () => {
|
||||||
|
const ctx = createContext({
|
||||||
|
blockData: { 'block-1': { input: 'hello', other: 'data' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = resolveBlockReference('start', [], ctx)
|
||||||
|
expect(result).toEqual({
|
||||||
|
value: { input: 'hello', other: 'data' },
|
||||||
|
blockId: 'block-1',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should resolve simple field path', () => {
|
||||||
|
const ctx = createContext({
|
||||||
|
blockData: { 'block-1': { input: 'hello' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = resolveBlockReference('start', ['input'], ctx)
|
||||||
|
expect(result).toEqual({ value: 'hello', blockId: 'block-1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should resolve nested field path', () => {
|
||||||
|
const ctx = createContext({
|
||||||
|
blockData: { 'block-1': { response: { data: { name: 'test' } } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = resolveBlockReference('start', ['response', 'data', 'name'], ctx)
|
||||||
|
expect(result).toEqual({ value: 'test', blockId: 'block-1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should resolve array index path', () => {
|
||||||
|
const ctx = createContext({
|
||||||
|
blockData: { 'block-1': { items: ['a', 'b', 'c'] } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = resolveBlockReference('start', ['items', '1'], ctx)
|
||||||
|
expect(result).toEqual({ value: 'b', blockId: 'block-1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return undefined value when field exists but has no value', () => {
|
||||||
|
const ctx = createContext({
|
||||||
|
blockData: { 'block-1': { input: undefined } },
|
||||||
|
blockOutputSchemas: {
|
||||||
|
'block-1': { input: { type: 'string' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = resolveBlockReference('start', ['input'], ctx)
|
||||||
|
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null value when field has null', () => {
|
||||||
|
const ctx = createContext({
|
||||||
|
blockData: { 'block-1': { input: null } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = resolveBlockReference('start', ['input'], ctx)
|
||||||
|
expect(result).toEqual({ value: null, blockId: 'block-1' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('schema validation', () => {
|
||||||
|
it('should throw InvalidFieldError when field not in schema', () => {
|
||||||
|
const ctx = createContext({
|
||||||
|
blockData: { 'block-1': { existing: 'value' } },
|
||||||
|
blockOutputSchemas: {
|
||||||
|
'block-1': {
|
||||||
|
input: { type: 'string' },
|
||||||
|
conversationId: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(() => resolveBlockReference('start', ['invalid'], ctx)).toThrow(InvalidFieldError)
|
||||||
|
expect(() => resolveBlockReference('start', ['invalid'], ctx)).toThrow(
|
||||||
|
/"invalid" doesn't exist on block "start"/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include available fields in error message', () => {
|
||||||
|
const ctx = createContext({
|
||||||
|
blockData: { 'block-1': {} },
|
||||||
|
blockOutputSchemas: {
|
||||||
|
'block-1': {
|
||||||
|
input: { type: 'string' },
|
||||||
|
conversationId: { type: 'string' },
|
||||||
|
files: { type: 'files' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolveBlockReference('start', ['typo'], ctx)
|
||||||
|
expect.fail('Should have thrown')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(InvalidFieldError)
|
||||||
|
const fieldError = error as InvalidFieldError
|
||||||
|
expect(fieldError.availableFields).toContain('input')
|
||||||
|
expect(fieldError.availableFields).toContain('conversationId')
|
||||||
|
expect(fieldError.availableFields).toContain('files')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow valid field even when value is undefined', () => {
|
||||||
|
const ctx = createContext({
|
||||||
|
blockData: { 'block-1': {} },
|
||||||
|
blockOutputSchemas: {
|
||||||
|
'block-1': { input: { type: 'string' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = resolveBlockReference('start', ['input'], ctx)
|
||||||
|
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should validate path when block has no output yet', () => {
|
||||||
|
const ctx = createContext({
|
||||||
|
blockData: {},
|
||||||
|
blockOutputSchemas: {
|
||||||
|
'block-1': { input: { type: 'string' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(() => resolveBlockReference('start', ['invalid'], ctx)).toThrow(InvalidFieldError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return undefined for valid field when block has no output', () => {
|
||||||
|
const ctx = createContext({
|
||||||
|
blockData: {},
|
||||||
|
blockOutputSchemas: {
|
||||||
|
'block-1': { input: { type: 'string' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = resolveBlockReference('start', ['input'], ctx)
|
||||||
|
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('without schema (pass-through mode)', () => {
|
||||||
|
it('should return undefined value without throwing when no schema', () => {
|
||||||
|
const ctx = createContext({
|
||||||
|
blockData: { 'block-1': { existing: 'value' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = resolveBlockReference('start', ['missing'], ctx)
|
||||||
|
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('file type handling', () => {
|
||||||
|
it('should allow file property access', () => {
|
||||||
|
const ctx = createContext({
|
||||||
|
blockData: {
|
||||||
|
'block-1': {
|
||||||
|
files: [{ name: 'test.txt', url: 'http://example.com/file' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
blockOutputSchemas: {
|
||||||
|
'block-1': { files: { type: 'files' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = resolveBlockReference('start', ['files', '0', 'name'], ctx)
|
||||||
|
expect(result).toEqual({ value: 'test.txt', blockId: 'block-1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should validate file property names', () => {
|
||||||
|
const ctx = createContext({
|
||||||
|
blockData: { 'block-1': { files: [] } },
|
||||||
|
blockOutputSchemas: {
|
||||||
|
'block-1': { files: { type: 'files' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(() => resolveBlockReference('start', ['files', '0', 'invalid'], ctx)).toThrow(
|
||||||
|
InvalidFieldError
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('InvalidFieldError', () => {
|
||||||
|
it('should have correct properties', () => {
|
||||||
|
const error = new InvalidFieldError('myBlock', 'invalid.path', ['field1', 'field2'])
|
||||||
|
|
||||||
|
expect(error.blockName).toBe('myBlock')
|
||||||
|
expect(error.fieldPath).toBe('invalid.path')
|
||||||
|
expect(error.availableFields).toEqual(['field1', 'field2'])
|
||||||
|
expect(error.name).toBe('InvalidFieldError')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format message correctly', () => {
|
||||||
|
const error = new InvalidFieldError('start', 'typo', ['input', 'files'])
|
||||||
|
|
||||||
|
expect(error.message).toBe(
|
||||||
|
'"typo" doesn\'t exist on block "start". Available fields: input, files'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty available fields', () => {
|
||||||
|
const error = new InvalidFieldError('start', 'field', [])
|
||||||
|
|
||||||
|
expect(error.message).toBe('"field" doesn\'t exist on block "start". Available fields: none')
|
||||||
|
})
|
||||||
|
})
|
||||||
210
apps/sim/executor/utils/block-reference.ts
Normal file
210
apps/sim/executor/utils/block-reference.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { USER_FILE_ACCESSIBLE_PROPERTIES } from '@/lib/workflows/types'
|
||||||
|
import { normalizeName } from '@/executor/constants'
|
||||||
|
import { navigatePath } from '@/executor/variables/resolvers/reference'
|
||||||
|
|
||||||
|
export type OutputSchema = Record<string, { type?: string; description?: string } | unknown>
|
||||||
|
|
||||||
|
export interface BlockReferenceContext {
|
||||||
|
blockNameMapping: Record<string, string>
|
||||||
|
blockData: Record<string, unknown>
|
||||||
|
blockOutputSchemas?: Record<string, OutputSchema>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockReferenceResult {
|
||||||
|
value: unknown
|
||||||
|
blockId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidFieldError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly blockName: string,
|
||||||
|
public readonly fieldPath: string,
|
||||||
|
public readonly availableFields: string[]
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
`"${fieldPath}" doesn't exist on block "${blockName}". ` +
|
||||||
|
`Available fields: ${availableFields.length > 0 ? availableFields.join(', ') : 'none'}`
|
||||||
|
)
|
||||||
|
this.name = 'InvalidFieldError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFileType(value: unknown): boolean {
|
||||||
|
if (typeof value !== 'object' || value === null) return false
|
||||||
|
const typed = value as { type?: string }
|
||||||
|
return typed.type === 'file[]' || typed.type === 'files'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isArrayType(value: unknown): value is { type: 'array'; items?: unknown } {
|
||||||
|
if (typeof value !== 'object' || value === null) return false
|
||||||
|
return (value as { type?: string }).type === 'array'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArrayItems(schema: unknown): unknown {
|
||||||
|
if (typeof schema !== 'object' || schema === null) return undefined
|
||||||
|
return (schema as { items?: unknown }).items
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProperties(schema: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (typeof schema !== 'object' || schema === null) return undefined
|
||||||
|
const props = (schema as { properties?: unknown }).properties
|
||||||
|
return typeof props === 'object' && props !== null
|
||||||
|
? (props as Record<string, unknown>)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupField(schema: unknown, fieldName: string): unknown | undefined {
|
||||||
|
if (typeof schema !== 'object' || schema === null) return undefined
|
||||||
|
const typed = schema as Record<string, unknown>
|
||||||
|
|
||||||
|
if (fieldName in typed) {
|
||||||
|
return typed[fieldName]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = getProperties(schema)
|
||||||
|
if (props && fieldName in props) {
|
||||||
|
return props[fieldName]
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInSchema(schema: OutputSchema | undefined, pathParts: string[]): boolean {
|
||||||
|
if (!schema || pathParts.length === 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: unknown = schema
|
||||||
|
|
||||||
|
for (let i = 0; i < pathParts.length; i++) {
|
||||||
|
const part = pathParts[i]
|
||||||
|
|
||||||
|
if (current === null || current === undefined) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(part)) {
|
||||||
|
if (isFileType(current)) {
|
||||||
|
const nextPart = pathParts[i + 1]
|
||||||
|
return (
|
||||||
|
!nextPart ||
|
||||||
|
USER_FILE_ACCESSIBLE_PROPERTIES.includes(
|
||||||
|
nextPart as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isArrayType(current)) {
|
||||||
|
current = getArrayItems(current)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayMatch = part.match(/^([^[]+)\[(\d+)\]$/)
|
||||||
|
if (arrayMatch) {
|
||||||
|
const [, prop] = arrayMatch
|
||||||
|
const fieldDef = lookupField(current, prop)
|
||||||
|
if (!fieldDef) return false
|
||||||
|
|
||||||
|
if (isFileType(fieldDef)) {
|
||||||
|
const nextPart = pathParts[i + 1]
|
||||||
|
return (
|
||||||
|
!nextPart ||
|
||||||
|
USER_FILE_ACCESSIBLE_PROPERTIES.includes(
|
||||||
|
nextPart as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
current = isArrayType(fieldDef) ? getArrayItems(fieldDef) : fieldDef
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isFileType(current) &&
|
||||||
|
USER_FILE_ACCESSIBLE_PROPERTIES.includes(
|
||||||
|
part as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number]
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldDef = lookupField(current, part)
|
||||||
|
if (fieldDef !== undefined) {
|
||||||
|
if (isFileType(fieldDef)) {
|
||||||
|
const nextPart = pathParts[i + 1]
|
||||||
|
if (!nextPart) return true
|
||||||
|
if (/^\d+$/.test(nextPart)) {
|
||||||
|
const afterIndex = pathParts[i + 2]
|
||||||
|
return (
|
||||||
|
!afterIndex ||
|
||||||
|
USER_FILE_ACCESSIBLE_PROPERTIES.includes(
|
||||||
|
afterIndex as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return USER_FILE_ACCESSIBLE_PROPERTIES.includes(
|
||||||
|
nextPart as (typeof USER_FILE_ACCESSIBLE_PROPERTIES)[number]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
current = fieldDef
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isArrayType(current)) {
|
||||||
|
const items = getArrayItems(current)
|
||||||
|
const itemField = lookupField(items, part)
|
||||||
|
if (itemField !== undefined) {
|
||||||
|
current = itemField
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSchemaFieldNames(schema: OutputSchema | undefined): string[] {
|
||||||
|
if (!schema) return []
|
||||||
|
return Object.keys(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBlockReference(
|
||||||
|
blockName: string,
|
||||||
|
pathParts: string[],
|
||||||
|
context: BlockReferenceContext
|
||||||
|
): BlockReferenceResult | undefined {
|
||||||
|
const normalizedName = normalizeName(blockName)
|
||||||
|
const blockId = context.blockNameMapping[normalizedName]
|
||||||
|
|
||||||
|
if (!blockId) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockOutput = context.blockData[blockId]
|
||||||
|
const schema = context.blockOutputSchemas?.[blockId]
|
||||||
|
|
||||||
|
if (blockOutput === undefined) {
|
||||||
|
if (schema && pathParts.length > 0) {
|
||||||
|
if (!isPathInSchema(schema, pathParts)) {
|
||||||
|
throw new InvalidFieldError(blockName, pathParts.join('.'), getSchemaFieldNames(schema))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { value: undefined, blockId }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathParts.length === 0) {
|
||||||
|
return { value: blockOutput, blockId }
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = navigatePath(blockOutput, pathParts)
|
||||||
|
|
||||||
|
if (value === undefined && schema) {
|
||||||
|
if (!isPathInSchema(schema, pathParts)) {
|
||||||
|
throw new InvalidFieldError(blockName, pathParts.join('.'), getSchemaFieldNames(schema))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value, blockId }
|
||||||
|
}
|
||||||
@@ -40,3 +40,30 @@ export function isJSONString(value: string): boolean {
|
|||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
return trimmed.startsWith('{') || trimmed.startsWith('[')
|
return trimmed.startsWith('{') || trimmed.startsWith('[')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively parses JSON strings within an object or array.
|
||||||
|
* Useful for normalizing data that may contain stringified JSON at various levels.
|
||||||
|
*/
|
||||||
|
export function parseObjectStrings(data: unknown): unknown {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
if (typeof parsed === 'object' && parsed !== null) {
|
||||||
|
return parseObjectStrings(parsed)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
} catch {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(data)) {
|
||||||
|
return data.map((item) => parseObjectStrings(item))
|
||||||
|
} else if (typeof data === 'object' && data !== null) {
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
result[key] = parseObjectStrings(value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import type { ResolutionContext } from './reference'
|
|||||||
|
|
||||||
vi.mock('@sim/logger', () => loggerMock)
|
vi.mock('@sim/logger', () => loggerMock)
|
||||||
|
|
||||||
|
vi.mock('@/lib/workflows/blocks/block-outputs', () => ({
|
||||||
|
getBlockOutputs: vi.fn(() => ({})),
|
||||||
|
}))
|
||||||
|
|
||||||
function createTestWorkflow(
|
function createTestWorkflow(
|
||||||
blocks: Array<{
|
blocks: Array<{
|
||||||
id: string
|
id: string
|
||||||
@@ -140,16 +144,21 @@ describe('BlockResolver', () => {
|
|||||||
expect(resolver.resolve('<source.nonexistent>', ctx)).toBeUndefined()
|
expect(resolver.resolve('<source.nonexistent>', ctx)).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it.concurrent('should throw error for path not in output schema', () => {
|
it.concurrent('should throw error for path not in output schema', async () => {
|
||||||
|
const { getBlockOutputs } = await import('@/lib/workflows/blocks/block-outputs')
|
||||||
|
const mockGetBlockOutputs = vi.mocked(getBlockOutputs)
|
||||||
|
const customOutputs = {
|
||||||
|
validField: { type: 'string', description: 'A valid field' },
|
||||||
|
nested: {
|
||||||
|
child: { type: 'number', description: 'Nested child' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockGetBlockOutputs.mockReturnValue(customOutputs as any)
|
||||||
|
|
||||||
const workflow = createTestWorkflow([
|
const workflow = createTestWorkflow([
|
||||||
{
|
{
|
||||||
id: 'source',
|
id: 'source',
|
||||||
outputs: {
|
outputs: customOutputs,
|
||||||
validField: { type: 'string', description: 'A valid field' },
|
|
||||||
nested: {
|
|
||||||
child: { type: 'number', description: 'Nested child' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
const resolver = new BlockResolver(workflow)
|
const resolver = new BlockResolver(workflow)
|
||||||
@@ -161,6 +170,8 @@ describe('BlockResolver', () => {
|
|||||||
/"invalidField" doesn't exist on block "source"/
|
/"invalidField" doesn't exist on block "source"/
|
||||||
)
|
)
|
||||||
expect(() => resolver.resolve('<source.invalidField>', ctx)).toThrow(/Available fields:/)
|
expect(() => resolver.resolve('<source.invalidField>', ctx)).toThrow(/Available fields:/)
|
||||||
|
|
||||||
|
mockGetBlockOutputs.mockReturnValue({})
|
||||||
})
|
})
|
||||||
|
|
||||||
it.concurrent('should return undefined for path in schema but missing in data', () => {
|
it.concurrent('should return undefined for path in schema but missing in data', () => {
|
||||||
@@ -298,45 +309,6 @@ describe('BlockResolver', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('tryParseJSON', () => {
|
|
||||||
it.concurrent('should parse valid JSON object string', () => {
|
|
||||||
const resolver = new BlockResolver(createTestWorkflow())
|
|
||||||
expect(resolver.tryParseJSON('{"key": "value"}')).toEqual({ key: 'value' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('should parse valid JSON array string', () => {
|
|
||||||
const resolver = new BlockResolver(createTestWorkflow())
|
|
||||||
expect(resolver.tryParseJSON('[1, 2, 3]')).toEqual([1, 2, 3])
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('should return original value for non-string input', () => {
|
|
||||||
const resolver = new BlockResolver(createTestWorkflow())
|
|
||||||
const obj = { key: 'value' }
|
|
||||||
expect(resolver.tryParseJSON(obj)).toBe(obj)
|
|
||||||
expect(resolver.tryParseJSON(123)).toBe(123)
|
|
||||||
expect(resolver.tryParseJSON(null)).toBe(null)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('should return original string for non-JSON strings', () => {
|
|
||||||
const resolver = new BlockResolver(createTestWorkflow())
|
|
||||||
expect(resolver.tryParseJSON('plain text')).toBe('plain text')
|
|
||||||
expect(resolver.tryParseJSON('123')).toBe('123')
|
|
||||||
expect(resolver.tryParseJSON('')).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('should return original string for invalid JSON', () => {
|
|
||||||
const resolver = new BlockResolver(createTestWorkflow())
|
|
||||||
expect(resolver.tryParseJSON('{invalid json}')).toBe('{invalid json}')
|
|
||||||
expect(resolver.tryParseJSON('[1, 2,')).toBe('[1, 2,')
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('should handle whitespace around JSON', () => {
|
|
||||||
const resolver = new BlockResolver(createTestWorkflow())
|
|
||||||
expect(resolver.tryParseJSON(' {"key": "value"} ')).toEqual({ key: 'value' })
|
|
||||||
expect(resolver.tryParseJSON('\n[1, 2]\n')).toEqual([1, 2])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Response block backwards compatibility', () => {
|
describe('Response block backwards compatibility', () => {
|
||||||
it.concurrent('should resolve new format: <responseBlock.data>', () => {
|
it.concurrent('should resolve new format: <responseBlock.data>', () => {
|
||||||
const workflow = createTestWorkflow([
|
const workflow = createTestWorkflow([
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { USER_FILE_ACCESSIBLE_PROPERTIES } from '@/lib/workflows/types'
|
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||||
import {
|
import {
|
||||||
isReference,
|
isReference,
|
||||||
normalizeName,
|
normalizeName,
|
||||||
parseReferencePath,
|
parseReferencePath,
|
||||||
SPECIAL_REFERENCE_PREFIXES,
|
SPECIAL_REFERENCE_PREFIXES,
|
||||||
} from '@/executor/constants'
|
} from '@/executor/constants'
|
||||||
|
import {
|
||||||
|
InvalidFieldError,
|
||||||
|
type OutputSchema,
|
||||||
|
resolveBlockReference,
|
||||||
|
} from '@/executor/utils/block-reference'
|
||||||
import {
|
import {
|
||||||
navigatePath,
|
navigatePath,
|
||||||
type ResolutionContext,
|
type ResolutionContext,
|
||||||
@@ -13,123 +18,6 @@ import {
|
|||||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||||
import { getTool } from '@/tools/utils'
|
import { getTool } from '@/tools/utils'
|
||||||
|
|
||||||
function isPathInOutputSchema(
|
|
||||||
outputs: Record<string, any> | undefined,
|
|
||||||
pathParts: string[]
|
|
||||||
): boolean {
|
|
||||||
if (!outputs || pathParts.length === 0) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFileArrayType = (value: any): boolean =>
|
|
||||||
value?.type === 'file[]' || value?.type === 'files'
|
|
||||||
|
|
||||||
let current: any = outputs
|
|
||||||
for (let i = 0; i < pathParts.length; i++) {
|
|
||||||
const part = pathParts[i]
|
|
||||||
|
|
||||||
const arrayMatch = part.match(/^([^[]+)\[(\d+)\]$/)
|
|
||||||
if (arrayMatch) {
|
|
||||||
const [, prop] = arrayMatch
|
|
||||||
let fieldDef: any
|
|
||||||
|
|
||||||
if (prop in current) {
|
|
||||||
fieldDef = current[prop]
|
|
||||||
} else if (current.properties && prop in current.properties) {
|
|
||||||
fieldDef = current.properties[prop]
|
|
||||||
} else if (current.type === 'array' && current.items) {
|
|
||||||
if (current.items.properties && prop in current.items.properties) {
|
|
||||||
fieldDef = current.items.properties[prop]
|
|
||||||
} else if (prop in current.items) {
|
|
||||||
fieldDef = current.items[prop]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fieldDef) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFileArrayType(fieldDef)) {
|
|
||||||
if (i + 1 < pathParts.length) {
|
|
||||||
return USER_FILE_ACCESSIBLE_PROPERTIES.includes(pathParts[i + 1] as any)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fieldDef.type === 'array' && fieldDef.items) {
|
|
||||||
current = fieldDef.items
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
current = fieldDef
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^\d+$/.test(part)) {
|
|
||||||
if (isFileArrayType(current)) {
|
|
||||||
if (i + 1 < pathParts.length) {
|
|
||||||
const nextPart = pathParts[i + 1]
|
|
||||||
return USER_FILE_ACCESSIBLE_PROPERTIES.includes(nextPart as any)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current === null || current === undefined) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part in current) {
|
|
||||||
const nextCurrent = current[part]
|
|
||||||
if (nextCurrent?.type === 'file[]' && i + 1 < pathParts.length) {
|
|
||||||
const nextPart = pathParts[i + 1]
|
|
||||||
if (/^\d+$/.test(nextPart) && i + 2 < pathParts.length) {
|
|
||||||
const propertyPart = pathParts[i + 2]
|
|
||||||
return USER_FILE_ACCESSIBLE_PROPERTIES.includes(propertyPart as any)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
current = nextCurrent
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.properties && part in current.properties) {
|
|
||||||
current = current.properties[part]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.type === 'array' && current.items) {
|
|
||||||
if (current.items.properties && part in current.items.properties) {
|
|
||||||
current = current.items.properties[part]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (part in current.items) {
|
|
||||||
current = current.items[part]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFileArrayType(current) && USER_FILE_ACCESSIBLE_PROPERTIES.includes(part as any)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('type' in current && typeof current.type === 'string') {
|
|
||||||
if (!current.properties && !current.items) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSchemaFieldNames(outputs: Record<string, any> | undefined): string[] {
|
|
||||||
if (!outputs) return []
|
|
||||||
return Object.keys(outputs)
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BlockResolver implements Resolver {
|
export class BlockResolver implements Resolver {
|
||||||
private nameToBlockId: Map<string, string>
|
private nameToBlockId: Map<string, string>
|
||||||
private blockById: Map<string, SerializedBlock>
|
private blockById: Map<string, SerializedBlock>
|
||||||
@@ -169,77 +57,94 @@ export class BlockResolver implements Resolver {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const block = this.blockById.get(blockId)
|
const block = this.blockById.get(blockId)!
|
||||||
const output = this.getBlockOutput(blockId, context)
|
const output = this.getBlockOutput(blockId, context)
|
||||||
|
|
||||||
if (output === undefined) {
|
const blockData: Record<string, unknown> = {}
|
||||||
|
const blockOutputSchemas: Record<string, OutputSchema> = {}
|
||||||
|
|
||||||
|
if (output !== undefined) {
|
||||||
|
blockData[blockId] = output
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockType = block.metadata?.id
|
||||||
|
const params = block.config?.params as Record<string, unknown> | undefined
|
||||||
|
const subBlocks = params
|
||||||
|
? Object.fromEntries(Object.entries(params).map(([k, v]) => [k, { value: v }]))
|
||||||
|
: undefined
|
||||||
|
const toolId = block.config?.tool
|
||||||
|
const toolConfig = toolId ? getTool(toolId) : undefined
|
||||||
|
const outputSchema =
|
||||||
|
toolConfig?.outputs ?? (blockType ? getBlockOutputs(blockType, subBlocks) : block.outputs)
|
||||||
|
|
||||||
|
if (outputSchema && Object.keys(outputSchema).length > 0) {
|
||||||
|
blockOutputSchemas[blockId] = outputSchema
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = resolveBlockReference(blockName, pathParts, {
|
||||||
|
blockNameMapping: Object.fromEntries(this.nameToBlockId),
|
||||||
|
blockData,
|
||||||
|
blockOutputSchemas,
|
||||||
|
})!
|
||||||
|
|
||||||
|
if (result.value !== undefined) {
|
||||||
|
return result.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.handleBackwardsCompat(block, output, pathParts)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof InvalidFieldError) {
|
||||||
|
const fallback = this.handleBackwardsCompat(block, output, pathParts)
|
||||||
|
if (fallback !== undefined) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleBackwardsCompat(
|
||||||
|
block: SerializedBlock,
|
||||||
|
output: unknown,
|
||||||
|
pathParts: string[]
|
||||||
|
): unknown {
|
||||||
|
if (output === undefined || pathParts.length === 0) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
if (pathParts.length === 0) {
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try the original path first
|
|
||||||
let result = navigatePath(output, pathParts)
|
|
||||||
|
|
||||||
// If successful, return it immediately
|
|
||||||
if (result !== undefined) {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response block backwards compatibility:
|
|
||||||
// Old: <responseBlock.response.data> -> New: <responseBlock.data>
|
|
||||||
// Only apply fallback if:
|
|
||||||
// 1. Block type is 'response'
|
|
||||||
// 2. Path starts with 'response.'
|
|
||||||
// 3. Output doesn't have a 'response' key (confirming it's the new format)
|
|
||||||
if (
|
if (
|
||||||
block?.metadata?.id === 'response' &&
|
block.metadata?.id === 'response' &&
|
||||||
pathParts[0] === 'response' &&
|
pathParts[0] === 'response' &&
|
||||||
output?.response === undefined
|
(output as Record<string, unknown>)?.response === undefined
|
||||||
) {
|
) {
|
||||||
const adjustedPathParts = pathParts.slice(1)
|
const adjustedPathParts = pathParts.slice(1)
|
||||||
if (adjustedPathParts.length === 0) {
|
if (adjustedPathParts.length === 0) {
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
result = navigatePath(output, adjustedPathParts)
|
const fallbackResult = navigatePath(output, adjustedPathParts)
|
||||||
if (result !== undefined) {
|
if (fallbackResult !== undefined) {
|
||||||
return result
|
return fallbackResult
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workflow block backwards compatibility:
|
|
||||||
// Old: <workflowBlock.result.response.data> -> New: <workflowBlock.result.data>
|
|
||||||
// Only apply fallback if:
|
|
||||||
// 1. Block type is 'workflow' or 'workflow_input'
|
|
||||||
// 2. Path starts with 'result.response.'
|
|
||||||
// 3. output.result.response doesn't exist (confirming child used new format)
|
|
||||||
const isWorkflowBlock =
|
const isWorkflowBlock =
|
||||||
block?.metadata?.id === 'workflow' || block?.metadata?.id === 'workflow_input'
|
block.metadata?.id === 'workflow' || block.metadata?.id === 'workflow_input'
|
||||||
|
const outputRecord = output as Record<string, Record<string, unknown> | undefined>
|
||||||
if (
|
if (
|
||||||
isWorkflowBlock &&
|
isWorkflowBlock &&
|
||||||
pathParts[0] === 'result' &&
|
pathParts[0] === 'result' &&
|
||||||
pathParts[1] === 'response' &&
|
pathParts[1] === 'response' &&
|
||||||
output?.result?.response === undefined
|
outputRecord?.result?.response === undefined
|
||||||
) {
|
) {
|
||||||
const adjustedPathParts = ['result', ...pathParts.slice(2)]
|
const adjustedPathParts = ['result', ...pathParts.slice(2)]
|
||||||
result = navigatePath(output, adjustedPathParts)
|
const fallbackResult = navigatePath(output, adjustedPathParts)
|
||||||
if (result !== undefined) {
|
if (fallbackResult !== undefined) {
|
||||||
return result
|
return fallbackResult
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolId = block?.config?.tool
|
|
||||||
const toolConfig = toolId ? getTool(toolId) : undefined
|
|
||||||
const outputSchema = toolConfig?.outputs ?? block?.outputs
|
|
||||||
const schemaFields = getSchemaFieldNames(outputSchema)
|
|
||||||
if (schemaFields.length > 0 && !isPathInOutputSchema(outputSchema, pathParts)) {
|
|
||||||
throw new Error(
|
|
||||||
`"${pathParts.join('.')}" doesn't exist on block "${blockName}". ` +
|
|
||||||
`Available fields: ${schemaFields.join(', ')}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,21 +241,4 @@ export class BlockResolver implements Resolver {
|
|||||||
}
|
}
|
||||||
return String(value)
|
return String(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
tryParseJSON(value: any): any {
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (trimmed.length > 0 && (trimmed.startsWith('{') || trimmed.startsWith('['))) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(trimmed)
|
|
||||||
} catch {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,23 +27,28 @@ export function navigatePath(obj: any, path: string[]): any {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle array indexing like "items[0]" or just numeric indices
|
const arrayMatch = part.match(/^([^[]+)(\[.+)$/)
|
||||||
const arrayMatch = part.match(/^([^[]+)\[(\d+)\](.*)$/)
|
|
||||||
if (arrayMatch) {
|
if (arrayMatch) {
|
||||||
// Handle complex array access like "items[0]"
|
const [, prop, bracketsPart] = arrayMatch
|
||||||
const [, prop, index] = arrayMatch
|
|
||||||
current = current[prop]
|
current = current[prop]
|
||||||
if (current === undefined || current === null) {
|
if (current === undefined || current === null) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
const idx = Number.parseInt(index, 10)
|
|
||||||
current = Array.isArray(current) ? current[idx] : undefined
|
const indices = bracketsPart.match(/\[(\d+)\]/g)
|
||||||
|
if (indices) {
|
||||||
|
for (const indexMatch of indices) {
|
||||||
|
if (current === null || current === undefined) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const idx = Number.parseInt(indexMatch.slice(1, -1), 10)
|
||||||
|
current = Array.isArray(current) ? current[idx] : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (/^\d+$/.test(part)) {
|
} else if (/^\d+$/.test(part)) {
|
||||||
// Handle plain numeric index
|
|
||||||
const index = Number.parseInt(part, 10)
|
const index = Number.parseInt(part, 10)
|
||||||
current = Array.isArray(current) ? current[index] : undefined
|
current = Array.isArray(current) ? current[index] : undefined
|
||||||
} else {
|
} else {
|
||||||
// Handle regular property access
|
|
||||||
current = current[part]
|
current = current[part]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,34 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import type { CustomToolDefinition, CustomToolSchema } from '@/stores/custom-tools'
|
import { getQueryClient } from '@/app/_shell/providers/query-provider'
|
||||||
import { useCustomToolsStore } from '@/stores/custom-tools'
|
|
||||||
|
|
||||||
const logger = createLogger('CustomToolsQueries')
|
const logger = createLogger('CustomToolsQueries')
|
||||||
const API_ENDPOINT = '/api/tools/custom'
|
const API_ENDPOINT = '/api/tools/custom'
|
||||||
|
|
||||||
|
export interface CustomToolSchema {
|
||||||
|
type: string
|
||||||
|
function: {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
parameters: {
|
||||||
|
type: string
|
||||||
|
properties: Record<string, unknown>
|
||||||
|
required?: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomToolDefinition {
|
||||||
|
id: string
|
||||||
|
workspaceId: string | null
|
||||||
|
userId: string | null
|
||||||
|
title: string
|
||||||
|
schema: CustomToolSchema
|
||||||
|
code: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query key factories for custom tools queries
|
* Query key factories for custom tools queries
|
||||||
*/
|
*/
|
||||||
@@ -64,8 +87,39 @@ function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomTo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncCustomToolsToStore(tools: CustomToolDefinition[]) {
|
/**
|
||||||
useCustomToolsStore.getState().setTools(tools)
|
* Extract workspaceId from the current URL path
|
||||||
|
* Expected format: /workspace/{workspaceId}/...
|
||||||
|
*/
|
||||||
|
function getWorkspaceIdFromUrl(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
const match = window.location.pathname.match(/^\/workspace\/([^/]+)/)
|
||||||
|
return match?.[1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all custom tools from the query cache (for non-React code)
|
||||||
|
* If workspaceId is not provided, extracts it from the current URL
|
||||||
|
*/
|
||||||
|
export function getCustomTools(workspaceId?: string): CustomToolDefinition[] {
|
||||||
|
if (typeof window === 'undefined') return []
|
||||||
|
const wsId = workspaceId ?? getWorkspaceIdFromUrl()
|
||||||
|
if (!wsId) return []
|
||||||
|
const queryClient = getQueryClient()
|
||||||
|
return queryClient.getQueryData<CustomToolDefinition[]>(customToolsKeys.list(wsId)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific custom tool from the query cache by ID or title (for non-React code)
|
||||||
|
* Custom tools are referenced by title in the system (custom_${title}), so title lookup is required.
|
||||||
|
* If workspaceId is not provided, extracts it from the current URL
|
||||||
|
*/
|
||||||
|
export function getCustomTool(
|
||||||
|
identifier: string,
|
||||||
|
workspaceId?: string
|
||||||
|
): CustomToolDefinition | undefined {
|
||||||
|
const tools = getCustomTools(workspaceId)
|
||||||
|
return tools.find((tool) => tool.id === identifier || tool.title === identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,19 +188,13 @@ async function fetchCustomTools(workspaceId: string): Promise<CustomToolDefiniti
|
|||||||
* Hook to fetch custom tools
|
* Hook to fetch custom tools
|
||||||
*/
|
*/
|
||||||
export function useCustomTools(workspaceId: string) {
|
export function useCustomTools(workspaceId: string) {
|
||||||
const query = useQuery<CustomToolDefinition[]>({
|
return useQuery<CustomToolDefinition[]>({
|
||||||
queryKey: customToolsKeys.list(workspaceId),
|
queryKey: customToolsKeys.list(workspaceId),
|
||||||
queryFn: () => fetchCustomTools(workspaceId),
|
queryFn: () => fetchCustomTools(workspaceId),
|
||||||
enabled: !!workspaceId,
|
enabled: !!workspaceId,
|
||||||
staleTime: 60 * 1000, // 1 minute - tools don't change frequently
|
staleTime: 60 * 1000, // 1 minute - tools don't change frequently
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (query.data) {
|
|
||||||
syncCustomToolsToStore(query.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return query
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { getNextWorkflowColor } from '@/lib/workflows/colors'
|
import { getNextWorkflowColor } from '@/lib/workflows/colors'
|
||||||
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
||||||
import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/workflows/input-format'
|
|
||||||
import { deploymentKeys } from '@/hooks/queries/deployments'
|
import { deploymentKeys } from '@/hooks/queries/deployments'
|
||||||
import {
|
import {
|
||||||
createOptimisticMutationHandlers,
|
createOptimisticMutationHandlers,
|
||||||
@@ -26,34 +25,35 @@ export const workflowKeys = {
|
|||||||
deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const,
|
deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const,
|
||||||
deploymentVersion: (workflowId: string | undefined, version: number | undefined) =>
|
deploymentVersion: (workflowId: string | undefined, version: number | undefined) =>
|
||||||
[...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const,
|
[...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const,
|
||||||
inputFields: (workflowId: string | undefined) =>
|
state: (workflowId: string | undefined) =>
|
||||||
[...workflowKeys.all, 'inputFields', workflowId ?? ''] as const,
|
[...workflowKeys.all, 'state', workflowId ?? ''] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches workflow input fields from the workflow state.
|
* Fetches workflow state from the API.
|
||||||
|
* Used as the base query for both state preview and input fields extraction.
|
||||||
*/
|
*/
|
||||||
async function fetchWorkflowInputFields(workflowId: string): Promise<WorkflowInputField[]> {
|
async function fetchWorkflowState(workflowId: string): Promise<WorkflowState | null> {
|
||||||
const response = await fetch(`/api/workflows/${workflowId}`)
|
const response = await fetch(`/api/workflows/${workflowId}`)
|
||||||
if (!response.ok) throw new Error('Failed to fetch workflow')
|
if (!response.ok) throw new Error('Failed to fetch workflow')
|
||||||
const { data } = await response.json()
|
const { data } = await response.json()
|
||||||
return extractInputFieldsFromBlocks(data?.state?.blocks)
|
return data?.state ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to fetch workflow input fields for configuration.
|
* Hook to fetch workflow state.
|
||||||
* Uses React Query for caching and deduplication.
|
* Used by workflow blocks to show a preview of the child workflow
|
||||||
|
* and as a base query for input fields extraction.
|
||||||
*
|
*
|
||||||
* @param workflowId - The workflow ID to fetch input fields for
|
* @param workflowId - The workflow ID to fetch state for
|
||||||
* @returns Query result with input fields array
|
* @returns Query result with workflow state
|
||||||
*/
|
*/
|
||||||
export function useWorkflowInputFields(workflowId: string | undefined) {
|
export function useWorkflowState(workflowId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: workflowKeys.inputFields(workflowId),
|
queryKey: workflowKeys.state(workflowId),
|
||||||
queryFn: () => fetchWorkflowInputFields(workflowId!),
|
queryFn: () => fetchWorkflowState(workflowId!),
|
||||||
enabled: Boolean(workflowId),
|
enabled: Boolean(workflowId),
|
||||||
staleTime: 0,
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
refetchOnMount: 'always',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,13 +532,19 @@ export interface ChildDeploymentStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches deployment status for a child workflow
|
* Fetches deployment status for a child workflow.
|
||||||
|
* Uses Promise.all to fetch status and deployments in parallel for better performance.
|
||||||
*/
|
*/
|
||||||
async function fetchChildDeploymentStatus(workflowId: string): Promise<ChildDeploymentStatus> {
|
async function fetchChildDeploymentStatus(workflowId: string): Promise<ChildDeploymentStatus> {
|
||||||
const statusRes = await fetch(`/api/workflows/${workflowId}/status`, {
|
const fetchOptions = {
|
||||||
cache: 'no-store',
|
cache: 'no-store' as const,
|
||||||
headers: { 'Cache-Control': 'no-cache' },
|
headers: { 'Cache-Control': 'no-cache' },
|
||||||
})
|
}
|
||||||
|
|
||||||
|
const [statusRes, deploymentsRes] = await Promise.all([
|
||||||
|
fetch(`/api/workflows/${workflowId}/status`, fetchOptions),
|
||||||
|
fetch(`/api/workflows/${workflowId}/deployments`, fetchOptions),
|
||||||
|
])
|
||||||
|
|
||||||
if (!statusRes.ok) {
|
if (!statusRes.ok) {
|
||||||
throw new Error('Failed to fetch workflow status')
|
throw new Error('Failed to fetch workflow status')
|
||||||
@@ -546,11 +552,6 @@ async function fetchChildDeploymentStatus(workflowId: string): Promise<ChildDepl
|
|||||||
|
|
||||||
const statusData = await statusRes.json()
|
const statusData = await statusRes.json()
|
||||||
|
|
||||||
const deploymentsRes = await fetch(`/api/workflows/${workflowId}/deployments`, {
|
|
||||||
cache: 'no-store',
|
|
||||||
headers: { 'Cache-Control': 'no-cache' },
|
|
||||||
})
|
|
||||||
|
|
||||||
let activeVersion: number | null = null
|
let activeVersion: number | null = null
|
||||||
if (deploymentsRes.ok) {
|
if (deploymentsRes.ok) {
|
||||||
const deploymentsJson = await deploymentsRes.json()
|
const deploymentsJson = await deploymentsRes.json()
|
||||||
@@ -580,7 +581,7 @@ export function useChildDeploymentStatus(workflowId: string | undefined) {
|
|||||||
queryKey: workflowKeys.deploymentStatus(workflowId),
|
queryKey: workflowKeys.deploymentStatus(workflowId),
|
||||||
queryFn: () => fetchChildDeploymentStatus(workflowId!),
|
queryFn: () => fetchChildDeploymentStatus(workflowId!),
|
||||||
enabled: Boolean(workflowId),
|
enabled: Boolean(workflowId),
|
||||||
staleTime: 30 * 1000, // 30 seconds
|
staleTime: 0,
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
SelectorOption,
|
SelectorOption,
|
||||||
SelectorQueryArgs,
|
SelectorQueryArgs,
|
||||||
} from '@/hooks/selectors/types'
|
} from '@/hooks/selectors/types'
|
||||||
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
|
||||||
const SELECTOR_STALE = 60 * 1000
|
const SELECTOR_STALE = 60 * 1000
|
||||||
|
|
||||||
@@ -853,6 +854,36 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
|||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'sim.workflows': {
|
||||||
|
key: 'sim.workflows',
|
||||||
|
staleTime: 0, // Always fetch fresh from store
|
||||||
|
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||||
|
'selectors',
|
||||||
|
'sim.workflows',
|
||||||
|
context.excludeWorkflowId ?? 'none',
|
||||||
|
],
|
||||||
|
enabled: () => true,
|
||||||
|
fetchList: async ({ context }: SelectorQueryArgs): Promise<SelectorOption[]> => {
|
||||||
|
const { workflows } = useWorkflowRegistry.getState()
|
||||||
|
return Object.entries(workflows)
|
||||||
|
.filter(([id]) => id !== context.excludeWorkflowId)
|
||||||
|
.map(([id, workflow]) => ({
|
||||||
|
id,
|
||||||
|
label: workflow.name || `Workflow ${id.slice(0, 8)}`,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
|
},
|
||||||
|
fetchById: async ({ detailId }: SelectorQueryArgs): Promise<SelectorOption | null> => {
|
||||||
|
if (!detailId) return null
|
||||||
|
const { workflows } = useWorkflowRegistry.getState()
|
||||||
|
const workflow = workflows[detailId]
|
||||||
|
if (!workflow) return null
|
||||||
|
return {
|
||||||
|
id: detailId,
|
||||||
|
label: workflow.name || `Workflow ${detailId.slice(0, 8)}`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSelectorDefinition(key: SelectorKey): SelectorDefinition {
|
export function getSelectorDefinition(key: SelectorKey): SelectorDefinition {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type SelectorKey =
|
|||||||
| 'webflow.sites'
|
| 'webflow.sites'
|
||||||
| 'webflow.collections'
|
| 'webflow.collections'
|
||||||
| 'webflow.items'
|
| 'webflow.items'
|
||||||
|
| 'sim.workflows'
|
||||||
|
|
||||||
export interface SelectorOption {
|
export interface SelectorOption {
|
||||||
id: string
|
id: string
|
||||||
@@ -52,6 +53,7 @@ export interface SelectorContext {
|
|||||||
siteId?: string
|
siteId?: string
|
||||||
collectionId?: string
|
collectionId?: string
|
||||||
spreadsheetId?: string
|
spreadsheetId?: string
|
||||||
|
excludeWorkflowId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectorQueryArgs {
|
export interface SelectorQueryArgs {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useShallow } from 'zustand/react/shallow'
|
|||||||
import { useSession } from '@/lib/auth/auth-client'
|
import { useSession } from '@/lib/auth/auth-client'
|
||||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||||
import { getBlock } from '@/blocks'
|
import { getBlock } from '@/blocks'
|
||||||
|
import { normalizeName } from '@/executor/constants'
|
||||||
import { useUndoRedo } from '@/hooks/use-undo-redo'
|
import { useUndoRedo } from '@/hooks/use-undo-redo'
|
||||||
import {
|
import {
|
||||||
BLOCK_OPERATIONS,
|
BLOCK_OPERATIONS,
|
||||||
@@ -23,7 +24,7 @@ import { useUndoRedoStore } from '@/stores/undo-redo'
|
|||||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||||
import { filterNewEdges, mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
|
import { filterNewEdges, mergeSubblockState } from '@/stores/workflows/utils'
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
|
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
|
||||||
|
|
||||||
@@ -112,9 +113,6 @@ export function useCollaborativeWorkflow() {
|
|||||||
const {
|
const {
|
||||||
isConnected,
|
isConnected,
|
||||||
currentWorkflowId,
|
currentWorkflowId,
|
||||||
presenceUsers,
|
|
||||||
joinWorkflow,
|
|
||||||
leaveWorkflow,
|
|
||||||
emitWorkflowOperation,
|
emitWorkflowOperation,
|
||||||
emitSubblockUpdate,
|
emitSubblockUpdate,
|
||||||
emitVariableUpdate,
|
emitVariableUpdate,
|
||||||
@@ -142,13 +140,7 @@ export function useCollaborativeWorkflow() {
|
|||||||
// Track if we're applying remote changes to avoid infinite loops
|
// Track if we're applying remote changes to avoid infinite loops
|
||||||
const isApplyingRemoteChange = useRef(false)
|
const isApplyingRemoteChange = useRef(false)
|
||||||
|
|
||||||
// Track last applied position timestamps to prevent out-of-order updates
|
|
||||||
const lastPositionTimestamps = useRef<Map<string, number>>(new Map())
|
|
||||||
|
|
||||||
// Operation queue
|
|
||||||
const {
|
const {
|
||||||
queue,
|
|
||||||
hasOperationError,
|
|
||||||
addToQueue,
|
addToQueue,
|
||||||
confirmOperation,
|
confirmOperation,
|
||||||
failOperation,
|
failOperation,
|
||||||
@@ -160,22 +152,6 @@ export function useCollaborativeWorkflow() {
|
|||||||
return !!currentWorkflowId && activeWorkflowId === currentWorkflowId
|
return !!currentWorkflowId && activeWorkflowId === currentWorkflowId
|
||||||
}, [currentWorkflowId, activeWorkflowId])
|
}, [currentWorkflowId, activeWorkflowId])
|
||||||
|
|
||||||
// Clear position timestamps when switching workflows
|
|
||||||
// Note: Workflow joining is now handled automatically by socket connect event based on URL
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeWorkflowId && currentWorkflowId !== activeWorkflowId) {
|
|
||||||
logger.info(`Active workflow changed to: ${activeWorkflowId}`, {
|
|
||||||
isConnected,
|
|
||||||
currentWorkflowId,
|
|
||||||
activeWorkflowId,
|
|
||||||
presenceUsers: presenceUsers.length,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Clear position timestamps when switching workflows
|
|
||||||
lastPositionTimestamps.current.clear()
|
|
||||||
}
|
|
||||||
}, [activeWorkflowId, isConnected, currentWorkflowId])
|
|
||||||
|
|
||||||
// Register emit functions with operation queue store
|
// Register emit functions with operation queue store
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerEmitFunctions(
|
registerEmitFunctions(
|
||||||
@@ -1620,15 +1596,8 @@ export function useCollaborativeWorkflow() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Connection status
|
|
||||||
isConnected,
|
isConnected,
|
||||||
currentWorkflowId,
|
currentWorkflowId,
|
||||||
presenceUsers,
|
|
||||||
hasOperationError,
|
|
||||||
|
|
||||||
// Workflow management
|
|
||||||
joinWorkflow,
|
|
||||||
leaveWorkflow,
|
|
||||||
|
|
||||||
// Collaborative operations
|
// Collaborative operations
|
||||||
collaborativeBatchUpdatePositions,
|
collaborativeBatchUpdatePositions,
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
|
|||||||
|
|
||||||
const logger = createLogger('Auth')
|
const logger = createLogger('Auth')
|
||||||
|
|
||||||
|
import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft'
|
||||||
|
|
||||||
const validStripeKey = env.STRIPE_SECRET_KEY
|
const validStripeKey = env.STRIPE_SECRET_KEY
|
||||||
|
|
||||||
let stripeClient = null
|
let stripeClient = null
|
||||||
@@ -187,6 +189,10 @@ export const auth = betterAuth({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshTokenExpiresAt = isMicrosoftProvider(account.providerId)
|
||||||
|
? getMicrosoftRefreshTokenExpiry()
|
||||||
|
: account.refreshTokenExpiresAt
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(schema.account)
|
.update(schema.account)
|
||||||
.set({
|
.set({
|
||||||
@@ -195,7 +201,7 @@ export const auth = betterAuth({
|
|||||||
refreshToken: account.refreshToken,
|
refreshToken: account.refreshToken,
|
||||||
idToken: account.idToken,
|
idToken: account.idToken,
|
||||||
accessTokenExpiresAt: account.accessTokenExpiresAt,
|
accessTokenExpiresAt: account.accessTokenExpiresAt,
|
||||||
refreshTokenExpiresAt: account.refreshTokenExpiresAt,
|
refreshTokenExpiresAt,
|
||||||
scope: scopeToStore,
|
scope: scopeToStore,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
@@ -292,6 +298,13 @@ export const auth = betterAuth({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMicrosoftProvider(account.providerId)) {
|
||||||
|
await db
|
||||||
|
.update(schema.account)
|
||||||
|
.set({ refreshTokenExpiresAt: getMicrosoftRefreshTokenExpiry() })
|
||||||
|
.where(eq(schema.account.id, account.id))
|
||||||
|
}
|
||||||
|
|
||||||
// Sync webhooks for credential sets after connecting a new credential
|
// Sync webhooks for credential sets after connecting a new credential
|
||||||
const requestId = crypto.randomUUID().slice(0, 8)
|
const requestId = crypto.randomUUID().slice(0, 8)
|
||||||
const userMemberships = await db
|
const userMemberships = await db
|
||||||
@@ -387,7 +400,6 @@ export const auth = betterAuth({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
allowDifferentEmails: true,
|
allowDifferentEmails: true,
|
||||||
trustedProviders: [
|
trustedProviders: [
|
||||||
// Standard OAuth providers
|
|
||||||
'google',
|
'google',
|
||||||
'github',
|
'github',
|
||||||
'email-password',
|
'email-password',
|
||||||
@@ -403,8 +415,32 @@ export const auth = betterAuth({
|
|||||||
'hubspot',
|
'hubspot',
|
||||||
'linkedin',
|
'linkedin',
|
||||||
'spotify',
|
'spotify',
|
||||||
|
'google-email',
|
||||||
// Common SSO provider patterns
|
'google-calendar',
|
||||||
|
'google-drive',
|
||||||
|
'google-docs',
|
||||||
|
'google-sheets',
|
||||||
|
'google-forms',
|
||||||
|
'google-vault',
|
||||||
|
'google-groups',
|
||||||
|
'vertex-ai',
|
||||||
|
'github-repo',
|
||||||
|
'microsoft-teams',
|
||||||
|
'microsoft-excel',
|
||||||
|
'microsoft-planner',
|
||||||
|
'outlook',
|
||||||
|
'onedrive',
|
||||||
|
'sharepoint',
|
||||||
|
'jira',
|
||||||
|
'airtable',
|
||||||
|
'dropbox',
|
||||||
|
'salesforce',
|
||||||
|
'wealthbox',
|
||||||
|
'zoom',
|
||||||
|
'wordpress',
|
||||||
|
'linear',
|
||||||
|
'shopify',
|
||||||
|
'trello',
|
||||||
...SSO_TRUSTED_PROVIDERS,
|
...SSO_TRUSTED_PROVIDERS,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
type GetBlockUpstreamReferencesResultType,
|
type GetBlockUpstreamReferencesResultType,
|
||||||
} from '@/lib/copilot/tools/shared/schemas'
|
} from '@/lib/copilot/tools/shared/schemas'
|
||||||
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
|
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
|
||||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
|
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||||
@@ -141,7 +141,7 @@ export class GetBlockUpstreamReferencesClientTool extends BaseClientTool {
|
|||||||
const accessibleIds = new Set<string>(ancestorIds)
|
const accessibleIds = new Set<string>(ancestorIds)
|
||||||
accessibleIds.add(blockId)
|
accessibleIds.add(blockId)
|
||||||
|
|
||||||
const starterBlock = Object.values(blocks).find((b) => isValidStartBlockType(b.type))
|
const starterBlock = Object.values(blocks).find((b) => isInputDefinitionTrigger(b.type))
|
||||||
if (starterBlock && ancestorIds.includes(starterBlock.id)) {
|
if (starterBlock && ancestorIds.includes(starterBlock.id)) {
|
||||||
accessibleIds.add(starterBlock.id)
|
accessibleIds.add(starterBlock.id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type BaseClientToolMetadata,
|
type BaseClientToolMetadata,
|
||||||
ClientToolCallState,
|
ClientToolCallState,
|
||||||
} from '@/lib/copilot/tools/client/base-tool'
|
} from '@/lib/copilot/tools/client/base-tool'
|
||||||
import { useCustomToolsStore } from '@/stores/custom-tools'
|
import { getCustomTool } from '@/hooks/queries/custom-tools'
|
||||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
|
||||||
@@ -83,17 +83,15 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
|||||||
getDynamicText: (params, state) => {
|
getDynamicText: (params, state) => {
|
||||||
const operation = params?.operation as 'add' | 'edit' | 'delete' | 'list' | undefined
|
const operation = params?.operation as 'add' | 'edit' | 'delete' | 'list' | undefined
|
||||||
|
|
||||||
// Return undefined if no operation yet - use static defaults
|
|
||||||
if (!operation) return undefined
|
if (!operation) return undefined
|
||||||
|
|
||||||
// Get tool name from schema, or look it up from the store by toolId
|
|
||||||
let toolName = params?.schema?.function?.name
|
let toolName = params?.schema?.function?.name
|
||||||
if (!toolName && params?.toolId) {
|
if (!toolName && params?.toolId) {
|
||||||
try {
|
try {
|
||||||
const tool = useCustomToolsStore.getState().getTool(params.toolId)
|
const tool = getCustomTool(params.toolId)
|
||||||
toolName = tool?.schema?.function?.name
|
toolName = tool?.schema?.function?.name
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore errors accessing store
|
// Ignore errors accessing cache
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +166,6 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
|||||||
* Add operations execute directly without confirmation.
|
* Add operations execute directly without confirmation.
|
||||||
*/
|
*/
|
||||||
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
|
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
|
||||||
// Try currentArgs first, then fall back to store (for when called before execute())
|
|
||||||
const args = this.currentArgs || this.getArgsFromStore()
|
const args = this.currentArgs || this.getArgsFromStore()
|
||||||
const operation = args?.operation
|
const operation = args?.operation
|
||||||
if (operation === 'edit' || operation === 'delete') {
|
if (operation === 'edit' || operation === 'delete') {
|
||||||
@@ -199,12 +196,9 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
|||||||
|
|
||||||
async execute(args?: ManageCustomToolArgs): Promise<void> {
|
async execute(args?: ManageCustomToolArgs): Promise<void> {
|
||||||
this.currentArgs = args
|
this.currentArgs = args
|
||||||
// For add and list operations, execute directly without confirmation
|
|
||||||
// For edit/delete, the copilot store will check hasInterrupt() and wait for confirmation
|
|
||||||
if (args?.operation === 'add' || args?.operation === 'list') {
|
if (args?.operation === 'add' || args?.operation === 'list') {
|
||||||
await this.handleAccept(args)
|
await this.handleAccept(args)
|
||||||
}
|
}
|
||||||
// edit/delete will wait for user confirmation via handleAccept
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -222,7 +216,6 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
|||||||
|
|
||||||
const { operation, toolId, schema, code } = args
|
const { operation, toolId, schema, code } = args
|
||||||
|
|
||||||
// Get workspace ID from the workflow registry
|
|
||||||
const { hydration } = useWorkflowRegistry.getState()
|
const { hydration } = useWorkflowRegistry.getState()
|
||||||
const workspaceId = hydration.workspaceId
|
const workspaceId = hydration.workspaceId
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
@@ -247,7 +240,6 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
|||||||
await this.deleteCustomTool({ toolId, workspaceId }, logger)
|
await this.deleteCustomTool({ toolId, workspaceId }, logger)
|
||||||
break
|
break
|
||||||
case 'list':
|
case 'list':
|
||||||
// List operation is read-only, just mark as complete
|
|
||||||
await this.markToolComplete(200, 'Listed custom tools')
|
await this.markToolComplete(200, 'Listed custom tools')
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
@@ -326,13 +318,10 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
|||||||
throw new Error('Tool ID is required for editing a custom tool')
|
throw new Error('Tool ID is required for editing a custom tool')
|
||||||
}
|
}
|
||||||
|
|
||||||
// At least one of schema or code must be provided
|
|
||||||
if (!schema && !code) {
|
if (!schema && !code) {
|
||||||
throw new Error('At least one of schema or code must be provided for editing')
|
throw new Error('At least one of schema or code must be provided for editing')
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to send the full tool data to the API for updates
|
|
||||||
// First, fetch the existing tool to merge with updates
|
|
||||||
const existingResponse = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`)
|
const existingResponse = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`)
|
||||||
const existingData = await existingResponse.json()
|
const existingData = await existingResponse.json()
|
||||||
|
|
||||||
@@ -345,7 +334,6 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
|||||||
throw new Error(`Tool with ID ${toolId} not found`)
|
throw new Error(`Tool with ID ${toolId} not found`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge updates with existing tool - use function name as title
|
|
||||||
const mergedSchema = schema ?? existingTool.schema
|
const mergedSchema = schema ?? existingTool.schema
|
||||||
const updatedTool = {
|
const updatedTool = {
|
||||||
id: toolId,
|
id: toolId,
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ function resolveSubBlockOptions(sb: SubBlockConfig): string[] | undefined {
|
|||||||
interface OutputFieldSchema {
|
interface OutputFieldSchema {
|
||||||
type: string
|
type: string
|
||||||
description?: string
|
description?: string
|
||||||
|
properties?: Record<string, OutputFieldSchema>
|
||||||
|
items?: { type: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -256,6 +258,42 @@ function mapSubBlockTypeToSchemaType(type: string): string {
|
|||||||
return typeMap[type] || 'string'
|
return typeMap[type] || 'string'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a single output field schema, including nested properties
|
||||||
|
*/
|
||||||
|
function extractOutputField(def: any): OutputFieldSchema {
|
||||||
|
if (typeof def === 'string') {
|
||||||
|
return { type: def }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof def !== 'object' || def === null) {
|
||||||
|
return { type: 'any' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const field: OutputFieldSchema = {
|
||||||
|
type: def.type || 'any',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.description) {
|
||||||
|
field.description = def.description
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include nested properties if present
|
||||||
|
if (def.properties && typeof def.properties === 'object') {
|
||||||
|
field.properties = {}
|
||||||
|
for (const [propKey, propDef] of Object.entries(def.properties)) {
|
||||||
|
field.properties[propKey] = extractOutputField(propDef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include items schema for arrays
|
||||||
|
if (def.items && typeof def.items === 'object') {
|
||||||
|
field.items = { type: def.items.type || 'any' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts trigger outputs from the first available trigger
|
* Extracts trigger outputs from the first available trigger
|
||||||
*/
|
*/
|
||||||
@@ -272,15 +310,7 @@ function extractTriggerOutputs(blockConfig: any): Record<string, OutputFieldSche
|
|||||||
const trigger = getTrigger(triggerId)
|
const trigger = getTrigger(triggerId)
|
||||||
if (trigger.outputs) {
|
if (trigger.outputs) {
|
||||||
for (const [key, def] of Object.entries(trigger.outputs)) {
|
for (const [key, def] of Object.entries(trigger.outputs)) {
|
||||||
if (typeof def === 'string') {
|
outputs[key] = extractOutputField(def)
|
||||||
outputs[key] = { type: def }
|
|
||||||
} else if (typeof def === 'object' && def !== null) {
|
|
||||||
const typedDef = def as { type?: string; description?: string }
|
|
||||||
outputs[key] = {
|
|
||||||
type: typedDef.type || 'any',
|
|
||||||
description: typedDef.description,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,11 +342,7 @@ function extractOutputs(
|
|||||||
const tool = toolsRegistry[toolId]
|
const tool = toolsRegistry[toolId]
|
||||||
if (tool?.outputs) {
|
if (tool?.outputs) {
|
||||||
for (const [key, def] of Object.entries(tool.outputs)) {
|
for (const [key, def] of Object.entries(tool.outputs)) {
|
||||||
const typedDef = def as { type: string; description?: string }
|
outputs[key] = extractOutputField(def)
|
||||||
outputs[key] = {
|
|
||||||
type: typedDef.type || 'any',
|
|
||||||
description: typedDef.description,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return outputs
|
return outputs
|
||||||
}
|
}
|
||||||
@@ -329,15 +355,7 @@ function extractOutputs(
|
|||||||
// Use block-level outputs
|
// Use block-level outputs
|
||||||
if (blockConfig.outputs) {
|
if (blockConfig.outputs) {
|
||||||
for (const [key, def] of Object.entries(blockConfig.outputs)) {
|
for (const [key, def] of Object.entries(blockConfig.outputs)) {
|
||||||
if (typeof def === 'string') {
|
outputs[key] = extractOutputField(def)
|
||||||
outputs[key] = { type: def }
|
|
||||||
} else if (typeof def === 'object' && def !== null) {
|
|
||||||
const typedDef = def as { type?: string; description?: string }
|
|
||||||
outputs[key] = {
|
|
||||||
type: typedDef.type || 'any',
|
|
||||||
description: typedDef.description,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2468,16 +2468,17 @@ async function validateWorkflowSelectorIds(
|
|||||||
const result = await validateSelectorIds(selector.selectorType, selector.value, context)
|
const result = await validateSelectorIds(selector.selectorType, selector.value, context)
|
||||||
|
|
||||||
if (result.invalid.length > 0) {
|
if (result.invalid.length > 0) {
|
||||||
|
// Include warning info (like available credentials) in the error message for better LLM feedback
|
||||||
|
const warningInfo = result.warning ? `. ${result.warning}` : ''
|
||||||
errors.push({
|
errors.push({
|
||||||
blockId: selector.blockId,
|
blockId: selector.blockId,
|
||||||
blockType: selector.blockType,
|
blockType: selector.blockType,
|
||||||
field: selector.fieldName,
|
field: selector.fieldName,
|
||||||
value: selector.value,
|
value: selector.value,
|
||||||
error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} - ID(s) do not exist`,
|
error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} - ID(s) do not exist or user doesn't have access${warningInfo}`,
|
||||||
})
|
})
|
||||||
}
|
} else if (result.warning) {
|
||||||
|
// Log warnings that don't have errors (shouldn't happen for credentials but may for other selectors)
|
||||||
if (result.warning) {
|
|
||||||
logger.warn(result.warning, {
|
logger.warn(result.warning, {
|
||||||
blockId: selector.blockId,
|
blockId: selector.blockId,
|
||||||
fieldName: selector.fieldName,
|
fieldName: selector.fieldName,
|
||||||
|
|||||||
@@ -39,6 +39,31 @@ export async function validateSelectorIds(
|
|||||||
.from(account)
|
.from(account)
|
||||||
.where(and(inArray(account.id, idsArray), eq(account.userId, context.userId)))
|
.where(and(inArray(account.id, idsArray), eq(account.userId, context.userId)))
|
||||||
existingIds = results.map((r) => r.id)
|
existingIds = results.map((r) => r.id)
|
||||||
|
|
||||||
|
// If any IDs are invalid, fetch user's available credentials to include in error message
|
||||||
|
const existingSet = new Set(existingIds)
|
||||||
|
const invalidIds = idsArray.filter((id) => !existingSet.has(id))
|
||||||
|
if (invalidIds.length > 0) {
|
||||||
|
// Fetch all of the user's credentials to provide helpful feedback
|
||||||
|
const allUserCredentials = await db
|
||||||
|
.select({ id: account.id, providerId: account.providerId })
|
||||||
|
.from(account)
|
||||||
|
.where(eq(account.userId, context.userId))
|
||||||
|
|
||||||
|
const availableCredentials = allUserCredentials
|
||||||
|
.map((c) => `${c.id} (${c.providerId})`)
|
||||||
|
.join(', ')
|
||||||
|
const noCredentialsMessage = 'User has no credentials configured.'
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: existingIds,
|
||||||
|
invalid: invalidIds,
|
||||||
|
warning:
|
||||||
|
allUserCredentials.length > 0
|
||||||
|
? `Available credentials for this user: ${availableCredentials}`
|
||||||
|
: noCredentialsMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import dns from 'dns/promises'
|
import dns from 'dns/promises'
|
||||||
import http from 'http'
|
import http from 'http'
|
||||||
import https from 'https'
|
import https from 'https'
|
||||||
|
import type { LookupFunction } from 'net'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import * as ipaddr from 'ipaddr.js'
|
import * as ipaddr from 'ipaddr.js'
|
||||||
|
|
||||||
@@ -907,26 +908,28 @@ export async function secureFetchWithPinnedIP(
|
|||||||
const isIPv6 = resolvedIP.includes(':')
|
const isIPv6 = resolvedIP.includes(':')
|
||||||
const family = isIPv6 ? 6 : 4
|
const family = isIPv6 ? 6 : 4
|
||||||
|
|
||||||
const agentOptions = {
|
const lookup: LookupFunction = (_hostname, options, callback) => {
|
||||||
lookup: (
|
if (options.all) {
|
||||||
_hostname: string,
|
callback(null, [{ address: resolvedIP, family }])
|
||||||
_options: unknown,
|
} else {
|
||||||
callback: (err: NodeJS.ErrnoException | null, address: string, family: number) => void
|
|
||||||
) => {
|
|
||||||
callback(null, resolvedIP, family)
|
callback(null, resolvedIP, family)
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const agent = isHttps
|
const agentOptions: http.AgentOptions = { lookup }
|
||||||
? new https.Agent(agentOptions as https.AgentOptions)
|
|
||||||
: new http.Agent(agentOptions as http.AgentOptions)
|
const agent = isHttps ? new https.Agent(agentOptions) : new http.Agent(agentOptions)
|
||||||
|
|
||||||
|
// Remove accept-encoding since Node.js http/https doesn't auto-decompress
|
||||||
|
// Headers are lowercase due to Web Headers API normalization in executeToolRequest
|
||||||
|
const { 'accept-encoding': _, ...sanitizedHeaders } = options.headers ?? {}
|
||||||
|
|
||||||
const requestOptions: http.RequestOptions = {
|
const requestOptions: http.RequestOptions = {
|
||||||
hostname: parsed.hostname,
|
hostname: parsed.hostname,
|
||||||
port,
|
port,
|
||||||
path: parsed.pathname + parsed.search,
|
path: parsed.pathname + parsed.search,
|
||||||
method: options.method || 'GET',
|
method: options.method || 'GET',
|
||||||
headers: options.headers || {},
|
headers: sanitizedHeaders,
|
||||||
agent,
|
agent,
|
||||||
timeout: options.timeout || 30000,
|
timeout: options.timeout || 30000,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,11 @@ async function executeCode(request) {
|
|||||||
await jail.set('environmentVariables', new ivm.ExternalCopy(envVars).copyInto())
|
await jail.set('environmentVariables', new ivm.ExternalCopy(envVars).copyInto())
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(contextVariables)) {
|
for (const [key, value] of Object.entries(contextVariables)) {
|
||||||
await jail.set(key, new ivm.ExternalCopy(value).copyInto())
|
if (value === undefined) {
|
||||||
|
await jail.set(key, undefined)
|
||||||
|
} else {
|
||||||
|
await jail.set(key, new ivm.ExternalCopy(value).copyInto())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchCallback = new ivm.Reference(async (url, optionsJson) => {
|
const fetchCallback = new ivm.Reference(async (url, optionsJson) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||||
import type { InputFormatField } from '@/lib/workflows/types'
|
import type { InputFormatField } from '@/lib/workflows/types'
|
||||||
import type { McpToolSchema } from './types'
|
import type { McpToolSchema } from './types'
|
||||||
|
|
||||||
@@ -217,7 +217,7 @@ export function extractInputFormatFromBlocks(
|
|||||||
const blockObj = block as Record<string, unknown>
|
const blockObj = block as Record<string, unknown>
|
||||||
const blockType = blockObj.type as string
|
const blockType = blockObj.type as string
|
||||||
|
|
||||||
if (isValidStartBlockType(blockType)) {
|
if (isInputDefinitionTrigger(blockType)) {
|
||||||
// Try to get inputFormat from subBlocks.inputFormat.value
|
// Try to get inputFormat from subBlocks.inputFormat.value
|
||||||
const subBlocks = blockObj.subBlocks as Record<string, { value?: unknown }> | undefined
|
const subBlocks = blockObj.subBlocks as Record<string, { value?: unknown }> | undefined
|
||||||
const subBlockValue = subBlocks?.inputFormat?.value
|
const subBlockValue = subBlocks?.inputFormat?.value
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './microsoft'
|
||||||
export * from './oauth'
|
export * from './oauth'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
export * from './utils'
|
export * from './utils'
|
||||||
|
|||||||
19
apps/sim/lib/oauth/microsoft.ts
Normal file
19
apps/sim/lib/oauth/microsoft.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export const MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS = 90
|
||||||
|
export const PROACTIVE_REFRESH_THRESHOLD_DAYS = 7
|
||||||
|
|
||||||
|
export const MICROSOFT_PROVIDERS = new Set([
|
||||||
|
'microsoft-excel',
|
||||||
|
'microsoft-planner',
|
||||||
|
'microsoft-teams',
|
||||||
|
'outlook',
|
||||||
|
'onedrive',
|
||||||
|
'sharepoint',
|
||||||
|
])
|
||||||
|
|
||||||
|
export function isMicrosoftProvider(providerId: string): boolean {
|
||||||
|
return MICROSOFT_PROVIDERS.has(providerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMicrosoftRefreshTokenExpiry(): Date {
|
||||||
|
return new Date(Date.now() + MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS * 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
@@ -835,6 +835,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
|
|||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
useBasicAuth: true,
|
useBasicAuth: true,
|
||||||
|
supportsRefreshTokenRotation: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'confluence': {
|
case 'confluence': {
|
||||||
@@ -883,6 +884,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
|
|||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
useBasicAuth: false,
|
useBasicAuth: false,
|
||||||
|
supportsRefreshTokenRotation: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'microsoft':
|
case 'microsoft':
|
||||||
@@ -910,6 +912,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
|
|||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
useBasicAuth: true,
|
useBasicAuth: true,
|
||||||
|
supportsRefreshTokenRotation: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'dropbox': {
|
case 'dropbox': {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user