Compare commits

..

32 Commits

Author SHA1 Message Date
Waleed
0ce0f98aa5 v0.5.65: gemini updates, textract integration, ui updates (#2909)
* fix(google): wrap primitive tool responses for Gemini API compatibility (#2900)

* fix(canonical): copilot path + update parent (#2901)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output (#2902)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output

* fix(imap): add top-level fields to IMAP trigger output

* improvement(browseruse): add profile id param (#2903)

* improvement(browseruse): add profile id param

* make request a stub since we have directExec

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels (#2880)

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels

* comments

* improvement(files): update execution for passing base64 strings (#2906)

* progress

* improvement(execution): update execution for passing base64 strings

* fix types

* cleanup comments

* path security vuln

* reject promise correctly

* fix redirect case

* remove proxy routes

* fix tests

* use ipaddr

* feat(tools): added textract, added v2 for mistral, updated tag dropdown (#2904)

* feat(tools): added textract

* cleanup

* ack pr comments

* reorder

* removed upload for textract async version

* fix additional fields dropdown in editor, update parser to leave validation to be done on the server

* added mistral v2, files v2, and finalized textract

* updated the rest of the old file patterns, updated mistral outputs for v2

* updated tag dropdown to parse non-operation fields as well

* updated extension finder

* cleanup

* added description for inputs to workflow

* use helper for internal route check

* fix tag dropdown merge conflict change

* remove duplicate code

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(ui): change add inputs button to match output selector (#2907)

* fix(canvas): removed invite to workspace from canvas popover (#2908)

* fix(canvas): removed invite to workspace

* removed unused props

* fix(copilot): legacy tool display names (#2911)

* fix(a2a): canonical merge  (#2912)

* fix canonical merge

* fix empty array case

* fix(change-detection): copilot diffs have extra field (#2913)

* improvement(logs): improved logs ui bugs, added subflow disable UI (#2910)

* improvement(logs): improved logs ui bugs, added subflow disable UI

* added duplicate to action bar for subflows

* feat(broadcast): email v0.5 (#2905)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-20 23:54:55 -08:00
Waleed
dff1c9d083 v0.5.64: unsubscribe, search improvements, metrics, additional SSO configuration 2026-01-20 00:34:11 -08:00
Vikhyath Mondreti
b09f683072 v0.5.63: ui and performance improvements, more google tools 2026-01-18 15:22:42 -08:00
Vikhyath Mondreti
a8bb0db660 v0.5.62: webhook bug fixes, seeding default subblock values, block selection fixes 2026-01-16 20:27:06 -08:00
Waleed
af82820a28 v0.5.61: webhook improvements, workflow controls, react query for deployment status, chat fixes, reducto and pulse OCR, linear fixes 2026-01-16 18:06:23 -08:00
Waleed
4372841797 v0.5.60: invitation flow improvements, chat fixes, a2a improvements, additional copilot actions 2026-01-15 00:02:18 -08:00
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
308 changed files with 5092 additions and 19880 deletions

View File

@@ -2,9 +2,10 @@
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
@@ -21,10 +22,8 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { inter } from '@/app/_styles/fonts/inter/inter'
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 { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('LoginForm')
@@ -106,7 +105,8 @@ export default function LoginPage({
const [password, setPassword] = useState('')
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
const buttonClass = useBrandedButtonClass()
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [isButtonHovered, setIsButtonHovered] = useState(false)
const [callbackUrl, setCallbackUrl] = useState('/workspace')
const [isInviteFlow, setIsInviteFlow] = useState(false)
@@ -114,6 +114,7 @@ export default function LoginPage({
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
const [isResetButtonHovered, setIsResetButtonHovered] = useState(false)
const [resetStatus, setResetStatus] = useState<{
type: 'success' | 'error' | null
message: string
@@ -122,7 +123,6 @@ export default function LoginPage({
const [email, setEmail] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [resetSuccessMessage, setResetSuccessMessage] = useState<string | null>(null)
useEffect(() => {
setMounted(true)
@@ -139,12 +139,32 @@ export default function LoginPage({
const inviteFlow = searchParams.get('invite_flow') === 'true'
setIsInviteFlow(inviteFlow)
}
const resetSuccess = searchParams.get('resetSuccess') === 'true'
if (resetSuccess) {
setResetSuccessMessage('Password reset successful. Please sign in with your new password.')
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])
useEffect(() => {
@@ -182,13 +202,6 @@ export default function LoginPage({
e.preventDefault()
setIsLoading(true)
const redirectToVerify = (emailToVerify: string) => {
if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', emailToVerify)
}
router.push('/verify')
}
const formData = new FormData(e.currentTarget)
const emailRaw = formData.get('email') as string
const email = emailRaw.trim().toLowerCase()
@@ -208,7 +221,6 @@ export default function LoginPage({
try {
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace'
let errorHandled = false
const result = await client.signIn.email(
{
@@ -219,16 +231,11 @@ export default function LoginPage({
{
onError: (ctx) => {
logger.error('Login error:', ctx.error)
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
errorHandled = true
redirectToVerify(email)
return
}
errorHandled = true
const errorMessage: string[] = ['Invalid email or password']
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
return
}
if (
ctx.error.code?.includes('BAD_REQUEST') ||
ctx.error.message?.includes('Email and password sign in is not enabled')
@@ -264,7 +271,6 @@ export default function LoginPage({
errorMessage.push('Too many requests. Please wait a moment before trying again.')
}
setResetSuccessMessage(null)
setPasswordErrors(errorMessage)
setShowValidationError(true)
},
@@ -272,25 +278,15 @@ export default function LoginPage({
)
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)
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) {
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
redirectToVerify(email)
if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', email)
}
router.push('/verify')
return
}
@@ -404,13 +400,6 @@ export default function LoginPage({
</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 */}
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
@@ -493,14 +482,24 @@ export default function LoginPage({
</div>
</div>
<BrandedButton
<Button
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}
loading={isLoading}
loadingText='Signing in'
>
Sign in
</BrandedButton>
<span className='flex items-center gap-1'>
{isLoading ? 'Signing in...' : 'Sign in'}
<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>
)}
@@ -611,15 +610,25 @@ export default function LoginPage({
<p>{resetStatus.message}</p>
</div>
)}
<BrandedButton
<Button
type='button'
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}
loading={isSubmittingReset}
loadingText='Sending'
>
Send Reset Link
</BrandedButton>
<span className='flex items-center gap-1'>
{isSubmittingReset ? 'Sending...' : 'Send Reset Link'}
<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>
</DialogContent>
</Dialog>

View File

@@ -1,12 +1,12 @@
'use client'
import { useState } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { useEffect, useState } from 'react'
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
interface RequestResetFormProps {
email: string
@@ -27,6 +27,36 @@ export function RequestResetForm({
statusMessage,
className,
}: 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) => {
e.preventDefault()
onSubmit(email)
@@ -64,14 +94,24 @@ export function RequestResetForm({
)}
</div>
<BrandedButton
<Button
type='submit'
disabled={isSubmitting}
loading={isSubmitting}
loadingText='Sending'
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'
>
Send Reset Link
</BrandedButton>
<span className='flex items-center gap-1'>
{isSubmitting ? 'Sending...' : 'Send Reset Link'}
<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>
)
}
@@ -98,6 +138,35 @@ export function SetNewPasswordForm({
const [validationMessage, setValidationMessage] = useState('')
const [showPassword, setShowPassword] = 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) => {
e.preventDefault()
@@ -227,14 +296,24 @@ export function SetNewPasswordForm({
)}
</div>
<BrandedButton
type='submit'
<Button
disabled={isSubmitting || !token}
loading={isSubmitting}
loadingText='Resetting'
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'
>
Reset Password
</BrandedButton>
<span className='flex items-center gap-1'>
{isSubmitting ? 'Resetting...' : 'Reset Password'}
<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>
)
}

View File

@@ -2,9 +2,10 @@
import { Suspense, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { client, useSession } from '@/lib/auth/auth-client'
@@ -13,10 +14,8 @@ import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { inter } from '@/app/_styles/fonts/inter/inter'
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 { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('SignupForm')
@@ -96,7 +95,8 @@ function SignupFormContent({
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [redirectUrl, setRedirectUrl] = useState('')
const [isInviteFlow, setIsInviteFlow] = useState(false)
const buttonClass = useBrandedButtonClass()
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [isButtonHovered, setIsButtonHovered] = useState(false)
const [name, setName] = useState('')
const [nameErrors, setNameErrors] = useState<string[]>([])
@@ -126,6 +126,31 @@ function SignupFormContent({
if (inviteFlowParam === '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])
const validatePassword = (passwordValue: string): string[] => {
@@ -475,14 +500,24 @@ function SignupFormContent({
</div>
</div>
<BrandedButton
<Button
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}
loading={isLoading}
loadingText='Creating account'
>
Create account
</BrandedButton>
<span className='flex items-center gap-1'>
{isLoading ? 'Creating account' : 'Create account'}
<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>
)}

View File

@@ -13,7 +13,6 @@ import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('SSOForm')
@@ -58,7 +57,7 @@ export default function SSOForm() {
const [email, setEmail] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const buttonClass = useBrandedButtonClass()
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [callbackUrl, setCallbackUrl] = useState('/workspace')
useEffect(() => {
@@ -91,6 +90,31 @@ export default function SSOForm() {
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])
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {

View File

@@ -8,7 +8,6 @@ import { cn } from '@/lib/core/utils/cn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { useVerification } from '@/app/(auth)/verify/use-verification'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
interface VerifyContentProps {
hasEmailService: boolean
@@ -59,7 +58,34 @@ function VerificationForm({
setCountdown(30)
}
const buttonClass = useBrandedButtonClass()
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
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 (
<>

View File

@@ -4,6 +4,7 @@ import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { X } from 'lucide-react'
import { Textarea } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -17,7 +18,6 @@ import { isHosted } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
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 Nav from '@/app/(landing)/components/nav/nav'
@@ -493,17 +493,18 @@ export default function CareersPage() {
{/* Submit Button */}
<div className='flex justify-end pt-2'>
<BrandedButton
<Button
type='submit'
disabled={isSubmitting || submitStatus === 'success'}
loading={isSubmitting}
loadingText='Submitting'
showArrow={false}
fullWidth={false}
className='min-w-[200px]'
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'
size='lg'
>
{submitStatus === 'success' ? 'Submitted' : 'Submit Application'}
</BrandedButton>
{isSubmitting
? 'Submitting...'
: submitStatus === 'success'
? 'Submitted'
: 'Submit Application'}
</Button>
</div>
</form>
</section>

View File

@@ -59,7 +59,7 @@ export default function StatusIndicator() {
href={statusUrl}
target='_blank'
rel='noopener noreferrer'
className={`flex min-w-[165px] items-center gap-[6px] whitespace-nowrap text-[12px] transition-colors ${STATUS_COLORS[status]}`}
className={`flex items-center gap-[6px] whitespace-nowrap text-[12px] transition-colors ${STATUS_COLORS[status]}`}
aria-label={`System status: ${message}`}
>
<StatusDotIcon status={status} className='h-[6px] w-[6px]' aria-hidden='true' />

View File

@@ -11,7 +11,6 @@ import { useBrandConfig } from '@/lib/branding/branding'
import { isHosted } from '@/lib/core/config/feature-flags'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('nav')
@@ -21,12 +20,11 @@ interface NavProps {
}
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
const [githubStars, setGithubStars] = useState('25.8k')
const [githubStars, setGithubStars] = useState('25.1k')
const [isHovered, setIsHovered] = useState(false)
const [isLoginHovered, setIsLoginHovered] = useState(false)
const router = useRouter()
const brand = useBrandConfig()
const buttonClass = useBrandedButtonClass()
useEffect(() => {
if (variant !== 'landing') return
@@ -185,7 +183,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
href='/signup'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
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='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]'
aria-label='Get started with Sim - Sign up for free'
prefetch={true}
>

View File

@@ -1,27 +0,0 @@
'use client'
import { useState } from 'react'
import { ArrowLeft, ChevronLeft } from 'lucide-react'
import Link from 'next/link'
export function BackLink() {
const [isHovered, setIsHovered] = useState(false)
return (
<Link
href='/studio'
className='group flex items-center gap-1 text-gray-600 text-sm hover:text-gray-900'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<span className='group-hover:-translate-x-0.5 inline-flex transition-transform duration-200'>
{isHovered ? (
<ArrowLeft className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronLeft className='h-4 w-4' aria-hidden='true' />
)}
</span>
Back to Sim Studio
</Link>
)
}

View File

@@ -5,10 +5,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
import { FAQ } from '@/lib/blog/faq'
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BackLink } from '@/app/(landing)/studio/[slug]/back-link'
import { ShareButton } from '@/app/(landing)/studio/[slug]/share-button'
export async function generateStaticParams() {
const posts = await getAllPostMeta()
@@ -51,7 +48,9 @@ export default async function Page({ params }: { params: Promise<{ slug: string
/>
<header className='mx-auto max-w-[1450px] px-6 pt-8 sm:px-8 sm:pt-12 md:px-12 md:pt-16'>
<div className='mb-6'>
<BackLink />
<Link href='/studio' className='text-gray-600 text-sm hover:text-gray-900'>
Back to Sim Studio
</Link>
</div>
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
<div className='w-full flex-shrink-0 md:w-[450px]'>
@@ -76,31 +75,28 @@ export default async function Page({ params }: { params: Promise<{ slug: string
>
{post.title}
</h1>
<div className='mt-4 flex items-center justify-between'>
<div className='flex items-center gap-3'>
{(post.authors || [post.author]).map((a, idx) => (
<div key={idx} className='flex items-center gap-2'>
{a?.avatarUrl ? (
<Avatar className='size-6'>
<AvatarImage src={a.avatarUrl} alt={a.name} />
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
</Avatar>
) : null}
<Link
href={a?.url || '#'}
target='_blank'
rel='noopener noreferrer author'
className='text-[14px] text-gray-600 leading-[1.5] hover:text-gray-900 sm:text-[16px]'
itemProp='author'
itemScope
itemType='https://schema.org/Person'
>
<span itemProp='name'>{a?.name}</span>
</Link>
</div>
))}
</div>
<ShareButton url={`${getBaseUrl()}/studio/${slug}`} title={post.title} />
<div className='mt-4 flex items-center gap-3'>
{(post.authors || [post.author]).map((a, idx) => (
<div key={idx} className='flex items-center gap-2'>
{a?.avatarUrl ? (
<Avatar className='size-6'>
<AvatarImage src={a.avatarUrl} alt={a.name} />
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
</Avatar>
) : null}
<Link
href={a?.url || '#'}
target='_blank'
rel='noopener noreferrer author'
className='text-[14px] text-gray-600 leading-[1.5] hover:text-gray-900 sm:text-[16px]'
itemProp='author'
itemScope
itemType='https://schema.org/Person'
>
<span itemProp='name'>{a?.name}</span>
</Link>
</div>
))}
</div>
</div>
</div>

View File

@@ -1,65 +0,0 @@
'use client'
import { useState } from 'react'
import { Share2 } from 'lucide-react'
import { Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
interface ShareButtonProps {
url: string
title: string
}
export function ShareButton({ url, title }: ShareButtonProps) {
const [open, setOpen] = useState(false)
const [copied, setCopied] = useState(false)
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(url)
setCopied(true)
setTimeout(() => {
setCopied(false)
setOpen(false)
}, 1000)
} catch {
setOpen(false)
}
}
const handleShareTwitter = () => {
const tweetUrl = `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`
window.open(tweetUrl, '_blank', 'noopener,noreferrer')
setOpen(false)
}
const handleShareLinkedIn = () => {
const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`
window.open(linkedInUrl, '_blank', 'noopener,noreferrer')
setOpen(false)
}
return (
<Popover
open={open}
onOpenChange={setOpen}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverTrigger asChild>
<button
className='flex items-center gap-1.5 text-gray-600 text-sm hover:text-gray-900'
aria-label='Share this post'
>
<Share2 className='h-4 w-4' />
<span>Share</span>
</button>
</PopoverTrigger>
<PopoverContent align='end' minWidth={140}>
<PopoverItem onClick={handleCopyLink}>{copied ? 'Copied!' : 'Copy link'}</PopoverItem>
<PopoverItem onClick={handleShareTwitter}>Share on X</PopoverItem>
<PopoverItem onClick={handleShareLinkedIn}>Share on LinkedIn</PopoverItem>
</PopoverContent>
</Popover>
)
}

View File

@@ -22,7 +22,7 @@ export default async function StudioIndex({
? filtered.sort((a, b) => {
if (a.featured && !b.featured) return -1
if (!a.featured && b.featured) return 1
return new Date(b.date).getTime() - new Date(a.date).getTime()
return 0
})
: filtered

View File

@@ -8,7 +8,6 @@ import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getRedisClient } from '@/lib/core/config/redis'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('A2AAgentCardAPI')
@@ -96,11 +95,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<Ro
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
if (!workspaceAccess.canWrite) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
if (
@@ -166,11 +160,6 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
if (!workspaceAccess.canWrite) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
await db.delete(a2aAgent).where(eq(a2aAgent.id, agentId))
logger.info(`Deleted A2A agent: ${agentId}`)
@@ -205,11 +194,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
if (!workspaceAccess.canWrite) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const action = body.action as 'publish' | 'unpublish' | 'refresh'

View File

@@ -16,7 +16,6 @@ import {
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getBrandConfig } from '@/lib/branding/branding'
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
import { validateExternalUrl } from '@/lib/core/security/input-validation'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { markExecutionCancelled } from '@/lib/execution/cancellation'
@@ -1119,13 +1118,17 @@ async function handlePushNotificationSet(
)
}
const urlValidation = validateExternalUrl(
params.pushNotificationConfig.url,
'Push notification URL'
)
if (!urlValidation.isValid) {
try {
const url = new URL(params.pushNotificationConfig.url)
if (url.protocol !== 'https:') {
return NextResponse.json(
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Push notification URL must use HTTPS'),
{ status: 400 }
)
}
} catch {
return NextResponse.json(
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, urlValidation.error || 'Invalid URL'),
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Invalid push notification URL'),
{ status: 400 }
)
}

View File

@@ -4,11 +4,6 @@ import { createLogger } from '@sim/logger'
import { and, desc, eq, inArray } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { refreshOAuthToken } from '@/lib/oauth'
import {
getMicrosoftRefreshTokenExpiry,
isMicrosoftProvider,
PROACTIVE_REFRESH_THRESHOLD_DAYS,
} from '@/lib/oauth/microsoft'
const logger = createLogger('OAuthUtilsAPI')
@@ -210,32 +205,15 @@ export async function refreshAccessTokenIfNeeded(
}
// Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
const expiresAt = credential.accessTokenExpiresAt
const now = new Date()
// 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 shouldRefresh =
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
const accessToken = credential.accessToken
if (shouldRefresh) {
logger.info(`[${requestId}] Refreshing token for credential`)
logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
try {
const refreshedToken = await refreshOAuthToken(
credential.providerId,
@@ -249,15 +227,11 @@ export async function refreshAccessTokenIfNeeded(
userId: credential.userId,
hasRefreshToken: !!credential.refreshToken,
})
if (!accessTokenNeedsRefresh && accessToken) {
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
return accessToken
}
return null
}
// Prepare update data
const updateData: Record<string, unknown> = {
const updateData: any = {
accessToken: refreshedToken.accessToken,
accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000),
updatedAt: new Date(),
@@ -269,10 +243,6 @@ export async function refreshAccessTokenIfNeeded(
updateData.refreshToken = refreshedToken.refreshToken
}
if (isMicrosoftProvider(credential.providerId)) {
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
}
// Update the token in the database
await db.update(account).set(updateData).where(eq(account.id, credentialId))
@@ -286,10 +256,6 @@ export async function refreshAccessTokenIfNeeded(
credentialId,
userId: credential.userId,
})
if (!accessTokenNeedsRefresh && accessToken) {
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
return accessToken
}
return null
}
} else if (!accessToken) {
@@ -311,27 +277,10 @@ export async function refreshTokenIfNeeded(
credentialId: string
): Promise<{ accessToken: string; refreshed: boolean }> {
// Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
const expiresAt = credential.accessTokenExpiresAt
const now = new Date()
// 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 shouldRefresh =
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
// If token appears valid and present, return it directly
if (!shouldRefresh) {
@@ -344,17 +293,13 @@ export async function refreshTokenIfNeeded(
if (!refreshResult) {
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')
}
const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult
// Prepare update data
const updateData: Record<string, unknown> = {
const updateData: any = {
accessToken: refreshedToken,
accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry
updatedAt: new Date(),
@@ -366,10 +311,6 @@ export async function refreshTokenIfNeeded(
updateData.refreshToken = newRefreshToken
}
if (isMicrosoftProvider(credential.providerId)) {
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
}
await db.update(account).set(updateData).where(eq(account.id, credentialId))
logger.info(`[${requestId}] Successfully refreshed access token`)
@@ -390,11 +331,6 @@ 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)
throw error
}

View File

@@ -15,8 +15,7 @@ const resetPasswordSchema = z.object({
.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 lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
.regex(/[0-9]/, 'Password must contain at least one number'),
})
export async function POST(request: NextRequest) {

View File

@@ -104,11 +104,17 @@ export async function POST(req: NextRequest) {
})
// Build execution params starting with LLM-provided arguments
// Resolve all {{ENV_VAR}} references in the arguments (deep for nested objects)
// Resolve all {{ENV_VAR}} references in the arguments
const executionParams: Record<string, any> = resolveEnvVarReferences(
toolArgs,
decryptedEnvVars,
{ deep: true }
{
resolveExactMatch: true,
allowEmbedded: true,
trimKeys: true,
onMissing: 'keep',
deep: true,
}
) as Record<string, any>
logger.info(`[${tracker.requestId}] Resolved env var references in arguments`, {

View File

@@ -11,7 +11,7 @@ import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -36,7 +36,7 @@ async function getWorkflowInputSchema(workflowId: string): Promise<any[]> {
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
const startBlock = blocks.find((block) => isInputDefinitionTrigger(block.type))
const startBlock = blocks.find((block) => isValidStartBlockType(block.type))
if (!startBlock) {
return []

View File

@@ -84,14 +84,6 @@ vi.mock('@/lib/execution/isolated-vm', () => ({
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: true,
userId: 'user-123',
authType: 'session',
}),
}))
vi.mock('@/lib/execution/e2b', () => ({
executeInE2B: vi.fn(),
}))
@@ -118,24 +110,6 @@ describe('Function Execute API Route', () => {
})
describe('Security Tests', () => {
it('should reject unauthorized requests', async () => {
const { checkHybridAuth } = await import('@/lib/auth/hybrid')
vi.mocked(checkHybridAuth).mockResolvedValueOnce({
success: false,
error: 'Unauthorized',
})
const req = createMockRequest('POST', {
code: 'return "test"',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'Unauthorized')
})
it.concurrent('should use isolated-vm for secure sandboxed execution', async () => {
const req = createMockRequest('POST', {
code: 'return "test"',
@@ -302,11 +276,8 @@ describe('Function Execute API Route', () => {
it.concurrent('should resolve tag variables with <tag_name> syntax', async () => {
const req = createMockRequest('POST', {
code: 'return <email>',
blockData: {
'block-123': { id: '123', subject: 'Test Email' },
},
blockNameMapping: {
email: 'block-123',
params: {
email: { id: '123', subject: 'Test Email' },
},
})
@@ -334,13 +305,9 @@ describe('Function Execute API Route', () => {
it.concurrent('should only match valid variable names in angle brackets', async () => {
const req = createMockRequest('POST', {
code: 'return <validVar> + "<invalid@email.com>" + <another_valid>',
blockData: {
'block-1': 'hello',
'block-2': 'world',
},
blockNameMapping: {
validvar: 'block-1',
another_valid: 'block-2',
params: {
validVar: 'hello',
another_valid: 'world',
},
})
@@ -354,22 +321,28 @@ describe('Function Execute API Route', () => {
it.concurrent(
'should handle Gmail webhook data with email addresses containing angle brackets',
async () => {
const emailData = {
id: '123',
from: 'Waleed Latif <waleed@sim.ai>',
to: 'User <user@example.com>',
subject: 'Test Email',
bodyText: 'Hello world',
const gmailData = {
email: {
id: '123',
from: 'Waleed Latif <waleed@sim.ai>',
to: 'User <user@example.com>',
subject: 'Test Email',
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', {
code: 'return <email>',
blockData: {
'block-email': emailData,
},
blockNameMapping: {
email: 'block-email',
},
params: gmailData,
})
const response = await POST(req)
@@ -383,20 +356,17 @@ describe('Function Execute API Route', () => {
it.concurrent(
'should properly serialize complex email objects with special characters',
async () => {
const emailData = {
from: 'Test User <test@example.com>',
bodyHtml: '<div>HTML content with "quotes" and \'apostrophes\'</div>',
bodyText: 'Text with\nnewlines\tand\ttabs',
const complexEmailData = {
email: {
from: 'Test User <test@example.com>',
bodyHtml: '<div>HTML content with "quotes" and \'apostrophes\'</div>',
bodyText: 'Text with\nnewlines\tand\ttabs',
},
}
const req = createMockRequest('POST', {
code: 'return <email>',
blockData: {
'block-email': emailData,
},
blockNameMapping: {
email: 'block-email',
},
params: complexEmailData,
})
const response = await POST(req)
@@ -549,23 +519,18 @@ describe('Function Execute API Route', () => {
})
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', {
code: 'return <complexData>',
blockData: {
'block-complex': complexData,
},
blockNameMapping: {
complexdata: 'block-complex',
params: {
complexData: {
special: 'chars"with\'quotes',
unicode: '🎉 Unicode content',
nested: {
deep: {
value: 'test',
},
},
},
},
})

View File

@@ -1,16 +1,15 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { isE2bEnabled } from '@/lib/core/config/feature-flags'
import { generateRequestId } from '@/lib/core/utils/request'
import { executeInE2B } from '@/lib/execution/e2b'
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
import {
createEnvVarPattern,
createWorkflowVariablePattern,
resolveEnvVarReferences,
} from '@/executor/utils/reference-validation'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
@@ -19,8 +18,8 @@ export const MAX_DURATION = 210
const logger = createLogger('FunctionExecuteAPI')
const E2B_JS_WRAPPER_LINES = 3
const E2B_PYTHON_WRAPPER_LINES = 1
const E2B_JS_WRAPPER_LINES = 3 // Lines before user code: ';(async () => {', ' try {', ' const __sim_result = await (async () => {'
const E2B_PYTHON_WRAPPER_LINES = 1 // Lines before user code: 'def __sim_main__():'
type TypeScriptModule = typeof import('typescript')
@@ -135,21 +134,33 @@ function extractEnhancedError(
if (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')
for (const line of stackLines) {
// Pattern 1: Compilation errors - "user-function.js:6"
let match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
// Pattern 2: Runtime errors - "at user-function.js:5:12"
if (!match) {
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) {
const stackLine = Number.parseInt(match[1], 10)
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
// Check if this is a syntax error in wrapper code caused by incomplete user code
const isWrapperSyntaxError =
stackLine > userCodeStartLine &&
error.name === 'SyntaxError' &&
@@ -157,6 +168,7 @@ function extractEnhancedError(
error.message.includes('Unexpected end of input'))
if (isWrapperSyntaxError && userCode) {
// Map wrapper syntax errors to the last line of user code
const codeLines = userCode.split('\n')
const lastUserLine = codeLines.length
enhanced.line = lastUserLine
@@ -169,6 +181,7 @@ function extractEnhancedError(
enhanced.line = adjustedLine
enhanced.column = stackColumn
// Extract the actual line content from user code
if (userCode) {
const codeLines = userCode.split('\n')
if (adjustedLine <= codeLines.length) {
@@ -179,6 +192,7 @@ function extractEnhancedError(
}
if (stackLine <= userCodeStartLine) {
// Error is in wrapper code itself
enhanced.line = stackLine
enhanced.column = stackColumn
break
@@ -186,6 +200,7 @@ function extractEnhancedError(
}
}
// Clean up stack trace to show user-relevant information
const cleanedStackLines: string[] = stackLines
.filter(
(line: string) =>
@@ -199,6 +214,9 @@ function extractEnhancedError(
}
}
// Keep original message without adding error type prefix
// The error type will be added later in createUserFriendlyErrorMessage
return enhanced
}
@@ -213,6 +231,7 @@ function formatE2BError(
userCode: string,
prologueLineCount: number
): { formattedError: string; cleanedOutput: string } {
// Calculate line offset based on language and prologue
const wrapperLines =
language === CodeLanguage.Python ? E2B_PYTHON_WRAPPER_LINES : E2B_JS_WRAPPER_LINES
const totalOffset = prologueLineCount + wrapperLines
@@ -222,20 +241,27 @@ function formatE2BError(
let cleanErrorMsg = ''
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+)/)
if (cellMatch) {
const originalLine = Number.parseInt(cellMatch[1], 10)
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
.replace(/\s*\(detected at line \d+\)/g, '')
.replace(/\s*\([^)]+\.py, line \d+\)/g, '')
.trim()
} 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 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+)\)/)
if (jsErrorMatch) {
cleanErrorType = jsErrorMatch[1]
@@ -243,11 +269,13 @@ function formatE2BError(
const originalLine = Number.parseInt(jsErrorMatch[3], 10)
userLine = originalLine - totalOffset
} else {
// Fallback: look for line number in the arrow pointer line (> 11 |)
const arrowMatch = errorMessage.match(/^>\s*(\d+)\s*\|/m)
if (arrowMatch) {
const originalLine = Number.parseInt(arrowMatch[1], 10)
userLine = originalLine - totalOffset
}
// Try to extract error type and message
const errorMatch = firstLine.match(/^(\w+Error):\s*(.+)/)
if (errorMatch) {
cleanErrorType = errorMatch[1]
@@ -261,11 +289,13 @@ function formatE2BError(
}
}
// Build the final clean error message
const finalErrorMsg =
cleanErrorType && cleanErrorMsg
? `${cleanErrorType}: ${cleanErrorMsg}`
: cleanErrorMsg || errorMessage
// Format with line number if available
let formattedError = finalErrorMsg
if (userLine && userLine > 0) {
const codeLines = userCode.split('\n')
@@ -281,6 +311,7 @@ function formatE2BError(
}
}
// For stdout, just return the clean error message without the full traceback
const cleanedOutput = finalErrorMsg
return { formattedError, cleanedOutput }
@@ -296,6 +327,7 @@ function createUserFriendlyErrorMessage(
): string {
let errorMessage = enhanced.message
// Add line information if available
if (enhanced.line !== undefined) {
let lineInfo = `Line ${enhanced.line}`
@@ -306,14 +338,18 @@ function createUserFriendlyErrorMessage(
errorMessage = `${lineInfo} - ${errorMessage}`
} else {
// If no line number, try to extract it from stack trace for display
if (enhanced.stack) {
const stackMatch = enhanced.stack.match(/user-function\.js:(\d+)(?::(\d+))?/)
if (stackMatch) {
const line = Number.parseInt(stackMatch[1], 10)
let lineInfo = `Line ${line}`
// Try to get line content if we have userCode
if (userCode) {
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) {
const lineContent = codeLines[line - 1]?.trim()
if (lineContent) {
@@ -327,6 +363,7 @@ function createUserFriendlyErrorMessage(
}
}
// Add error type prefix with consistent naming
if (enhanced.name !== 'Error') {
const errorTypePrefix =
enhanced.name === 'SyntaxError'
@@ -337,6 +374,7 @@ function createUserFriendlyErrorMessage(
? 'Reference Error'
: enhanced.name
// Only add prefix if not already present
if (!errorMessage.toLowerCase().includes(errorTypePrefix.toLowerCase())) {
errorMessage = `${errorTypePrefix}: ${errorMessage}`
}
@@ -345,6 +383,9 @@ function createUserFriendlyErrorMessage(
return errorMessage
}
/**
* Resolves workflow variables with <variable.name> syntax
*/
function resolveWorkflowVariables(
code: string,
workflowVariables: Record<string, any>,
@@ -364,35 +405,39 @@ function resolveWorkflowVariables(
while ((match = regex.exec(code)) !== null) {
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(
([_, variable]) => normalizeName(variable.name || '') === variableName
)
if (!foundVariable) {
const availableVars = Object.values(workflowVariables)
.map((v) => v.name)
.filter(Boolean)
throw new Error(
`Variable "${variableName}" doesn't exist.` +
(availableVars.length > 0 ? ` Available: ${availableVars.join(', ')}` : '')
)
}
let variableValue: unknown = ''
if (foundVariable) {
const variable = foundVariable[1]
variableValue = variable.value
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') {
if (variable.value !== undefined && variable.value !== null) {
try {
variableValue = JSON.parse(variableValue)
// Handle 'string' type the same as 'plain' for backward compatibility
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 {
// Keep as-is
// Fallback to original value on error
variableValue = variable.value
}
}
}
@@ -405,9 +450,11 @@ function resolveWorkflowVariables(
})
}
// Process replacements in reverse order to maintain correct indices
for (let i = replacements.length - 1; i >= 0; i--) {
const { match: matchStr, index, variableName, variableValue } = replacements[i]
// Use variable reference approach
const safeVarName = `__variable_${variableName.replace(/[^a-zA-Z0-9_]/g, '_')}`
contextVariables[safeVarName] = variableValue
resolvedCode =
@@ -417,6 +464,9 @@ function resolveWorkflowVariables(
return resolvedCode
}
/**
* Resolves environment variables with {{var_name}} syntax
*/
function resolveEnvironmentVariables(
code: string,
params: Record<string, any>,
@@ -432,28 +482,32 @@ function resolveEnvironmentVariables(
const resolverVars: Record<string, string> = {}
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (value) {
resolverVars[key] = String(value)
}
})
Object.entries(envVars).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (value) {
resolverVars[key] = value
}
})
while ((match = regex.exec(code)) !== null) {
const varName = match[1].trim()
if (!(varName in resolverVars)) {
continue
}
const resolved = resolveEnvVarReferences(match[0], resolverVars, {
allowEmbedded: true,
resolveExactMatch: true,
trimKeys: true,
onMissing: 'empty',
deep: false,
})
const varValue =
typeof resolved === 'string' ? resolved : resolved == null ? '' : String(resolved)
replacements.push({
match: match[0],
index: match.index,
varName,
varValue: resolverVars[varName],
varValue: String(varValue),
})
}
@@ -469,59 +523,64 @@ function resolveEnvironmentVariables(
return resolvedCode
}
/**
* Resolves tags with <tag_name> syntax (including nested paths like <block.response.data>)
*/
function resolveTagVariables(
code: string,
blockData: Record<string, unknown>,
params: Record<string, any>,
blockData: Record<string, any>,
blockNameMapping: Record<string, string>,
blockOutputSchemas: Record<string, OutputSchema>,
contextVariables: Record<string, unknown>,
language = 'javascript'
contextVariables: Record<string, any>
): string {
let resolvedCode = code
const undefinedLiteral = language === 'python' ? 'None' : 'undefined'
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'
)
const tagMatches = resolvedCode.match(tagPattern) || []
for (const match of tagMatches) {
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)
const result = resolveBlockReference(blockName, fieldPath, {
blockNameMapping,
blockData,
blockOutputSchemas,
})
// Handle nested paths like "getrecord.response.data" or "function1.response.result"
// First try params, then blockData directly, then try with block name mapping
let tagValue = getNestedValue(params, tagName) || getNestedValue(blockData, tagName) || ''
if (!result) {
continue
}
// If not found and the path starts with a block name, try mapping the block name to ID
if (!tagValue && tagName.includes(REFERENCE.PATH_DELIMITER)) {
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
const normalizedBlockName = pathParts[0] // This should already be normalized like "function1"
let tagValue = result.value
// Direct lookup using normalized block name
const blockId = blockNameMapping[normalizedBlockName] ?? null
if (tagValue === undefined) {
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), undefinedLiteral)
continue
}
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 (blockId) {
const remainingPath = pathParts.slice(1).join('.')
const fullPath = `${blockId}.${remainingPath}`
tagValue = getNestedValue(blockData, fullPath) || ''
}
}
const safeVarName = `__tag_${tagName.replace(/_/g, '_1').replace(/\./g, '_0')}`
// If the value is a stringified JSON, parse it back to object
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
// Replace the template with a variable reference
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
}
@@ -537,31 +596,44 @@ function resolveTagVariables(
*/
function resolveCodeVariables(
code: string,
params: Record<string, unknown>,
params: Record<string, any>,
envVars: Record<string, string> = {},
blockData: Record<string, unknown> = {},
blockData: Record<string, any> = {},
blockNameMapping: Record<string, string> = {},
blockOutputSchemas: Record<string, OutputSchema> = {},
workflowVariables: Record<string, unknown> = {},
language = 'javascript'
): { resolvedCode: string; contextVariables: Record<string, unknown> } {
workflowVariables: Record<string, any> = {}
): { resolvedCode: string; contextVariables: Record<string, any> } {
let resolvedCode = code
const contextVariables: Record<string, unknown> = {}
const contextVariables: Record<string, any> = {}
// Resolve workflow variables with <variable.name> syntax first
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
// Resolve environment variables with {{var_name}} syntax
resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
// Resolve tags with <tag_name> syntax (including nested paths like <block.response.data>)
resolvedCode = resolveTagVariables(
resolvedCode,
params,
blockData,
blockNameMapping,
blockOutputSchemas,
contextVariables,
language
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
* This handles the common case where print() or console.log() adds a trailing \n
@@ -582,12 +654,6 @@ export async function POST(req: NextRequest) {
let resolvedCode = '' // Store resolved code for error reporting
try {
const auth = await checkHybridAuth(req)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized function execution attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const { DEFAULT_EXECUTION_TIMEOUT_MS } = await import('@/lib/execution/constants')
@@ -600,12 +666,12 @@ export async function POST(req: NextRequest) {
envVars = {},
blockData = {},
blockNameMapping = {},
blockOutputSchemas = {},
workflowVariables = {},
workflowId,
isCustomTool = false,
} = body
// Extract internal parameters that shouldn't be passed to the execution context
const executionParams = { ...params }
executionParams._context = undefined
@@ -617,21 +683,21 @@ export async function POST(req: NextRequest) {
isCustomTool,
})
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
// Resolve variables in the code with workflow environment variables
const codeResolution = resolveCodeVariables(
code,
executionParams,
envVars,
blockData,
blockNameMapping,
blockOutputSchemas,
workflowVariables,
lang
workflowVariables
)
resolvedCode = codeResolution.resolvedCode
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 jsRemainingCode = resolvedCode
let hasImports = false
@@ -641,22 +707,31 @@ export async function POST(req: NextRequest) {
jsImports = extractionResult.imports
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)
hasImports = jsImports.trim().length > 0 || hasRequireStatements
}
// Python always requires E2B
if (lang === CodeLanguage.Python && !isE2bEnabled) {
throw new Error(
'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) {
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.'
)
}
// Use E2B if:
// - E2B is enabled AND
// - Not a custom tool AND
// - (Python OR JavaScript with imports)
const useE2B =
isE2bEnabled &&
!isCustomTool &&
@@ -669,10 +744,13 @@ export async function POST(req: NextRequest) {
language: lang,
})
let prologue = ''
const epilogue = ''
if (lang === CodeLanguage.JavaScript) {
// Track prologue lines for error adjustment
let prologueLineCount = 0
// Reuse the imports we already extracted earlier
const imports = jsImports
const remainingCode = jsRemainingCode
@@ -687,11 +765,7 @@ export async function POST(req: NextRequest) {
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) {
if (v === undefined) {
prologue += `const ${k} = undefined;\n`
} else {
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
}
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
prologueLineCount++
}
@@ -708,7 +782,7 @@ export async function POST(req: NextRequest) {
' }',
'})();',
].join('\n')
const codeForE2B = importSection + prologue + wrapped
const codeForE2B = importSection + prologue + wrapped + epilogue
const execStart = Date.now()
const {
@@ -730,6 +804,7 @@ export async function POST(req: NextRequest) {
error: e2bError,
})
// If there was an execution error, format it properly
if (e2bError) {
const { formattedError, cleanedOutput } = formatE2BError(
e2bError,
@@ -753,7 +828,7 @@ export async function POST(req: NextRequest) {
output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime },
})
}
// Track prologue lines for error adjustment
let prologueLineCount = 0
prologue += 'import json\n'
prologueLineCount++
@@ -762,11 +837,7 @@ export async function POST(req: NextRequest) {
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) {
if (v === undefined) {
prologue += `${k} = None\n`
} else {
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
}
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
prologueLineCount++
}
const wrapped = [
@@ -775,7 +846,7 @@ export async function POST(req: NextRequest) {
'__sim_result__ = __sim_main__()',
"print('__SIM_RESULT__=' + json.dumps(__sim_result__))",
].join('\n')
const codeForE2B = prologue + wrapped
const codeForE2B = prologue + wrapped + epilogue
const execStart = Date.now()
const {
@@ -797,6 +868,7 @@ export async function POST(req: NextRequest) {
error: e2bError,
})
// If there was an execution error, format it properly
if (e2bError) {
const { formattedError, cleanedOutput } = formatE2BError(
e2bError,
@@ -825,6 +897,7 @@ export async function POST(req: NextRequest) {
const wrapperLines = ['(async () => {', ' try {']
if (isCustomTool) {
wrapperLines.push(' // For custom tools, make parameters directly accessible')
Object.keys(executionParams).forEach((key) => {
wrapperLines.push(` const ${key} = params.${key};`)
})
@@ -858,10 +931,12 @@ export async function POST(req: NextRequest) {
})
const ivmError = isolatedResult.error
// Adjust line number for prepended param destructuring in custom tools
let adjustedLine = ivmError.line
let adjustedLineContent = ivmError.lineContent
if (prependedLineCount > 0 && ivmError.line !== undefined) {
adjustedLine = Math.max(1, ivmError.line - prependedLineCount)
// Get line content from original user code, not the prepended code
const codeLines = resolvedCode.split('\n')
if (adjustedLine <= codeLines.length) {
adjustedLineContent = codeLines[adjustedLine - 1]?.trim()

View File

@@ -157,7 +157,7 @@ describe('Knowledge Base Documents API Route', () => {
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
'kb-123',
{
enabledFilter: undefined,
includeDisabled: false,
search: undefined,
limit: 50,
offset: 0,
@@ -166,7 +166,7 @@ describe('Knowledge Base Documents API Route', () => {
)
})
it('should return documents with default filter', async () => {
it('should filter disabled documents by default', async () => {
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
const { getDocuments } = await import('@/lib/knowledge/documents/service')
@@ -194,7 +194,7 @@ describe('Knowledge Base Documents API Route', () => {
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
'kb-123',
{
enabledFilter: undefined,
includeDisabled: false,
search: undefined,
limit: 50,
offset: 0,
@@ -203,7 +203,7 @@ describe('Knowledge Base Documents API Route', () => {
)
})
it('should filter documents by enabled status when requested', async () => {
it('should include disabled documents when requested', async () => {
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
const { getDocuments } = await import('@/lib/knowledge/documents/service')
@@ -223,7 +223,7 @@ describe('Knowledge Base Documents API Route', () => {
},
})
const url = 'http://localhost:3000/api/knowledge/kb-123/documents?enabledFilter=disabled'
const url = 'http://localhost:3000/api/knowledge/kb-123/documents?includeDisabled=true'
const req = new Request(url, { method: 'GET' }) as any
const { GET } = await import('@/app/api/knowledge/[id]/documents/route')
@@ -233,7 +233,7 @@ describe('Knowledge Base Documents API Route', () => {
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
'kb-123',
{
enabledFilter: 'disabled',
includeDisabled: true,
search: undefined,
limit: 50,
offset: 0,
@@ -361,7 +361,8 @@ describe('Knowledge Base Documents API Route', () => {
expect(vi.mocked(createSingleDocument)).toHaveBeenCalledWith(
validDocumentData,
'kb-123',
expect.any(String)
expect.any(String),
'user-123'
)
})
@@ -469,7 +470,8 @@ describe('Knowledge Base Documents API Route', () => {
expect(vi.mocked(createDocumentRecords)).toHaveBeenCalledWith(
validBulkData.documents,
'kb-123',
expect.any(String)
expect.any(String),
'user-123'
)
expect(vi.mocked(processDocumentsWithQueue)).toHaveBeenCalled()
})

View File

@@ -5,7 +5,6 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import {
bulkDocumentOperation,
bulkDocumentOperationByFilter,
createDocumentRecords,
createSingleDocument,
getDocuments,
@@ -58,20 +57,13 @@ const BulkCreateDocumentsSchema = z.object({
bulk: z.literal(true),
})
const BulkUpdateDocumentsSchema = z
.object({
operation: z.enum(['enable', 'disable', 'delete']),
documentIds: z
.array(z.string())
.min(1, 'At least one document ID is required')
.max(100, 'Cannot operate on more than 100 documents at once')
.optional(),
selectAll: z.boolean().optional(),
enabledFilter: z.enum(['all', 'enabled', 'disabled']).optional(),
})
.refine((data) => data.selectAll || (data.documentIds && data.documentIds.length > 0), {
message: 'Either selectAll must be true or documentIds must be provided',
})
const BulkUpdateDocumentsSchema = z.object({
operation: z.enum(['enable', 'disable', 'delete']),
documentIds: z
.array(z.string())
.min(1, 'At least one document ID is required')
.max(100, 'Cannot operate on more than 100 documents at once'),
})
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = randomUUID().slice(0, 8)
@@ -98,17 +90,14 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
}
const url = new URL(req.url)
const enabledFilter = url.searchParams.get('enabledFilter') as
| 'all'
| 'enabled'
| 'disabled'
| null
const includeDisabled = url.searchParams.get('includeDisabled') === 'true'
const search = url.searchParams.get('search') || undefined
const limit = Number.parseInt(url.searchParams.get('limit') || '50')
const offset = Number.parseInt(url.searchParams.get('offset') || '0')
const sortByParam = url.searchParams.get('sortBy')
const sortOrderParam = url.searchParams.get('sortOrder')
// Validate sort parameters
const validSortFields: DocumentSortField[] = [
'filename',
'fileSize',
@@ -116,7 +105,6 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
'chunkCount',
'uploadedAt',
'processingStatus',
'enabled',
]
const validSortOrders: SortOrder[] = ['asc', 'desc']
@@ -132,7 +120,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
const result = await getDocuments(
knowledgeBaseId,
{
enabledFilter: enabledFilter || undefined,
includeDisabled,
search,
limit,
offset,
@@ -202,7 +190,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const createdDocuments = await createDocumentRecords(
validatedData.documents,
knowledgeBaseId,
requestId
requestId,
userId
)
logger.info(
@@ -261,10 +250,16 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
throw validationError
}
} else {
// Handle single document creation
try {
const validatedData = CreateDocumentSchema.parse(body)
const newDocument = await createSingleDocument(validatedData, knowledgeBaseId, requestId)
const newDocument = await createSingleDocument(
validatedData,
knowledgeBaseId,
requestId,
userId
)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
@@ -299,6 +294,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
} catch (error) {
logger.error(`[${requestId}] Error creating document`, error)
// Check if it's a storage limit error
const errorMessage = error instanceof Error ? error.message : 'Failed to create document'
const isStorageLimitError =
errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit')
@@ -335,22 +331,16 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
try {
const validatedData = BulkUpdateDocumentsSchema.parse(body)
const { operation, documentIds, selectAll, enabledFilter } = validatedData
const { operation, documentIds } = validatedData
try {
let result
if (selectAll) {
result = await bulkDocumentOperationByFilter(
knowledgeBaseId,
operation,
enabledFilter,
requestId
)
} else if (documentIds && documentIds.length > 0) {
result = await bulkDocumentOperation(knowledgeBaseId, operation, documentIds, requestId)
} else {
return NextResponse.json({ error: 'No documents specified' }, { status: 400 })
}
const result = await bulkDocumentOperation(
knowledgeBaseId,
operation,
documentIds,
requestId,
session.user.id
)
return NextResponse.json({
success: true,

View File

@@ -1,10 +1,11 @@
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { McpClient } from '@/lib/mcp/client'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
import type { McpTransport } from '@/lib/mcp/types'
import type { McpServerConfig, McpTransport } from '@/lib/mcp/types'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
const logger = createLogger('McpServerTestAPI')
@@ -18,6 +19,30 @@ function isUrlBasedTransport(transport: McpTransport): boolean {
return transport === 'streamable-http'
}
/**
* Resolve environment variables in strings
*/
function resolveEnvVars(value: string, envVars: Record<string, string>): string {
const missingVars: string[] = []
const resolvedValue = resolveEnvVarReferences(value, envVars, {
allowEmbedded: true,
resolveExactMatch: true,
trimKeys: true,
onMissing: 'keep',
deep: false,
missingKeys: missingVars,
}) as string
if (missingVars.length > 0) {
const uniqueMissing = Array.from(new Set(missingVars))
uniqueMissing.forEach((envKey) => {
logger.warn(`Environment variable "${envKey}" not found in MCP server test`)
})
}
return resolvedValue
}
interface TestConnectionRequest {
name: string
transport: McpTransport
@@ -71,30 +96,39 @@ export const POST = withMcpAuth('write')(
)
}
// Build initial config for resolution
const initialConfig = {
let resolvedUrl = body.url
let resolvedHeaders = body.headers || {}
try {
const envVars = await getEffectiveDecryptedEnv(userId, workspaceId)
if (resolvedUrl) {
resolvedUrl = resolveEnvVars(resolvedUrl, envVars)
}
const resolvedHeadersObj: Record<string, string> = {}
for (const [key, value] of Object.entries(resolvedHeaders)) {
resolvedHeadersObj[key] = resolveEnvVars(value, envVars)
}
resolvedHeaders = resolvedHeadersObj
} catch (envError) {
logger.warn(
`[${requestId}] Failed to resolve environment variables, using raw values:`,
envError
)
}
const testConfig: McpServerConfig = {
id: `test-${requestId}`,
name: body.name,
transport: body.transport,
url: body.url,
headers: body.headers || {},
url: resolvedUrl,
headers: resolvedHeaders,
timeout: body.timeout || 10000,
retries: 1, // Only one retry for tests
enabled: true,
}
// Resolve env vars using shared utility (non-strict mode for testing)
const { config: testConfig, missingVars } = await resolveMcpConfigEnvVars(
initialConfig,
userId,
workspaceId,
{ strict: false }
)
if (missingVars.length > 0) {
logger.warn(`[${requestId}] Some environment variables not found:`, { missingVars })
}
const testSecurityPolicy = {
requireConsent: false,
auditLevel: 'none' as const,

View File

@@ -3,9 +3,7 @@ import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { StreamingExecution } from '@/executor/types'
import { executeProviderRequest } from '@/providers'
@@ -22,11 +20,6 @@ export async function POST(request: NextRequest) {
const startTime = Date.now()
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
logger.info(`[${requestId}] Provider API request started`, {
timestamp: new Date().toISOString(),
userAgent: request.headers.get('User-Agent'),
@@ -92,13 +85,6 @@ export async function POST(request: NextRequest) {
verbosity,
})
if (workspaceId) {
const workspaceAccess = await checkWorkspaceAccess(workspaceId, auth.userId)
if (!workspaceAccess.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
let finalApiKey: string | undefined = apiKey
try {
if (provider === 'vertex' && vertexCredential) {

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { validateExternalUrl } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
@@ -40,18 +39,6 @@ export async function POST(request: NextRequest) {
const body = await request.json()
const validatedData = A2ASetPushNotificationSchema.parse(body)
const urlValidation = validateExternalUrl(validatedData.webhookUrl, 'Webhook URL')
if (!urlValidation.isValid) {
logger.warn(`[${requestId}] Invalid webhook URL`, { error: urlValidation.error })
return NextResponse.json(
{
success: false,
error: urlValidation.error,
},
{ status: 400 }
)
}
logger.info(`[${requestId}] A2A set push notification request`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { buildDeleteQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLDeleteAPI')
@@ -22,12 +21,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MySQL delete attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = DeleteSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLExecuteAPI')
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MySQL execute attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = ExecuteSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { buildInsertQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLInsertAPI')
@@ -43,12 +42,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MySQL insert attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = InsertSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createMySQLConnection, executeIntrospect } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLIntrospectAPI')
@@ -20,12 +19,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MySQL introspect attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = IntrospectSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLQueryAPI')
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MySQL query attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = QuerySchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { buildUpdateQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLUpdateAPI')
@@ -41,12 +40,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MySQL update attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = UpdateSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createPostgresConnection, executeDelete } from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLDeleteAPI')
@@ -22,12 +21,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized PostgreSQL delete attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = DeleteSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import {
createPostgresConnection,
executeQuery,
@@ -25,12 +24,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized PostgreSQL execute attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = ExecuteSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createPostgresConnection, executeInsert } from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLInsertAPI')
@@ -43,12 +42,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized PostgreSQL insert attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = InsertSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createPostgresConnection, executeIntrospect } from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLIntrospectAPI')
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized PostgreSQL introspect attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = IntrospectSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createPostgresConnection, executeQuery } from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLQueryAPI')
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized PostgreSQL query attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = QuerySchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createPostgresConnection, executeUpdate } from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLUpdateAPI')
@@ -41,12 +40,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized PostgreSQL update attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = UpdateSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createSSHConnection, escapeShellArg, executeSSHCommand } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHCheckCommandExistsAPI')
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH check command exists attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = CheckCommandExistsSchema.parse(body)

View File

@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import type { Client, SFTPWrapper, Stats } from 'ssh2'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import {
createSSHConnection,
getFileType,
@@ -40,15 +39,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH check file exists attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = CheckFileExistsSchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import {
createSSHConnection,
escapeShellArg,
@@ -28,15 +27,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH create directory attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = CreateDirectorySchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
@@ -59,6 +53,7 @@ export async function POST(request: NextRequest) {
const dirPath = sanitizePath(params.path)
const escapedPath = escapeShellArg(dirPath)
// Check if directory already exists
const checkResult = await executeSSHCommand(
client,
`test -d '${escapedPath}' && echo "exists"`
@@ -75,6 +70,7 @@ export async function POST(request: NextRequest) {
})
}
// Create directory
const mkdirFlag = params.recursive ? '-p' : ''
const command = `mkdir ${mkdirFlag} -m ${params.permissions} '${escapedPath}'`
const result = await executeSSHCommand(client, command)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import {
createSSHConnection,
escapeShellArg,
@@ -28,15 +27,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH delete file attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = DeleteFileSchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
@@ -59,6 +53,7 @@ export async function POST(request: NextRequest) {
const filePath = sanitizePath(params.path)
const escapedPath = escapeShellArg(filePath)
// Check if path exists
const checkResult = await executeSSHCommand(
client,
`test -e '${escapedPath}' && echo "exists"`
@@ -67,6 +62,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: `Path does not exist: ${filePath}` }, { status: 404 })
}
// Build delete command
let command: string
if (params.recursive) {
command = params.force ? `rm -rf '${escapedPath}'` : `rm -r '${escapedPath}'`

View File

@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import type { Client, SFTPWrapper } from 'ssh2'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHDownloadFileAPI')
@@ -35,15 +34,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH download file attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = DownloadFileSchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createSSHConnection, executeSSHCommand, sanitizeCommand } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHExecuteCommandAPI')
@@ -22,15 +21,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH execute command attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = ExecuteCommandSchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
@@ -50,6 +44,7 @@ export async function POST(request: NextRequest) {
})
try {
// Build command with optional working directory
let command = sanitizeCommand(params.command)
if (params.workingDirectory) {
command = `cd "${params.workingDirectory}" && ${command}`

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createSSHConnection, escapeShellArg, executeSSHCommand } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHExecuteScriptAPI')
@@ -23,15 +22,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH execute script attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = ExecuteScriptSchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
@@ -51,10 +45,13 @@ export async function POST(request: NextRequest) {
})
try {
// Create a temporary script file, execute it, and clean up
const scriptPath = `/tmp/sim_script_${requestId}.sh`
const escapedScriptPath = escapeShellArg(scriptPath)
const escapedInterpreter = escapeShellArg(params.interpreter)
// Build the command to create, execute, and clean up the script
// Note: heredoc with quoted delimiter ('SIMEOF') prevents variable expansion
let command = `cat > '${escapedScriptPath}' << 'SIMEOF'
${params.script}
SIMEOF

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createSSHConnection, executeSSHCommand } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHGetSystemInfoAPI')
@@ -20,15 +19,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH get system info attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = GetSystemInfoSchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },

View File

@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import type { Client, FileEntry, SFTPWrapper } from 'ssh2'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import {
createSSHConnection,
getFileType,
@@ -61,15 +60,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH list directory attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = ListDirectorySchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import {
createSSHConnection,
escapeShellArg,
@@ -28,16 +27,9 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH move/rename attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = MoveRenameSchema.parse(body)
// Validate SSH authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },

View File

@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import type { Client, SFTPWrapper } from 'ssh2'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHReadFileContentAPI')
@@ -36,12 +35,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH read file content attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = ReadFileContentSchema.parse(body)

View File

@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import type { Client, SFTPWrapper } from 'ssh2'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHUploadFileAPI')
@@ -38,12 +37,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH upload file attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = UploadFileSchema.parse(body)

View File

@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import type { Client, SFTPWrapper } from 'ssh2'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHWriteFileContentAPI')
@@ -37,15 +36,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH write file content attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = WriteFileContentSchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },

View File

@@ -1,211 +0,0 @@
/**
* POST /api/v1/admin/credits
*
* Issue credits to a user by user ID or email.
*
* Body:
* - userId?: string - The user ID to issue credits to
* - email?: string - The user email to issue credits to (alternative to userId)
* - amount: number - The amount of credits to issue (in dollars)
* - reason?: string - Reason for issuing credits (for audit logging)
*
* Response: AdminSingleResponse<{
* success: true,
* entityType: 'user' | 'organization',
* entityId: string,
* amount: number,
* newCreditBalance: number,
* newUsageLimit: number,
* }>
*
* For Pro users: credits are added to user_stats.credit_balance
* For Team users: credits are added to organization.credit_balance
* Usage limits are updated accordingly to allow spending the credits.
*/
import { db } from '@sim/db'
import { organization, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { addCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminCreditsAPI')
export const POST = withAdminAuth(async (request) => {
try {
const body = await request.json()
const { userId, email, amount, reason } = body
if (!userId && !email) {
return badRequestResponse('Either userId or email is required')
}
if (userId && typeof userId !== 'string') {
return badRequestResponse('userId must be a string')
}
if (email && typeof email !== 'string') {
return badRequestResponse('email must be a string')
}
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) {
return badRequestResponse('amount must be a positive number')
}
let resolvedUserId: string
let userEmail: string | null = null
if (userId) {
const [userData] = await db
.select({ id: user.id, email: user.email })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userData) {
return notFoundResponse('User')
}
resolvedUserId = userData.id
userEmail = userData.email
} else {
const normalizedEmail = email.toLowerCase().trim()
const [userData] = await db
.select({ id: user.id, email: user.email })
.from(user)
.where(eq(user.email, normalizedEmail))
.limit(1)
if (!userData) {
return notFoundResponse('User with email')
}
resolvedUserId = userData.id
userEmail = userData.email
}
const userSubscription = await getHighestPrioritySubscription(resolvedUserId)
if (!userSubscription || !['pro', 'team', 'enterprise'].includes(userSubscription.plan)) {
return badRequestResponse(
'User must have an active Pro, Team, or Enterprise subscription to receive credits'
)
}
let entityType: 'user' | 'organization'
let entityId: string
const plan = userSubscription.plan
let seats: number | null = null
if (plan === 'team' || plan === 'enterprise') {
entityType = 'organization'
entityId = userSubscription.referenceId
const [orgExists] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
if (!orgExists) {
return notFoundResponse('Organization')
}
const [subData] = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, entityId), eq(subscription.status, 'active')))
.limit(1)
seats = getEffectiveSeats(subData)
} else {
entityType = 'user'
entityId = resolvedUserId
const [existingStats] = await db
.select({ id: userStats.id })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
if (!existingStats) {
await db.insert(userStats).values({
id: nanoid(),
userId: entityId,
})
}
}
await addCredits(entityType, entityId, amount)
let newCreditBalance: number
if (entityType === 'organization') {
const [orgData] = await db
.select({ creditBalance: organization.creditBalance })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
newCreditBalance = Number.parseFloat(orgData?.creditBalance || '0')
} else {
const [stats] = await db
.select({ creditBalance: userStats.creditBalance })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
newCreditBalance = Number.parseFloat(stats?.creditBalance || '0')
}
await setUsageLimitForCredits(entityType, entityId, plan, seats, newCreditBalance)
let newUsageLimit: number
if (entityType === 'organization') {
const [orgData] = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
newUsageLimit = Number.parseFloat(orgData?.orgUsageLimit || '0')
} else {
const [stats] = await db
.select({ currentUsageLimit: userStats.currentUsageLimit })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
newUsageLimit = Number.parseFloat(stats?.currentUsageLimit || '0')
}
logger.info('Admin API: Issued credits', {
resolvedUserId,
userEmail,
entityType,
entityId,
amount,
newCreditBalance,
newUsageLimit,
reason: reason || 'No reason provided',
})
return singleResponse({
success: true,
userId: resolvedUserId,
userEmail,
entityType,
entityId,
amount,
newCreditBalance,
newUsageLimit,
})
} catch (error) {
logger.error('Admin API: Failed to issue credits', { error })
return internalErrorResponse('Failed to issue credits')
}
})

View File

@@ -63,9 +63,6 @@
* GET /api/v1/admin/subscriptions/:id - Get subscription details
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
*
* Credits:
* POST /api/v1/admin/credits - Issue credits to user (by userId or email)
*
* Access Control (Permission Groups):
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)

View File

@@ -640,7 +640,6 @@ export interface AdminDeployResult {
isDeployed: boolean
version: number
deployedAt: string
warnings?: string[]
}
export interface AdminUndeployResult {

View File

@@ -1,23 +1,14 @@
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { db, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { eq } from 'drizzle-orm'
import { generateRequestId } from '@/lib/core/utils/request'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import {
cleanupWebhooksForWorkflow,
restorePreviousVersionWebhooks,
saveTriggerWebhooksForDeploy,
} from '@/lib/webhooks/deploy'
import { cleanupWebhooksForWorkflow } from '@/lib/webhooks/deploy'
import {
deployWorkflow,
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -37,11 +28,10 @@ interface RouteParams {
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
const requestId = generateRequestId()
try {
const [workflowRecord] = await db
.select()
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
@@ -60,18 +50,6 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`)
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const deployResult = await deployWorkflow({
workflowId,
deployedBy: ADMIN_ACTOR_ID,
@@ -87,32 +65,6 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return internalErrorResponse('Failed to resolve deployment version')
}
const workflowData = workflowRecord as Record<string, unknown>
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId,
workflow: workflowData,
userId: workflowRecord.userId,
blocks: normalizedData.blocks,
requestId,
deploymentVersionId: deployResult.deploymentVersionId,
previousVersionId,
})
if (!triggerSaveResult.success) {
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: deployResult.deploymentVersionId,
})
await undeployWorkflow({ workflowId })
return internalErrorResponse(
triggerSaveResult.error?.message || 'Failed to sync trigger configuration'
)
}
const scheduleResult = await createSchedulesForDeploy(
workflowId,
normalizedData.blocks,
@@ -120,58 +72,15 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
deployResult.deploymentVersionId
)
if (!scheduleResult.success) {
logger.error(
`[${requestId}] Admin API: Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`
)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: deployResult.deploymentVersionId,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData,
userId: workflowRecord.userId,
previousVersionId,
requestId,
})
}
await undeployWorkflow({ workflowId })
return internalErrorResponse(scheduleResult.error || 'Failed to create schedule')
logger.warn(`Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`)
}
if (previousVersionId && previousVersionId !== deployResult.deploymentVersionId) {
try {
logger.info(`[${requestId}] Admin API: Cleaning up previous version ${previousVersionId}`)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
} catch (cleanupError) {
logger.error(
`[${requestId}] Admin API: Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
}
}
logger.info(
`[${requestId}] Admin API: Deployed workflow ${workflowId} as v${deployResult.version}`
)
// Sync MCP tools with the latest parameter schema
await syncMcpToolsForWorkflow({ workflowId, requestId, context: 'deploy' })
logger.info(`Admin API: Deployed workflow ${workflowId} as v${deployResult.version}`)
const response: AdminDeployResult = {
isDeployed: true,
version: deployResult.version!,
deployedAt: deployResult.deployedAt!.toISOString(),
warnings: triggerSaveResult.warnings,
}
return singleResponse(response)
@@ -196,6 +105,7 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
return notFoundResponse('Workflow')
}
// Clean up external webhook subscriptions before undeploying
await cleanupWebhooksForWorkflow(
workflowId,
workflowRecord as Record<string, unknown>,
@@ -207,8 +117,6 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
return internalErrorResponse(result.error || 'Failed to undeploy workflow')
}
await removeMcpToolsForWorkflow(workflowId, requestId)
logger.info(`Admin API: Undeployed workflow ${workflowId}`)
const response: AdminUndeployResult = {

View File

@@ -1,15 +1,7 @@
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { db, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import { eq } from 'drizzle-orm'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -17,7 +9,6 @@ import {
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('AdminWorkflowActivateVersionAPI')
@@ -27,12 +18,11 @@ interface RouteParams {
}
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const requestId = generateRequestId()
const { id: workflowId, versionId } = await context.params
try {
const [workflowRecord] = await db
.select()
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
@@ -46,161 +36,23 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return badRequestResponse('Invalid version number')
}
const [versionRow] = await db
.select({
id: workflowDeploymentVersion.id,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.limit(1)
if (!versionRow?.state) {
return notFoundResponse('Deployment version')
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const deployedState = versionRow.state as { blocks?: Record<string, BlockState> }
const blocks = deployedState.blocks
if (!blocks || typeof blocks !== 'object') {
return internalErrorResponse('Invalid deployed state structure')
}
const workflowData = workflowRecord as Record<string, unknown>
const scheduleValidation = validateWorkflowSchedules(blocks)
if (!scheduleValidation.isValid) {
return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId,
workflow: workflowData,
userId: workflowRecord.userId,
blocks,
requestId,
deploymentVersionId: versionRow.id,
previousVersionId,
forceRecreateSubscriptions: true,
})
if (!triggerSaveResult.success) {
logger.error(
`[${requestId}] Admin API: Failed to sync triggers for workflow ${workflowId}`,
triggerSaveResult.error
)
return internalErrorResponse(
triggerSaveResult.error?.message || 'Failed to sync trigger configuration'
)
}
const scheduleResult = await createSchedulesForDeploy(workflowId, blocks, db, versionRow.id)
if (!scheduleResult.success) {
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData,
userId: workflowRecord.userId,
previousVersionId,
requestId,
})
}
return internalErrorResponse(scheduleResult.error || 'Failed to sync schedules')
}
const result = await activateWorkflowVersion({ workflowId, version: versionNum })
if (!result.success) {
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData,
userId: workflowRecord.userId,
previousVersionId,
requestId,
})
}
if (result.error === 'Deployment version not found') {
return notFoundResponse('Deployment version')
}
return internalErrorResponse(result.error || 'Failed to activate version')
}
if (previousVersionId && previousVersionId !== versionRow.id) {
try {
logger.info(
`[${requestId}] Admin API: Cleaning up previous version ${previousVersionId} webhooks/schedules`
)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
logger.info(`[${requestId}] Admin API: Previous version cleanup completed`)
} catch (cleanupError) {
logger.error(
`[${requestId}] Admin API: Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
}
}
await syncMcpToolsForWorkflow({
workflowId,
requestId,
state: versionRow.state,
context: 'activate',
})
logger.info(
`[${requestId}] Admin API: Activated version ${versionNum} for workflow ${workflowId}`
)
logger.info(`Admin API: Activated version ${versionNum} for workflow ${workflowId}`)
return singleResponse({
success: true,
version: versionNum,
deployedAt: result.deployedAt!.toISOString(),
warnings: triggerSaveResult.warnings,
})
} catch (error) {
logger.error(
`[${requestId}] Admin API: Failed to activate version for workflow ${workflowId}`,
{
error,
}
)
logger.error(`Admin API: Failed to activate version for workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to activate deployment version')
}
})

View File

@@ -7,7 +7,11 @@ import { getSession } from '@/lib/auth'
import { validateInteger } from '@/lib/core/security/input-validation'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions'
import {
cleanupExternalWebhook,
createExternalWebhookSubscription,
shouldRecreateExternalWebhookSubscription,
} from '@/lib/webhooks/provider-subscriptions'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WebhookAPI')
@@ -83,6 +87,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
}
// Update a webhook
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
@@ -97,7 +102,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
const body = await request.json()
const { isActive, failedCount } = body
const { path, provider, providerConfig, isActive, failedCount } = body
if (failedCount !== undefined) {
const validation = validateInteger(failedCount, 'failedCount', { min: 0 })
@@ -107,6 +112,28 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
}
let resolvedProviderConfig = providerConfig
if (providerConfig) {
const { resolveEnvVarsInObject } = await import('@/lib/webhooks/env-resolver')
const webhookDataForResolve = await db
.select({
workspaceId: workflow.workspaceId,
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(eq(webhook.id, id))
.limit(1)
if (webhookDataForResolve.length > 0) {
resolvedProviderConfig = await resolveEnvVarsInObject(
providerConfig,
session.user.id,
webhookDataForResolve[0].workspaceId || undefined
)
}
}
// Find the webhook and check permissions
const webhooks = await db
.select({
webhook: webhook,
@@ -127,12 +154,16 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
const webhookData = webhooks[0]
// Check if user has permission to modify this webhook
let canModify = false
// Case 1: User owns the workflow
if (webhookData.workflow.userId === session.user.id) {
canModify = true
}
// Case 2: Workflow belongs to a workspace and user has write or admin permission
if (!canModify && webhookData.workflow.workspaceId) {
const userPermission = await getUserEntityPermissions(
session.user.id,
@@ -151,14 +182,76 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const existingProviderConfig =
(webhookData.webhook.providerConfig as Record<string, unknown>) || {}
let nextProviderConfig =
providerConfig !== undefined &&
resolvedProviderConfig &&
typeof resolvedProviderConfig === 'object'
? (resolvedProviderConfig as Record<string, unknown>)
: existingProviderConfig
const nextProvider = (provider ?? webhookData.webhook.provider) as string
if (
providerConfig !== undefined &&
shouldRecreateExternalWebhookSubscription({
previousProvider: webhookData.webhook.provider as string,
nextProvider,
previousConfig: existingProviderConfig,
nextConfig: nextProviderConfig,
})
) {
await cleanupExternalWebhook(
{ ...webhookData.webhook, providerConfig: existingProviderConfig },
webhookData.workflow,
requestId
)
const result = await createExternalWebhookSubscription(
request,
{
...webhookData.webhook,
provider: nextProvider,
providerConfig: nextProviderConfig,
},
webhookData.workflow,
session.user.id,
requestId
)
nextProviderConfig = result.updatedProviderConfig as Record<string, unknown>
}
logger.debug(`[${requestId}] Updating webhook properties`, {
hasPathUpdate: path !== undefined,
hasProviderUpdate: provider !== undefined,
hasConfigUpdate: providerConfig !== undefined,
hasActiveUpdate: isActive !== undefined,
hasFailedCountUpdate: failedCount !== undefined,
})
// Merge providerConfig to preserve credential-related fields
let finalProviderConfig = webhooks[0].webhook.providerConfig
if (providerConfig !== undefined) {
const existingConfig = existingProviderConfig
finalProviderConfig = {
...nextProviderConfig,
credentialId: existingConfig.credentialId,
credentialSetId: existingConfig.credentialSetId,
userId: existingConfig.userId,
historyId: existingConfig.historyId,
lastCheckedTimestamp: existingConfig.lastCheckedTimestamp,
setupCompleted: existingConfig.setupCompleted,
externalId: nextProviderConfig.externalId ?? existingConfig.externalId,
}
}
const updatedWebhook = await db
.update(webhook)
.set({
path: path !== undefined ? path : webhooks[0].webhook.path,
provider: provider !== undefined ? provider : webhooks[0].webhook.provider,
providerConfig: finalProviderConfig,
isActive: isActive !== undefined ? isActive : webhooks[0].webhook.isActive,
failedCount: failedCount !== undefined ? failedCount : webhooks[0].webhook.failedCount,
updatedAt: new Date(),
@@ -241,8 +334,11 @@ export async function DELETE(
}
const foundWebhook = webhookData.webhook
const credentialSetId = foundWebhook.credentialSetId as string | undefined
const blockId = foundWebhook.blockId as string | undefined
const { cleanupExternalWebhook } = await import('@/lib/webhooks/provider-subscriptions')
const providerConfig = foundWebhook.providerConfig as Record<string, unknown> | null
const credentialSetId = providerConfig?.credentialSetId as string | undefined
const blockId = providerConfig?.blockId as string | undefined
if (credentialSetId && blockId) {
const allCredentialSetWebhooks = await db
@@ -250,9 +346,10 @@ export async function DELETE(
.from(webhook)
.where(and(eq(webhook.workflowId, webhookData.workflow.id), eq(webhook.blockId, blockId)))
const webhooksToDelete = allCredentialSetWebhooks.filter(
(w) => w.credentialSetId === credentialSetId
)
const webhooksToDelete = allCredentialSetWebhooks.filter((w) => {
const config = w.providerConfig as Record<string, unknown> | null
return config?.credentialSetId === credentialSetId
})
for (const w of webhooksToDelete) {
await cleanupExternalWebhook(w, webhookData.workflow, requestId)

View File

@@ -7,21 +7,8 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver'
import {
cleanupExternalWebhook,
createExternalWebhookSubscription,
} from '@/lib/webhooks/provider-subscriptions'
import { mergeNonUserFields } from '@/lib/webhooks/utils'
import {
configureGmailPolling,
configureOutlookPolling,
configureRssPolling,
syncWebhooksForCredentialSet,
} from '@/lib/webhooks/utils.server'
import { createExternalWebhookSubscription } from '@/lib/webhooks/provider-subscriptions'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { extractCredentialSetId, isCredentialSetValue } from '@/executor/constants'
const logger = createLogger('WebhooksAPI')
@@ -311,10 +298,14 @@ export async function POST(request: NextRequest) {
}
}
let savedWebhook: any = null
const originalProviderConfig = providerConfig || {}
let savedWebhook: any = null // Variable to hold the result of save/update
// Use the original provider config - Gmail/Outlook configuration functions will inject userId automatically
const finalProviderConfig = providerConfig || {}
const { resolveEnvVarsInObject } = await import('@/lib/webhooks/env-resolver')
let resolvedProviderConfig = await resolveEnvVarsInObject(
originalProviderConfig,
finalProviderConfig,
userId,
workflowRecord.workspaceId || undefined
)
@@ -328,6 +319,8 @@ export async function POST(request: NextRequest) {
const directCredentialSetId = resolvedProviderConfig?.credentialSetId as string | undefined
if (directCredentialSetId || rawCredentialId) {
const { isCredentialSetValue, extractCredentialSetId } = await import('@/executor/constants')
const credentialSetId =
directCredentialSetId ||
(rawCredentialId && isCredentialSetValue(rawCredentialId)
@@ -339,6 +332,11 @@ export async function POST(request: NextRequest) {
`[${requestId}] Credential set detected for ${provider} trigger. Syncing webhooks for set ${credentialSetId}`
)
const { getProviderIdFromServiceId } = await import('@/lib/oauth')
const { syncWebhooksForCredentialSet, configureGmailPolling, configureOutlookPolling } =
await import('@/lib/webhooks/utils.server')
// Map provider to OAuth provider ID
const oauthProviderId = getProviderIdFromServiceId(provider)
const {
@@ -471,9 +469,6 @@ export async function POST(request: NextRequest) {
providerConfig: providerConfigOverride,
})
const userProvided = originalProviderConfig as Record<string, unknown>
const configToSave: Record<string, unknown> = { ...userProvided }
try {
const result = await createExternalWebhookSubscription(
request,
@@ -482,9 +477,7 @@ export async function POST(request: NextRequest) {
userId,
requestId
)
const updatedConfig = result.updatedProviderConfig as Record<string, unknown>
mergeNonUserFields(configToSave, updatedConfig, userProvided)
resolvedProviderConfig = updatedConfig
resolvedProviderConfig = result.updatedProviderConfig as Record<string, unknown>
externalSubscriptionCreated = result.externalSubscriptionCreated
} catch (err) {
logger.error(`[${requestId}] Error creating external webhook subscription`, err)
@@ -497,22 +490,25 @@ export async function POST(request: NextRequest) {
)
}
// Now save to database (only if subscription succeeded or provider doesn't need external subscription)
try {
if (targetWebhookId) {
logger.info(`[${requestId}] Updating existing webhook for path: ${finalPath}`, {
webhookId: targetWebhookId,
provider,
hasCredentialId: !!(configToSave as any)?.credentialId,
credentialId: (configToSave as any)?.credentialId,
hasCredentialId: !!(resolvedProviderConfig as any)?.credentialId,
credentialId: (resolvedProviderConfig as any)?.credentialId,
})
const updatedResult = await db
.update(webhook)
.set({
blockId,
provider,
providerConfig: configToSave,
providerConfig: resolvedProviderConfig,
credentialSetId:
((configToSave as Record<string, unknown>)?.credentialSetId as string | null) || null,
((resolvedProviderConfig as Record<string, unknown>)?.credentialSetId as
| string
| null) || null,
isActive: true,
updatedAt: new Date(),
})
@@ -535,9 +531,11 @@ export async function POST(request: NextRequest) {
blockId,
path: finalPath,
provider,
providerConfig: configToSave,
providerConfig: resolvedProviderConfig,
credentialSetId:
((configToSave as Record<string, unknown>)?.credentialSetId as string | null) || null,
((resolvedProviderConfig as Record<string, unknown>)?.credentialSetId as
| string
| null) || null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
@@ -549,8 +547,9 @@ export async function POST(request: NextRequest) {
if (externalSubscriptionCreated) {
logger.error(`[${requestId}] DB save failed, cleaning up external subscription`, dbError)
try {
const { cleanupExternalWebhook } = await import('@/lib/webhooks/provider-subscriptions')
await cleanupExternalWebhook(
createTempWebhookData(configToSave),
createTempWebhookData(resolvedProviderConfig),
workflowRecord,
requestId
)
@@ -568,6 +567,7 @@ export async function POST(request: NextRequest) {
if (savedWebhook && provider === 'gmail') {
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
try {
const { configureGmailPolling } = await import('@/lib/webhooks/utils.server')
const success = await configureGmailPolling(savedWebhook, requestId)
if (!success) {
@@ -606,6 +606,7 @@ export async function POST(request: NextRequest) {
`[${requestId}] Outlook provider detected. Setting up Outlook webhook configuration.`
)
try {
const { configureOutlookPolling } = await import('@/lib/webhooks/utils.server')
const success = await configureOutlookPolling(savedWebhook, requestId)
if (!success) {
@@ -642,6 +643,7 @@ export async function POST(request: NextRequest) {
if (savedWebhook && provider === 'rss') {
logger.info(`[${requestId}] RSS provider detected. Setting up RSS webhook configuration.`)
try {
const { configureRssPolling } = await import('@/lib/webhooks/utils.server')
const success = await configureRssPolling(savedWebhook, requestId)
if (!success) {

View File

@@ -4,11 +4,7 @@ import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import {
cleanupWebhooksForWorkflow,
restorePreviousVersionWebhooks,
saveTriggerWebhooksForDeploy,
} from '@/lib/webhooks/deploy'
import { cleanupWebhooksForWorkflow, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import {
deployWorkflow,
loadWorkflowFromNormalizedTables,
@@ -139,18 +135,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const deployResult = await deployWorkflow({
workflowId: id,
deployedBy: actorUserId,
@@ -177,7 +161,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
blocks: normalizedData.blocks,
requestId,
deploymentVersionId,
previousVersionId,
})
if (!triggerSaveResult.success) {
@@ -211,15 +194,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
requestId,
deploymentVersionId,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData as Record<string, unknown>,
userId: actorUserId,
previousVersionId,
requestId,
})
}
await undeployWorkflow({ workflowId: id })
return createErrorResponse(scheduleResult.error || 'Failed to create schedule', 500)
}
@@ -234,25 +208,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
)
}
if (previousVersionId && previousVersionId !== deploymentVersionId) {
try {
logger.info(`[${requestId}] Cleaning up previous version ${previousVersionId} DB records`)
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
} catch (cleanupError) {
logger.error(
`[${requestId}] Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
// Non-fatal - continue with success response
}
}
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
// Sync MCP tools with the latest parameter schema
@@ -273,7 +228,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
nextRunAt: scheduleInfo.nextRunAt,
}
: undefined,
warnings: triggerSaveResult.warnings,
})
} catch (error: any) {
logger.error(`[${requestId}] Error deploying workflow: ${id}`, {

View File

@@ -4,7 +4,7 @@ import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import { saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
@@ -85,11 +85,6 @@ export async function POST(
return createErrorResponse('Invalid deployed state structure', 500)
}
const scheduleValidation = validateWorkflowSchedules(blocks)
if (!scheduleValidation.isValid) {
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId: id,
@@ -98,8 +93,6 @@ export async function POST(
blocks,
requestId,
deploymentVersionId: versionRow.id,
previousVersionId,
forceRecreateSubscriptions: true,
})
if (!triggerSaveResult.success) {
@@ -109,6 +102,11 @@ export async function POST(
)
}
const scheduleValidation = validateWorkflowSchedules(blocks)
if (!scheduleValidation.isValid) {
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
}
const scheduleResult = await createSchedulesForDeploy(id, blocks, db, versionRow.id)
if (!scheduleResult.success) {
@@ -118,15 +116,6 @@ export async function POST(
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData as Record<string, unknown>,
userId: actorUserId,
previousVersionId,
requestId,
})
}
return createErrorResponse(scheduleResult.error || 'Failed to sync schedules', 500)
}
@@ -138,15 +127,6 @@ export async function POST(
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData as Record<string, unknown>,
userId: actorUserId,
previousVersionId,
requestId,
})
}
return createErrorResponse(result.error || 'Failed to activate deployment', 400)
}
@@ -160,7 +140,6 @@ export async function POST(
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
logger.info(`[${requestId}] Previous version cleanup completed`)
} catch (cleanupError) {
@@ -178,11 +157,7 @@ export async function POST(
context: 'activate',
})
return createSuccessResponse({
success: true,
deployedAt: result.deployedAt,
warnings: triggerSaveResult.warnings,
})
return createSuccessResponse({ success: true, deployedAt: result.deployedAt })
} catch (error: any) {
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)
return createErrorResponse(error.message || 'Failed to activate deployment', 500)

View File

@@ -116,6 +116,7 @@ type AsyncExecutionParams = {
userId: string
input: any
triggerType: CoreTriggerType
preflighted?: boolean
}
/**
@@ -138,6 +139,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
userId,
input,
triggerType,
preflighted: params.preflighted,
}
try {
@@ -274,6 +276,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
requestId
)
const shouldPreflightEnvVars = isAsyncMode && isTriggerDevEnabled
const preprocessResult = await preprocessExecution({
workflowId,
userId,
@@ -282,7 +285,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
requestId,
checkDeployment: !shouldUseDraftState,
loggingSession,
preflightEnvVars: shouldPreflightEnvVars,
useDraftState: shouldUseDraftState,
envUserId: isClientSession ? userId : undefined,
})
if (!preprocessResult.success) {
@@ -314,6 +319,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
userId: actorUserId,
input,
triggerType: loggingTriggerType,
preflighted: shouldPreflightEnvVars,
})
}

View File

@@ -1,27 +0,0 @@
'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,7 +2,6 @@ import { BookOpen, Github, Rss } from 'lucide-react'
import Link from 'next/link'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedLink } from '@/app/changelog/components/branded-link'
import ChangelogList from '@/app/changelog/components/timeline-list'
export interface ChangelogEntry {
@@ -67,24 +66,25 @@ export default async function ChangelogContent() {
<hr className='mt-6 border-border' />
<div className='mt-6 flex flex-wrap items-center gap-3 text-sm'>
<BrandedLink
<Link
href='https://github.com/simstudioai/sim/releases'
target='_blank'
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' />
View on GitHub
</BrandedLink>
</Link>
<Link
href='https://docs.sim.ai'
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'
className='inline-flex items-center gap-2 rounded-md border border-border px-3 py-1.5 hover:bg-muted'
>
<BookOpen className='h-4 w-4' />
Documentation
</Link>
<Link
href='/changelog.xml'
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'
className='inline-flex items-center gap-2 rounded-md border border-border px-3 py-1.5 hover:bg-muted'
>
<Rss className='h-4 w-4' />
RSS Feed

View File

@@ -117,7 +117,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
const [error, setError] = useState<string | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const [starCount, setStarCount] = useState('25.8k')
const [starCount, setStarCount] = useState('25.1k')
const [conversationId, setConversationId] = useState('')
const [showScrollButton, setShowScrollButton] = useState(false)

View File

@@ -207,6 +207,7 @@ function TemplateCardInner({
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
/>
) : (
<div className='h-full w-full bg-[var(--surface-4)]' />

View File

@@ -61,7 +61,6 @@ export function EditChunkModal({
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null)
const [tokenizerOn, setTokenizerOn] = useState(false)
const [hoveredTokenIndex, setHoveredTokenIndex] = useState<number | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const error = mutationError?.message ?? null
@@ -255,8 +254,6 @@ export function EditChunkModal({
style={{
backgroundColor: getTokenBgColor(index),
}}
onMouseEnter={() => setHoveredTokenIndex(index)}
onMouseLeave={() => setHoveredTokenIndex(null)}
>
{token}
</span>
@@ -284,11 +281,6 @@ export function EditChunkModal({
<div className='flex items-center gap-[8px]'>
<span className='text-[12px] text-[var(--text-secondary)]'>Tokenizer</span>
<Switch checked={tokenizerOn} onCheckedChange={setTokenizerOn} />
{tokenizerOn && hoveredTokenIndex !== null && (
<span className='text-[12px] text-[var(--text-tertiary)]'>
Token #{hoveredTokenIndex + 1}
</span>
)}
</div>
<span className='text-[12px] text-[var(--text-secondary)]'>
{tokenCount.toLocaleString()}

View File

@@ -36,7 +36,6 @@ import {
import { Input } from '@/components/ui/input'
import { SearchHighlight } from '@/components/ui/search-highlight'
import { Skeleton } from '@/components/ui/skeleton'
import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatting'
import type { ChunkData } from '@/lib/knowledge/types'
import {
ChunkContextMenu,
@@ -59,6 +58,55 @@ import {
const logger = createLogger('Document')
/**
* Formats a date string to relative time (e.g., "2h ago", "3d ago")
*/
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) {
return 'just now'
}
if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60)
return `${minutes}m ago`
}
if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600)
return `${hours}h ago`
}
if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400)
return `${days}d ago`
}
if (diffInSeconds < 2592000) {
const weeks = Math.floor(diffInSeconds / 604800)
return `${weeks}w ago`
}
if (diffInSeconds < 31536000) {
const months = Math.floor(diffInSeconds / 2592000)
return `${months}mo ago`
}
const years = Math.floor(diffInSeconds / 31536000)
return `${years}y ago`
}
/**
* Formats a date string to absolute format for tooltip display
*/
function formatAbsoluteDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
interface DocumentProps {
knowledgeBaseId: string
documentId: string
@@ -256,6 +304,7 @@ export function Document({
const [searchQuery, setSearchQuery] = useState('')
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
const [isSearching, setIsSearching] = useState(false)
const {
chunks: initialChunks,
@@ -295,6 +344,7 @@ export function Document({
const handler = setTimeout(() => {
startTransition(() => {
setDebouncedSearchQuery(searchQuery)
setIsSearching(searchQuery.trim().length > 0)
})
}, 200)
@@ -303,7 +353,6 @@ export function Document({
}
}, [searchQuery])
const isSearching = debouncedSearchQuery.trim().length > 0
const showingSearch = isSearching && searchQuery.trim().length > 0 && searchResults.length > 0
const SEARCH_PAGE_SIZE = 50
const maxSearchPages = Math.ceil(searchResults.length / SEARCH_PAGE_SIZE)

View File

@@ -27,10 +27,6 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Table,
TableBody,
TableCell,
@@ -44,11 +40,8 @@ import { Input } from '@/components/ui/input'
import { SearchHighlight } from '@/components/ui/search-highlight'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/core/utils/cn'
import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatting'
import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import type { DocumentData } from '@/lib/knowledge/types'
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
import {
ActionBar,
AddDocumentsModal,
@@ -196,8 +189,8 @@ function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps)
</div>
</div>
<div>
<Skeleton className='mt-[4px] h-[21px] w-[300px] rounded-[4px]' />
<div className='mt-[4px]'>
<Skeleton className='h-[21px] w-[300px] rounded-[4px]' />
</div>
<div className='mt-[16px] flex items-center gap-[8px]'>
@@ -215,12 +208,9 @@ function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps)
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='flex items-center gap-[8px]'>
<Skeleton className='h-[32px] w-[52px] rounded-[6px]' />
<Button disabled variant='tertiary' className='h-[32px] rounded-[6px]'>
Add Documents
</Button>
</div>
<Button disabled variant='tertiary' className='h-[32px] rounded-[6px]'>
Add Documents
</Button>
</div>
<div className='mt-[12px] flex flex-1 flex-col overflow-hidden'>
@@ -232,11 +222,73 @@ function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps)
)
}
/**
* Formats a date string to relative time (e.g., "2h ago", "3d ago")
*/
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) {
return 'just now'
}
if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60)
return `${minutes}m ago`
}
if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600)
return `${hours}h ago`
}
if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400)
return `${days}d ago`
}
if (diffInSeconds < 2592000) {
const weeks = Math.floor(diffInSeconds / 604800)
return `${weeks}w ago`
}
if (diffInSeconds < 31536000) {
const months = Math.floor(diffInSeconds / 2592000)
return `${months}mo ago`
}
const years = Math.floor(diffInSeconds / 31536000)
return `${years}y ago`
}
/**
* Formats a date string to absolute format for tooltip display
*/
function formatAbsoluteDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
interface KnowledgeBaseProps {
id: string
knowledgeBaseName?: string
}
function getFileIcon(mimeType: string, filename: string) {
const IconComponent = getDocumentIcon(mimeType, filename)
return <IconComponent className='h-6 w-5 flex-shrink-0' />
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
}
const AnimatedLoader = ({ className }: { className?: string }) => (
<Loader2 className={cn(className, 'animate-spin')} />
)
@@ -284,24 +336,53 @@ const getStatusBadge = (doc: DocumentData) => {
}
}
const TAG_SLOTS = [
'tag1',
'tag2',
'tag3',
'tag4',
'tag5',
'tag6',
'tag7',
'number1',
'number2',
'number3',
'number4',
'number5',
'date1',
'date2',
'boolean1',
'boolean2',
'boolean3',
] as const
type TagSlot = (typeof TAG_SLOTS)[number]
interface TagValue {
slot: AllTagSlot
slot: TagSlot
displayName: string
value: string
}
const TAG_FIELD_TYPES: Record<string, string> = {
tag: 'text',
number: 'number',
date: 'date',
boolean: 'boolean',
}
/**
* Computes tag values for a document
*/
function getDocumentTags(doc: DocumentData, definitions: TagDefinition[]): TagValue[] {
const result: TagValue[] = []
for (const slot of ALL_TAG_SLOTS) {
for (const slot of TAG_SLOTS) {
const raw = doc[slot]
if (raw == null) continue
const def = definitions.find((d) => d.tagSlot === slot)
const fieldType = def?.fieldType || getFieldTypeForSlot(slot) || 'text'
const fieldType = def?.fieldType || TAG_FIELD_TYPES[slot.replace(/\d+$/, '')] || 'text'
let value: string
if (fieldType === 'date') {
@@ -343,8 +424,6 @@ export function KnowledgeBase({
const [searchQuery, setSearchQuery] = useState('')
const [showTagsModal, setShowTagsModal] = useState(false)
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
const [isFilterPopoverOpen, setIsFilterPopoverOpen] = useState(false)
/**
* Memoize the search query setter to prevent unnecessary re-renders
@@ -355,7 +434,6 @@ export function KnowledgeBase({
}, [])
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(new Set())
const [isSelectAllMode, setIsSelectAllMode] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showAddDocumentsModal, setShowAddDocumentsModal] = useState(false)
const [showDeleteDocumentModal, setShowDeleteDocumentModal] = useState(false)
@@ -382,6 +460,7 @@ export function KnowledgeBase({
error: knowledgeBaseError,
refresh: refreshKnowledgeBase,
} = useKnowledgeBase(id)
const [hasProcessingDocuments, setHasProcessingDocuments] = useState(false)
const {
documents,
@@ -390,7 +469,6 @@ export function KnowledgeBase({
isFetching: isFetchingDocuments,
isPlaceholderData: isPlaceholderDocuments,
error: documentsError,
hasProcessingDocuments,
updateDocument,
refreshDocuments,
} = useKnowledgeBaseDocuments(id, {
@@ -399,14 +477,7 @@ export function KnowledgeBase({
offset: (currentPage - 1) * DOCUMENTS_PER_PAGE,
sortBy,
sortOrder,
refetchInterval: (data) => {
if (isDeleting) return false
const hasPending = data?.documents?.some(
(doc) => doc.processingStatus === 'pending' || doc.processingStatus === 'processing'
)
return hasPending ? 3000 : false
},
enabledFilter,
refetchInterval: hasProcessingDocuments && !isDeleting ? 3000 : false,
})
const { tagDefinitions } = useKnowledgeBaseTagDefinitions(id)
@@ -472,52 +543,52 @@ export function KnowledgeBase({
</TableHead>
)
useEffect(() => {
const processing = documents.some(
(doc) => doc.processingStatus === 'pending' || doc.processingStatus === 'processing'
)
setHasProcessingDocuments(processing)
if (processing) {
checkForDeadProcesses()
}
}, [documents])
/**
* Checks for documents with stale processing states and marks them as failed
*/
const checkForDeadProcesses = useCallback(
(docsToCheck: DocumentData[]) => {
const now = new Date()
const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes
const checkForDeadProcesses = () => {
const now = new Date()
const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes
const staleDocuments = docsToCheck.filter((doc) => {
if (doc.processingStatus !== 'processing' || !doc.processingStartedAt) {
return false
}
const staleDocuments = documents.filter((doc) => {
if (doc.processingStatus !== 'processing' || !doc.processingStartedAt) {
return false
}
const processingDuration = now.getTime() - new Date(doc.processingStartedAt).getTime()
return processingDuration > DEAD_PROCESS_THRESHOLD_MS
})
const processingDuration = now.getTime() - new Date(doc.processingStartedAt).getTime()
return processingDuration > DEAD_PROCESS_THRESHOLD_MS
})
if (staleDocuments.length === 0) return
if (staleDocuments.length === 0) return
logger.warn(`Found ${staleDocuments.length} documents with dead processes`)
logger.warn(`Found ${staleDocuments.length} documents with dead processes`)
staleDocuments.forEach((doc) => {
updateDocumentMutation(
{
knowledgeBaseId: id,
documentId: doc.id,
updates: { markFailedDueToTimeout: true },
staleDocuments.forEach((doc) => {
updateDocumentMutation(
{
knowledgeBaseId: id,
documentId: doc.id,
updates: { markFailedDueToTimeout: true },
},
{
onSuccess: () => {
logger.info(`Successfully marked dead process as failed for document: ${doc.filename}`)
},
{
onSuccess: () => {
logger.info(
`Successfully marked dead process as failed for document: ${doc.filename}`
)
},
}
)
})
},
[id, updateDocumentMutation]
)
useEffect(() => {
if (hasProcessingDocuments) {
checkForDeadProcesses(documents)
}
}, [hasProcessingDocuments, documents, checkForDeadProcesses])
}
)
})
}
const handleToggleEnabled = (docId: string) => {
const document = documents.find((doc) => doc.id === docId)
@@ -677,7 +748,6 @@ export function KnowledgeBase({
setSelectedDocuments(new Set(documents.map((doc) => doc.id)))
} else {
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}
}
@@ -723,26 +793,6 @@ export function KnowledgeBase({
* Handles bulk enabling of selected documents
*/
const handleBulkEnable = () => {
if (isSelectAllMode) {
bulkDocumentMutation(
{
knowledgeBaseId: id,
operation: 'enable',
selectAll: true,
enabledFilter,
},
{
onSuccess: (result) => {
logger.info(`Successfully enabled ${result.successCount} documents`)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
refreshDocuments()
},
}
)
return
}
const documentsToEnable = documents.filter(
(doc) => selectedDocuments.has(doc.id) && !doc.enabled
)
@@ -771,26 +821,6 @@ export function KnowledgeBase({
* Handles bulk disabling of selected documents
*/
const handleBulkDisable = () => {
if (isSelectAllMode) {
bulkDocumentMutation(
{
knowledgeBaseId: id,
operation: 'disable',
selectAll: true,
enabledFilter,
},
{
onSuccess: (result) => {
logger.info(`Successfully disabled ${result.successCount} documents`)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
refreshDocuments()
},
}
)
return
}
const documentsToDisable = documents.filter(
(doc) => selectedDocuments.has(doc.id) && doc.enabled
)
@@ -815,35 +845,18 @@ export function KnowledgeBase({
)
}
/**
* Opens the bulk delete confirmation modal
*/
const handleBulkDelete = () => {
if (selectedDocuments.size === 0) return
setShowBulkDeleteModal(true)
}
/**
* Confirms and executes the bulk deletion of selected documents
*/
const confirmBulkDelete = () => {
if (isSelectAllMode) {
bulkDocumentMutation(
{
knowledgeBaseId: id,
operation: 'delete',
selectAll: true,
enabledFilter,
},
{
onSuccess: (result) => {
logger.info(`Successfully deleted ${result.successCount} documents`)
refreshDocuments()
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
},
onSettled: () => {
setShowBulkDeleteModal(false)
},
}
)
return
}
const documentsToDelete = documents.filter((doc) => selectedDocuments.has(doc.id))
if (documentsToDelete.length === 0) return
@@ -868,17 +881,14 @@ export function KnowledgeBase({
}
const selectedDocumentsList = documents.filter((doc) => selectedDocuments.has(doc.id))
const enabledCount = isSelectAllMode
? enabledFilter === 'disabled'
? 0
: pagination.total
: selectedDocumentsList.filter((doc) => doc.enabled).length
const disabledCount = isSelectAllMode
? enabledFilter === 'enabled'
? 0
: pagination.total
: selectedDocumentsList.filter((doc) => !doc.enabled).length
const enabledCount = selectedDocumentsList.filter((doc) => doc.enabled).length
const disabledCount = selectedDocumentsList.filter((doc) => !doc.enabled).length
/**
* Handle right-click on a document row
* If right-clicking on an unselected document, select only that document
* If right-clicking on a selected document with multiple selections, keep all selections
*/
const handleDocumentContextMenu = useCallback(
(e: React.MouseEvent, doc: DocumentData) => {
const isCurrentlySelected = selectedDocuments.has(doc.id)
@@ -995,13 +1005,11 @@ export function KnowledgeBase({
</div>
</div>
<div>
{knowledgeBase?.description && (
<p className='mt-[4px] line-clamp-2 max-w-[40vw] font-medium text-[14px] text-[var(--text-tertiary)]'>
{knowledgeBase.description}
</p>
)}
</div>
{knowledgeBase?.description && (
<p className='mt-[4px] line-clamp-2 max-w-[40vw] font-medium text-[14px] text-[var(--text-tertiary)]'>
{knowledgeBase.description}
</p>
)}
<div className='mt-[16px] flex items-center gap-[8px]'>
<span className='text-[14px] text-[var(--text-muted)]'>
@@ -1044,76 +1052,21 @@ export function KnowledgeBase({
))}
</div>
<div className='flex items-center gap-[8px]'>
<Popover open={isFilterPopoverOpen} onOpenChange={setIsFilterPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='default' className='h-[32px] rounded-[6px]'>
{enabledFilter === 'all'
? 'All'
: enabledFilter === 'enabled'
? 'Enabled'
: 'Disabled'}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' side='bottom' sideOffset={4}>
<div className='flex flex-col gap-[2px]'>
<PopoverItem
active={enabledFilter === 'all'}
onClick={() => {
setEnabledFilter('all')
setIsFilterPopoverOpen(false)
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}}
>
All
</PopoverItem>
<PopoverItem
active={enabledFilter === 'enabled'}
onClick={() => {
setEnabledFilter('enabled')
setIsFilterPopoverOpen(false)
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}}
>
Enabled
</PopoverItem>
<PopoverItem
active={enabledFilter === 'disabled'}
onClick={() => {
setEnabledFilter('disabled')
setIsFilterPopoverOpen(false)
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}}
>
Disabled
</PopoverItem>
</div>
</PopoverContent>
</Popover>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
onClick={handleAddDocuments}
disabled={userPermissions.canEdit !== true}
variant='tertiary'
className='h-[32px] rounded-[6px]'
>
Add Documents
</Button>
</Tooltip.Trigger>
{userPermissions.canEdit !== true && (
<Tooltip.Content>Write permission required to add documents</Tooltip.Content>
)}
</Tooltip.Root>
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
onClick={handleAddDocuments}
disabled={userPermissions.canEdit !== true}
variant='tertiary'
className='h-[32px] rounded-[6px]'
>
Add Documents
</Button>
</Tooltip.Trigger>
{userPermissions.canEdit !== true && (
<Tooltip.Content>Write permission required to add documents</Tooltip.Content>
)}
</Tooltip.Root>
</div>
{error && !isLoadingKnowledgeBase && (
@@ -1136,20 +1089,14 @@ export function KnowledgeBase({
<div className='mt-[10px] flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
{searchQuery
? 'No documents found'
: enabledFilter !== 'all'
? 'Nothing matches your filter'
: 'No documents yet'}
{searchQuery ? 'No documents found' : 'No documents yet'}
</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>
{searchQuery
? 'Try a different search term'
: enabledFilter !== 'all'
? 'Try changing the filter'
: userPermissions.canEdit === true
? 'Add documents to get started'
: 'Documents will appear here once added'}
: userPermissions.canEdit === true
? 'Add documents to get started'
: 'Documents will appear here once added'}
</p>
</div>
</div>
@@ -1173,7 +1120,7 @@ export function KnowledgeBase({
{renderSortableHeader('tokenCount', 'Tokens', 'hidden w-[8%] lg:table-cell')}
{renderSortableHeader('chunkCount', 'Chunks', 'w-[8%]')}
{renderSortableHeader('uploadedAt', 'Uploaded', 'w-[11%]')}
{renderSortableHeader('enabled', 'Status', 'w-[10%]')}
{renderSortableHeader('processingStatus', 'Status', 'w-[10%]')}
<TableHead className='w-[12%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
Tags
</TableHead>
@@ -1217,10 +1164,7 @@ export function KnowledgeBase({
</TableCell>
<TableCell className='w-[180px] max-w-[180px] px-[12px] py-[8px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
{(() => {
const IconComponent = getDocumentIcon(doc.mimeType, doc.filename)
return <IconComponent className='h-6 w-5 flex-shrink-0' />
})()}
{getFileIcon(doc.mimeType, doc.filename)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
@@ -1564,14 +1508,6 @@ export function KnowledgeBase({
enabledCount={enabledCount}
disabledCount={disabledCount}
isLoading={isBulkOperating}
totalCount={pagination.total}
isAllPageSelected={isAllSelected}
isAllSelected={isSelectAllMode}
onSelectAll={() => setIsSelectAllMode(true)}
onClearSelectAll={() => {
setIsSelectAllMode(false)
setSelectedDocuments(new Set())
}}
/>
<DocumentContextMenu

View File

@@ -13,11 +13,6 @@ interface ActionBarProps {
disabledCount?: number
isLoading?: boolean
className?: string
totalCount?: number
isAllPageSelected?: boolean
isAllSelected?: boolean
onSelectAll?: () => void
onClearSelectAll?: () => void
}
export function ActionBar({
@@ -29,21 +24,14 @@ export function ActionBar({
disabledCount = 0,
isLoading = false,
className,
totalCount = 0,
isAllPageSelected = false,
isAllSelected = false,
onSelectAll,
onClearSelectAll,
}: ActionBarProps) {
const userPermissions = useUserPermissionsContext()
if (selectedCount === 0 && !isAllSelected) return null
if (selectedCount === 0) return null
const canEdit = userPermissions.canEdit
const showEnableButton = disabledCount > 0 && onEnable && canEdit
const showDisableButton = enabledCount > 0 && onDisable && canEdit
const showSelectAllOption =
isAllPageSelected && !isAllSelected && totalCount > selectedCount && onSelectAll
return (
<motion.div
@@ -55,31 +43,7 @@ export function ActionBar({
>
<div className='flex items-center gap-[8px] rounded-[10px] border border-[var(--border)] bg-[var(--surface-2)] px-[8px] py-[6px]'>
<span className='px-[4px] text-[13px] text-[var(--text-secondary)]'>
{isAllSelected ? totalCount : selectedCount} selected
{showSelectAllOption && (
<>
{' · '}
<button
type='button'
onClick={onSelectAll}
className='text-[var(--brand-primary)] hover:underline'
>
Select all
</button>
</>
)}
{isAllSelected && onClearSelectAll && (
<>
{' · '}
<button
type='button'
onClick={onClearSelectAll}
className='text-[var(--brand-primary)] hover:underline'
>
Clear
</button>
</>
)}
{selectedCount} selected
</span>
<div className='flex items-center gap-[5px]'>

View File

@@ -123,11 +123,7 @@ export function RenameDocumentModal({
>
Cancel
</Button>
<Button
variant='tertiary'
type='submit'
disabled={isSubmitting || !name?.trim() || name.trim() === initialName}
>
<Button variant='tertiary' type='submit' disabled={isSubmitting || !name?.trim()}>
{isSubmitting ? 'Renaming...' : 'Rename'}
</Button>
</div>

View File

@@ -3,7 +3,6 @@
import { useCallback, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Badge, DocumentAttachment, Tooltip } from '@/components/emcn'
import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatting'
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
@@ -22,6 +21,55 @@ interface BaseCardProps {
onDelete?: (id: string) => Promise<void>
}
/**
* Formats a date string to relative time (e.g., "2h ago", "3d ago")
*/
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) {
return 'just now'
}
if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60)
return `${minutes}m ago`
}
if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600)
return `${hours}h ago`
}
if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400)
return `${days}d ago`
}
if (diffInSeconds < 2592000) {
const weeks = Math.floor(diffInSeconds / 604800)
return `${weeks}w ago`
}
if (diffInSeconds < 31536000) {
const months = Math.floor(diffInSeconds / 2592000)
return `${months}mo ago`
}
const years = Math.floor(diffInSeconds / 31536000)
return `${years}y ago`
}
/**
* Formats a date string to absolute format for tooltip display
*/
function formatAbsoluteDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
/**
* Skeleton placeholder for a knowledge base card
*/

View File

@@ -344,51 +344,53 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
<Textarea
id='description'
placeholder='Describe this knowledge base (optional)'
rows={4}
rows={3}
{...register('description')}
className={cn(errors.description && 'border-[var(--text-error)]')}
/>
</div>
<div className='grid grid-cols-2 gap-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='minChunkSize'>Min Chunk Size (characters)</Label>
<Input
id='minChunkSize'
placeholder='100'
{...register('minChunkSize', { valueAsNumber: true })}
className={cn(errors.minChunkSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='min-chunk-size'
/>
<div className='space-y-[12px] rounded-[6px] bg-[var(--surface-5)] px-[12px] py-[14px]'>
<div className='grid grid-cols-2 gap-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='minChunkSize'>Min Chunk Size (characters)</Label>
<Input
id='minChunkSize'
placeholder='100'
{...register('minChunkSize', { valueAsNumber: true })}
className={cn(errors.minChunkSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='min-chunk-size'
/>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='maxChunkSize'>Max Chunk Size (tokens)</Label>
<Input
id='maxChunkSize'
placeholder='1024'
{...register('maxChunkSize', { valueAsNumber: true })}
className={cn(errors.maxChunkSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='max-chunk-size'
/>
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='maxChunkSize'>Max Chunk Size (tokens)</Label>
<Label htmlFor='overlapSize'>Overlap (tokens)</Label>
<Input
id='maxChunkSize'
placeholder='1024'
{...register('maxChunkSize', { valueAsNumber: true })}
className={cn(errors.maxChunkSize && 'border-[var(--text-error)]')}
id='overlapSize'
placeholder='200'
{...register('overlapSize', { valueAsNumber: true })}
className={cn(errors.overlapSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='max-chunk-size'
name='overlap-size'
/>
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='overlapSize'>Overlap (tokens)</Label>
<Input
id='overlapSize'
placeholder='200'
{...register('overlapSize', { valueAsNumber: true })}
className={cn(errors.overlapSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='overlap-size'
/>
<p className='text-[11px] text-[var(--text-muted)]'>
1 token 4 characters. Max chunk size and overlap are in tokens.
</p>

View File

@@ -59,7 +59,7 @@ export function EditKnowledgeBaseModal({
handleSubmit,
reset,
watch,
formState: { errors, isDirty },
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
@@ -127,7 +127,7 @@ export function EditKnowledgeBaseModal({
<Textarea
id='description'
placeholder='Describe this knowledge base (optional)'
rows={4}
rows={3}
{...register('description')}
className={cn(errors.description && 'border-[var(--text-error)]')}
/>
@@ -161,7 +161,7 @@ export function EditKnowledgeBaseModal({
<Button
variant='tertiary'
type='submit'
disabled={isSubmitting || !nameValue?.trim() || !isDirty}
disabled={isSubmitting || !nameValue?.trim()}
>
{isSubmitting ? 'Saving...' : 'Save'}
</Button>

View File

@@ -16,8 +16,8 @@ import {
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn'
import {
BlockDetailsSidebar,
getLeftmostBlockId,
PreviewEditor,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { useExecutionSnapshot } from '@/hooks/queries/logs'
@@ -248,10 +248,11 @@ export function ExecutionSnapshot({
cursorStyle='pointer'
executedBlocks={blockExecutions}
selectedBlockId={pinnedBlockId}
lightweight
/>
</div>
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
<PreviewEditor
<BlockDetailsSidebar
block={workflowState.blocks[pinnedBlockId]}
executionData={blockExecutions[pinnedBlockId]}
allBlockExecutions={blockExecutions}

View File

@@ -2,7 +2,8 @@
import type React from 'react'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { ArrowDown, ArrowUp, Check, Clipboard, Search, X } from 'lucide-react'
import clsx from 'clsx'
import { ArrowDown, ArrowUp, X } from 'lucide-react'
import { createPortal } from 'react-dom'
import {
Button,
@@ -14,11 +15,9 @@ import {
PopoverContent,
PopoverDivider,
PopoverItem,
Tooltip,
} from '@/components/emcn'
import { WorkflowIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getBlock, getBlockByToolName } from '@/blocks'
@@ -27,6 +26,7 @@ import type { TraceSpan } from '@/stores/logs/filters/types'
interface TraceSpansProps {
traceSpans?: TraceSpan[]
totalDuration?: number
}
/**
@@ -100,20 +100,6 @@ function parseTime(value?: string | number | null): number {
return Number.isFinite(ms) ? ms : 0
}
/**
* Checks if a span or any of its descendants has an error
*/
function hasErrorInTree(span: TraceSpan): boolean {
if (span.status === 'error') return true
if (span.children && span.children.length > 0) {
return span.children.some((child) => hasErrorInTree(child))
}
if (span.toolCalls && span.toolCalls.length > 0) {
return span.toolCalls.some((tc) => tc.error)
}
return false
}
/**
* Normalizes and sorts trace spans recursively.
* Merges children from both span.children and span.output.childTraceSpans,
@@ -156,6 +142,14 @@ function normalizeAndSortSpans(spans: TraceSpan[]): TraceSpan[] {
const DEFAULT_BLOCK_COLOR = '#6b7280'
/**
* Formats duration in ms
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
}
/**
* Gets icon and color for a span type using block config
*/
@@ -236,7 +230,7 @@ function ProgressBar({
}, [span, childSpans, workflowStartTime, totalDuration])
return (
<div className='relative h-[5px] w-full overflow-hidden rounded-[18px] bg-[var(--divider)]'>
<div className='relative mb-[8px] h-[5px] w-full overflow-hidden rounded-[18px] bg-[var(--divider)]'>
{segments.map((segment, index) => (
<div
key={index}
@@ -252,6 +246,143 @@ function ProgressBar({
)
}
interface ExpandableRowHeaderProps {
name: string
duration: number
isError: boolean
isExpanded: boolean
hasChildren: boolean
showIcon: boolean
icon: React.ComponentType<{ className?: string }> | null
bgColor: string
onToggle: () => void
}
/**
* Reusable expandable row header with chevron, icon, name, and duration
*/
function ExpandableRowHeader({
name,
duration,
isError,
isExpanded,
hasChildren,
showIcon,
icon: Icon,
bgColor,
onToggle,
}: ExpandableRowHeaderProps) {
return (
<div
className={clsx('group flex items-center justify-between', hasChildren && 'cursor-pointer')}
onClick={hasChildren ? onToggle : undefined}
onKeyDown={
hasChildren
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={hasChildren ? 'button' : undefined}
tabIndex={hasChildren ? 0 : undefined}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-label={hasChildren ? (isExpanded ? 'Collapse' : 'Expand') : undefined}
>
<div className='flex items-center gap-[8px]'>
{hasChildren && (
<ChevronDown
className='h-[10px] w-[10px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]'
style={{ transform: isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' }}
/>
)}
{showIcon && (
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: bgColor }}
>
{Icon && <Icon className={clsx('text-white', '!h-[9px] !w-[9px]')} />}
</div>
)}
<span
className='font-medium text-[12px]'
style={{ color: isError ? 'var(--text-error)' : 'var(--text-secondary)' }}
>
{name}
</span>
</div>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(duration)}
</span>
</div>
)
}
interface SpanContentProps {
span: TraceSpan
spanId: string
isError: boolean
workflowStartTime: number
totalDuration: number
expandedSections: Set<string>
onToggle: (section: string) => void
}
/**
* Reusable component for rendering span content (progress bar + input/output sections)
*/
function SpanContent({
span,
spanId,
isError,
workflowStartTime,
totalDuration,
expandedSections,
onToggle,
}: SpanContentProps) {
const hasInput = Boolean(span.input)
const hasOutput = Boolean(span.output)
return (
<>
<ProgressBar
span={span}
childSpans={span.children}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
/>
{hasInput && (
<InputOutputSection
label='Input'
data={span.input}
isError={false}
spanId={spanId}
sectionType='input'
expandedSections={expandedSections}
onToggle={onToggle}
/>
)}
{hasInput && hasOutput && <div className='border-[var(--border)] border-t border-dashed' />}
{hasOutput && (
<InputOutputSection
label={isError ? 'Error' : 'Output'}
data={span.output}
isError={isError}
spanId={spanId}
sectionType='output'
expandedSections={expandedSections}
onToggle={onToggle}
/>
)}
</>
)
}
/**
* Renders input/output section with collapsible content, context menu, and search
*/
@@ -275,14 +406,16 @@ function InputOutputSection({
const sectionKey = `${spanId}-${sectionType}`
const isExpanded = expandedSections.has(sectionKey)
const contentRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// Context menu state
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const [copied, setCopied] = useState(false)
// Code viewer features
const {
wrapText,
toggleWrapText,
isSearchActive,
searchQuery,
setSearchQuery,
@@ -314,8 +447,6 @@ function InputOutputSection({
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(jsonString)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
closeContextMenu()
}, [jsonString, closeContextMenu])
@@ -324,8 +455,13 @@ function InputOutputSection({
closeContextMenu()
}, [activateSearch, closeContextMenu])
const handleToggleWrap = useCallback(() => {
toggleWrapText()
closeContextMenu()
}, [toggleWrapText, closeContextMenu])
return (
<div className='relative flex min-w-0 flex-col gap-[6px] overflow-hidden'>
<div className='relative flex min-w-0 flex-col gap-[8px] overflow-hidden'>
<div
className='group flex cursor-pointer items-center justify-between'
onClick={() => onToggle(sectionKey)}
@@ -341,7 +477,7 @@ function InputOutputSection({
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${label.toLowerCase()}`}
>
<span
className={cn(
className={clsx(
'font-medium text-[12px] transition-colors',
isError
? 'text-[var(--text-error)]'
@@ -351,7 +487,9 @@ function InputOutputSection({
{label}
</span>
<ChevronDown
className='h-[8px] w-[8px] text-[var(--text-tertiary)] transition-colors transition-transform group-hover:text-[var(--text-primary)]'
className={clsx(
'h-[10px] w-[10px] text-[var(--text-tertiary)] transition-colors transition-transform group-hover:text-[var(--text-primary)]'
)}
style={{
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
@@ -359,57 +497,16 @@ function InputOutputSection({
</div>
{isExpanded && (
<>
<div ref={contentRef} onContextMenu={handleContextMenu} className='relative'>
<div ref={contentRef} onContextMenu={handleContextMenu}>
<Code.Viewer
code={jsonString}
language='json'
className='!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText
className='!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText={wrapText}
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
/>
{/* Glass action buttons overlay */}
{!isSearchActive && (
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
onClick={(e) => {
e.stopPropagation()
handleCopy()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
{copied ? (
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
) : (
<Clipboard className='h-[10px] w-[10px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
onClick={(e) => {
e.stopPropagation()
activateSearch()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div>
{/* Search Overlay */}
@@ -482,10 +579,13 @@ function InputOutputSection({
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
<PopoverItem showCheck={wrapText} onClick={handleToggleWrap}>
Wrap Text
</PopoverItem>
</PopoverContent>
</Popover>,
document.body
@@ -496,229 +596,355 @@ function InputOutputSection({
)
}
interface TraceSpanNodeProps {
interface NestedBlockItemProps {
span: TraceSpan
parentId: string
index: number
expandedSections: Set<string>
onToggle: (section: string) => void
workflowStartTime: number
totalDuration: number
depth: number
expandedNodes: Set<string>
expandedSections: Set<string>
onToggleNode: (nodeId: string) => void
onToggleSection: (section: string) => void
expandedChildren: Set<string>
onToggleChildren: (spanId: string) => void
}
/**
* Recursive tree node component for rendering trace spans
* Recursive component for rendering nested blocks at any depth
*/
const TraceSpanNode = memo(function TraceSpanNode({
function NestedBlockItem({
span,
parentId,
index,
expandedSections,
onToggle,
workflowStartTime,
totalDuration,
depth,
expandedNodes,
expandedSections,
onToggleNode,
onToggleSection,
}: TraceSpanNodeProps): React.ReactNode {
expandedChildren,
onToggleChildren,
}: NestedBlockItemProps): React.ReactNode {
const spanId = span.id || `${parentId}-nested-${index}`
const isError = span.status === 'error'
const { icon: SpanIcon, bgColor } = getBlockIconAndColor(span.type, span.name)
const hasChildren = Boolean(span.children && span.children.length > 0)
const isChildrenExpanded = expandedChildren.has(spanId)
return (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
<ExpandableRowHeader
name={span.name}
duration={span.duration || 0}
isError={isError}
isExpanded={isChildrenExpanded}
hasChildren={hasChildren}
showIcon={!isIterationType(span.type)}
icon={SpanIcon}
bgColor={bgColor}
onToggle={() => onToggleChildren(spanId)}
/>
<SpanContent
span={span}
spanId={spanId}
isError={isError}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
expandedSections={expandedSections}
onToggle={onToggle}
/>
{/* Nested children */}
{hasChildren && isChildrenExpanded && (
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l pl-[10px]'>
{span.children!.map((child, childIndex) => (
<NestedBlockItem
key={child.id || `${spanId}-child-${childIndex}`}
span={child}
parentId={spanId}
index={childIndex}
expandedSections={expandedSections}
onToggle={onToggle}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
expandedChildren={expandedChildren}
onToggleChildren={onToggleChildren}
/>
))}
</div>
)}
</div>
)
}
interface TraceSpanItemProps {
span: TraceSpan
totalDuration: number
workflowStartTime: number
isFirstSpan?: boolean
}
/**
* Individual trace span card component.
* Memoized to prevent re-renders when sibling spans change.
*/
const TraceSpanItem = memo(function TraceSpanItem({
span,
totalDuration,
workflowStartTime,
isFirstSpan = false,
}: TraceSpanItemProps): React.ReactNode {
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
const [expandedChildren, setExpandedChildren] = useState<Set<string>>(new Set())
const [isCardExpanded, setIsCardExpanded] = useState(false)
const toggleSet = useSetToggle()
const spanId = span.id || `span-${span.name}-${span.startTime}`
const spanStartTime = new Date(span.startTime).getTime()
const spanEndTime = new Date(span.endTime).getTime()
const duration = span.duration || spanEndTime - spanStartTime
const isDirectError = span.status === 'error'
const hasNestedError = hasErrorInTree(span)
const showErrorStyle = isDirectError || hasNestedError
const hasChildren = Boolean(span.children && span.children.length > 0)
const hasToolCalls = Boolean(span.toolCalls && span.toolCalls.length > 0)
const isError = span.status === 'error'
const inlineChildTypes = new Set([
'tool',
'model',
'loop-iteration',
'parallel-iteration',
'workflow',
])
// For workflow-in-workflow blocks, all children should be rendered inline/nested
const isWorkflowBlock = span.type?.toLowerCase().includes('workflow')
const inlineChildren = isWorkflowBlock
? span.children || []
: span.children?.filter((child) => inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
const otherChildren = isWorkflowBlock
? []
: span.children?.filter((child) => !inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
const toolCallSpans = useMemo(() => {
if (!hasToolCalls) return []
return span.toolCalls!.map((toolCall, index) => {
const toolStartTime = toolCall.startTime
? new Date(toolCall.startTime).getTime()
: spanStartTime
const toolEndTime = toolCall.endTime
? new Date(toolCall.endTime).getTime()
: toolStartTime + (toolCall.duration || 0)
return {
id: `${spanId}-tool-${index}`,
name: toolCall.name,
type: 'tool',
duration: toolCall.duration || toolEndTime - toolStartTime,
startTime: new Date(toolStartTime).toISOString(),
endTime: new Date(toolEndTime).toISOString(),
status: toolCall.error ? ('error' as const) : ('success' as const),
input: toolCall.input,
output: toolCall.error
? { error: toolCall.error, ...(toolCall.output || {}) }
: toolCall.output,
} as TraceSpan
})
}, [hasToolCalls, span.toolCalls, spanId, spanStartTime])
const handleSectionToggle = useCallback(
(section: string) => toggleSet(setExpandedSections, section),
[toggleSet]
)
const handleChildrenToggle = useCallback(
(childSpanId: string) => toggleSet(setExpandedChildren, childSpanId),
[toggleSet]
)
const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name)
// Root workflow execution is always expanded and has no toggle
const isRootWorkflow = depth === 0
// Check if this card has expandable inline content
const hasInlineContent =
(isWorkflowBlock && inlineChildren.length > 0) ||
(!isWorkflowBlock && (toolCallSpans.length > 0 || inlineChildren.length > 0))
// Build all children including tool calls
const allChildren = useMemo(() => {
const children: TraceSpan[] = []
// Add tool calls as child spans
if (span.toolCalls && span.toolCalls.length > 0) {
span.toolCalls.forEach((toolCall, index) => {
const toolStartTime = toolCall.startTime
? new Date(toolCall.startTime).getTime()
: spanStartTime
const toolEndTime = toolCall.endTime
? new Date(toolCall.endTime).getTime()
: toolStartTime + (toolCall.duration || 0)
children.push({
id: `${spanId}-tool-${index}`,
name: toolCall.name,
type: 'tool',
duration: toolCall.duration || toolEndTime - toolStartTime,
startTime: new Date(toolStartTime).toISOString(),
endTime: new Date(toolEndTime).toISOString(),
status: toolCall.error ? ('error' as const) : ('success' as const),
input: toolCall.input,
output: toolCall.error
? { error: toolCall.error, ...(toolCall.output || {}) }
: toolCall.output,
} as TraceSpan)
})
}
// Add regular children
if (span.children && span.children.length > 0) {
children.push(...span.children)
}
// Sort by start time
return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime))
}, [span, spanId, spanStartTime])
const hasChildren = allChildren.length > 0
const isExpanded = isRootWorkflow || expandedNodes.has(spanId)
const isToggleable = !isRootWorkflow
const hasInput = Boolean(span.input)
const hasOutput = Boolean(span.output)
// For progress bar - show child segments for workflow/iteration types
const lowerType = span.type?.toLowerCase() || ''
const showChildrenInProgressBar =
isIterationType(lowerType) || lowerType === 'workflow' || lowerType === 'workflow_input'
const isExpandable = !isFirstSpan && hasInlineContent
return (
<div className='flex min-w-0 flex-col'>
{/* Node Header Row */}
<div
className={cn(
'group flex items-center justify-between gap-[8px] py-[6px]',
isToggleable && 'cursor-pointer'
<>
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px]'>
<ExpandableRowHeader
name={span.name}
duration={duration}
isError={isError}
isExpanded={isCardExpanded}
hasChildren={isExpandable}
showIcon={!isFirstSpan}
icon={BlockIcon}
bgColor={bgColor}
onToggle={() => setIsCardExpanded((prev) => !prev)}
/>
<SpanContent
span={span}
spanId={spanId}
isError={isError}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
expandedSections={expandedSections}
onToggle={handleSectionToggle}
/>
{/* For workflow blocks, keep children nested within the card (not as separate cards) */}
{!isFirstSpan && isWorkflowBlock && inlineChildren.length > 0 && isCardExpanded && (
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l pl-[10px]'>
{inlineChildren.map((childSpan, index) => (
<NestedBlockItem
key={childSpan.id || `${spanId}-nested-${index}`}
span={childSpan}
parentId={spanId}
index={index}
expandedSections={expandedSections}
onToggle={handleSectionToggle}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
expandedChildren={expandedChildren}
onToggleChildren={handleChildrenToggle}
/>
))}
</div>
)}
onClick={isToggleable ? () => onToggleNode(spanId) : undefined}
onKeyDown={
isToggleable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggleNode(spanId)
}
}
: undefined
}
role={isToggleable ? 'button' : undefined}
tabIndex={isToggleable ? 0 : undefined}
aria-expanded={isToggleable ? isExpanded : undefined}
aria-label={isToggleable ? (isExpanded ? 'Collapse' : 'Expand') : undefined}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
{!isIterationType(span.type) && (
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: bgColor }}
>
{BlockIcon && <BlockIcon className='h-[9px] w-[9px] text-white' />}
</div>
)}
<span
className='min-w-0 max-w-[180px] truncate font-medium text-[12px]'
style={{ color: showErrorStyle ? 'var(--text-error)' : 'var(--text-secondary)' }}
>
{span.name}
</span>
{isToggleable && (
<ChevronDown
className='h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-colors transition-transform duration-100 group-hover:text-[var(--text-primary)]'
style={{
transform: `translateY(-0.25px) ${isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)'}`,
}}
/>
)}
</div>
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(duration, { precision: 2 })}
</span>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className='flex min-w-0 flex-col gap-[10px]'>
{/* Progress Bar */}
<ProgressBar
span={span}
childSpans={showChildrenInProgressBar ? span.children : undefined}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
/>
{/* For non-workflow blocks, render inline children/tool calls */}
{!isFirstSpan && !isWorkflowBlock && isCardExpanded && (
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l pl-[10px]'>
{[...toolCallSpans, ...inlineChildren].map((childSpan, index) => {
const childId = childSpan.id || `${spanId}-inline-${index}`
const childIsError = childSpan.status === 'error'
const childLowerType = childSpan.type?.toLowerCase() || ''
const hasNestedChildren = Boolean(childSpan.children && childSpan.children.length > 0)
const isNestedExpanded = expandedChildren.has(childId)
const showChildrenInProgressBar =
isIterationType(childLowerType) || childLowerType === 'workflow'
const { icon: ChildIcon, bgColor: childBgColor } = getBlockIconAndColor(
childSpan.type,
childSpan.name
)
{/* Input/Output Sections */}
{(hasInput || hasOutput) && (
<div className='flex min-w-0 flex-col gap-[6px] overflow-hidden py-[2px]'>
{hasInput && (
<InputOutputSection
label='Input'
data={span.input}
isError={false}
spanId={spanId}
sectionType='input'
expandedSections={expandedSections}
onToggle={onToggleSection}
/>
)}
return (
<div
key={`inline-${childId}`}
className='flex min-w-0 flex-col gap-[8px] overflow-hidden'
>
<ExpandableRowHeader
name={childSpan.name}
duration={childSpan.duration || 0}
isError={childIsError}
isExpanded={isNestedExpanded}
hasChildren={hasNestedChildren}
showIcon={!isIterationType(childSpan.type)}
icon={ChildIcon}
bgColor={childBgColor}
onToggle={() => handleChildrenToggle(childId)}
/>
{hasInput && hasOutput && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
{hasOutput && (
<InputOutputSection
label={isDirectError ? 'Error' : 'Output'}
data={span.output}
isError={isDirectError}
spanId={spanId}
sectionType='output'
expandedSections={expandedSections}
onToggle={onToggleSection}
/>
)}
</div>
)}
{/* Nested Children */}
{hasChildren && (
<div className='flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[10px]'>
{allChildren.map((child, index) => (
<div key={child.id || `${spanId}-child-${index}`} className='pl-[6px]'>
<TraceSpanNode
span={child}
<ProgressBar
span={childSpan}
childSpans={showChildrenInProgressBar ? childSpan.children : undefined}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
depth={depth + 1}
expandedNodes={expandedNodes}
expandedSections={expandedSections}
onToggleNode={onToggleNode}
onToggleSection={onToggleSection}
/>
{childSpan.input && (
<InputOutputSection
label='Input'
data={childSpan.input}
isError={false}
spanId={childId}
sectionType='input'
expandedSections={expandedSections}
onToggle={handleSectionToggle}
/>
)}
{childSpan.input && childSpan.output && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
{childSpan.output && (
<InputOutputSection
label={childIsError ? 'Error' : 'Output'}
data={childSpan.output}
isError={childIsError}
spanId={childId}
sectionType='output'
expandedSections={expandedSections}
onToggle={handleSectionToggle}
/>
)}
{/* Nested children */}
{showChildrenInProgressBar && hasNestedChildren && isNestedExpanded && (
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l pl-[10px]'>
{childSpan.children!.map((nestedChild, nestedIndex) => (
<NestedBlockItem
key={nestedChild.id || `${childId}-nested-${nestedIndex}`}
span={nestedChild}
parentId={childId}
index={nestedIndex}
expandedSections={expandedSections}
onToggle={handleSectionToggle}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
expandedChildren={expandedChildren}
onToggleChildren={handleChildrenToggle}
/>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* For the first span (workflow execution), render all children as separate top-level cards */}
{isFirstSpan &&
hasChildren &&
span.children!.map((childSpan, index) => (
<TraceSpanItem
key={childSpan.id || `${spanId}-child-${index}`}
span={childSpan}
totalDuration={totalDuration}
workflowStartTime={workflowStartTime}
isFirstSpan={false}
/>
))}
{!isFirstSpan &&
otherChildren.map((childSpan, index) => (
<TraceSpanItem
key={childSpan.id || `${spanId}-other-${index}`}
span={childSpan}
totalDuration={totalDuration}
workflowStartTime={workflowStartTime}
isFirstSpan={false}
/>
))}
</>
)
})
/**
* Displays workflow execution trace spans with nested tree structure.
* Displays workflow execution trace spans with nested structure.
* Memoized to prevent re-renders when parent LogDetails updates.
*/
export const TraceSpans = memo(function TraceSpans({ traceSpans }: TraceSpansProps) {
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set())
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
const toggleSet = useSetToggle()
export const TraceSpans = memo(function TraceSpans({
traceSpans,
totalDuration = 0,
}: TraceSpansProps) {
const { workflowStartTime, actualTotalDuration, normalizedSpans } = useMemo(() => {
if (!traceSpans || traceSpans.length === 0) {
return { workflowStartTime: 0, actualTotalDuration: 0, normalizedSpans: [] }
return { workflowStartTime: 0, actualTotalDuration: totalDuration, normalizedSpans: [] }
}
let earliest = Number.POSITIVE_INFINITY
@@ -736,37 +962,26 @@ export const TraceSpans = memo(function TraceSpans({ traceSpans }: TraceSpansPro
actualTotalDuration: latest - earliest,
normalizedSpans: normalizeAndSortSpans(traceSpans),
}
}, [traceSpans])
const handleToggleNode = useCallback(
(nodeId: string) => toggleSet(setExpandedNodes, nodeId),
[toggleSet]
)
const handleToggleSection = useCallback(
(section: string) => toggleSet(setExpandedSections, section),
[toggleSet]
)
}, [traceSpans, totalDuration])
if (!traceSpans || traceSpans.length === 0) {
return <div className='text-[12px] text-[var(--text-secondary)]'>No trace data available</div>
}
return (
<div className='flex w-full min-w-0 flex-col overflow-hidden'>
{normalizedSpans.map((span, index) => (
<TraceSpanNode
key={span.id || index}
span={span}
workflowStartTime={workflowStartTime}
totalDuration={actualTotalDuration}
depth={0}
expandedNodes={expandedNodes}
expandedSections={expandedSections}
onToggleNode={handleToggleNode}
onToggleSection={handleToggleSection}
/>
))}
<div className='flex w-full min-w-0 flex-col gap-[6px] overflow-hidden rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>Trace Span</span>
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
{normalizedSpans.map((span, index) => (
<TraceSpanItem
key={span.id || index}
span={span}
totalDuration={actualTotalDuration}
workflowStartTime={workflowStartTime}
isFirstSpan={index === 0}
/>
))}
</div>
</div>
)
})

View File

@@ -1,23 +1,10 @@
'use client'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Search, X } from 'lucide-react'
import { createPortal } from 'react-dom'
import {
Button,
Code,
Eye,
Input,
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
Tooltip,
} from '@/components/emcn'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronUp, X } from 'lucide-react'
import { Button, Eye } from '@/components/emcn'
import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { cn } from '@/lib/core/utils/cn'
import {
ExecutionSnapshot,
FileCards,
@@ -30,194 +17,11 @@ import {
StatusBadge,
TriggerBadge,
} from '@/app/workspace/[workspaceId]/logs/utils'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { formatCost } from '@/providers/utils'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useLogDetailsUIStore } from '@/stores/logs/store'
/**
* Workflow Output section with code viewer, copy, search, and context menu functionality
*/
function WorkflowOutputSection({ output }: { output: Record<string, unknown> }) {
const contentRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
// Context menu state
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const {
isSearchActive,
searchQuery,
setSearchQuery,
matchCount,
currentMatchIndex,
activateSearch,
closeSearch,
goToNextMatch,
goToPreviousMatch,
handleMatchCountChange,
searchInputRef,
} = useCodeViewerFeatures({ contentRef })
const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output])
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setIsContextMenuOpen(true)
}, [])
const closeContextMenu = useCallback(() => {
setIsContextMenuOpen(false)
}, [])
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(jsonString)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
closeContextMenu()
}, [jsonString, closeContextMenu])
const handleSearch = useCallback(() => {
activateSearch()
closeContextMenu()
}, [activateSearch, closeContextMenu])
return (
<div className='relative flex min-w-0 flex-col overflow-hidden'>
<div ref={contentRef} onContextMenu={handleContextMenu} className='relative'>
<Code.Viewer
code={jsonString}
language='json'
className='!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
/>
{/* Glass action buttons overlay */}
{!isSearchActive && (
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
onClick={(e) => {
e.stopPropagation()
handleCopy()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
{copied ? (
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
) : (
<Clipboard className='h-[10px] w-[10px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
onClick={(e) => {
e.stopPropagation()
activateSearch()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div>
{/* Search Overlay */}
{isSearchActive && (
<div
className='absolute top-0 right-0 z-30 flex h-[34px] items-center gap-[6px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-1)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
>
<Input
ref={searchInputRef}
type='text'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder='Search...'
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
/>
<span
className={cn(
'min-w-[45px] text-center text-[11px]',
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
)}
>
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'}
</span>
<Button
variant='ghost'
className='!p-1'
onClick={goToPreviousMatch}
disabled={matchCount === 0}
aria-label='Previous match'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={goToNextMatch}
disabled={matchCount === 0}
aria-label='Next match'
>
<ArrowDown className='h-[12px] w-[12px]' />
</Button>
<Button variant='ghost' className='!p-1' onClick={closeSearch} aria-label='Close search'>
<X className='h-[12px] w-[12px]' />
</Button>
</div>
)}
{/* Context Menu - rendered in portal to avoid transform/overflow clipping */}
{typeof document !== 'undefined' &&
createPortal(
<Popover
open={isContextMenuOpen}
onOpenChange={closeContextMenu}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
</PopoverContent>
</Popover>,
document.body
)}
</div>
)
}
interface LogDetailsProps {
/** The log to display details for */
log: WorkflowLog | null
@@ -274,18 +78,6 @@ export const LogDetails = memo(function LogDetails({
return isWorkflowExecutionLog && log?.cost
}, [log, isWorkflowExecutionLog])
// Extract and clean the workflow final output (remove childTraceSpans for cleaner display)
const workflowOutput = useMemo(() => {
const executionData = log?.executionData as
| { finalOutput?: Record<string, unknown> }
| undefined
if (!executionData?.finalOutput) return null
const { childTraceSpans, ...cleanOutput } = executionData.finalOutput as {
childTraceSpans?: unknown
} & Record<string, unknown>
return cleanOutput
}, [log?.executionData])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
@@ -295,12 +87,12 @@ export const LogDetails = memo(function LogDetails({
if (isOpen) {
if (e.key === 'ArrowUp' && hasPrev && onNavigatePrev) {
e.preventDefault()
onNavigatePrev()
handleNavigate(onNavigatePrev)
}
if (e.key === 'ArrowDown' && hasNext && onNavigateNext) {
e.preventDefault()
onNavigateNext()
handleNavigate(onNavigateNext)
}
}
}
@@ -309,6 +101,10 @@ export const LogDetails = memo(function LogDetails({
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose, hasPrev, hasNext, onNavigatePrev, onNavigateNext])
const handleNavigate = (navigateFunction: () => void) => {
navigateFunction()
}
const formattedTimestamp = useMemo(
() => (log ? formatDate(log.createdAt) : null),
[log?.createdAt]
@@ -346,7 +142,7 @@ export const LogDetails = memo(function LogDetails({
<Button
variant='ghost'
className='!p-[4px]'
onClick={() => hasPrev && onNavigatePrev?.()}
onClick={() => hasPrev && handleNavigate(onNavigatePrev!)}
disabled={!hasPrev}
aria-label='Previous log'
>
@@ -355,7 +151,7 @@ export const LogDetails = memo(function LogDetails({
<Button
variant='ghost'
className='!p-[4px]'
onClick={() => hasNext && onNavigateNext?.()}
onClick={() => hasNext && handleNavigate(onNavigateNext!)}
disabled={!hasNext}
aria-label='Next log'
>
@@ -408,7 +204,7 @@ export const LogDetails = memo(function LogDetails({
{/* Execution ID */}
{log.executionId && (
<div className='flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Execution ID
</span>
@@ -419,7 +215,7 @@ export const LogDetails = memo(function LogDetails({
)}
{/* Details Section */}
<div className='-my-[4px] flex min-w-0 flex-col overflow-hidden'>
<div className='flex min-w-0 flex-col overflow-hidden'>
{/* Level */}
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
@@ -471,35 +267,19 @@ export const LogDetails = memo(function LogDetails({
{/* Workflow State */}
{isWorkflowExecutionLog && log.executionId && !permissionConfig.hideTraceSpans && (
<div className='-mt-[8px] flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow State
</span>
<Button
variant='active'
<button
onClick={() => setIsExecutionSnapshotOpen(true)}
className='flex w-full items-center justify-between px-[10px] py-[6px]'
className='flex items-center justify-between rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px] transition-colors hover:bg-[var(--surface-4)]'
>
<span className='font-medium text-[12px]'>View Snapshot</span>
<Eye className='h-[14px] w-[14px]' />
</Button>
</div>
)}
{/* Workflow Output */}
{isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && (
<div className='mt-[4px] flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px] dark:bg-transparent'>
<span
className={cn(
'font-medium text-[12px]',
workflowOutput.error
? 'text-[var(--text-error)]'
: 'text-[var(--text-tertiary)]'
)}
>
Workflow Output
</span>
<WorkflowOutputSection output={workflowOutput} />
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
View Snapshot
</span>
<Eye className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
</button>
</div>
)}
@@ -507,12 +287,10 @@ export const LogDetails = memo(function LogDetails({
{isWorkflowExecutionLog &&
log.executionData?.traceSpans &&
!permissionConfig.hideTraceSpans && (
<div className='mt-[4px] flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px] dark:bg-transparent'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Trace Span
</span>
<TraceSpans traceSpans={log.executionData.traceSpans} />
</div>
<TraceSpans
traceSpans={log.executionData.traceSpans}
totalDuration={log.executionData.totalDuration}
/>
)}
{/* Files */}

View File

@@ -94,9 +94,7 @@ export default function Logs() {
const [previewLogId, setPreviewLogId] = useState<string | null>(null)
const activeLogId = isPreviewOpen ? previewLogId : selectedLogId
const activeLogQuery = useLogDetail(activeLogId ?? undefined, {
refetchInterval: isLive ? 3000 : false,
})
const activeLogQuery = useLogDetail(activeLogId ?? undefined)
const logFilters = useMemo(
() => ({
@@ -115,7 +113,7 @@ export default function Logs() {
const logsQuery = useLogsList(workspaceId, logFilters, {
enabled: Boolean(workspaceId) && isInitialized.current,
refetchInterval: isLive ? 3000 : false,
refetchInterval: isLive ? 5000 : false,
})
const dashboardFilters = useMemo(
@@ -134,7 +132,7 @@ export default function Logs() {
const dashboardStatsQuery = useDashboardStats(workspaceId, dashboardFilters, {
enabled: Boolean(workspaceId) && isInitialized.current,
refetchInterval: isLive ? 3000 : false,
refetchInterval: isLive ? 5000 : false,
})
const logs = useMemo(() => {
@@ -162,6 +160,12 @@ export default function Logs() {
}
}, [debouncedSearchQuery, setStoreSearchQuery])
useEffect(() => {
if (!isLive || !selectedLogId) return
const interval = setInterval(() => activeLogQuery.refetch(), 5000)
return () => clearInterval(interval)
}, [isLive, selectedLogId, activeLogQuery])
const handleLogClick = useCallback(
(log: WorkflowLog) => {
if (selectedLogId === log.id && isSidebarOpen) {
@@ -275,11 +279,8 @@ export default function Logs() {
setIsVisuallyRefreshing(true)
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
logsQuery.refetch()
if (selectedLogId) {
activeLogQuery.refetch()
}
}
}, [isLive, logsQuery, activeLogQuery, selectedLogId])
}, [isLive, logsQuery])
const prevIsFetchingRef = useRef(logsQuery.isFetching)
useEffect(() => {

View File

@@ -213,6 +213,7 @@ function TemplateCardInner({
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
cursorStyle='pointer'
/>
) : (

View File

@@ -2,12 +2,12 @@ import { memo, useCallback } from 'react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
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 { useNotificationStore } from '@/stores/notifications'
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'
const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 }
@@ -48,38 +48,29 @@ export const ActionBar = memo(
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
} = useCollaborativeWorkflow()
const { setPendingSelection } = useWorkflowRegistry()
const addNotification = useNotificationStore((s) => s.addNotification)
const { activeWorkflowId, setPendingSelection } = useWorkflowRegistry()
const handleDuplicateBlock = useCallback(() => {
const { copyBlocks, preparePasteData, activeWorkflowId } = useWorkflowRegistry.getState()
const existingBlocks = useWorkflowStore.getState().blocks
copyBlocks([blockId])
const blocks = useWorkflowStore.getState().blocks
const sourceBlock = blocks[blockId]
if (!sourceBlock) return
const pasteData = preparePasteData(DEFAULT_DUPLICATE_OFFSET)
if (!pasteData) return
const newId = crypto.randomUUID()
const newName = getUniqueBlockName(sourceBlock.name, blocks)
const subBlockValues =
useSubBlockStore.getState().workflowValues[activeWorkflowId || '']?.[blockId] || {}
const blocks = Object.values(pasteData.blocks)
const validation = validateTriggerPaste(blocks, existingBlocks, 'duplicate')
if (!validation.isValid) {
addNotification({
level: 'error',
message: validation.message!,
workflowId: activeWorkflowId || undefined,
})
return
}
const { block, subBlockValues: filteredValues } = prepareDuplicateBlockState({
sourceBlock,
newId,
newName,
positionOffset: DEFAULT_DUPLICATE_OFFSET,
subBlockValues,
})
setPendingSelection(blocks.map((b) => b.id))
collaborativeBatchAddBlocks(
blocks,
pasteData.edges,
pasteData.loops,
pasteData.parallels,
pasteData.subBlockValues
)
}, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection])
setPendingSelection([newId])
collaborativeBatchAddBlocks([block], [], {}, {}, { [newId]: filteredValues })
}, [blockId, activeWorkflowId, collaborativeBatchAddBlocks, setPendingSelection])
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
useCallback(
@@ -99,7 +90,7 @@ export const ActionBar = memo(
const userPermissions = useUserPermissionsContext()
const isStartBlock = isInputDefinitionTrigger(blockType)
const isStartBlock = isValidStartBlockType(blockType)
const isResponseBlock = blockType === 'response'
const isNoteBlock = blockType === 'note'
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
@@ -128,30 +119,7 @@ export const ActionBar = memo(
'dark:border-transparent dark:bg-[var(--surface-4)]'
)}
>
{!isNoteBlock && (
<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>
)}
{isSubflowBlock && (
{!isNoteBlock && !isSubflowBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -245,6 +213,29 @@ export const ActionBar = memo(
</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.Trigger asChild>
<Button

View File

@@ -8,7 +8,7 @@ import {
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
/**
* Block information for context menu actions
@@ -74,16 +74,12 @@ export function BlockMenu({
const allEnabled = selectedBlocks.every((b) => b.enabled)
const allDisabled = selectedBlocks.every((b) => !b.enabled)
const hasSingletonBlock = selectedBlocks.some(
(b) =>
TriggerUtils.requiresSingleInstance(b.type) || TriggerUtils.isSingleInstanceBlockType(b.type)
)
const hasTriggerBlock = selectedBlocks.some((b) => TriggerUtils.isTriggerBlock(b))
const hasStarterBlock = selectedBlocks.some((b) => isValidStartBlockType(b.type))
const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note')
const isSubflow =
isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel')
const canRemoveFromSubflow = showRemoveFromSubflow && !hasTriggerBlock
const canRemoveFromSubflow = showRemoveFromSubflow && !hasStarterBlock
const getToggleEnabledLabel = () => {
if (allEnabled) return 'Disable'
@@ -131,7 +127,7 @@ export function BlockMenu({
<span>Paste</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>V</span>
</PopoverItem>
{!hasSingletonBlock && (
{!hasStarterBlock && (
<PopoverItem
disabled={disableEdit}
onClick={() => {

View File

@@ -138,24 +138,18 @@ export const Notifications = memo(function Notifications() {
}`}
>
<div className='flex h-full flex-col justify-between px-[8px] pt-[6px] pb-[8px]'>
<div className='flex items-start gap-[8px]'>
<div
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>
<div
className={`font-medium text-[12px] leading-[16px] ${
hasAction ? 'line-clamp-2' : 'line-clamp-4'
}`}
>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => removeNotification(notification.id)}
aria-label='Dismiss notification'
className='!p-1.5 -m-1.5 shrink-0'
className='!p-1.5 -m-1.5 float-right ml-[16px]'
>
<X className='h-3 w-3' />
</Button>
@@ -164,6 +158,10 @@ export const Notifications = memo(function Notifications() {
<Tooltip.Shortcut keys='⌘E'>Clear all</Tooltip.Shortcut>
</Tooltip.Content>
</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>
{hasAction && (
<Button

View File

@@ -80,8 +80,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
isAborting,
} = useCopilotStore()
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
const hasCheckpoints = messageCheckpoints.length > 0 && messageCheckpoints.some((cp) => cp?.id)
@@ -212,10 +210,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
const isLastTextBlock =
index === message.contentBlocks!.length - 1 && block.type === 'text'
const parsed = parseSpecialTags(block.content)
// Mask credential IDs in the displayed content
const cleanBlockContent = maskCredentialValue(
parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
)
const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
if (!cleanBlockContent.trim()) return null
@@ -243,7 +238,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
return (
<div key={blockKey} className='w-full'>
<ThinkingBlock
content={maskCredentialValue(block.content)}
content={block.content}
isStreaming={isActivelyStreaming}
hasFollowingContent={hasFollowingContent}
hasSpecialTags={hasSpecialTags}

View File

@@ -782,7 +782,6 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
const [isExpanded, setIsExpanded] = useState(true)
const [duration, setDuration] = useState(0)
const startTimeRef = useRef<number>(Date.now())
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
const wasStreamingRef = useRef(false)
// Only show streaming animations for current message
@@ -817,16 +816,14 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
currentText += parsed.cleanContent
} else if (block.type === 'subagent_tool_call' && block.toolCall) {
if (currentText.trim()) {
// Mask any credential IDs in the accumulated text before displaying
segments.push({ type: 'text', content: maskCredentialValue(currentText) })
segments.push({ type: 'text', content: currentText })
currentText = ''
}
segments.push({ type: 'tool', block })
}
}
if (currentText.trim()) {
// Mask any credential IDs in the accumulated text before displaying
segments.push({ type: 'text', content: maskCredentialValue(currentText) })
segments.push({ type: 'text', content: currentText })
}
const allParsed = parseSpecialTags(allRawText)
@@ -955,7 +952,6 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
toolCall: CopilotToolCall
}) {
const blocks = useWorkflowStore((s) => s.blocks)
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
const cachedBlockInfoRef = useRef<Record<string, { name: string; type: string }>>({})
@@ -987,7 +983,6 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
title: string
value: any
isPassword?: boolean
isCredential?: boolean
}
interface BlockChange {
@@ -1096,7 +1091,6 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
title: subBlockConfig.title ?? subBlockConfig.id,
value,
isPassword: subBlockConfig.password === true,
isCredential: subBlockConfig.type === 'oauth-input',
})
}
}
@@ -1178,15 +1172,8 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
{subBlocksToShow && subBlocksToShow.length > 0 && (
<div className='border-[var(--border-1)] border-t px-2.5 py-1.5'>
{subBlocksToShow.map((sb) => {
// Mask password fields and credential IDs
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)
}
// Mask password fields like the canvas does
const displayValue = sb.isPassword ? '•••' : getDisplayValue(sb.value)
return (
<div key={sb.id} className='flex items-start gap-1.5 py-0.5 text-[11px]'>
<span
@@ -1425,10 +1412,10 @@ function RunSkipButtons({
setIsProcessing(true)
setButtonsHidden(true)
try {
// Add to auto-allowed list first
await addAutoAllowedTool(toolCall.name)
if (!isIntegrationTool(toolCall.name)) {
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
}
// Then execute
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
} finally {
setIsProcessing(false)
actionInProgressRef.current = false
@@ -1451,10 +1438,10 @@ function RunSkipButtons({
if (buttonsHidden) return null
// Show "Always Allow" for all tools that require confirmation
const showAlwaysAllow = true
// Hide "Always Allow" for integration tools (only show for client tools with interrupts)
const showAlwaysAllow = !isIntegrationTool(toolCall.name)
// Standardized buttons for all interrupt tools: Allow, Always Allow, Skip
// Standardized buttons for all interrupt tools: Allow, (Always Allow for client tools only), Skip
return (
<div className='mt-[10px] flex gap-[6px]'>
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
@@ -1523,11 +1510,7 @@ export function ToolCall({
toolCall.name === 'user_memory' ||
toolCall.name === 'edit_respond' ||
toolCall.name === 'debug_respond' ||
toolCall.name === 'plan_respond' ||
toolCall.name === 'research_respond' ||
toolCall.name === 'info_respond' ||
toolCall.name === 'deploy_respond' ||
toolCall.name === 'superagent_respond'
toolCall.name === 'plan_respond'
)
return null

View File

@@ -209,20 +209,9 @@ export interface SlashCommand {
export const TOP_LEVEL_COMMANDS: readonly SlashCommand[] = [
{ id: 'fast', label: 'Fast' },
{ id: 'research', label: 'Research' },
{ id: 'actions', label: 'Actions' },
{ id: 'superagent', label: 'Actions' },
] as const
/**
* Maps UI command IDs to API command IDs.
* Some commands have different IDs for display vs API (e.g., "actions" -> "superagent")
*/
export function getApiCommandId(uiCommandId: string): string {
const commandMapping: Record<string, string> = {
actions: 'superagent',
}
return commandMapping[uiCommandId] || uiCommandId
}
export const WEB_COMMANDS: readonly SlashCommand[] = [
{ id: 'search', label: 'Search' },
{ id: 'read', label: 'Read' },

View File

@@ -105,10 +105,10 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
isSendingMessage,
])
/** Load auto-allowed tools once on mount - runs immediately, independent of workflow */
/** Load auto-allowed tools once on mount */
const hasLoadedAutoAllowedToolsRef = useRef(false)
useEffect(() => {
if (!hasLoadedAutoAllowedToolsRef.current) {
if (hasMountedRef.current && !hasLoadedAutoAllowedToolsRef.current) {
hasLoadedAutoAllowedToolsRef.current = true
loadAutoAllowedTools().catch((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 { cn } from '@/lib/core/utils/cn'
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import {
type FieldConfig,
useCreateForm,
@@ -147,7 +147,7 @@ export function FormDeploy({
useEffect(() => {
const blocks = Object.values(useWorkflowStore.getState().blocks)
const startBlock = blocks.find((b) => isInputDefinitionTrigger(b.type))
const startBlock = blocks.find((b) => isValidStartBlockType(b.type))
if (startBlock) {
const inputFormat = useSubBlockStore.getState().getValue(startBlock.id, 'inputFormat')

View File

@@ -14,7 +14,7 @@ import {
Textarea,
} from '@/components/emcn'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import type { InputFormatField } from '@/lib/workflows/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/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)) {
if (!block || typeof block !== 'object') continue
const blockType = (block as { type?: string }).type
if (blockType && isInputDefinitionTrigger(blockType)) {
if (blockType && isValidStartBlockType(blockType)) {
return blockId
}
}

View File

@@ -18,8 +18,8 @@ import {
import { Skeleton } from '@/components/ui'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import {
BlockDetailsSidebar,
getLeftmostBlockId,
PreviewEditor,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
@@ -337,7 +337,7 @@ export function GeneralDeploy({
/>
</div>
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
<PreviewEditor
<BlockDetailsSidebar
block={workflowToShow.blocks[expandedSelectedBlockId]}
workflowVariables={workflowToShow.variables}
loops={workflowToShow.loops}

View File

@@ -15,7 +15,7 @@ import {
import { Skeleton } from '@/components/ui'
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import type { InputFormatField } from '@/lib/workflows/types'
import {
useAddWorkflowMcpTool,
@@ -107,7 +107,7 @@ export function McpDeploy({
for (const [blockId, block] of Object.entries(blocks)) {
if (!block || typeof block !== 'object') continue
const blockType = (block as { type?: string }).type
if (blockType && isInputDefinitionTrigger(blockType)) {
if (blockType && isValidStartBlockType(blockType)) {
return blockId
}
}

View File

@@ -446,6 +446,7 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
/>
</div>
)

View File

@@ -95,7 +95,6 @@ export function DeployModal({
const [activeTab, setActiveTab] = useState<TabView>('general')
const [chatSubmitting, setChatSubmitting] = useState(false)
const [apiDeployError, setApiDeployError] = useState<string | null>(null)
const [apiDeployWarnings, setApiDeployWarnings] = useState<string[]>([])
const [isChatFormValid, setIsChatFormValid] = useState(false)
const [selectedStreamingOutputs, setSelectedStreamingOutputs] = useState<string[]>([])
@@ -228,7 +227,6 @@ export function DeployModal({
if (open && workflowId) {
setActiveTab('general')
setApiDeployError(null)
setApiDeployWarnings([])
}
}, [open, workflowId])
@@ -284,13 +282,9 @@ export function DeployModal({
if (!workflowId) return
setApiDeployError(null)
setApiDeployWarnings([])
try {
const result = await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
if (result.warnings && result.warnings.length > 0) {
setApiDeployWarnings(result.warnings)
}
await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
await refetchDeployedState()
} catch (error: unknown) {
logger.error('Error deploying workflow:', { error })
@@ -303,13 +297,8 @@ export function DeployModal({
async (version: number) => {
if (!workflowId) return
setApiDeployWarnings([])
try {
const result = await activateVersionMutation.mutateAsync({ workflowId, version })
if (result.warnings && result.warnings.length > 0) {
setApiDeployWarnings(result.warnings)
}
await activateVersionMutation.mutateAsync({ workflowId, version })
await refetchDeployedState()
} catch (error) {
logger.error('Error promoting version:', { error })
@@ -335,13 +324,9 @@ export function DeployModal({
if (!workflowId) return
setApiDeployError(null)
setApiDeployWarnings([])
try {
const result = await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
if (result.warnings && result.warnings.length > 0) {
setApiDeployWarnings(result.warnings)
}
await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
await refetchDeployedState()
} catch (error: unknown) {
logger.error('Error redeploying workflow:', { error })
@@ -353,7 +338,6 @@ export function DeployModal({
const handleCloseModal = useCallback(() => {
setChatSubmitting(false)
setApiDeployError(null)
setApiDeployWarnings([])
onOpenChange(false)
}, [onOpenChange])
@@ -495,14 +479,6 @@ export function DeployModal({
<div>{apiDeployError}</div>
</div>
)}
{apiDeployWarnings.length > 0 && (
<div className='mb-3 rounded-[4px] border border-amber-500/30 bg-amber-500/10 p-3 text-amber-700 dark:text-amber-400 text-sm'>
<div className='font-semibold'>Deployment Warning</div>
{apiDeployWarnings.map((warning, index) => (
<div key={index}>{warning}</div>
))}
</div>
)}
<ModalTabsContent value='general'>
<GeneralDeploy
workflowId={workflowId}

View File

@@ -35,10 +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 { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
import type { GenerationType } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { normalizeName } from '@/stores/workflows/utils'
const logger = createLogger('Code')
@@ -89,27 +88,21 @@ interface CodePlaceholder {
/**
* Creates a syntax highlighter function with custom reference and environment variable highlighting.
* @param effectiveLanguage - The language to use for syntax highlighting
* @param shouldHighlightReference - Function to determine if a block reference should be highlighted
* @param shouldHighlightEnvVar - Function to determine if an env var should be highlighted
* @param shouldHighlightReference - Function to determine if a reference should be highlighted
* @returns A function that highlights code with syntax and custom highlights
*/
const createHighlightFunction = (
effectiveLanguage: 'javascript' | 'python' | 'json',
shouldHighlightReference: (part: string) => boolean,
shouldHighlightEnvVar: (varName: string) => boolean
shouldHighlightReference: (part: string) => boolean
) => {
return (codeToHighlight: string): string => {
const placeholders: CodePlaceholder[] = []
let processedCode = codeToHighlight
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
const varName = match.slice(2, -2).trim()
if (shouldHighlightEnvVar(varName)) {
const placeholder = `__ENV_VAR_${placeholders.length}__`
placeholders.push({ placeholder, original: match, type: 'env' })
return placeholder
}
return match
const placeholder = `__ENV_VAR_${placeholders.length}__`
placeholders.push({ placeholder, original: match, type: 'env' })
return placeholder
})
processedCode = processedCode.replace(createReferencePattern(), (match) => {
@@ -219,7 +212,6 @@ export const Code = memo(function Code({
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const emitTagSelection = useTagSelection(blockId, subBlockId)
const [languageValue] = useSubBlockValue<string>(blockId, 'language')
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const effectiveLanguage = (languageValue as 'javascript' | 'python' | 'json') || language
@@ -611,15 +603,9 @@ export const Code = memo(function Code({
[generateCodeStream, isPromptVisible, isAiStreaming]
)
const shouldHighlightEnvVar = useMemo(
() => createShouldHighlightEnvVar(availableEnvVars),
[availableEnvVars]
)
const highlightCode = useMemo(
() =>
createHighlightFunction(effectiveLanguage, shouldHighlightReference, shouldHighlightEnvVar),
[effectiveLanguage, shouldHighlightReference, shouldHighlightEnvVar]
() => createHighlightFunction(effectiveLanguage, shouldHighlightReference),
[effectiveLanguage, shouldHighlightReference]
)
const handleValueChange = useCallback(

View File

@@ -1,7 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash'
import { useReactFlow } from 'reactflow'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
@@ -103,8 +102,7 @@ export const ComboBox = memo(function ComboBox({
[blockConfig?.subBlocks]
)
const canonicalModeOverrides = blockState?.data?.canonicalModes
const dependencyValues = useStoreWithEqualityFn(
useSubBlockStore,
const dependencyValues = useSubBlockStore(
useCallback(
(state) => {
if (dependsOnFields.length === 0 || !activeWorkflowId) return []

View File

@@ -1,5 +1,5 @@
import type { ReactElement } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
@@ -32,10 +32,9 @@ import {
} 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 { 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 { useTagSelection } from '@/hooks/kb/use-tag-selection'
import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { normalizeName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('ConditionInput')
@@ -124,11 +123,6 @@ export function ConditionInput({
const emitTagSelection = useTagSelection(blockId, subBlockId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const shouldHighlightEnvVar = useMemo(
() => createShouldHighlightEnvVar(availableEnvVars),
[availableEnvVars]
)
const containerRef = useRef<HTMLDivElement>(null)
const inputRefs = useRef<Map<string, HTMLTextAreaElement>>(new Map())
@@ -1142,18 +1136,14 @@ export function ConditionInput({
let processedCode = codeToHighlight
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
const varName = match.slice(2, -2).trim()
if (shouldHighlightEnvVar(varName)) {
const placeholder = `__ENV_VAR_${placeholders.length}__`
placeholders.push({
placeholder,
original: match,
type: 'env',
shouldHighlight: true,
})
return placeholder
}
return match
const placeholder = `__ENV_VAR_${placeholders.length}__`
placeholders.push({
placeholder,
original: match,
type: 'env',
shouldHighlight: true,
})
return placeholder
})
processedCode = processedCode.replace(

View File

@@ -1,6 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { Badge } from '@/components/emcn'
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
@@ -101,8 +100,7 @@ export const Dropdown = memo(function Dropdown({
[blockConfig?.subBlocks]
)
const canonicalModeOverrides = blockState?.data?.canonicalModes
const dependencyValues = useStoreWithEqualityFn(
useSubBlockStore,
const dependencyValues = useSubBlockStore(
useCallback(
(state) => {
if (dependsOnFields.length === 0 || !activeWorkflowId) return []

View File

@@ -2,12 +2,12 @@
import type { ReactNode } from 'react'
import { splitReferenceSegment } from '@/lib/workflows/sanitization/references'
import { normalizeName, REFERENCE } from '@/executor/constants'
import { REFERENCE } from '@/executor/constants'
import { createCombinedPattern } from '@/executor/utils/reference-validation'
import { normalizeName } from '@/stores/workflows/utils'
export interface HighlightContext {
accessiblePrefixes?: Set<string>
availableEnvVars?: Set<string>
highlightAll?: boolean
}
@@ -44,17 +44,9 @@ export function formatDisplayText(text: string, context?: HighlightContext): Rea
return false
}
const shouldHighlightEnvVar = (varName: string): boolean => {
if (context?.highlightAll) {
return true
}
if (context?.availableEnvVars === undefined) {
return true
}
return context.availableEnvVars.has(varName)
}
const nodes: ReactNode[] = []
// Match variable references without allowing nested brackets to prevent matching across references
// e.g., "<3. text <real.ref>" should match "<3" and "<real.ref>", not the whole string
const regex = createCombinedPattern()
let lastIndex = 0
let key = 0
@@ -74,16 +66,11 @@ export function formatDisplayText(text: string, context?: HighlightContext): Rea
}
if (matchText.startsWith(REFERENCE.ENV_VAR_START)) {
const varName = matchText.slice(2, -2).trim()
if (shouldHighlightEnvVar(varName)) {
nodes.push(
<span key={key++} className='text-[var(--brand-secondary)]'>
{matchText}
</span>
)
} else {
nodes.push(<span key={key++}>{matchText}</span>)
}
nodes.push(
<span key={key++} className='text-[var(--brand-secondary)]'>
{matchText}
</span>
)
} else {
const split = splitReferenceSegment(matchText)

View File

@@ -34,4 +34,3 @@ export { Text } from './text/text'
export { TimeInput } from './time-input/time-input'
export { ToolInput } from './tool-input/tool-input'
export { VariablesInput } from './variables-input/variables-input'
export { WorkflowSelectorInput } from './workflow-selector/workflow-selector-input'

View File

@@ -2,13 +2,12 @@ import { useMemo, useRef, useState } from 'react'
import { Badge, Input } from '@/components/emcn'
import { Label } from '@/components/ui/label'
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 { 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 { 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 { useWorkflowState } from '@/hooks/queries/workflows'
import { useWorkflowInputFields } from '@/hooks/queries/workflows'
/**
* Props for the InputMappingField component
@@ -71,11 +70,7 @@ export function InputMapping({
const overlayRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const workflowId = typeof selectedWorkflowId === 'string' ? selectedWorkflowId : undefined
const { data: workflowState, isLoading } = useWorkflowState(workflowId)
const childInputFields = useMemo(
() => (workflowState?.blocks ? extractInputFieldsFromBlocks(workflowState.blocks) : []),
[workflowState?.blocks]
)
const { data: childInputFields = [], isLoading } = useWorkflowInputFields(workflowId)
const [collapsedFields, setCollapsedFields] = useState<Record<string, boolean>>({})
const valueObj: Record<string, string> = useMemo(() => {

View File

@@ -1,12 +1,4 @@
import {
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash'
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
@@ -390,137 +382,92 @@ export function MessagesInput({
textareaRefs.current[fieldId]?.focus()
}, [])
const syncOverlay = useCallback((fieldId: string) => {
const autoResizeTextarea = useCallback((fieldId: string) => {
const textarea = textareaRefs.current[fieldId]
if (!textarea) return
const overlay = overlayRefs.current[fieldId]
if (!textarea || !overlay) return
overlay.style.width = `${textarea.clientWidth}px`
overlay.scrollTop = textarea.scrollTop
overlay.scrollLeft = textarea.scrollLeft
// If user has manually resized, respect their chosen height and only sync overlay.
if (userResizedRef.current[fieldId]) {
const currentHeight =
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 autoResizeTextarea = useCallback(
(fieldId: string) => {
const textarea = textareaRefs.current[fieldId]
const overlay = overlayRefs.current[fieldId]
if (!textarea) return
const handleResizeStart = useCallback((fieldId: string, e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
if (!textarea.value.trim()) {
userResizedRef.current[fieldId] = false
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]
if (activeTextarea) {
activeTextarea.style.height = `${nextHeight}px`
}
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`
const overlay = overlayRefs.current[activeFieldId]
if (overlay) {
overlay.style.height = `${height}px`
overlay.style.height = `${nextHeight}px`
}
}
const handleMouseUp = () => {
if (resizeStateRef.current) {
const { fieldId: activeFieldId } = resizeStateRef.current
userResizedRef.current[activeFieldId] = true
}
syncOverlay(fieldId)
},
[syncOverlay]
)
isResizingRef.current = false
resizeStateRef.current = null
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
const handleResizeStart = useCallback(
(fieldId: string, e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
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) => {
autoResizeTextarea(`message-${index}`)
})
}, [currentMessages, autoResizeTextarea])
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [])
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])
currentMessages.forEach((_, index) => {
const fieldId = `message-${index}`
autoResizeTextarea(fieldId)
})
}, [currentMessages, autoResizeTextarea])
return (
<div className='flex w-full flex-col gap-[10px]'>
@@ -674,15 +621,19 @@ export function MessagesInput({
</div>
{/* Content Input with overlay for variable highlighting */}
<div className='relative w-full overflow-hidden'>
<div className='relative w-full'>
<textarea
ref={(el) => {
textareaRefs.current[fieldId] = el
}}
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'
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'
rows={3}
placeholder='Enter message content...'
value={message.content}
onChange={fieldHandlers.onChange}
onChange={(e) => {
fieldHandlers.onChange(e)
autoResizeTextarea(fieldId)
}}
onKeyDown={(e) => {
if (e.key === 'Tab' && !isPreview && !disabled) {
e.preventDefault()
@@ -719,13 +670,12 @@ export function MessagesInput({
ref={(el) => {
overlayRefs.current[fieldId] = el
}}
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'
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]'
>
{formatDisplayText(message.content, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
{message.content.endsWith('\n') && '\u200B'}
</div>
{/* Env var dropdown for this message */}
@@ -755,7 +705,7 @@ export function MessagesInput({
{!isPreview && !disabled && (
<div
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)]'
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)]'
onMouseDown={(e) => handleResizeStart(fieldId, e)}
onDragStart={(e) => {
e.preventDefault()

View File

@@ -35,11 +35,11 @@ import type {
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { getBlock } from '@/blocks'
import type { BlockConfig } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
import type { Variable } from '@/stores/panel'
import { useVariablesStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { normalizeName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
@@ -1312,16 +1312,15 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
if (currentLoop && isLoopBlock) {
containingLoopBlockId = blockId
const loopType = currentLoop.loopType || 'for'
const contextualTags: string[] = ['index']
if (loopType === 'forEach') {
contextualTags.push('currentItem')
contextualTags.push('items')
}
const loopBlock = blocks[blockId]
if (loopBlock) {
const loopBlockName = loopBlock.name || loopBlock.type
const normalizedLoopName = normalizeName(loopBlockName)
const contextualTags: string[] = [`${normalizedLoopName}.index`]
if (loopType === 'forEach') {
contextualTags.push(`${normalizedLoopName}.currentItem`)
contextualTags.push(`${normalizedLoopName}.items`)
}
loopBlockGroup = {
blockName: loopBlockName,
@@ -1329,23 +1328,21 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
blockType: 'loop',
tags: contextualTags,
distance: 0,
isContextual: true,
}
}
} else if (containingLoop) {
const [loopId, loop] = containingLoop
containingLoopBlockId = loopId
const loopType = loop.loopType || 'for'
const contextualTags: string[] = ['index']
if (loopType === 'forEach') {
contextualTags.push('currentItem')
contextualTags.push('items')
}
const containingLoopBlock = blocks[loopId]
if (containingLoopBlock) {
const loopBlockName = containingLoopBlock.name || containingLoopBlock.type
const normalizedLoopName = normalizeName(loopBlockName)
const contextualTags: string[] = [`${normalizedLoopName}.index`]
if (loopType === 'forEach') {
contextualTags.push(`${normalizedLoopName}.currentItem`)
contextualTags.push(`${normalizedLoopName}.items`)
}
loopBlockGroup = {
blockName: loopBlockName,
@@ -1353,7 +1350,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
blockType: 'loop',
tags: contextualTags,
distance: 0,
isContextual: true,
}
}
}
@@ -1367,16 +1363,15 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const [parallelId, parallel] = containingParallel
containingParallelBlockId = parallelId
const parallelType = parallel.parallelType || 'count'
const contextualTags: string[] = ['index']
if (parallelType === 'collection') {
contextualTags.push('currentItem')
contextualTags.push('items')
}
const containingParallelBlock = blocks[parallelId]
if (containingParallelBlock) {
const parallelBlockName = containingParallelBlock.name || containingParallelBlock.type
const normalizedParallelName = normalizeName(parallelBlockName)
const contextualTags: string[] = [`${normalizedParallelName}.index`]
if (parallelType === 'collection') {
contextualTags.push(`${normalizedParallelName}.currentItem`)
contextualTags.push(`${normalizedParallelName}.items`)
}
parallelBlockGroup = {
blockName: parallelBlockName,
@@ -1384,7 +1379,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
blockType: 'parallel',
tags: contextualTags,
distance: 0,
isContextual: true,
}
}
}
@@ -1651,29 +1645,38 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const nestedBlockTagGroups: NestedBlockTagGroup[] = useMemo(() => {
return filteredBlockTagGroups.map((group: BlockTagGroup) => {
const normalizedBlockName = normalizeName(group.blockName)
// Handle loop/parallel contextual tags (index, currentItem, items)
const directTags: NestedTag[] = []
const tagsForTree: string[] = []
group.tags.forEach((tag: string) => {
const tagParts = tag.split('.')
if (tagParts.length === 1) {
// Loop/parallel contextual tags without block prefix
if (
(group.blockType === 'loop' || group.blockType === 'parallel') &&
tagParts.length === 1
) {
directTags.push({
key: tag,
display: tag,
fullTag: tag,
})
} else if (tagParts.length === 2) {
// Direct property like blockname.property
directTags.push({
key: tagParts[1],
display: tagParts[1],
fullTag: tag,
})
} else {
// Nested property - add to tree builder
tagsForTree.push(tag)
}
})
// Build recursive tree from nested tags
const nestedTags = [...directTags, ...buildNestedTagTree(tagsForTree, normalizedBlockName)]
return {
@@ -1797,19 +1800,13 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
processedTag = tag
}
} else if (
blockGroup?.isContextual &&
blockGroup &&
(blockGroup.blockType === 'loop' || blockGroup.blockType === 'parallel')
) {
const tagParts = tag.split('.')
if (tagParts.length === 1) {
processedTag = blockGroup.blockType
if (!tag.includes('.') && ['index', 'currentItem', 'items'].includes(tag)) {
processedTag = `${blockGroup.blockType}.${tag}`
} else {
const lastPart = tagParts[tagParts.length - 1]
if (['index', 'currentItem', 'items'].includes(lastPart)) {
processedTag = `${blockGroup.blockType}.${lastPart}`
} else {
processedTag = tag
}
processedTag = tag
}
}

View File

@@ -7,8 +7,6 @@ export interface BlockTagGroup {
blockType: string
tags: string[]
distance: number
/** True if this is a contextual group (loop/parallel iteration context available inside the subflow) */
isContextual?: boolean
}
/**

Some files were not shown because too many files have changed in this diff Show More