Compare commits

...

31 Commits

Author SHA1 Message Date
Waleed
e9c4251c1c v0.5.68: router block reasoning, executor improvements, variable resolution consolidation, helm updates (#2946)
* improvement(workflow-item): stabilize avatar layout and fix name truncation (#2939)

* improvement(workflow-item): stabilize avatar layout and fix name truncation

* fix(avatars): revert overflow bg to hardcoded color for contrast

* fix(executor): stop parallel execution when block errors (#2940)

* improvement(helm): add per-deployment extraVolumes support (#2942)

* fix(gmail): expose messageId field in read email block (#2943)

* fix(resolver): consolidate reference resolution  (#2941)

* fix(resolver): consolidate code to resolve references

* fix edge cases

* use already formatted error

* fix multi index

* fix backwards compat reachability

* handle backwards compatibility accurately

* use shared constant correctly

* feat(router): expose reasoning output in router v2 block (#2945)

* fix(copilot): always allow, credential masking (#2947)

* Fix always allow, credential validation

* Credential masking

* Autoload

* fix(executor): handle condition dead-end branches in loops (#2944)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
2026-01-22 13:48:15 -08:00
Waleed
748793e07d fix(executor): handle condition dead-end branches in loops (#2944) 2026-01-22 13:30:11 -08:00
Siddharth Ganesan
91da7e183a fix(copilot): always allow, credential masking (#2947)
* Fix always allow, credential validation

* Credential masking

* Autoload
2026-01-22 13:07:16 -08:00
Waleed
ab09a5ad23 feat(router): expose reasoning output in router v2 block (#2945) 2026-01-22 12:43:57 -08:00
Vikhyath Mondreti
fcd0240db6 fix(resolver): consolidate reference resolution (#2941)
* fix(resolver): consolidate code to resolve references

* fix edge cases

* use already formatted error

* fix multi index

* fix backwards compat reachability

* handle backwards compatibility accurately

* use shared constant correctly
2026-01-22 12:38:50 -08:00
Waleed
4e4149792a fix(gmail): expose messageId field in read email block (#2943) 2026-01-22 11:46:34 -08:00
Waleed
9a8b591257 improvement(helm): add per-deployment extraVolumes support (#2942) 2026-01-22 11:35:23 -08:00
Waleed
f3ae3f8442 fix(executor): stop parallel execution when block errors (#2940) 2026-01-22 11:34:40 -08:00
Waleed
66dfe2c6b2 improvement(workflow-item): stabilize avatar layout and fix name truncation (#2939)
* improvement(workflow-item): stabilize avatar layout and fix name truncation

* fix(avatars): revert overflow bg to hardcoded color for contrast
2026-01-22 11:26:47 -08:00
Waleed
cc2be33d6b v0.5.67: loading, password reset, ui improvements, helm updates (#2928)
* fix(zustand): updated to useShallow from deprecated createWithEqualityFn (#2919)

* fix(logger): use direct env access for webpack inlining (#2920)

* fix(notifications): text overflow with line-clamp (#2921)

* chore(helm): add env vars for Vertex AI, orgs, and telemetry (#2922)

* fix(auth): improve reset password flow and consolidate brand detection (#2924)

* fix(auth): improve reset password flow and consolidate brand detection

* fix(auth): set errorHandled for EMAIL_NOT_VERIFIED to prevent duplicate error

* fix(auth): clear success message on login errors

* chore(auth): fix import order per lint

* fix(action-bar): duplicate subflows with children (#2923)

* fix(action-bar): duplicate subflows with children

* fix(action-bar): add validateTriggerPaste for subflow duplicate

* fix(resolver): agent response format, input formats, root level (#2925)

* fix(resolvers): agent response format, input formats, root level

* fix response block initial seeding

* fix tests

* fix(messages-input): fix cursor alignment and auto-resize with overlay (#2926)

* fix(messages-input): fix cursor alignment and auto-resize with overlay

* fixed remaining zustand warnings

* fix(stores): remove dead code causing log spam on startup (#2927)

* fix(stores): remove dead code causing log spam on startup

* fix(stores): replace custom tools zustand store with react query cache

* improvement(ui): use BrandedButton and BrandedLink components (#2930)

- Refactor auth forms to use BrandedButton component
- Add BrandedLink component for changelog page
- Reduce code duplication in login, signup, reset-password forms
- Update star count default value

* fix(custom-tools): remove unsafe title fallback in getCustomTool (#2929)

* fix(custom-tools): remove unsafe title fallback in getCustomTool

* fix(custom-tools): restore title fallback in getCustomTool lookup

Custom tools are referenced by title (custom_${title}), not database ID.
The title fallback is required for client-side tool resolution to work.

* fix(null-bodies): empty bodies handling (#2931)

* fix(null-statuses): empty bodies handling

* address bugbot comment

* fix(token-refresh): microsoft, notion, x, linear (#2933)

* fix(microsoft): proactive refresh needed

* fix(x): missing token refresh flag

* notion and linear missing flag too

* address bugbot comment

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback (#2932)

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback

* refactor(auth): extract redirectToVerify helper to reduce duplication

* fix(workflow-selector): use dedicated selector for workflow dropdown (#2934)

* feat(workflow-block): preview (#2935)

* improvement(copilot): tool configs to show nested props (#2936)

* fix(auth): add genericOAuth providers to trustedProviders (#2937)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-21 22:53:25 -08:00
Waleed
376f7cb571 fix(auth): add genericOAuth providers to trustedProviders (#2937) 2026-01-21 22:44:30 -08:00
Vikhyath Mondreti
42159c23b9 improvement(copilot): tool configs to show nested props (#2936) 2026-01-21 20:02:59 -08:00
Emir Karabeg
2f0f246002 feat(workflow-block): preview (#2935) 2026-01-21 19:12:28 -08:00
Waleed
900d3ef9ea fix(workflow-selector): use dedicated selector for workflow dropdown (#2934) 2026-01-21 18:38:03 -08:00
Waleed
f3fcc28f89 fix(auth): handle EMAIL_NOT_VERIFIED in onError callback (#2932)
* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback

* refactor(auth): extract redirectToVerify helper to reduce duplication
2026-01-21 18:34:49 -08:00
Vikhyath Mondreti
7cfdf46724 fix(token-refresh): microsoft, notion, x, linear (#2933)
* fix(microsoft): proactive refresh needed

* fix(x): missing token refresh flag

* notion and linear missing flag too

* address bugbot comment
2026-01-21 18:30:53 -08:00
Vikhyath Mondreti
d681451297 fix(null-bodies): empty bodies handling (#2931)
* fix(null-statuses): empty bodies handling

* address bugbot comment
2026-01-21 18:10:33 -08:00
Waleed
5987a6d060 fix(custom-tools): remove unsafe title fallback in getCustomTool (#2929)
* fix(custom-tools): remove unsafe title fallback in getCustomTool

* fix(custom-tools): restore title fallback in getCustomTool lookup

Custom tools are referenced by title (custom_${title}), not database ID.
The title fallback is required for client-side tool resolution to work.
2026-01-21 17:36:10 -08:00
Waleed
e2ccefb2f4 improvement(ui): use BrandedButton and BrandedLink components (#2930)
- Refactor auth forms to use BrandedButton component
- Add BrandedLink component for changelog page
- Reduce code duplication in login, signup, reset-password forms
- Update star count default value
2026-01-21 17:25:30 -08:00
Waleed
103b31a569 fix(stores): remove dead code causing log spam on startup (#2927)
* fix(stores): remove dead code causing log spam on startup

* fix(stores): replace custom tools zustand store with react query cache
2026-01-21 16:08:26 -08:00
Waleed
004e058353 fix(messages-input): fix cursor alignment and auto-resize with overlay (#2926)
* fix(messages-input): fix cursor alignment and auto-resize with overlay

* fixed remaining zustand warnings
2026-01-21 15:30:13 -08:00
Vikhyath Mondreti
5157f0bbb2 fix(resolver): agent response format, input formats, root level (#2925)
* fix(resolvers): agent response format, input formats, root level

* fix response block initial seeding

* fix tests
2026-01-21 14:55:23 -08:00
Waleed
8bbcf31b83 fix(action-bar): duplicate subflows with children (#2923)
* fix(action-bar): duplicate subflows with children

* fix(action-bar): add validateTriggerPaste for subflow duplicate
2026-01-21 14:54:29 -08:00
Waleed
9e814315dd fix(auth): improve reset password flow and consolidate brand detection (#2924)
* fix(auth): improve reset password flow and consolidate brand detection

* fix(auth): set errorHandled for EMAIL_NOT_VERIFIED to prevent duplicate error

* fix(auth): clear success message on login errors

* chore(auth): fix import order per lint
2026-01-21 14:42:14 -08:00
Waleed
0ea0256623 chore(helm): add env vars for Vertex AI, orgs, and telemetry (#2922) 2026-01-21 11:36:16 -08:00
Waleed
fb8868c854 fix(notifications): text overflow with line-clamp (#2921) 2026-01-21 10:20:21 -08:00
Waleed
ea4964052d fix(logger): use direct env access for webpack inlining (#2920) 2026-01-21 10:14:40 -08:00
Waleed
268e2f114f fix(zustand): updated to useShallow from deprecated createWithEqualityFn (#2919) 2026-01-21 09:47:48 -08:00
Vikhyath Mondreti
45371e521e v0.5.66: external http requests fix, ring highlighting 2026-01-21 02:55:39 -08:00
Vikhyath Mondreti
5988d0e46f fix(ring): duplicate should clear original block (#2916)
* fix(ring): duplicate should clear original block

* rename correctly
2026-01-21 02:40:58 -08:00
Vikhyath Mondreti
145db9d8c3 fix(http): options not parsed accurately (#2914)
* fix(http): options not parsed accurately

* fix lint

* remove boilerplate code'
2026-01-21 01:36:29 -08:00
129 changed files with 4499 additions and 1872 deletions

View File

@@ -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>

View File

@@ -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>
) )
} }

View File

@@ -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>
)} )}

View File

@@ -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>) => {

View File

@@ -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 (
<> <>

View File

@@ -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>

View File

@@ -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}
> >

View File

@@ -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
} }

View File

@@ -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) {

View File

@@ -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 []

View File

@@ -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',
},
},
},
}, },
}) })

View File

@@ -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()

View 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>
)
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)]' />

View File

@@ -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}

View File

@@ -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'
/> />
) : ( ) : (

View File

@@ -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

View File

@@ -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={() => {

View File

@@ -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

View File

@@ -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 (

View File

@@ -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'>

View File

@@ -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)

View File

@@ -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')

View File

@@ -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
} }
} }

View File

@@ -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}

View File

@@ -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
} }
} }

View File

@@ -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>
) )

View File

@@ -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')

View File

@@ -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 []

View File

@@ -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')

View File

@@ -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 []

View File

@@ -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>

View File

@@ -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'

View File

@@ -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(() => {

View File

@@ -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()

View File

@@ -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'

View File

@@ -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 {

View File

@@ -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...'}
/>
)
}

View File

@@ -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

View File

@@ -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
) )

View File

@@ -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

View File

@@ -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)]'>

View File

@@ -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>

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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)
} }

View File

@@ -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]

View File

@@ -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 {

View File

@@ -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>
) )
} }

View File

@@ -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

View File

@@ -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'

View File

@@ -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>
) )

View File

@@ -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>
) )

View File

@@ -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)]'

View File

@@ -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
{ {

View File

@@ -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' },
}, },
} }

View File

@@ -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,
}, },

View File

@@ -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.",

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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()
} }
} }

View File

@@ -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 })
} }
} }

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)

View File

@@ -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('')
})
})

View File

@@ -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 {

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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 }
} }

View 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')
})
})

View 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 }
}

View File

@@ -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
}

View File

@@ -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([

View File

@@ -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
}
} }

View File

@@ -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]
} }
} }

View File

@@ -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
} }
/** /**

View File

@@ -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,
}) })
} }

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,
], ],
}, },

View File

@@ -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)
} }

View File

@@ -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,

View File

@@ -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,
}
}
} }
} }

View File

@@ -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,

View File

@@ -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
} }

View File

@@ -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,
} }

View File

@@ -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) => {

View File

@@ -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

View File

@@ -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'

View 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)
}

View File

@@ -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