mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-24 14:27:56 -05:00
Compare commits
29 Commits
fix/copilo
...
fix/nested
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf22dd75ad | ||
|
|
eb767b5ede | ||
|
|
594bcac5f2 | ||
|
|
d3f20311d0 | ||
|
|
587d44ad6f | ||
|
|
8bf2e69942 | ||
|
|
12100e6881 | ||
|
|
23294683e1 | ||
|
|
b913cff46e | ||
|
|
428781ce7d | ||
|
|
f0ee67f3ed | ||
|
|
f44594c380 | ||
|
|
6464cfa7f2 | ||
|
|
7f4edc85ef | ||
|
|
efef91ece0 | ||
|
|
64efeaa2e6 | ||
|
|
9b72b52b33 | ||
|
|
1467862488 | ||
|
|
7f2262857c | ||
|
|
1b309b50e6 | ||
|
|
f765b83a26 | ||
|
|
aa99db6fdd | ||
|
|
748793e07d | ||
|
|
91da7e183a | ||
|
|
ab09a5ad23 | ||
|
|
fcd0240db6 | ||
|
|
4e4149792a | ||
|
|
9a8b591257 | ||
|
|
f3ae3f8442 |
@@ -59,7 +59,7 @@ export default function StatusIndicator() {
|
|||||||
href={statusUrl}
|
href={statusUrl}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
className={`flex items-center gap-[6px] whitespace-nowrap text-[12px] transition-colors ${STATUS_COLORS[status]}`}
|
className={`flex min-w-[165px] items-center gap-[6px] whitespace-nowrap text-[12px] transition-colors ${STATUS_COLORS[status]}`}
|
||||||
aria-label={`System status: ${message}`}
|
aria-label={`System status: ${message}`}
|
||||||
>
|
>
|
||||||
<StatusDotIcon status={status} className='h-[6px] w-[6px]' aria-hidden='true' />
|
<StatusDotIcon status={status} className='h-[6px] w-[6px]' aria-hidden='true' />
|
||||||
|
|||||||
27
apps/sim/app/(landing)/studio/[slug]/back-link.tsx
Normal file
27
apps/sim/app/(landing)/studio/[slug]/back-link.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
|
|||||||
import { FAQ } from '@/lib/blog/faq'
|
import { FAQ } from '@/lib/blog/faq'
|
||||||
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
|
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
|
||||||
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'
|
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'
|
||||||
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
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() {
|
export async function generateStaticParams() {
|
||||||
const posts = await getAllPostMeta()
|
const posts = await getAllPostMeta()
|
||||||
@@ -48,9 +51,7 @@ 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'>
|
<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'>
|
<div className='mb-6'>
|
||||||
<Link href='/studio' className='text-gray-600 text-sm hover:text-gray-900'>
|
<BackLink />
|
||||||
← Back to Sim Studio
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
|
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
|
||||||
<div className='w-full flex-shrink-0 md:w-[450px]'>
|
<div className='w-full flex-shrink-0 md:w-[450px]'>
|
||||||
@@ -75,7 +76,8 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
|||||||
>
|
>
|
||||||
{post.title}
|
{post.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div className='mt-4 flex items-center gap-3'>
|
<div className='mt-4 flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
{(post.authors || [post.author]).map((a, idx) => (
|
{(post.authors || [post.author]).map((a, idx) => (
|
||||||
<div key={idx} className='flex items-center gap-2'>
|
<div key={idx} className='flex items-center gap-2'>
|
||||||
{a?.avatarUrl ? (
|
{a?.avatarUrl ? (
|
||||||
@@ -98,6 +100,8 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<ShareButton url={`${getBaseUrl()}/studio/${slug}`} title={post.title} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr className='mt-8 border-gray-200 border-t sm:mt-12' />
|
<hr className='mt-8 border-gray-200 border-t sm:mt-12' />
|
||||||
|
|||||||
65
apps/sim/app/(landing)/studio/[slug]/share-button.tsx
Normal file
65
apps/sim/app/(landing)/studio/[slug]/share-button.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export default async function StudioIndex({
|
|||||||
? filtered.sort((a, b) => {
|
? filtered.sort((a, b) => {
|
||||||
if (a.featured && !b.featured) return -1
|
if (a.featured && !b.featured) return -1
|
||||||
if (!a.featured && b.featured) return 1
|
if (!a.featured && b.featured) return 1
|
||||||
return 0
|
return new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
})
|
})
|
||||||
: filtered
|
: filtered
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
|
|||||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { getRedisClient } from '@/lib/core/config/redis'
|
import { getRedisClient } from '@/lib/core/config/redis'
|
||||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||||
|
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||||
|
|
||||||
const logger = createLogger('A2AAgentCardAPI')
|
const logger = createLogger('A2AAgentCardAPI')
|
||||||
|
|
||||||
@@ -95,6 +96,11 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<Ro
|
|||||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
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 body = await request.json()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -160,6 +166,11 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
|
|||||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
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))
|
await db.delete(a2aAgent).where(eq(a2aAgent.id, agentId))
|
||||||
|
|
||||||
logger.info(`Deleted A2A agent: ${agentId}`)
|
logger.info(`Deleted A2A agent: ${agentId}`)
|
||||||
@@ -194,6 +205,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
|||||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
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 body = await request.json()
|
||||||
const action = body.action as 'publish' | 'unpublish' | 'refresh'
|
const action = body.action as 'publish' | 'unpublish' | 'refresh'
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { getBrandConfig } from '@/lib/branding/branding'
|
import { getBrandConfig } from '@/lib/branding/branding'
|
||||||
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
|
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 { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||||
@@ -1118,17 +1119,13 @@ async function handlePushNotificationSet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const urlValidation = validateExternalUrl(
|
||||||
const url = new URL(params.pushNotificationConfig.url)
|
params.pushNotificationConfig.url,
|
||||||
if (url.protocol !== 'https:') {
|
'Push notification URL'
|
||||||
return NextResponse.json(
|
|
||||||
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Push notification URL must use HTTPS'),
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
)
|
||||||
}
|
if (!urlValidation.isValid) {
|
||||||
} catch {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Invalid push notification URL'),
|
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, urlValidation.error || 'Invalid URL'),
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,17 +104,11 @@ export async function POST(req: NextRequest) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Build execution params starting with LLM-provided arguments
|
// Build execution params starting with LLM-provided arguments
|
||||||
// Resolve all {{ENV_VAR}} references in the arguments
|
// Resolve all {{ENV_VAR}} references in the arguments (deep for nested objects)
|
||||||
const executionParams: Record<string, any> = resolveEnvVarReferences(
|
const executionParams: Record<string, any> = resolveEnvVarReferences(
|
||||||
toolArgs,
|
toolArgs,
|
||||||
decryptedEnvVars,
|
decryptedEnvVars,
|
||||||
{
|
{ deep: true }
|
||||||
resolveExactMatch: true,
|
|
||||||
allowEmbedded: true,
|
|
||||||
trimKeys: true,
|
|
||||||
onMissing: 'keep',
|
|
||||||
deep: true,
|
|
||||||
}
|
|
||||||
) as Record<string, any>
|
) as Record<string, any>
|
||||||
|
|
||||||
logger.info(`[${tracker.requestId}] Resolved env var references in arguments`, {
|
logger.info(`[${tracker.requestId}] Resolved env var references in arguments`, {
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ vi.mock('@/lib/execution/isolated-vm', () => ({
|
|||||||
|
|
||||||
vi.mock('@sim/logger', () => loggerMock)
|
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', () => ({
|
vi.mock('@/lib/execution/e2b', () => ({
|
||||||
executeInE2B: vi.fn(),
|
executeInE2B: vi.fn(),
|
||||||
}))
|
}))
|
||||||
@@ -110,6 +118,24 @@ describe('Function Execute API Route', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('Security Tests', () => {
|
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 () => {
|
it.concurrent('should use isolated-vm for secure sandboxed execution', async () => {
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
code: 'return "test"',
|
code: 'return "test"',
|
||||||
@@ -313,7 +339,7 @@ describe('Function Execute API Route', () => {
|
|||||||
'block-2': 'world',
|
'block-2': 'world',
|
||||||
},
|
},
|
||||||
blockNameMapping: {
|
blockNameMapping: {
|
||||||
validVar: 'block-1',
|
validvar: 'block-1',
|
||||||
another_valid: 'block-2',
|
another_valid: 'block-2',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -539,7 +565,7 @@ describe('Function Execute API Route', () => {
|
|||||||
'block-complex': complexData,
|
'block-complex': complexData,
|
||||||
},
|
},
|
||||||
blockNameMapping: {
|
blockNameMapping: {
|
||||||
complexData: 'block-complex',
|
complexdata: 'block-complex',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { isE2bEnabled } from '@/lib/core/config/feature-flags'
|
import { isE2bEnabled } from '@/lib/core/config/feature-flags'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { executeInE2B } from '@/lib/execution/e2b'
|
import { executeInE2B } from '@/lib/execution/e2b'
|
||||||
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
|
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
|
||||||
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
|
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
|
||||||
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
|
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
|
||||||
|
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
|
||||||
import {
|
import {
|
||||||
createEnvVarPattern,
|
createEnvVarPattern,
|
||||||
createWorkflowVariablePattern,
|
createWorkflowVariablePattern,
|
||||||
} from '@/executor/utils/reference-validation'
|
} from '@/executor/utils/reference-validation'
|
||||||
import { navigatePath } from '@/executor/variables/resolvers/reference'
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
@@ -470,14 +471,17 @@ function resolveEnvironmentVariables(
|
|||||||
|
|
||||||
function resolveTagVariables(
|
function resolveTagVariables(
|
||||||
code: string,
|
code: string,
|
||||||
blockData: Record<string, any>,
|
blockData: Record<string, unknown>,
|
||||||
blockNameMapping: Record<string, string>,
|
blockNameMapping: Record<string, string>,
|
||||||
contextVariables: Record<string, any>
|
blockOutputSchemas: Record<string, OutputSchema>,
|
||||||
|
contextVariables: Record<string, unknown>,
|
||||||
|
language = 'javascript'
|
||||||
): string {
|
): string {
|
||||||
let resolvedCode = code
|
let resolvedCode = code
|
||||||
|
const undefinedLiteral = language === 'python' ? 'None' : 'undefined'
|
||||||
|
|
||||||
const tagPattern = new RegExp(
|
const tagPattern = new RegExp(
|
||||||
`${REFERENCE.START}([a-zA-Z_][a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])${REFERENCE.END}`,
|
`${REFERENCE.START}([a-zA-Z_](?:[a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])?)${REFERENCE.END}`,
|
||||||
'g'
|
'g'
|
||||||
)
|
)
|
||||||
const tagMatches = resolvedCode.match(tagPattern) || []
|
const tagMatches = resolvedCode.match(tagPattern) || []
|
||||||
@@ -486,41 +490,37 @@ function resolveTagVariables(
|
|||||||
const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim()
|
const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim()
|
||||||
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
|
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
|
||||||
const blockName = pathParts[0]
|
const blockName = pathParts[0]
|
||||||
|
const fieldPath = pathParts.slice(1)
|
||||||
|
|
||||||
const blockId = blockNameMapping[blockName]
|
const result = resolveBlockReference(blockName, fieldPath, {
|
||||||
if (!blockId) {
|
blockNameMapping,
|
||||||
|
blockData,
|
||||||
|
blockOutputSchemas,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockOutput = blockData[blockId]
|
let tagValue = result.value
|
||||||
if (blockOutput === undefined) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let tagValue: any
|
|
||||||
if (pathParts.length === 1) {
|
|
||||||
tagValue = blockOutput
|
|
||||||
} else {
|
|
||||||
tagValue = navigatePath(blockOutput, pathParts.slice(1))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tagValue === undefined) {
|
if (tagValue === undefined) {
|
||||||
|
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), undefinedLiteral)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (typeof tagValue === 'string') {
|
||||||
typeof tagValue === 'string' &&
|
const trimmed = tagValue.trimStart()
|
||||||
tagValue.length > 100 &&
|
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||||
(tagValue.startsWith('{') || tagValue.startsWith('['))
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
tagValue = JSON.parse(tagValue)
|
tagValue = JSON.parse(tagValue)
|
||||||
} catch {
|
} catch {
|
||||||
// Keep as-is
|
// Keep as string if not valid JSON
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeVarName = `__tag_${tagName.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
const safeVarName = `__tag_${tagName.replace(/_/g, '_1').replace(/\./g, '_0')}`
|
||||||
contextVariables[safeVarName] = tagValue
|
contextVariables[safeVarName] = tagValue
|
||||||
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
|
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
|
||||||
}
|
}
|
||||||
@@ -537,18 +537,27 @@ function resolveTagVariables(
|
|||||||
*/
|
*/
|
||||||
function resolveCodeVariables(
|
function resolveCodeVariables(
|
||||||
code: string,
|
code: string,
|
||||||
params: Record<string, any>,
|
params: Record<string, unknown>,
|
||||||
envVars: Record<string, string> = {},
|
envVars: Record<string, string> = {},
|
||||||
blockData: Record<string, any> = {},
|
blockData: Record<string, unknown> = {},
|
||||||
blockNameMapping: Record<string, string> = {},
|
blockNameMapping: Record<string, string> = {},
|
||||||
workflowVariables: Record<string, any> = {}
|
blockOutputSchemas: Record<string, OutputSchema> = {},
|
||||||
): { resolvedCode: string; contextVariables: Record<string, any> } {
|
workflowVariables: Record<string, unknown> = {},
|
||||||
|
language = 'javascript'
|
||||||
|
): { resolvedCode: string; contextVariables: Record<string, unknown> } {
|
||||||
let resolvedCode = code
|
let resolvedCode = code
|
||||||
const contextVariables: Record<string, any> = {}
|
const contextVariables: Record<string, unknown> = {}
|
||||||
|
|
||||||
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
|
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
|
||||||
resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
|
resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
|
||||||
resolvedCode = resolveTagVariables(resolvedCode, blockData, blockNameMapping, contextVariables)
|
resolvedCode = resolveTagVariables(
|
||||||
|
resolvedCode,
|
||||||
|
blockData,
|
||||||
|
blockNameMapping,
|
||||||
|
blockOutputSchemas,
|
||||||
|
contextVariables,
|
||||||
|
language
|
||||||
|
)
|
||||||
|
|
||||||
return { resolvedCode, contextVariables }
|
return { resolvedCode, contextVariables }
|
||||||
}
|
}
|
||||||
@@ -573,6 +582,12 @@ export async function POST(req: NextRequest) {
|
|||||||
let resolvedCode = '' // Store resolved code for error reporting
|
let resolvedCode = '' // Store resolved code for error reporting
|
||||||
|
|
||||||
try {
|
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 body = await req.json()
|
||||||
|
|
||||||
const { DEFAULT_EXECUTION_TIMEOUT_MS } = await import('@/lib/execution/constants')
|
const { DEFAULT_EXECUTION_TIMEOUT_MS } = await import('@/lib/execution/constants')
|
||||||
@@ -585,6 +600,7 @@ export async function POST(req: NextRequest) {
|
|||||||
envVars = {},
|
envVars = {},
|
||||||
blockData = {},
|
blockData = {},
|
||||||
blockNameMapping = {},
|
blockNameMapping = {},
|
||||||
|
blockOutputSchemas = {},
|
||||||
workflowVariables = {},
|
workflowVariables = {},
|
||||||
workflowId,
|
workflowId,
|
||||||
isCustomTool = false,
|
isCustomTool = false,
|
||||||
@@ -601,20 +617,21 @@ export async function POST(req: NextRequest) {
|
|||||||
isCustomTool,
|
isCustomTool,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Resolve variables in the code with workflow environment variables
|
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
|
||||||
|
|
||||||
const codeResolution = resolveCodeVariables(
|
const codeResolution = resolveCodeVariables(
|
||||||
code,
|
code,
|
||||||
executionParams,
|
executionParams,
|
||||||
envVars,
|
envVars,
|
||||||
blockData,
|
blockData,
|
||||||
blockNameMapping,
|
blockNameMapping,
|
||||||
workflowVariables
|
blockOutputSchemas,
|
||||||
|
workflowVariables,
|
||||||
|
lang
|
||||||
)
|
)
|
||||||
resolvedCode = codeResolution.resolvedCode
|
resolvedCode = codeResolution.resolvedCode
|
||||||
const contextVariables = codeResolution.contextVariables
|
const contextVariables = codeResolution.contextVariables
|
||||||
|
|
||||||
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
|
|
||||||
|
|
||||||
let jsImports = ''
|
let jsImports = ''
|
||||||
let jsRemainingCode = resolvedCode
|
let jsRemainingCode = resolvedCode
|
||||||
let hasImports = false
|
let hasImports = false
|
||||||
@@ -670,7 +687,11 @@ export async function POST(req: NextRequest) {
|
|||||||
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
|
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
|
||||||
prologueLineCount++
|
prologueLineCount++
|
||||||
for (const [k, v] of Object.entries(contextVariables)) {
|
for (const [k, v] of Object.entries(contextVariables)) {
|
||||||
|
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++
|
prologueLineCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,7 +762,11 @@ export async function POST(req: NextRequest) {
|
|||||||
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
|
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
|
||||||
prologueLineCount++
|
prologueLineCount++
|
||||||
for (const [k, v] of Object.entries(contextVariables)) {
|
for (const [k, v] of Object.entries(contextVariables)) {
|
||||||
|
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++
|
prologueLineCount++
|
||||||
}
|
}
|
||||||
const wrapped = [
|
const wrapped = [
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ describe('Knowledge Base Documents API Route', () => {
|
|||||||
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
|
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
|
||||||
'kb-123',
|
'kb-123',
|
||||||
{
|
{
|
||||||
includeDisabled: false,
|
enabledFilter: undefined,
|
||||||
search: undefined,
|
search: undefined,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -166,7 +166,7 @@ describe('Knowledge Base Documents API Route', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should filter disabled documents by default', async () => {
|
it('should return documents with default filter', async () => {
|
||||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||||
const { getDocuments } = await import('@/lib/knowledge/documents/service')
|
const { getDocuments } = await import('@/lib/knowledge/documents/service')
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ describe('Knowledge Base Documents API Route', () => {
|
|||||||
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
|
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
|
||||||
'kb-123',
|
'kb-123',
|
||||||
{
|
{
|
||||||
includeDisabled: false,
|
enabledFilter: undefined,
|
||||||
search: undefined,
|
search: undefined,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -203,7 +203,7 @@ describe('Knowledge Base Documents API Route', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should include disabled documents when requested', async () => {
|
it('should filter documents by enabled status when requested', async () => {
|
||||||
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
|
||||||
const { getDocuments } = await import('@/lib/knowledge/documents/service')
|
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?includeDisabled=true'
|
const url = 'http://localhost:3000/api/knowledge/kb-123/documents?enabledFilter=disabled'
|
||||||
const req = new Request(url, { method: 'GET' }) as any
|
const req = new Request(url, { method: 'GET' }) as any
|
||||||
|
|
||||||
const { GET } = await import('@/app/api/knowledge/[id]/documents/route')
|
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(
|
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
|
||||||
'kb-123',
|
'kb-123',
|
||||||
{
|
{
|
||||||
includeDisabled: true,
|
enabledFilter: 'disabled',
|
||||||
search: undefined,
|
search: undefined,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -361,8 +361,7 @@ describe('Knowledge Base Documents API Route', () => {
|
|||||||
expect(vi.mocked(createSingleDocument)).toHaveBeenCalledWith(
|
expect(vi.mocked(createSingleDocument)).toHaveBeenCalledWith(
|
||||||
validDocumentData,
|
validDocumentData,
|
||||||
'kb-123',
|
'kb-123',
|
||||||
expect.any(String),
|
expect.any(String)
|
||||||
'user-123'
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -470,8 +469,7 @@ describe('Knowledge Base Documents API Route', () => {
|
|||||||
expect(vi.mocked(createDocumentRecords)).toHaveBeenCalledWith(
|
expect(vi.mocked(createDocumentRecords)).toHaveBeenCalledWith(
|
||||||
validBulkData.documents,
|
validBulkData.documents,
|
||||||
'kb-123',
|
'kb-123',
|
||||||
expect.any(String),
|
expect.any(String)
|
||||||
'user-123'
|
|
||||||
)
|
)
|
||||||
expect(vi.mocked(processDocumentsWithQueue)).toHaveBeenCalled()
|
expect(vi.mocked(processDocumentsWithQueue)).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from 'zod'
|
|||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import {
|
import {
|
||||||
bulkDocumentOperation,
|
bulkDocumentOperation,
|
||||||
|
bulkDocumentOperationByFilter,
|
||||||
createDocumentRecords,
|
createDocumentRecords,
|
||||||
createSingleDocument,
|
createSingleDocument,
|
||||||
getDocuments,
|
getDocuments,
|
||||||
@@ -57,12 +58,19 @@ const BulkCreateDocumentsSchema = z.object({
|
|||||||
bulk: z.literal(true),
|
bulk: z.literal(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
const BulkUpdateDocumentsSchema = z.object({
|
const BulkUpdateDocumentsSchema = z
|
||||||
|
.object({
|
||||||
operation: z.enum(['enable', 'disable', 'delete']),
|
operation: z.enum(['enable', 'disable', 'delete']),
|
||||||
documentIds: z
|
documentIds: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
.min(1, 'At least one document ID is required')
|
.min(1, 'At least one document ID is required')
|
||||||
.max(100, 'Cannot operate on more than 100 documents at once'),
|
.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',
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
@@ -90,14 +98,17 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
const includeDisabled = url.searchParams.get('includeDisabled') === 'true'
|
const enabledFilter = url.searchParams.get('enabledFilter') as
|
||||||
|
| 'all'
|
||||||
|
| 'enabled'
|
||||||
|
| 'disabled'
|
||||||
|
| null
|
||||||
const search = url.searchParams.get('search') || undefined
|
const search = url.searchParams.get('search') || undefined
|
||||||
const limit = Number.parseInt(url.searchParams.get('limit') || '50')
|
const limit = Number.parseInt(url.searchParams.get('limit') || '50')
|
||||||
const offset = Number.parseInt(url.searchParams.get('offset') || '0')
|
const offset = Number.parseInt(url.searchParams.get('offset') || '0')
|
||||||
const sortByParam = url.searchParams.get('sortBy')
|
const sortByParam = url.searchParams.get('sortBy')
|
||||||
const sortOrderParam = url.searchParams.get('sortOrder')
|
const sortOrderParam = url.searchParams.get('sortOrder')
|
||||||
|
|
||||||
// Validate sort parameters
|
|
||||||
const validSortFields: DocumentSortField[] = [
|
const validSortFields: DocumentSortField[] = [
|
||||||
'filename',
|
'filename',
|
||||||
'fileSize',
|
'fileSize',
|
||||||
@@ -105,6 +116,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
'chunkCount',
|
'chunkCount',
|
||||||
'uploadedAt',
|
'uploadedAt',
|
||||||
'processingStatus',
|
'processingStatus',
|
||||||
|
'enabled',
|
||||||
]
|
]
|
||||||
const validSortOrders: SortOrder[] = ['asc', 'desc']
|
const validSortOrders: SortOrder[] = ['asc', 'desc']
|
||||||
|
|
||||||
@@ -120,7 +132,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
const result = await getDocuments(
|
const result = await getDocuments(
|
||||||
knowledgeBaseId,
|
knowledgeBaseId,
|
||||||
{
|
{
|
||||||
includeDisabled,
|
enabledFilter: enabledFilter || undefined,
|
||||||
search,
|
search,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
@@ -190,8 +202,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
const createdDocuments = await createDocumentRecords(
|
const createdDocuments = await createDocumentRecords(
|
||||||
validatedData.documents,
|
validatedData.documents,
|
||||||
knowledgeBaseId,
|
knowledgeBaseId,
|
||||||
requestId,
|
requestId
|
||||||
userId
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -250,16 +261,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
throw validationError
|
throw validationError
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Handle single document creation
|
|
||||||
try {
|
try {
|
||||||
const validatedData = CreateDocumentSchema.parse(body)
|
const validatedData = CreateDocumentSchema.parse(body)
|
||||||
|
|
||||||
const newDocument = await createSingleDocument(
|
const newDocument = await createSingleDocument(validatedData, knowledgeBaseId, requestId)
|
||||||
validatedData,
|
|
||||||
knowledgeBaseId,
|
|
||||||
requestId,
|
|
||||||
userId
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { PlatformEvents } = await import('@/lib/core/telemetry')
|
const { PlatformEvents } = await import('@/lib/core/telemetry')
|
||||||
@@ -294,7 +299,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error creating document`, 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 errorMessage = error instanceof Error ? error.message : 'Failed to create document'
|
||||||
const isStorageLimitError =
|
const isStorageLimitError =
|
||||||
errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit')
|
errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit')
|
||||||
@@ -331,16 +335,22 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const validatedData = BulkUpdateDocumentsSchema.parse(body)
|
const validatedData = BulkUpdateDocumentsSchema.parse(body)
|
||||||
const { operation, documentIds } = validatedData
|
const { operation, documentIds, selectAll, enabledFilter } = validatedData
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await bulkDocumentOperation(
|
let result
|
||||||
|
if (selectAll) {
|
||||||
|
result = await bulkDocumentOperationByFilter(
|
||||||
knowledgeBaseId,
|
knowledgeBaseId,
|
||||||
operation,
|
operation,
|
||||||
documentIds,
|
enabledFilter,
|
||||||
requestId,
|
requestId
|
||||||
session.user.id
|
|
||||||
)
|
)
|
||||||
|
} else if (documentIds && documentIds.length > 0) {
|
||||||
|
result = await bulkDocumentOperation(knowledgeBaseId, operation, documentIds, requestId)
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'No documents specified' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
|
||||||
import { McpClient } from '@/lib/mcp/client'
|
import { McpClient } from '@/lib/mcp/client'
|
||||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||||
import type { McpServerConfig, McpTransport } from '@/lib/mcp/types'
|
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
|
||||||
|
import type { McpTransport } from '@/lib/mcp/types'
|
||||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||||
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
|
||||||
|
|
||||||
const logger = createLogger('McpServerTestAPI')
|
const logger = createLogger('McpServerTestAPI')
|
||||||
|
|
||||||
@@ -19,30 +18,6 @@ function isUrlBasedTransport(transport: McpTransport): boolean {
|
|||||||
return transport === 'streamable-http'
|
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 {
|
interface TestConnectionRequest {
|
||||||
name: string
|
name: string
|
||||||
transport: McpTransport
|
transport: McpTransport
|
||||||
@@ -96,39 +71,30 @@ export const POST = withMcpAuth('write')(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolvedUrl = body.url
|
// Build initial config for resolution
|
||||||
let resolvedHeaders = body.headers || {}
|
const initialConfig = {
|
||||||
|
|
||||||
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}`,
|
id: `test-${requestId}`,
|
||||||
name: body.name,
|
name: body.name,
|
||||||
transport: body.transport,
|
transport: body.transport,
|
||||||
url: resolvedUrl,
|
url: body.url,
|
||||||
headers: resolvedHeaders,
|
headers: body.headers || {},
|
||||||
timeout: body.timeout || 10000,
|
timeout: body.timeout || 10000,
|
||||||
retries: 1, // Only one retry for tests
|
retries: 1, // Only one retry for tests
|
||||||
enabled: true,
|
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 = {
|
const testSecurityPolicy = {
|
||||||
requireConsent: false,
|
requireConsent: false,
|
||||||
auditLevel: 'none' as const,
|
auditLevel: 'none' as const,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { account } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||||
import type { StreamingExecution } from '@/executor/types'
|
import type { StreamingExecution } from '@/executor/types'
|
||||||
import { executeProviderRequest } from '@/providers'
|
import { executeProviderRequest } from '@/providers'
|
||||||
@@ -20,6 +22,11 @@ export async function POST(request: NextRequest) {
|
|||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
try {
|
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`, {
|
logger.info(`[${requestId}] Provider API request started`, {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
userAgent: request.headers.get('User-Agent'),
|
userAgent: request.headers.get('User-Agent'),
|
||||||
@@ -85,6 +92,13 @@ export async function POST(request: NextRequest) {
|
|||||||
verbosity,
|
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
|
let finalApiKey: string | undefined = apiKey
|
||||||
try {
|
try {
|
||||||
if (provider === 'vertex' && vertexCredential) {
|
if (provider === 'vertex' && vertexCredential) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { createA2AClient } from '@/lib/a2a/utils'
|
import { createA2AClient } from '@/lib/a2a/utils'
|
||||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
|
import { validateExternalUrl } from '@/lib/core/security/input-validation'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -39,6 +40,18 @@ export async function POST(request: NextRequest) {
|
|||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const validatedData = A2ASetPushNotificationSchema.parse(body)
|
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`, {
|
logger.info(`[${requestId}] A2A set push notification request`, {
|
||||||
agentUrl: validatedData.agentUrl,
|
agentUrl: validatedData.agentUrl,
|
||||||
taskId: validatedData.taskId,
|
taskId: validatedData.taskId,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { buildDeleteQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
|
import { buildDeleteQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
|
||||||
|
|
||||||
const logger = createLogger('MySQLDeleteAPI')
|
const logger = createLogger('MySQLDeleteAPI')
|
||||||
@@ -21,6 +22,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = DeleteSchema.parse(body)
|
const params = DeleteSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
|
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
|
||||||
|
|
||||||
const logger = createLogger('MySQLExecuteAPI')
|
const logger = createLogger('MySQLExecuteAPI')
|
||||||
@@ -20,6 +21,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = ExecuteSchema.parse(body)
|
const params = ExecuteSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { buildInsertQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
|
import { buildInsertQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
|
||||||
|
|
||||||
const logger = createLogger('MySQLInsertAPI')
|
const logger = createLogger('MySQLInsertAPI')
|
||||||
@@ -42,6 +43,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = InsertSchema.parse(body)
|
const params = InsertSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { createMySQLConnection, executeIntrospect } from '@/app/api/tools/mysql/utils'
|
import { createMySQLConnection, executeIntrospect } from '@/app/api/tools/mysql/utils'
|
||||||
|
|
||||||
const logger = createLogger('MySQLIntrospectAPI')
|
const logger = createLogger('MySQLIntrospectAPI')
|
||||||
@@ -19,6 +20,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = IntrospectSchema.parse(body)
|
const params = IntrospectSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
|
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
|
||||||
|
|
||||||
const logger = createLogger('MySQLQueryAPI')
|
const logger = createLogger('MySQLQueryAPI')
|
||||||
@@ -20,6 +21,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = QuerySchema.parse(body)
|
const params = QuerySchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { buildUpdateQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
|
import { buildUpdateQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
|
||||||
|
|
||||||
const logger = createLogger('MySQLUpdateAPI')
|
const logger = createLogger('MySQLUpdateAPI')
|
||||||
@@ -40,6 +41,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = UpdateSchema.parse(body)
|
const params = UpdateSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { createPostgresConnection, executeDelete } from '@/app/api/tools/postgresql/utils'
|
import { createPostgresConnection, executeDelete } from '@/app/api/tools/postgresql/utils'
|
||||||
|
|
||||||
const logger = createLogger('PostgreSQLDeleteAPI')
|
const logger = createLogger('PostgreSQLDeleteAPI')
|
||||||
@@ -21,6 +22,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = DeleteSchema.parse(body)
|
const params = DeleteSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import {
|
import {
|
||||||
createPostgresConnection,
|
createPostgresConnection,
|
||||||
executeQuery,
|
executeQuery,
|
||||||
@@ -24,6 +25,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = ExecuteSchema.parse(body)
|
const params = ExecuteSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { createPostgresConnection, executeInsert } from '@/app/api/tools/postgresql/utils'
|
import { createPostgresConnection, executeInsert } from '@/app/api/tools/postgresql/utils'
|
||||||
|
|
||||||
const logger = createLogger('PostgreSQLInsertAPI')
|
const logger = createLogger('PostgreSQLInsertAPI')
|
||||||
@@ -42,6 +43,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
|
|
||||||
const params = InsertSchema.parse(body)
|
const params = InsertSchema.parse(body)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { createPostgresConnection, executeIntrospect } from '@/app/api/tools/postgresql/utils'
|
import { createPostgresConnection, executeIntrospect } from '@/app/api/tools/postgresql/utils'
|
||||||
|
|
||||||
const logger = createLogger('PostgreSQLIntrospectAPI')
|
const logger = createLogger('PostgreSQLIntrospectAPI')
|
||||||
@@ -20,6 +21,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = IntrospectSchema.parse(body)
|
const params = IntrospectSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { createPostgresConnection, executeQuery } from '@/app/api/tools/postgresql/utils'
|
import { createPostgresConnection, executeQuery } from '@/app/api/tools/postgresql/utils'
|
||||||
|
|
||||||
const logger = createLogger('PostgreSQLQueryAPI')
|
const logger = createLogger('PostgreSQLQueryAPI')
|
||||||
@@ -20,6 +21,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = QuerySchema.parse(body)
|
const params = QuerySchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { createPostgresConnection, executeUpdate } from '@/app/api/tools/postgresql/utils'
|
import { createPostgresConnection, executeUpdate } from '@/app/api/tools/postgresql/utils'
|
||||||
|
|
||||||
const logger = createLogger('PostgreSQLUpdateAPI')
|
const logger = createLogger('PostgreSQLUpdateAPI')
|
||||||
@@ -40,6 +41,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = UpdateSchema.parse(body)
|
const params = UpdateSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { createSSHConnection, escapeShellArg, executeSSHCommand } from '@/app/api/tools/ssh/utils'
|
import { createSSHConnection, escapeShellArg, executeSSHCommand } from '@/app/api/tools/ssh/utils'
|
||||||
|
|
||||||
const logger = createLogger('SSHCheckCommandExistsAPI')
|
const logger = createLogger('SSHCheckCommandExistsAPI')
|
||||||
@@ -20,6 +21,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = CheckCommandExistsSchema.parse(body)
|
const params = CheckCommandExistsSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import type { Client, SFTPWrapper, Stats } from 'ssh2'
|
import type { Client, SFTPWrapper, Stats } from 'ssh2'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import {
|
import {
|
||||||
createSSHConnection,
|
createSSHConnection,
|
||||||
getFileType,
|
getFileType,
|
||||||
@@ -39,10 +40,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = CheckFileExistsSchema.parse(body)
|
const params = CheckFileExistsSchema.parse(body)
|
||||||
|
|
||||||
// Validate authentication
|
|
||||||
if (!params.password && !params.privateKey) {
|
if (!params.password && !params.privateKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Either password or privateKey must be provided' },
|
{ error: 'Either password or privateKey must be provided' },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import {
|
import {
|
||||||
createSSHConnection,
|
createSSHConnection,
|
||||||
escapeShellArg,
|
escapeShellArg,
|
||||||
@@ -27,10 +28,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = CreateDirectorySchema.parse(body)
|
const params = CreateDirectorySchema.parse(body)
|
||||||
|
|
||||||
// Validate authentication
|
|
||||||
if (!params.password && !params.privateKey) {
|
if (!params.password && !params.privateKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Either password or privateKey must be provided' },
|
{ error: 'Either password or privateKey must be provided' },
|
||||||
@@ -53,7 +59,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const dirPath = sanitizePath(params.path)
|
const dirPath = sanitizePath(params.path)
|
||||||
const escapedPath = escapeShellArg(dirPath)
|
const escapedPath = escapeShellArg(dirPath)
|
||||||
|
|
||||||
// Check if directory already exists
|
|
||||||
const checkResult = await executeSSHCommand(
|
const checkResult = await executeSSHCommand(
|
||||||
client,
|
client,
|
||||||
`test -d '${escapedPath}' && echo "exists"`
|
`test -d '${escapedPath}' && echo "exists"`
|
||||||
@@ -70,7 +75,6 @@ export async function POST(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create directory
|
|
||||||
const mkdirFlag = params.recursive ? '-p' : ''
|
const mkdirFlag = params.recursive ? '-p' : ''
|
||||||
const command = `mkdir ${mkdirFlag} -m ${params.permissions} '${escapedPath}'`
|
const command = `mkdir ${mkdirFlag} -m ${params.permissions} '${escapedPath}'`
|
||||||
const result = await executeSSHCommand(client, command)
|
const result = await executeSSHCommand(client, command)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import {
|
import {
|
||||||
createSSHConnection,
|
createSSHConnection,
|
||||||
escapeShellArg,
|
escapeShellArg,
|
||||||
@@ -27,10 +28,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = DeleteFileSchema.parse(body)
|
const params = DeleteFileSchema.parse(body)
|
||||||
|
|
||||||
// Validate authentication
|
|
||||||
if (!params.password && !params.privateKey) {
|
if (!params.password && !params.privateKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Either password or privateKey must be provided' },
|
{ error: 'Either password or privateKey must be provided' },
|
||||||
@@ -53,7 +59,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const filePath = sanitizePath(params.path)
|
const filePath = sanitizePath(params.path)
|
||||||
const escapedPath = escapeShellArg(filePath)
|
const escapedPath = escapeShellArg(filePath)
|
||||||
|
|
||||||
// Check if path exists
|
|
||||||
const checkResult = await executeSSHCommand(
|
const checkResult = await executeSSHCommand(
|
||||||
client,
|
client,
|
||||||
`test -e '${escapedPath}' && echo "exists"`
|
`test -e '${escapedPath}' && echo "exists"`
|
||||||
@@ -62,7 +67,6 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: `Path does not exist: ${filePath}` }, { status: 404 })
|
return NextResponse.json({ error: `Path does not exist: ${filePath}` }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build delete command
|
|
||||||
let command: string
|
let command: string
|
||||||
if (params.recursive) {
|
if (params.recursive) {
|
||||||
command = params.force ? `rm -rf '${escapedPath}'` : `rm -r '${escapedPath}'`
|
command = params.force ? `rm -rf '${escapedPath}'` : `rm -r '${escapedPath}'`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import type { Client, SFTPWrapper } from 'ssh2'
|
import type { Client, SFTPWrapper } from 'ssh2'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
||||||
|
|
||||||
const logger = createLogger('SSHDownloadFileAPI')
|
const logger = createLogger('SSHDownloadFileAPI')
|
||||||
@@ -34,10 +35,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = DownloadFileSchema.parse(body)
|
const params = DownloadFileSchema.parse(body)
|
||||||
|
|
||||||
// Validate authentication
|
|
||||||
if (!params.password && !params.privateKey) {
|
if (!params.password && !params.privateKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Either password or privateKey must be provided' },
|
{ error: 'Either password or privateKey must be provided' },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { createSSHConnection, executeSSHCommand, sanitizeCommand } from '@/app/api/tools/ssh/utils'
|
import { createSSHConnection, executeSSHCommand, sanitizeCommand } from '@/app/api/tools/ssh/utils'
|
||||||
|
|
||||||
const logger = createLogger('SSHExecuteCommandAPI')
|
const logger = createLogger('SSHExecuteCommandAPI')
|
||||||
@@ -21,10 +22,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = ExecuteCommandSchema.parse(body)
|
const params = ExecuteCommandSchema.parse(body)
|
||||||
|
|
||||||
// Validate authentication
|
|
||||||
if (!params.password && !params.privateKey) {
|
if (!params.password && !params.privateKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Either password or privateKey must be provided' },
|
{ error: 'Either password or privateKey must be provided' },
|
||||||
@@ -44,7 +50,6 @@ export async function POST(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build command with optional working directory
|
|
||||||
let command = sanitizeCommand(params.command)
|
let command = sanitizeCommand(params.command)
|
||||||
if (params.workingDirectory) {
|
if (params.workingDirectory) {
|
||||||
command = `cd "${params.workingDirectory}" && ${command}`
|
command = `cd "${params.workingDirectory}" && ${command}`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { createSSHConnection, escapeShellArg, executeSSHCommand } from '@/app/api/tools/ssh/utils'
|
import { createSSHConnection, escapeShellArg, executeSSHCommand } from '@/app/api/tools/ssh/utils'
|
||||||
|
|
||||||
const logger = createLogger('SSHExecuteScriptAPI')
|
const logger = createLogger('SSHExecuteScriptAPI')
|
||||||
@@ -22,10 +23,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = ExecuteScriptSchema.parse(body)
|
const params = ExecuteScriptSchema.parse(body)
|
||||||
|
|
||||||
// Validate authentication
|
|
||||||
if (!params.password && !params.privateKey) {
|
if (!params.password && !params.privateKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Either password or privateKey must be provided' },
|
{ error: 'Either password or privateKey must be provided' },
|
||||||
@@ -45,13 +51,10 @@ export async function POST(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a temporary script file, execute it, and clean up
|
|
||||||
const scriptPath = `/tmp/sim_script_${requestId}.sh`
|
const scriptPath = `/tmp/sim_script_${requestId}.sh`
|
||||||
const escapedScriptPath = escapeShellArg(scriptPath)
|
const escapedScriptPath = escapeShellArg(scriptPath)
|
||||||
const escapedInterpreter = escapeShellArg(params.interpreter)
|
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'
|
let command = `cat > '${escapedScriptPath}' << 'SIMEOF'
|
||||||
${params.script}
|
${params.script}
|
||||||
SIMEOF
|
SIMEOF
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { createSSHConnection, executeSSHCommand } from '@/app/api/tools/ssh/utils'
|
import { createSSHConnection, executeSSHCommand } from '@/app/api/tools/ssh/utils'
|
||||||
|
|
||||||
const logger = createLogger('SSHGetSystemInfoAPI')
|
const logger = createLogger('SSHGetSystemInfoAPI')
|
||||||
@@ -19,10 +20,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = GetSystemInfoSchema.parse(body)
|
const params = GetSystemInfoSchema.parse(body)
|
||||||
|
|
||||||
// Validate authentication
|
|
||||||
if (!params.password && !params.privateKey) {
|
if (!params.password && !params.privateKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Either password or privateKey must be provided' },
|
{ error: 'Either password or privateKey must be provided' },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import type { Client, FileEntry, SFTPWrapper } from 'ssh2'
|
import type { Client, FileEntry, SFTPWrapper } from 'ssh2'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import {
|
import {
|
||||||
createSSHConnection,
|
createSSHConnection,
|
||||||
getFileType,
|
getFileType,
|
||||||
@@ -60,10 +61,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = ListDirectorySchema.parse(body)
|
const params = ListDirectorySchema.parse(body)
|
||||||
|
|
||||||
// Validate authentication
|
|
||||||
if (!params.password && !params.privateKey) {
|
if (!params.password && !params.privateKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Either password or privateKey must be provided' },
|
{ error: 'Either password or privateKey must be provided' },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import {
|
import {
|
||||||
createSSHConnection,
|
createSSHConnection,
|
||||||
escapeShellArg,
|
escapeShellArg,
|
||||||
@@ -27,9 +28,16 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = MoveRenameSchema.parse(body)
|
const params = MoveRenameSchema.parse(body)
|
||||||
|
|
||||||
|
// Validate SSH authentication
|
||||||
if (!params.password && !params.privateKey) {
|
if (!params.password && !params.privateKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Either password or privateKey must be provided' },
|
{ error: 'Either password or privateKey must be provided' },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import type { Client, SFTPWrapper } from 'ssh2'
|
import type { Client, SFTPWrapper } from 'ssh2'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
||||||
|
|
||||||
const logger = createLogger('SSHReadFileContentAPI')
|
const logger = createLogger('SSHReadFileContentAPI')
|
||||||
@@ -35,6 +36,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = ReadFileContentSchema.parse(body)
|
const params = ReadFileContentSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import type { Client, SFTPWrapper } from 'ssh2'
|
import type { Client, SFTPWrapper } from 'ssh2'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
||||||
|
|
||||||
const logger = createLogger('SSHUploadFileAPI')
|
const logger = createLogger('SSHUploadFileAPI')
|
||||||
@@ -37,6 +38,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = UploadFileSchema.parse(body)
|
const params = UploadFileSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import type { Client, SFTPWrapper } from 'ssh2'
|
import type { Client, SFTPWrapper } from 'ssh2'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
||||||
|
|
||||||
const logger = createLogger('SSHWriteFileContentAPI')
|
const logger = createLogger('SSHWriteFileContentAPI')
|
||||||
@@ -36,10 +37,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const requestId = randomUUID().slice(0, 8)
|
const requestId = randomUUID().slice(0, 8)
|
||||||
|
|
||||||
try {
|
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 body = await request.json()
|
||||||
const params = WriteFileContentSchema.parse(body)
|
const params = WriteFileContentSchema.parse(body)
|
||||||
|
|
||||||
// Validate authentication
|
|
||||||
if (!params.password && !params.privateKey) {
|
if (!params.password && !params.privateKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Either password or privateKey must be provided' },
|
{ error: 'Either password or privateKey must be provided' },
|
||||||
|
|||||||
211
apps/sim/app/api/v1/admin/credits/route.ts
Normal file
211
apps/sim/app/api/v1/admin/credits/route.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* 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')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -63,6 +63,9 @@
|
|||||||
* GET /api/v1/admin/subscriptions/:id - Get subscription details
|
* GET /api/v1/admin/subscriptions/:id - Get subscription details
|
||||||
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
|
* 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):
|
* Access Control (Permission Groups):
|
||||||
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
|
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
|
||||||
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)
|
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)
|
||||||
|
|||||||
@@ -640,6 +640,7 @@ export interface AdminDeployResult {
|
|||||||
isDeployed: boolean
|
isDeployed: boolean
|
||||||
version: number
|
version: number
|
||||||
deployedAt: string
|
deployedAt: string
|
||||||
|
warnings?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUndeployResult {
|
export interface AdminUndeployResult {
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { db, workflow } from '@sim/db'
|
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { cleanupWebhooksForWorkflow } from '@/lib/webhooks/deploy'
|
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||||
|
import {
|
||||||
|
cleanupWebhooksForWorkflow,
|
||||||
|
restorePreviousVersionWebhooks,
|
||||||
|
saveTriggerWebhooksForDeploy,
|
||||||
|
} from '@/lib/webhooks/deploy'
|
||||||
import {
|
import {
|
||||||
deployWorkflow,
|
deployWorkflow,
|
||||||
loadWorkflowFromNormalizedTables,
|
loadWorkflowFromNormalizedTables,
|
||||||
undeployWorkflow,
|
undeployWorkflow,
|
||||||
} from '@/lib/workflows/persistence/utils'
|
} from '@/lib/workflows/persistence/utils'
|
||||||
import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules'
|
import {
|
||||||
|
cleanupDeploymentVersion,
|
||||||
|
createSchedulesForDeploy,
|
||||||
|
validateWorkflowSchedules,
|
||||||
|
} from '@/lib/workflows/schedules'
|
||||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||||
import {
|
import {
|
||||||
badRequestResponse,
|
badRequestResponse,
|
||||||
@@ -28,10 +37,11 @@ interface RouteParams {
|
|||||||
|
|
||||||
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
|
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||||
const { id: workflowId } = await context.params
|
const { id: workflowId } = await context.params
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [workflowRecord] = await db
|
const [workflowRecord] = await db
|
||||||
.select({ id: workflow.id, name: workflow.name })
|
.select()
|
||||||
.from(workflow)
|
.from(workflow)
|
||||||
.where(eq(workflow.id, workflowId))
|
.where(eq(workflow.id, workflowId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@@ -50,6 +60,18 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
|
|||||||
return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`)
|
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({
|
const deployResult = await deployWorkflow({
|
||||||
workflowId,
|
workflowId,
|
||||||
deployedBy: ADMIN_ACTOR_ID,
|
deployedBy: ADMIN_ACTOR_ID,
|
||||||
@@ -65,6 +87,32 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
|
|||||||
return internalErrorResponse('Failed to resolve deployment version')
|
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(
|
const scheduleResult = await createSchedulesForDeploy(
|
||||||
workflowId,
|
workflowId,
|
||||||
normalizedData.blocks,
|
normalizedData.blocks,
|
||||||
@@ -72,15 +120,58 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
|
|||||||
deployResult.deploymentVersionId
|
deployResult.deploymentVersionId
|
||||||
)
|
)
|
||||||
if (!scheduleResult.success) {
|
if (!scheduleResult.success) {
|
||||||
logger.warn(`Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`)
|
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.info(`Admin API: Deployed workflow ${workflowId} as v${deployResult.version}`)
|
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' })
|
||||||
|
|
||||||
const response: AdminDeployResult = {
|
const response: AdminDeployResult = {
|
||||||
isDeployed: true,
|
isDeployed: true,
|
||||||
version: deployResult.version!,
|
version: deployResult.version!,
|
||||||
deployedAt: deployResult.deployedAt!.toISOString(),
|
deployedAt: deployResult.deployedAt!.toISOString(),
|
||||||
|
warnings: triggerSaveResult.warnings,
|
||||||
}
|
}
|
||||||
|
|
||||||
return singleResponse(response)
|
return singleResponse(response)
|
||||||
@@ -105,7 +196,6 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
|
|||||||
return notFoundResponse('Workflow')
|
return notFoundResponse('Workflow')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up external webhook subscriptions before undeploying
|
|
||||||
await cleanupWebhooksForWorkflow(
|
await cleanupWebhooksForWorkflow(
|
||||||
workflowId,
|
workflowId,
|
||||||
workflowRecord as Record<string, unknown>,
|
workflowRecord as Record<string, unknown>,
|
||||||
@@ -117,6 +207,8 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
|
|||||||
return internalErrorResponse(result.error || 'Failed to undeploy workflow')
|
return internalErrorResponse(result.error || 'Failed to undeploy workflow')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await removeMcpToolsForWorkflow(workflowId, requestId)
|
||||||
|
|
||||||
logger.info(`Admin API: Undeployed workflow ${workflowId}`)
|
logger.info(`Admin API: Undeployed workflow ${workflowId}`)
|
||||||
|
|
||||||
const response: AdminUndeployResult = {
|
const response: AdminUndeployResult = {
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { db, workflow } from '@sim/db'
|
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { eq } from 'drizzle-orm'
|
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 { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
|
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
|
||||||
|
import {
|
||||||
|
cleanupDeploymentVersion,
|
||||||
|
createSchedulesForDeploy,
|
||||||
|
validateWorkflowSchedules,
|
||||||
|
} from '@/lib/workflows/schedules'
|
||||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||||
import {
|
import {
|
||||||
badRequestResponse,
|
badRequestResponse,
|
||||||
@@ -9,6 +17,7 @@ import {
|
|||||||
notFoundResponse,
|
notFoundResponse,
|
||||||
singleResponse,
|
singleResponse,
|
||||||
} from '@/app/api/v1/admin/responses'
|
} from '@/app/api/v1/admin/responses'
|
||||||
|
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||||
|
|
||||||
const logger = createLogger('AdminWorkflowActivateVersionAPI')
|
const logger = createLogger('AdminWorkflowActivateVersionAPI')
|
||||||
|
|
||||||
@@ -18,11 +27,12 @@ interface RouteParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
|
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||||
|
const requestId = generateRequestId()
|
||||||
const { id: workflowId, versionId } = await context.params
|
const { id: workflowId, versionId } = await context.params
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [workflowRecord] = await db
|
const [workflowRecord] = await db
|
||||||
.select({ id: workflow.id })
|
.select()
|
||||||
.from(workflow)
|
.from(workflow)
|
||||||
.where(eq(workflow.id, workflowId))
|
.where(eq(workflow.id, workflowId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@@ -36,23 +46,161 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
|
|||||||
return badRequestResponse('Invalid version number')
|
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 })
|
const result = await activateWorkflowVersion({ workflowId, version: versionNum })
|
||||||
if (!result.success) {
|
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') {
|
if (result.error === 'Deployment version not found') {
|
||||||
return notFoundResponse('Deployment version')
|
return notFoundResponse('Deployment version')
|
||||||
}
|
}
|
||||||
return internalErrorResponse(result.error || 'Failed to activate version')
|
return internalErrorResponse(result.error || 'Failed to activate version')
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Admin API: Activated version ${versionNum} for workflow ${workflowId}`)
|
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}`
|
||||||
|
)
|
||||||
|
|
||||||
return singleResponse({
|
return singleResponse({
|
||||||
success: true,
|
success: true,
|
||||||
version: versionNum,
|
version: versionNum,
|
||||||
deployedAt: result.deployedAt!.toISOString(),
|
deployedAt: result.deployedAt!.toISOString(),
|
||||||
|
warnings: triggerSaveResult.warnings,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Admin API: Failed to activate version for workflow ${workflowId}`, { error })
|
logger.error(
|
||||||
|
`[${requestId}] Admin API: Failed to activate version for workflow ${workflowId}`,
|
||||||
|
{
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
)
|
||||||
return internalErrorResponse('Failed to activate deployment version')
|
return internalErrorResponse('Failed to activate deployment version')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ import { getSession } from '@/lib/auth'
|
|||||||
import { validateInteger } from '@/lib/core/security/input-validation'
|
import { validateInteger } from '@/lib/core/security/input-validation'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import {
|
import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions'
|
||||||
cleanupExternalWebhook,
|
|
||||||
createExternalWebhookSubscription,
|
|
||||||
shouldRecreateExternalWebhookSubscription,
|
|
||||||
} from '@/lib/webhooks/provider-subscriptions'
|
|
||||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||||
|
|
||||||
const logger = createLogger('WebhookAPI')
|
const logger = createLogger('WebhookAPI')
|
||||||
@@ -87,7 +83,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update a webhook
|
|
||||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
@@ -102,7 +97,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { path, provider, providerConfig, isActive, failedCount } = body
|
const { isActive, failedCount } = body
|
||||||
|
|
||||||
if (failedCount !== undefined) {
|
if (failedCount !== undefined) {
|
||||||
const validation = validateInteger(failedCount, 'failedCount', { min: 0 })
|
const validation = validateInteger(failedCount, 'failedCount', { min: 0 })
|
||||||
@@ -112,28 +107,6 @@ 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
|
const webhooks = await db
|
||||||
.select({
|
.select({
|
||||||
webhook: webhook,
|
webhook: webhook,
|
||||||
@@ -154,16 +127,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const webhookData = webhooks[0]
|
const webhookData = webhooks[0]
|
||||||
|
|
||||||
// Check if user has permission to modify this webhook
|
|
||||||
let canModify = false
|
let canModify = false
|
||||||
|
|
||||||
// Case 1: User owns the workflow
|
|
||||||
if (webhookData.workflow.userId === session.user.id) {
|
if (webhookData.workflow.userId === session.user.id) {
|
||||||
canModify = true
|
canModify = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 2: Workflow belongs to a workspace and user has write or admin permission
|
|
||||||
if (!canModify && webhookData.workflow.workspaceId) {
|
if (!canModify && webhookData.workflow.workspaceId) {
|
||||||
const userPermission = await getUserEntityPermissions(
|
const userPermission = await getUserEntityPermissions(
|
||||||
session.user.id,
|
session.user.id,
|
||||||
@@ -182,76 +151,14 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
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`, {
|
logger.debug(`[${requestId}] Updating webhook properties`, {
|
||||||
hasPathUpdate: path !== undefined,
|
|
||||||
hasProviderUpdate: provider !== undefined,
|
|
||||||
hasConfigUpdate: providerConfig !== undefined,
|
|
||||||
hasActiveUpdate: isActive !== undefined,
|
hasActiveUpdate: isActive !== undefined,
|
||||||
hasFailedCountUpdate: failedCount !== 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
|
const updatedWebhook = await db
|
||||||
.update(webhook)
|
.update(webhook)
|
||||||
.set({
|
.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,
|
isActive: isActive !== undefined ? isActive : webhooks[0].webhook.isActive,
|
||||||
failedCount: failedCount !== undefined ? failedCount : webhooks[0].webhook.failedCount,
|
failedCount: failedCount !== undefined ? failedCount : webhooks[0].webhook.failedCount,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -334,11 +241,8 @@ export async function DELETE(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const foundWebhook = webhookData.webhook
|
const foundWebhook = webhookData.webhook
|
||||||
const { cleanupExternalWebhook } = await import('@/lib/webhooks/provider-subscriptions')
|
const credentialSetId = foundWebhook.credentialSetId as string | undefined
|
||||||
|
const blockId = foundWebhook.blockId as string | undefined
|
||||||
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) {
|
if (credentialSetId && blockId) {
|
||||||
const allCredentialSetWebhooks = await db
|
const allCredentialSetWebhooks = await db
|
||||||
@@ -346,10 +250,9 @@ export async function DELETE(
|
|||||||
.from(webhook)
|
.from(webhook)
|
||||||
.where(and(eq(webhook.workflowId, webhookData.workflow.id), eq(webhook.blockId, blockId)))
|
.where(and(eq(webhook.workflowId, webhookData.workflow.id), eq(webhook.blockId, blockId)))
|
||||||
|
|
||||||
const webhooksToDelete = allCredentialSetWebhooks.filter((w) => {
|
const webhooksToDelete = allCredentialSetWebhooks.filter(
|
||||||
const config = w.providerConfig as Record<string, unknown> | null
|
(w) => w.credentialSetId === credentialSetId
|
||||||
return config?.credentialSetId === credentialSetId
|
)
|
||||||
})
|
|
||||||
|
|
||||||
for (const w of webhooksToDelete) {
|
for (const w of webhooksToDelete) {
|
||||||
await cleanupExternalWebhook(w, webhookData.workflow, requestId)
|
await cleanupExternalWebhook(w, webhookData.workflow, requestId)
|
||||||
|
|||||||
@@ -7,8 +7,21 @@ import { type NextRequest, NextResponse } from 'next/server'
|
|||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { createExternalWebhookSubscription } from '@/lib/webhooks/provider-subscriptions'
|
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 { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||||
|
import { extractCredentialSetId, isCredentialSetValue } from '@/executor/constants'
|
||||||
|
|
||||||
const logger = createLogger('WebhooksAPI')
|
const logger = createLogger('WebhooksAPI')
|
||||||
|
|
||||||
@@ -298,14 +311,10 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let savedWebhook: any = null // Variable to hold the result of save/update
|
let savedWebhook: any = null
|
||||||
|
const originalProviderConfig = providerConfig || {}
|
||||||
// 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(
|
let resolvedProviderConfig = await resolveEnvVarsInObject(
|
||||||
finalProviderConfig,
|
originalProviderConfig,
|
||||||
userId,
|
userId,
|
||||||
workflowRecord.workspaceId || undefined
|
workflowRecord.workspaceId || undefined
|
||||||
)
|
)
|
||||||
@@ -319,8 +328,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const directCredentialSetId = resolvedProviderConfig?.credentialSetId as string | undefined
|
const directCredentialSetId = resolvedProviderConfig?.credentialSetId as string | undefined
|
||||||
|
|
||||||
if (directCredentialSetId || rawCredentialId) {
|
if (directCredentialSetId || rawCredentialId) {
|
||||||
const { isCredentialSetValue, extractCredentialSetId } = await import('@/executor/constants')
|
|
||||||
|
|
||||||
const credentialSetId =
|
const credentialSetId =
|
||||||
directCredentialSetId ||
|
directCredentialSetId ||
|
||||||
(rawCredentialId && isCredentialSetValue(rawCredentialId)
|
(rawCredentialId && isCredentialSetValue(rawCredentialId)
|
||||||
@@ -332,11 +339,6 @@ export async function POST(request: NextRequest) {
|
|||||||
`[${requestId}] Credential set detected for ${provider} trigger. Syncing webhooks for set ${credentialSetId}`
|
`[${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 oauthProviderId = getProviderIdFromServiceId(provider)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -469,6 +471,9 @@ export async function POST(request: NextRequest) {
|
|||||||
providerConfig: providerConfigOverride,
|
providerConfig: providerConfigOverride,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const userProvided = originalProviderConfig as Record<string, unknown>
|
||||||
|
const configToSave: Record<string, unknown> = { ...userProvided }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await createExternalWebhookSubscription(
|
const result = await createExternalWebhookSubscription(
|
||||||
request,
|
request,
|
||||||
@@ -477,7 +482,9 @@ export async function POST(request: NextRequest) {
|
|||||||
userId,
|
userId,
|
||||||
requestId
|
requestId
|
||||||
)
|
)
|
||||||
resolvedProviderConfig = result.updatedProviderConfig as Record<string, unknown>
|
const updatedConfig = result.updatedProviderConfig as Record<string, unknown>
|
||||||
|
mergeNonUserFields(configToSave, updatedConfig, userProvided)
|
||||||
|
resolvedProviderConfig = updatedConfig
|
||||||
externalSubscriptionCreated = result.externalSubscriptionCreated
|
externalSubscriptionCreated = result.externalSubscriptionCreated
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`[${requestId}] Error creating external webhook subscription`, err)
|
logger.error(`[${requestId}] Error creating external webhook subscription`, err)
|
||||||
@@ -490,25 +497,22 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now save to database (only if subscription succeeded or provider doesn't need external subscription)
|
|
||||||
try {
|
try {
|
||||||
if (targetWebhookId) {
|
if (targetWebhookId) {
|
||||||
logger.info(`[${requestId}] Updating existing webhook for path: ${finalPath}`, {
|
logger.info(`[${requestId}] Updating existing webhook for path: ${finalPath}`, {
|
||||||
webhookId: targetWebhookId,
|
webhookId: targetWebhookId,
|
||||||
provider,
|
provider,
|
||||||
hasCredentialId: !!(resolvedProviderConfig as any)?.credentialId,
|
hasCredentialId: !!(configToSave as any)?.credentialId,
|
||||||
credentialId: (resolvedProviderConfig as any)?.credentialId,
|
credentialId: (configToSave as any)?.credentialId,
|
||||||
})
|
})
|
||||||
const updatedResult = await db
|
const updatedResult = await db
|
||||||
.update(webhook)
|
.update(webhook)
|
||||||
.set({
|
.set({
|
||||||
blockId,
|
blockId,
|
||||||
provider,
|
provider,
|
||||||
providerConfig: resolvedProviderConfig,
|
providerConfig: configToSave,
|
||||||
credentialSetId:
|
credentialSetId:
|
||||||
((resolvedProviderConfig as Record<string, unknown>)?.credentialSetId as
|
((configToSave as Record<string, unknown>)?.credentialSetId as string | null) || null,
|
||||||
| string
|
|
||||||
| null) || null,
|
|
||||||
isActive: true,
|
isActive: true,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
@@ -531,11 +535,9 @@ export async function POST(request: NextRequest) {
|
|||||||
blockId,
|
blockId,
|
||||||
path: finalPath,
|
path: finalPath,
|
||||||
provider,
|
provider,
|
||||||
providerConfig: resolvedProviderConfig,
|
providerConfig: configToSave,
|
||||||
credentialSetId:
|
credentialSetId:
|
||||||
((resolvedProviderConfig as Record<string, unknown>)?.credentialSetId as
|
((configToSave as Record<string, unknown>)?.credentialSetId as string | null) || null,
|
||||||
| string
|
|
||||||
| null) || null,
|
|
||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -547,9 +549,8 @@ export async function POST(request: NextRequest) {
|
|||||||
if (externalSubscriptionCreated) {
|
if (externalSubscriptionCreated) {
|
||||||
logger.error(`[${requestId}] DB save failed, cleaning up external subscription`, dbError)
|
logger.error(`[${requestId}] DB save failed, cleaning up external subscription`, dbError)
|
||||||
try {
|
try {
|
||||||
const { cleanupExternalWebhook } = await import('@/lib/webhooks/provider-subscriptions')
|
|
||||||
await cleanupExternalWebhook(
|
await cleanupExternalWebhook(
|
||||||
createTempWebhookData(resolvedProviderConfig),
|
createTempWebhookData(configToSave),
|
||||||
workflowRecord,
|
workflowRecord,
|
||||||
requestId
|
requestId
|
||||||
)
|
)
|
||||||
@@ -567,7 +568,6 @@ export async function POST(request: NextRequest) {
|
|||||||
if (savedWebhook && provider === 'gmail') {
|
if (savedWebhook && provider === 'gmail') {
|
||||||
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
|
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
|
||||||
try {
|
try {
|
||||||
const { configureGmailPolling } = await import('@/lib/webhooks/utils.server')
|
|
||||||
const success = await configureGmailPolling(savedWebhook, requestId)
|
const success = await configureGmailPolling(savedWebhook, requestId)
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
@@ -606,7 +606,6 @@ export async function POST(request: NextRequest) {
|
|||||||
`[${requestId}] Outlook provider detected. Setting up Outlook webhook configuration.`
|
`[${requestId}] Outlook provider detected. Setting up Outlook webhook configuration.`
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
const { configureOutlookPolling } = await import('@/lib/webhooks/utils.server')
|
|
||||||
const success = await configureOutlookPolling(savedWebhook, requestId)
|
const success = await configureOutlookPolling(savedWebhook, requestId)
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
@@ -643,7 +642,6 @@ export async function POST(request: NextRequest) {
|
|||||||
if (savedWebhook && provider === 'rss') {
|
if (savedWebhook && provider === 'rss') {
|
||||||
logger.info(`[${requestId}] RSS provider detected. Setting up RSS webhook configuration.`)
|
logger.info(`[${requestId}] RSS provider detected. Setting up RSS webhook configuration.`)
|
||||||
try {
|
try {
|
||||||
const { configureRssPolling } = await import('@/lib/webhooks/utils.server')
|
|
||||||
const success = await configureRssPolling(savedWebhook, requestId)
|
const success = await configureRssPolling(savedWebhook, requestId)
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { and, desc, eq } from 'drizzle-orm'
|
|||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||||
import { cleanupWebhooksForWorkflow, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
|
import {
|
||||||
|
cleanupWebhooksForWorkflow,
|
||||||
|
restorePreviousVersionWebhooks,
|
||||||
|
saveTriggerWebhooksForDeploy,
|
||||||
|
} from '@/lib/webhooks/deploy'
|
||||||
import {
|
import {
|
||||||
deployWorkflow,
|
deployWorkflow,
|
||||||
loadWorkflowFromNormalizedTables,
|
loadWorkflowFromNormalizedTables,
|
||||||
@@ -135,6 +139,18 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
|
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({
|
const deployResult = await deployWorkflow({
|
||||||
workflowId: id,
|
workflowId: id,
|
||||||
deployedBy: actorUserId,
|
deployedBy: actorUserId,
|
||||||
@@ -161,6 +177,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
blocks: normalizedData.blocks,
|
blocks: normalizedData.blocks,
|
||||||
requestId,
|
requestId,
|
||||||
deploymentVersionId,
|
deploymentVersionId,
|
||||||
|
previousVersionId,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!triggerSaveResult.success) {
|
if (!triggerSaveResult.success) {
|
||||||
@@ -194,6 +211,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
requestId,
|
requestId,
|
||||||
deploymentVersionId,
|
deploymentVersionId,
|
||||||
})
|
})
|
||||||
|
if (previousVersionId) {
|
||||||
|
await restorePreviousVersionWebhooks({
|
||||||
|
request,
|
||||||
|
workflow: workflowData as Record<string, unknown>,
|
||||||
|
userId: actorUserId,
|
||||||
|
previousVersionId,
|
||||||
|
requestId,
|
||||||
|
})
|
||||||
|
}
|
||||||
await undeployWorkflow({ workflowId: id })
|
await undeployWorkflow({ workflowId: id })
|
||||||
return createErrorResponse(scheduleResult.error || 'Failed to create schedule', 500)
|
return createErrorResponse(scheduleResult.error || 'Failed to create schedule', 500)
|
||||||
}
|
}
|
||||||
@@ -208,6 +234,25 @@ 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}`)
|
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
|
||||||
|
|
||||||
// Sync MCP tools with the latest parameter schema
|
// Sync MCP tools with the latest parameter schema
|
||||||
@@ -228,6 +273,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
nextRunAt: scheduleInfo.nextRunAt,
|
nextRunAt: scheduleInfo.nextRunAt,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
warnings: triggerSaveResult.warnings,
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`[${requestId}] Error deploying workflow: ${id}`, {
|
logger.error(`[${requestId}] Error deploying workflow: ${id}`, {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { and, eq } from 'drizzle-orm'
|
|||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||||
import { saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
|
import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
|
||||||
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
|
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
|
||||||
import {
|
import {
|
||||||
cleanupDeploymentVersion,
|
cleanupDeploymentVersion,
|
||||||
@@ -85,6 +85,11 @@ export async function POST(
|
|||||||
return createErrorResponse('Invalid deployed state structure', 500)
|
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({
|
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
|
||||||
request,
|
request,
|
||||||
workflowId: id,
|
workflowId: id,
|
||||||
@@ -93,6 +98,8 @@ export async function POST(
|
|||||||
blocks,
|
blocks,
|
||||||
requestId,
|
requestId,
|
||||||
deploymentVersionId: versionRow.id,
|
deploymentVersionId: versionRow.id,
|
||||||
|
previousVersionId,
|
||||||
|
forceRecreateSubscriptions: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!triggerSaveResult.success) {
|
if (!triggerSaveResult.success) {
|
||||||
@@ -102,11 +109,6 @@ 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)
|
const scheduleResult = await createSchedulesForDeploy(id, blocks, db, versionRow.id)
|
||||||
|
|
||||||
if (!scheduleResult.success) {
|
if (!scheduleResult.success) {
|
||||||
@@ -116,6 +118,15 @@ export async function POST(
|
|||||||
requestId,
|
requestId,
|
||||||
deploymentVersionId: versionRow.id,
|
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)
|
return createErrorResponse(scheduleResult.error || 'Failed to sync schedules', 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +138,15 @@ export async function POST(
|
|||||||
requestId,
|
requestId,
|
||||||
deploymentVersionId: versionRow.id,
|
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)
|
return createErrorResponse(result.error || 'Failed to activate deployment', 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +160,7 @@ export async function POST(
|
|||||||
workflow: workflowData as Record<string, unknown>,
|
workflow: workflowData as Record<string, unknown>,
|
||||||
requestId,
|
requestId,
|
||||||
deploymentVersionId: previousVersionId,
|
deploymentVersionId: previousVersionId,
|
||||||
|
skipExternalCleanup: true,
|
||||||
})
|
})
|
||||||
logger.info(`[${requestId}] Previous version cleanup completed`)
|
logger.info(`[${requestId}] Previous version cleanup completed`)
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
@@ -157,7 +178,11 @@ export async function POST(
|
|||||||
context: 'activate',
|
context: 'activate',
|
||||||
})
|
})
|
||||||
|
|
||||||
return createSuccessResponse({ success: true, deployedAt: result.deployedAt })
|
return createSuccessResponse({
|
||||||
|
success: true,
|
||||||
|
deployedAt: result.deployedAt,
|
||||||
|
warnings: triggerSaveResult.warnings,
|
||||||
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)
|
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)
|
||||||
return createErrorResponse(error.message || 'Failed to activate deployment', 500)
|
return createErrorResponse(error.message || 'Failed to activate deployment', 500)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { normalizeName } from '@/executor/constants'
|
|||||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||||
import type { ExecutionMetadata, IterationContext } from '@/executor/execution/types'
|
import type { ExecutionMetadata, IterationContext } from '@/executor/execution/types'
|
||||||
import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types'
|
import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types'
|
||||||
|
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||||
import { Serializer } from '@/serializer'
|
import { Serializer } from '@/serializer'
|
||||||
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
|
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
|
||||||
|
|
||||||
@@ -116,7 +117,6 @@ type AsyncExecutionParams = {
|
|||||||
userId: string
|
userId: string
|
||||||
input: any
|
input: any
|
||||||
triggerType: CoreTriggerType
|
triggerType: CoreTriggerType
|
||||||
preflighted?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,7 +139,6 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
|
|||||||
userId,
|
userId,
|
||||||
input,
|
input,
|
||||||
triggerType,
|
triggerType,
|
||||||
preflighted: params.preflighted,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -276,7 +275,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
requestId
|
requestId
|
||||||
)
|
)
|
||||||
|
|
||||||
const shouldPreflightEnvVars = isAsyncMode && isTriggerDevEnabled
|
|
||||||
const preprocessResult = await preprocessExecution({
|
const preprocessResult = await preprocessExecution({
|
||||||
workflowId,
|
workflowId,
|
||||||
userId,
|
userId,
|
||||||
@@ -285,9 +283,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
requestId,
|
requestId,
|
||||||
checkDeployment: !shouldUseDraftState,
|
checkDeployment: !shouldUseDraftState,
|
||||||
loggingSession,
|
loggingSession,
|
||||||
preflightEnvVars: shouldPreflightEnvVars,
|
|
||||||
useDraftState: shouldUseDraftState,
|
useDraftState: shouldUseDraftState,
|
||||||
envUserId: isClientSession ? userId : undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!preprocessResult.success) {
|
if (!preprocessResult.success) {
|
||||||
@@ -319,7 +315,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
userId: actorUserId,
|
userId: actorUserId,
|
||||||
input,
|
input,
|
||||||
triggerType: loggingTriggerType,
|
triggerType: loggingTriggerType,
|
||||||
preflighted: shouldPreflightEnvVars,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,17 +468,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(filteredResult)
|
return NextResponse.json(filteredResult)
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
const errorMessage = error.message || 'Unknown error'
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
logger.error(`[${requestId}] Non-SSE execution failed: ${errorMessage}`)
|
logger.error(`[${requestId}] Non-SSE execution failed: ${errorMessage}`)
|
||||||
|
|
||||||
const executionResult = error.executionResult
|
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
output: executionResult?.output,
|
output: executionResult?.output,
|
||||||
error: executionResult?.error || error.message || 'Execution failed',
|
error: executionResult?.error || errorMessage || 'Execution failed',
|
||||||
metadata: executionResult?.metadata
|
metadata: executionResult?.metadata
|
||||||
? {
|
? {
|
||||||
duration: executionResult.metadata.duration,
|
duration: executionResult.metadata.duration,
|
||||||
@@ -794,11 +789,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
// Cleanup base64 cache for this execution
|
// Cleanup base64 cache for this execution
|
||||||
await cleanupExecutionBase64Cache(executionId)
|
await cleanupExecutionBase64Cache(executionId)
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
const errorMessage = error.message || 'Unknown error'
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
logger.error(`[${requestId}] SSE execution failed: ${errorMessage}`)
|
logger.error(`[${requestId}] SSE execution failed: ${errorMessage}`)
|
||||||
|
|
||||||
const executionResult = error.executionResult
|
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
|
||||||
|
|
||||||
sendEvent({
|
sendEvent({
|
||||||
type: 'execution:error',
|
type: 'execution:error',
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export function EditChunkModal({
|
|||||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||||
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null)
|
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null)
|
||||||
const [tokenizerOn, setTokenizerOn] = useState(false)
|
const [tokenizerOn, setTokenizerOn] = useState(false)
|
||||||
|
const [hoveredTokenIndex, setHoveredTokenIndex] = useState<number | null>(null)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
const error = mutationError?.message ?? null
|
const error = mutationError?.message ?? null
|
||||||
@@ -254,6 +255,8 @@ export function EditChunkModal({
|
|||||||
style={{
|
style={{
|
||||||
backgroundColor: getTokenBgColor(index),
|
backgroundColor: getTokenBgColor(index),
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={() => setHoveredTokenIndex(index)}
|
||||||
|
onMouseLeave={() => setHoveredTokenIndex(null)}
|
||||||
>
|
>
|
||||||
{token}
|
{token}
|
||||||
</span>
|
</span>
|
||||||
@@ -281,6 +284,11 @@ export function EditChunkModal({
|
|||||||
<div className='flex items-center gap-[8px]'>
|
<div className='flex items-center gap-[8px]'>
|
||||||
<span className='text-[12px] text-[var(--text-secondary)]'>Tokenizer</span>
|
<span className='text-[12px] text-[var(--text-secondary)]'>Tokenizer</span>
|
||||||
<Switch checked={tokenizerOn} onCheckedChange={setTokenizerOn} />
|
<Switch checked={tokenizerOn} onCheckedChange={setTokenizerOn} />
|
||||||
|
{tokenizerOn && hoveredTokenIndex !== null && (
|
||||||
|
<span className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Token #{hoveredTokenIndex + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className='text-[12px] text-[var(--text-secondary)]'>
|
<span className='text-[12px] text-[var(--text-secondary)]'>
|
||||||
{tokenCount.toLocaleString()}
|
{tokenCount.toLocaleString()}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { SearchHighlight } from '@/components/ui/search-highlight'
|
import { SearchHighlight } from '@/components/ui/search-highlight'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatting'
|
||||||
import type { ChunkData } from '@/lib/knowledge/types'
|
import type { ChunkData } from '@/lib/knowledge/types'
|
||||||
import {
|
import {
|
||||||
ChunkContextMenu,
|
ChunkContextMenu,
|
||||||
@@ -58,55 +59,6 @@ import {
|
|||||||
|
|
||||||
const logger = createLogger('Document')
|
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 {
|
interface DocumentProps {
|
||||||
knowledgeBaseId: string
|
knowledgeBaseId: string
|
||||||
documentId: string
|
documentId: string
|
||||||
@@ -304,7 +256,6 @@ export function Document({
|
|||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
|
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
chunks: initialChunks,
|
chunks: initialChunks,
|
||||||
@@ -344,7 +295,6 @@ export function Document({
|
|||||||
const handler = setTimeout(() => {
|
const handler = setTimeout(() => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setDebouncedSearchQuery(searchQuery)
|
setDebouncedSearchQuery(searchQuery)
|
||||||
setIsSearching(searchQuery.trim().length > 0)
|
|
||||||
})
|
})
|
||||||
}, 200)
|
}, 200)
|
||||||
|
|
||||||
@@ -353,6 +303,7 @@ export function Document({
|
|||||||
}
|
}
|
||||||
}, [searchQuery])
|
}, [searchQuery])
|
||||||
|
|
||||||
|
const isSearching = debouncedSearchQuery.trim().length > 0
|
||||||
const showingSearch = isSearching && searchQuery.trim().length > 0 && searchResults.length > 0
|
const showingSearch = isSearching && searchQuery.trim().length > 0 && searchResults.length > 0
|
||||||
const SEARCH_PAGE_SIZE = 50
|
const SEARCH_PAGE_SIZE = 50
|
||||||
const maxSearchPages = Math.ceil(searchResults.length / SEARCH_PAGE_SIZE)
|
const maxSearchPages = Math.ceil(searchResults.length / SEARCH_PAGE_SIZE)
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ import {
|
|||||||
ModalContent,
|
ModalContent,
|
||||||
ModalFooter,
|
ModalFooter,
|
||||||
ModalHeader,
|
ModalHeader,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverItem,
|
||||||
|
PopoverTrigger,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
@@ -40,8 +44,11 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { SearchHighlight } from '@/components/ui/search-highlight'
|
import { SearchHighlight } from '@/components/ui/search-highlight'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
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 { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
|
||||||
import type { DocumentData } from '@/lib/knowledge/types'
|
import type { DocumentData } from '@/lib/knowledge/types'
|
||||||
|
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
|
||||||
import {
|
import {
|
||||||
ActionBar,
|
ActionBar,
|
||||||
AddDocumentsModal,
|
AddDocumentsModal,
|
||||||
@@ -189,8 +196,8 @@ function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-[4px]'>
|
<div>
|
||||||
<Skeleton className='h-[21px] w-[300px] rounded-[4px]' />
|
<Skeleton className='mt-[4px] h-[21px] w-[300px] rounded-[4px]' />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-[16px] flex items-center gap-[8px]'>
|
<div className='mt-[16px] flex items-center gap-[8px]'>
|
||||||
@@ -208,10 +215,13 @@ 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'
|
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>
|
||||||
|
<div className='flex items-center gap-[8px]'>
|
||||||
|
<Skeleton className='h-[32px] w-[52px] rounded-[6px]' />
|
||||||
<Button disabled variant='tertiary' className='h-[32px] rounded-[6px]'>
|
<Button disabled variant='tertiary' className='h-[32px] rounded-[6px]'>
|
||||||
Add Documents
|
Add Documents
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='mt-[12px] flex flex-1 flex-col overflow-hidden'>
|
<div className='mt-[12px] flex flex-1 flex-col overflow-hidden'>
|
||||||
<DocumentTableSkeleton rowCount={8} />
|
<DocumentTableSkeleton rowCount={8} />
|
||||||
@@ -222,73 +232,11 @@ 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 {
|
interface KnowledgeBaseProps {
|
||||||
id: string
|
id: string
|
||||||
knowledgeBaseName?: 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 }) => (
|
const AnimatedLoader = ({ className }: { className?: string }) => (
|
||||||
<Loader2 className={cn(className, 'animate-spin')} />
|
<Loader2 className={cn(className, 'animate-spin')} />
|
||||||
)
|
)
|
||||||
@@ -336,53 +284,24 @@ 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 {
|
interface TagValue {
|
||||||
slot: TagSlot
|
slot: AllTagSlot
|
||||||
displayName: string
|
displayName: string
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAG_FIELD_TYPES: Record<string, string> = {
|
|
||||||
tag: 'text',
|
|
||||||
number: 'number',
|
|
||||||
date: 'date',
|
|
||||||
boolean: 'boolean',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes tag values for a document
|
* Computes tag values for a document
|
||||||
*/
|
*/
|
||||||
function getDocumentTags(doc: DocumentData, definitions: TagDefinition[]): TagValue[] {
|
function getDocumentTags(doc: DocumentData, definitions: TagDefinition[]): TagValue[] {
|
||||||
const result: TagValue[] = []
|
const result: TagValue[] = []
|
||||||
|
|
||||||
for (const slot of TAG_SLOTS) {
|
for (const slot of ALL_TAG_SLOTS) {
|
||||||
const raw = doc[slot]
|
const raw = doc[slot]
|
||||||
if (raw == null) continue
|
if (raw == null) continue
|
||||||
|
|
||||||
const def = definitions.find((d) => d.tagSlot === slot)
|
const def = definitions.find((d) => d.tagSlot === slot)
|
||||||
const fieldType = def?.fieldType || TAG_FIELD_TYPES[slot.replace(/\d+$/, '')] || 'text'
|
const fieldType = def?.fieldType || getFieldTypeForSlot(slot) || 'text'
|
||||||
|
|
||||||
let value: string
|
let value: string
|
||||||
if (fieldType === 'date') {
|
if (fieldType === 'date') {
|
||||||
@@ -424,6 +343,8 @@ export function KnowledgeBase({
|
|||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [showTagsModal, setShowTagsModal] = useState(false)
|
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
|
* Memoize the search query setter to prevent unnecessary re-renders
|
||||||
@@ -434,6 +355,7 @@ export function KnowledgeBase({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(new Set())
|
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(new Set())
|
||||||
|
const [isSelectAllMode, setIsSelectAllMode] = useState(false)
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const [showAddDocumentsModal, setShowAddDocumentsModal] = useState(false)
|
const [showAddDocumentsModal, setShowAddDocumentsModal] = useState(false)
|
||||||
const [showDeleteDocumentModal, setShowDeleteDocumentModal] = useState(false)
|
const [showDeleteDocumentModal, setShowDeleteDocumentModal] = useState(false)
|
||||||
@@ -460,7 +382,6 @@ export function KnowledgeBase({
|
|||||||
error: knowledgeBaseError,
|
error: knowledgeBaseError,
|
||||||
refresh: refreshKnowledgeBase,
|
refresh: refreshKnowledgeBase,
|
||||||
} = useKnowledgeBase(id)
|
} = useKnowledgeBase(id)
|
||||||
const [hasProcessingDocuments, setHasProcessingDocuments] = useState(false)
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
documents,
|
documents,
|
||||||
@@ -469,6 +390,7 @@ export function KnowledgeBase({
|
|||||||
isFetching: isFetchingDocuments,
|
isFetching: isFetchingDocuments,
|
||||||
isPlaceholderData: isPlaceholderDocuments,
|
isPlaceholderData: isPlaceholderDocuments,
|
||||||
error: documentsError,
|
error: documentsError,
|
||||||
|
hasProcessingDocuments,
|
||||||
updateDocument,
|
updateDocument,
|
||||||
refreshDocuments,
|
refreshDocuments,
|
||||||
} = useKnowledgeBaseDocuments(id, {
|
} = useKnowledgeBaseDocuments(id, {
|
||||||
@@ -477,7 +399,14 @@ export function KnowledgeBase({
|
|||||||
offset: (currentPage - 1) * DOCUMENTS_PER_PAGE,
|
offset: (currentPage - 1) * DOCUMENTS_PER_PAGE,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
refetchInterval: hasProcessingDocuments && !isDeleting ? 3000 : false,
|
refetchInterval: (data) => {
|
||||||
|
if (isDeleting) return false
|
||||||
|
const hasPending = data?.documents?.some(
|
||||||
|
(doc) => doc.processingStatus === 'pending' || doc.processingStatus === 'processing'
|
||||||
|
)
|
||||||
|
return hasPending ? 3000 : false
|
||||||
|
},
|
||||||
|
enabledFilter,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { tagDefinitions } = useKnowledgeBaseTagDefinitions(id)
|
const { tagDefinitions } = useKnowledgeBaseTagDefinitions(id)
|
||||||
@@ -543,25 +472,15 @@ export function KnowledgeBase({
|
|||||||
</TableHead>
|
</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
|
* Checks for documents with stale processing states and marks them as failed
|
||||||
*/
|
*/
|
||||||
const checkForDeadProcesses = () => {
|
const checkForDeadProcesses = useCallback(
|
||||||
|
(docsToCheck: DocumentData[]) => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes
|
const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes
|
||||||
|
|
||||||
const staleDocuments = documents.filter((doc) => {
|
const staleDocuments = docsToCheck.filter((doc) => {
|
||||||
if (doc.processingStatus !== 'processing' || !doc.processingStartedAt) {
|
if (doc.processingStatus !== 'processing' || !doc.processingStartedAt) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -583,12 +502,22 @@ export function KnowledgeBase({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
logger.info(`Successfully marked dead process as failed for document: ${doc.filename}`)
|
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 handleToggleEnabled = (docId: string) => {
|
||||||
const document = documents.find((doc) => doc.id === docId)
|
const document = documents.find((doc) => doc.id === docId)
|
||||||
@@ -748,6 +677,7 @@ export function KnowledgeBase({
|
|||||||
setSelectedDocuments(new Set(documents.map((doc) => doc.id)))
|
setSelectedDocuments(new Set(documents.map((doc) => doc.id)))
|
||||||
} else {
|
} else {
|
||||||
setSelectedDocuments(new Set())
|
setSelectedDocuments(new Set())
|
||||||
|
setIsSelectAllMode(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,6 +723,26 @@ export function KnowledgeBase({
|
|||||||
* Handles bulk enabling of selected documents
|
* Handles bulk enabling of selected documents
|
||||||
*/
|
*/
|
||||||
const handleBulkEnable = () => {
|
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(
|
const documentsToEnable = documents.filter(
|
||||||
(doc) => selectedDocuments.has(doc.id) && !doc.enabled
|
(doc) => selectedDocuments.has(doc.id) && !doc.enabled
|
||||||
)
|
)
|
||||||
@@ -821,6 +771,26 @@ export function KnowledgeBase({
|
|||||||
* Handles bulk disabling of selected documents
|
* Handles bulk disabling of selected documents
|
||||||
*/
|
*/
|
||||||
const handleBulkDisable = () => {
|
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(
|
const documentsToDisable = documents.filter(
|
||||||
(doc) => selectedDocuments.has(doc.id) && doc.enabled
|
(doc) => selectedDocuments.has(doc.id) && doc.enabled
|
||||||
)
|
)
|
||||||
@@ -845,18 +815,35 @@ export function KnowledgeBase({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the bulk delete confirmation modal
|
|
||||||
*/
|
|
||||||
const handleBulkDelete = () => {
|
const handleBulkDelete = () => {
|
||||||
if (selectedDocuments.size === 0) return
|
if (selectedDocuments.size === 0) return
|
||||||
setShowBulkDeleteModal(true)
|
setShowBulkDeleteModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirms and executes the bulk deletion of selected documents
|
|
||||||
*/
|
|
||||||
const confirmBulkDelete = () => {
|
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))
|
const documentsToDelete = documents.filter((doc) => selectedDocuments.has(doc.id))
|
||||||
|
|
||||||
if (documentsToDelete.length === 0) return
|
if (documentsToDelete.length === 0) return
|
||||||
@@ -881,14 +868,17 @@ export function KnowledgeBase({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedDocumentsList = documents.filter((doc) => selectedDocuments.has(doc.id))
|
const selectedDocumentsList = documents.filter((doc) => selectedDocuments.has(doc.id))
|
||||||
const enabledCount = selectedDocumentsList.filter((doc) => doc.enabled).length
|
const enabledCount = isSelectAllMode
|
||||||
const disabledCount = selectedDocumentsList.filter((doc) => !doc.enabled).length
|
? 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
|
||||||
|
|
||||||
/**
|
|
||||||
* 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(
|
const handleDocumentContextMenu = useCallback(
|
||||||
(e: React.MouseEvent, doc: DocumentData) => {
|
(e: React.MouseEvent, doc: DocumentData) => {
|
||||||
const isCurrentlySelected = selectedDocuments.has(doc.id)
|
const isCurrentlySelected = selectedDocuments.has(doc.id)
|
||||||
@@ -1005,11 +995,13 @@ export function KnowledgeBase({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
{knowledgeBase?.description && (
|
{knowledgeBase?.description && (
|
||||||
<p className='mt-[4px] line-clamp-2 max-w-[40vw] font-medium text-[14px] text-[var(--text-tertiary)]'>
|
<p className='mt-[4px] line-clamp-2 max-w-[40vw] font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||||
{knowledgeBase.description}
|
{knowledgeBase.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='mt-[16px] flex items-center gap-[8px]'>
|
<div className='mt-[16px] flex items-center gap-[8px]'>
|
||||||
<span className='text-[14px] text-[var(--text-muted)]'>
|
<span className='text-[14px] text-[var(--text-muted)]'>
|
||||||
@@ -1052,6 +1044,60 @@ export function KnowledgeBase({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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.Root>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -1068,6 +1114,7 @@ export function KnowledgeBase({
|
|||||||
)}
|
)}
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && !isLoadingKnowledgeBase && (
|
{error && !isLoadingKnowledgeBase && (
|
||||||
<div className='mt-[24px]'>
|
<div className='mt-[24px]'>
|
||||||
@@ -1089,11 +1136,17 @@ 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='mt-[10px] flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
|
||||||
<div className='text-center'>
|
<div className='text-center'>
|
||||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||||
{searchQuery ? 'No documents found' : 'No documents yet'}
|
{searchQuery
|
||||||
|
? 'No documents found'
|
||||||
|
: enabledFilter !== 'all'
|
||||||
|
? 'Nothing matches your filter'
|
||||||
|
: 'No documents yet'}
|
||||||
</p>
|
</p>
|
||||||
<p className='mt-1 text-[var(--text-muted)] text-xs'>
|
<p className='mt-1 text-[var(--text-muted)] text-xs'>
|
||||||
{searchQuery
|
{searchQuery
|
||||||
? 'Try a different search term'
|
? 'Try a different search term'
|
||||||
|
: enabledFilter !== 'all'
|
||||||
|
? 'Try changing the filter'
|
||||||
: userPermissions.canEdit === true
|
: userPermissions.canEdit === true
|
||||||
? 'Add documents to get started'
|
? 'Add documents to get started'
|
||||||
: 'Documents will appear here once added'}
|
: 'Documents will appear here once added'}
|
||||||
@@ -1120,7 +1173,7 @@ export function KnowledgeBase({
|
|||||||
{renderSortableHeader('tokenCount', 'Tokens', 'hidden w-[8%] lg:table-cell')}
|
{renderSortableHeader('tokenCount', 'Tokens', 'hidden w-[8%] lg:table-cell')}
|
||||||
{renderSortableHeader('chunkCount', 'Chunks', 'w-[8%]')}
|
{renderSortableHeader('chunkCount', 'Chunks', 'w-[8%]')}
|
||||||
{renderSortableHeader('uploadedAt', 'Uploaded', 'w-[11%]')}
|
{renderSortableHeader('uploadedAt', 'Uploaded', 'w-[11%]')}
|
||||||
{renderSortableHeader('processingStatus', 'Status', 'w-[10%]')}
|
{renderSortableHeader('enabled', 'Status', 'w-[10%]')}
|
||||||
<TableHead className='w-[12%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
|
<TableHead className='w-[12%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
|
||||||
Tags
|
Tags
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -1164,7 +1217,10 @@ export function KnowledgeBase({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className='w-[180px] max-w-[180px] px-[12px] py-[8px]'>
|
<TableCell className='w-[180px] max-w-[180px] px-[12px] py-[8px]'>
|
||||||
<div className='flex min-w-0 items-center gap-[8px]'>
|
<div className='flex min-w-0 items-center gap-[8px]'>
|
||||||
{getFileIcon(doc.mimeType, doc.filename)}
|
{(() => {
|
||||||
|
const IconComponent = getDocumentIcon(doc.mimeType, doc.filename)
|
||||||
|
return <IconComponent className='h-6 w-5 flex-shrink-0' />
|
||||||
|
})()}
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<span
|
<span
|
||||||
@@ -1508,6 +1564,14 @@ export function KnowledgeBase({
|
|||||||
enabledCount={enabledCount}
|
enabledCount={enabledCount}
|
||||||
disabledCount={disabledCount}
|
disabledCount={disabledCount}
|
||||||
isLoading={isBulkOperating}
|
isLoading={isBulkOperating}
|
||||||
|
totalCount={pagination.total}
|
||||||
|
isAllPageSelected={isAllSelected}
|
||||||
|
isAllSelected={isSelectAllMode}
|
||||||
|
onSelectAll={() => setIsSelectAllMode(true)}
|
||||||
|
onClearSelectAll={() => {
|
||||||
|
setIsSelectAllMode(false)
|
||||||
|
setSelectedDocuments(new Set())
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DocumentContextMenu
|
<DocumentContextMenu
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ interface ActionBarProps {
|
|||||||
disabledCount?: number
|
disabledCount?: number
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
totalCount?: number
|
||||||
|
isAllPageSelected?: boolean
|
||||||
|
isAllSelected?: boolean
|
||||||
|
onSelectAll?: () => void
|
||||||
|
onClearSelectAll?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActionBar({
|
export function ActionBar({
|
||||||
@@ -24,14 +29,21 @@ export function ActionBar({
|
|||||||
disabledCount = 0,
|
disabledCount = 0,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
className,
|
className,
|
||||||
|
totalCount = 0,
|
||||||
|
isAllPageSelected = false,
|
||||||
|
isAllSelected = false,
|
||||||
|
onSelectAll,
|
||||||
|
onClearSelectAll,
|
||||||
}: ActionBarProps) {
|
}: ActionBarProps) {
|
||||||
const userPermissions = useUserPermissionsContext()
|
const userPermissions = useUserPermissionsContext()
|
||||||
|
|
||||||
if (selectedCount === 0) return null
|
if (selectedCount === 0 && !isAllSelected) return null
|
||||||
|
|
||||||
const canEdit = userPermissions.canEdit
|
const canEdit = userPermissions.canEdit
|
||||||
const showEnableButton = disabledCount > 0 && onEnable && canEdit
|
const showEnableButton = disabledCount > 0 && onEnable && canEdit
|
||||||
const showDisableButton = enabledCount > 0 && onDisable && canEdit
|
const showDisableButton = enabledCount > 0 && onDisable && canEdit
|
||||||
|
const showSelectAllOption =
|
||||||
|
isAllPageSelected && !isAllSelected && totalCount > selectedCount && onSelectAll
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -43,7 +55,31 @@ export function ActionBar({
|
|||||||
>
|
>
|
||||||
<div className='flex items-center gap-[8px] rounded-[10px] border border-[var(--border)] bg-[var(--surface-2)] px-[8px] py-[6px]'>
|
<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)]'>
|
<span className='px-[4px] text-[13px] text-[var(--text-secondary)]'>
|
||||||
{selectedCount} selected
|
{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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className='flex items-center gap-[5px]'>
|
<div className='flex items-center gap-[5px]'>
|
||||||
|
|||||||
@@ -123,7 +123,11 @@ export function RenameDocumentModal({
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='tertiary' type='submit' disabled={isSubmitting || !name?.trim()}>
|
<Button
|
||||||
|
variant='tertiary'
|
||||||
|
type='submit'
|
||||||
|
disabled={isSubmitting || !name?.trim() || name.trim() === initialName}
|
||||||
|
>
|
||||||
{isSubmitting ? 'Renaming...' : 'Rename'}
|
{isSubmitting ? 'Renaming...' : 'Rename'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { Badge, DocumentAttachment, Tooltip } from '@/components/emcn'
|
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 { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
||||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||||
@@ -21,55 +22,6 @@ interface BaseCardProps {
|
|||||||
onDelete?: (id: string) => Promise<void>
|
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
|
* Skeleton placeholder for a knowledge base card
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -344,13 +344,12 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
|||||||
<Textarea
|
<Textarea
|
||||||
id='description'
|
id='description'
|
||||||
placeholder='Describe this knowledge base (optional)'
|
placeholder='Describe this knowledge base (optional)'
|
||||||
rows={3}
|
rows={4}
|
||||||
{...register('description')}
|
{...register('description')}
|
||||||
className={cn(errors.description && 'border-[var(--text-error)]')}
|
className={cn(errors.description && 'border-[var(--text-error)]')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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='grid grid-cols-2 gap-[12px]'>
|
||||||
<div className='flex flex-col gap-[8px]'>
|
<div className='flex flex-col gap-[8px]'>
|
||||||
<Label htmlFor='minChunkSize'>Min Chunk Size (characters)</Label>
|
<Label htmlFor='minChunkSize'>Min Chunk Size (characters)</Label>
|
||||||
@@ -390,7 +389,6 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
|||||||
data-form-type='other'
|
data-form-type='other'
|
||||||
name='overlap-size'
|
name='overlap-size'
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<p className='text-[11px] text-[var(--text-muted)]'>
|
<p className='text-[11px] text-[var(--text-muted)]'>
|
||||||
1 token ≈ 4 characters. Max chunk size and overlap are in tokens.
|
1 token ≈ 4 characters. Max chunk size and overlap are in tokens.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function EditKnowledgeBaseModal({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
watch,
|
watch,
|
||||||
formState: { errors },
|
formState: { errors, isDirty },
|
||||||
} = useForm<FormValues>({
|
} = useForm<FormValues>({
|
||||||
resolver: zodResolver(FormSchema),
|
resolver: zodResolver(FormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -127,7 +127,7 @@ export function EditKnowledgeBaseModal({
|
|||||||
<Textarea
|
<Textarea
|
||||||
id='description'
|
id='description'
|
||||||
placeholder='Describe this knowledge base (optional)'
|
placeholder='Describe this knowledge base (optional)'
|
||||||
rows={3}
|
rows={4}
|
||||||
{...register('description')}
|
{...register('description')}
|
||||||
className={cn(errors.description && 'border-[var(--text-error)]')}
|
className={cn(errors.description && 'border-[var(--text-error)]')}
|
||||||
/>
|
/>
|
||||||
@@ -161,7 +161,7 @@ export function EditKnowledgeBaseModal({
|
|||||||
<Button
|
<Button
|
||||||
variant='tertiary'
|
variant='tertiary'
|
||||||
type='submit'
|
type='submit'
|
||||||
disabled={isSubmitting || !nameValue?.trim()}
|
disabled={isSubmitting || !nameValue?.trim() || !isDirty}
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Saving...' : 'Save'}
|
{isSubmitting ? 'Saving...' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import { ArrowDown, ArrowUp, Check, Clipboard, Search, X } from 'lucide-react'
|
||||||
import { ArrowDown, ArrowUp, X } from 'lucide-react'
|
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -15,9 +14,11 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverDivider,
|
PopoverDivider,
|
||||||
PopoverItem,
|
PopoverItem,
|
||||||
|
Tooltip,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import { WorkflowIcon } from '@/components/icons'
|
import { WorkflowIcon } from '@/components/icons'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
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 { 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 { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||||
import { getBlock, getBlockByToolName } from '@/blocks'
|
import { getBlock, getBlockByToolName } from '@/blocks'
|
||||||
@@ -26,7 +27,6 @@ import type { TraceSpan } from '@/stores/logs/filters/types'
|
|||||||
|
|
||||||
interface TraceSpansProps {
|
interface TraceSpansProps {
|
||||||
traceSpans?: TraceSpan[]
|
traceSpans?: TraceSpan[]
|
||||||
totalDuration?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,6 +100,20 @@ function parseTime(value?: string | number | null): number {
|
|||||||
return Number.isFinite(ms) ? ms : 0
|
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.
|
* Normalizes and sorts trace spans recursively.
|
||||||
* Merges children from both span.children and span.output.childTraceSpans,
|
* Merges children from both span.children and span.output.childTraceSpans,
|
||||||
@@ -142,14 +156,6 @@ function normalizeAndSortSpans(spans: TraceSpan[]): TraceSpan[] {
|
|||||||
|
|
||||||
const DEFAULT_BLOCK_COLOR = '#6b7280'
|
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
|
* Gets icon and color for a span type using block config
|
||||||
*/
|
*/
|
||||||
@@ -230,7 +236,7 @@ function ProgressBar({
|
|||||||
}, [span, childSpans, workflowStartTime, totalDuration])
|
}, [span, childSpans, workflowStartTime, totalDuration])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='relative mb-[8px] h-[5px] w-full overflow-hidden rounded-[18px] bg-[var(--divider)]'>
|
<div className='relative h-[5px] w-full overflow-hidden rounded-[18px] bg-[var(--divider)]'>
|
||||||
{segments.map((segment, index) => (
|
{segments.map((segment, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
@@ -246,143 +252,6 @@ 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
|
* Renders input/output section with collapsible content, context menu, and search
|
||||||
*/
|
*/
|
||||||
@@ -406,16 +275,14 @@ function InputOutputSection({
|
|||||||
const sectionKey = `${spanId}-${sectionType}`
|
const sectionKey = `${spanId}-${sectionType}`
|
||||||
const isExpanded = expandedSections.has(sectionKey)
|
const isExpanded = expandedSections.has(sectionKey)
|
||||||
const contentRef = useRef<HTMLDivElement>(null)
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
// Context menu state
|
// Context menu state
|
||||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
// Code viewer features
|
// Code viewer features
|
||||||
const {
|
const {
|
||||||
wrapText,
|
|
||||||
toggleWrapText,
|
|
||||||
isSearchActive,
|
isSearchActive,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
@@ -447,6 +314,8 @@ function InputOutputSection({
|
|||||||
|
|
||||||
const handleCopy = useCallback(() => {
|
const handleCopy = useCallback(() => {
|
||||||
navigator.clipboard.writeText(jsonString)
|
navigator.clipboard.writeText(jsonString)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1500)
|
||||||
closeContextMenu()
|
closeContextMenu()
|
||||||
}, [jsonString, closeContextMenu])
|
}, [jsonString, closeContextMenu])
|
||||||
|
|
||||||
@@ -455,13 +324,8 @@ function InputOutputSection({
|
|||||||
closeContextMenu()
|
closeContextMenu()
|
||||||
}, [activateSearch, closeContextMenu])
|
}, [activateSearch, closeContextMenu])
|
||||||
|
|
||||||
const handleToggleWrap = useCallback(() => {
|
|
||||||
toggleWrapText()
|
|
||||||
closeContextMenu()
|
|
||||||
}, [toggleWrapText, closeContextMenu])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='relative flex min-w-0 flex-col gap-[8px] overflow-hidden'>
|
<div className='relative flex min-w-0 flex-col gap-[6px] overflow-hidden'>
|
||||||
<div
|
<div
|
||||||
className='group flex cursor-pointer items-center justify-between'
|
className='group flex cursor-pointer items-center justify-between'
|
||||||
onClick={() => onToggle(sectionKey)}
|
onClick={() => onToggle(sectionKey)}
|
||||||
@@ -477,7 +341,7 @@ function InputOutputSection({
|
|||||||
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${label.toLowerCase()}`}
|
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${label.toLowerCase()}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={cn(
|
||||||
'font-medium text-[12px] transition-colors',
|
'font-medium text-[12px] transition-colors',
|
||||||
isError
|
isError
|
||||||
? 'text-[var(--text-error)]'
|
? 'text-[var(--text-error)]'
|
||||||
@@ -487,9 +351,7 @@ function InputOutputSection({
|
|||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={clsx(
|
className='h-[8px] w-[8px] text-[var(--text-tertiary)] transition-colors transition-transform group-hover:text-[var(--text-primary)]'
|
||||||
'h-[10px] w-[10px] text-[var(--text-tertiary)] transition-colors transition-transform group-hover:text-[var(--text-primary)]'
|
|
||||||
)}
|
|
||||||
style={{
|
style={{
|
||||||
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||||
}}
|
}}
|
||||||
@@ -497,16 +359,57 @@ function InputOutputSection({
|
|||||||
</div>
|
</div>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<>
|
<>
|
||||||
<div ref={contentRef} onContextMenu={handleContextMenu}>
|
<div ref={contentRef} onContextMenu={handleContextMenu} className='relative'>
|
||||||
<Code.Viewer
|
<Code.Viewer
|
||||||
code={jsonString}
|
code={jsonString}
|
||||||
language='json'
|
language='json'
|
||||||
className='!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
|
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={wrapText}
|
wrapText
|
||||||
searchQuery={isSearchActive ? searchQuery : undefined}
|
searchQuery={isSearchActive ? searchQuery : undefined}
|
||||||
currentMatchIndex={currentMatchIndex}
|
currentMatchIndex={currentMatchIndex}
|
||||||
onMatchCountChange={handleMatchCountChange}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Search Overlay */}
|
{/* Search Overlay */}
|
||||||
@@ -579,13 +482,10 @@ function InputOutputSection({
|
|||||||
height: '1px',
|
height: '1px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
<PopoverContent align='start' side='bottom' sideOffset={4}>
|
||||||
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
|
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
|
||||||
<PopoverDivider />
|
<PopoverDivider />
|
||||||
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
|
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
|
||||||
<PopoverItem showCheck={wrapText} onClick={handleToggleWrap}>
|
|
||||||
Wrap Text
|
|
||||||
</PopoverItem>
|
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>,
|
</Popover>,
|
||||||
document.body
|
document.body
|
||||||
@@ -596,136 +496,51 @@ function InputOutputSection({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NestedBlockItemProps {
|
interface TraceSpanNodeProps {
|
||||||
span: TraceSpan
|
span: TraceSpan
|
||||||
parentId: string
|
workflowStartTime: number
|
||||||
index: number
|
totalDuration: number
|
||||||
|
depth: number
|
||||||
|
expandedNodes: Set<string>
|
||||||
expandedSections: Set<string>
|
expandedSections: Set<string>
|
||||||
onToggle: (section: string) => void
|
onToggleNode: (nodeId: string) => void
|
||||||
workflowStartTime: number
|
onToggleSection: (section: string) => void
|
||||||
totalDuration: number
|
|
||||||
expandedChildren: Set<string>
|
|
||||||
onToggleChildren: (spanId: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursive component for rendering nested blocks at any depth
|
* Recursive tree node component for rendering trace spans
|
||||||
*/
|
*/
|
||||||
function NestedBlockItem({
|
const TraceSpanNode = memo(function TraceSpanNode({
|
||||||
span,
|
span,
|
||||||
parentId,
|
workflowStartTime,
|
||||||
index,
|
totalDuration,
|
||||||
|
depth,
|
||||||
|
expandedNodes,
|
||||||
expandedSections,
|
expandedSections,
|
||||||
onToggle,
|
onToggleNode,
|
||||||
workflowStartTime,
|
onToggleSection,
|
||||||
totalDuration,
|
}: 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 spanId = span.id || `span-${span.name}-${span.startTime}`
|
||||||
const spanStartTime = new Date(span.startTime).getTime()
|
const spanStartTime = new Date(span.startTime).getTime()
|
||||||
const spanEndTime = new Date(span.endTime).getTime()
|
const spanEndTime = new Date(span.endTime).getTime()
|
||||||
const duration = span.duration || spanEndTime - spanStartTime
|
const duration = span.duration || spanEndTime - spanStartTime
|
||||||
|
|
||||||
const hasChildren = Boolean(span.children && span.children.length > 0)
|
const isDirectError = span.status === 'error'
|
||||||
const hasToolCalls = Boolean(span.toolCalls && span.toolCalls.length > 0)
|
const hasNestedError = hasErrorInTree(span)
|
||||||
const isError = span.status === 'error'
|
const showErrorStyle = isDirectError || hasNestedError
|
||||||
|
|
||||||
const inlineChildTypes = new Set([
|
const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name)
|
||||||
'tool',
|
|
||||||
'model',
|
|
||||||
'loop-iteration',
|
|
||||||
'parallel-iteration',
|
|
||||||
'workflow',
|
|
||||||
])
|
|
||||||
|
|
||||||
// For workflow-in-workflow blocks, all children should be rendered inline/nested
|
// Root workflow execution is always expanded and has no toggle
|
||||||
const isWorkflowBlock = span.type?.toLowerCase().includes('workflow')
|
const isRootWorkflow = depth === 0
|
||||||
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(() => {
|
// Build all children including tool calls
|
||||||
if (!hasToolCalls) return []
|
const allChildren = useMemo(() => {
|
||||||
return span.toolCalls!.map((toolCall, index) => {
|
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
|
const toolStartTime = toolCall.startTime
|
||||||
? new Date(toolCall.startTime).getTime()
|
? new Date(toolCall.startTime).getTime()
|
||||||
: spanStartTime
|
: spanStartTime
|
||||||
@@ -733,7 +548,7 @@ const TraceSpanItem = memo(function TraceSpanItem({
|
|||||||
? new Date(toolCall.endTime).getTime()
|
? new Date(toolCall.endTime).getTime()
|
||||||
: toolStartTime + (toolCall.duration || 0)
|
: toolStartTime + (toolCall.duration || 0)
|
||||||
|
|
||||||
return {
|
children.push({
|
||||||
id: `${spanId}-tool-${index}`,
|
id: `${spanId}-tool-${index}`,
|
||||||
name: toolCall.name,
|
name: toolCall.name,
|
||||||
type: 'tool',
|
type: 'tool',
|
||||||
@@ -745,206 +560,165 @@ const TraceSpanItem = memo(function TraceSpanItem({
|
|||||||
output: toolCall.error
|
output: toolCall.error
|
||||||
? { error: toolCall.error, ...(toolCall.output || {}) }
|
? { error: toolCall.error, ...(toolCall.output || {}) }
|
||||||
: toolCall.output,
|
: toolCall.output,
|
||||||
} as TraceSpan
|
} as TraceSpan)
|
||||||
})
|
})
|
||||||
}, [hasToolCalls, span.toolCalls, spanId, spanStartTime])
|
}
|
||||||
|
|
||||||
const handleSectionToggle = useCallback(
|
// Add regular children
|
||||||
(section: string) => toggleSet(setExpandedSections, section),
|
if (span.children && span.children.length > 0) {
|
||||||
[toggleSet]
|
children.push(...span.children)
|
||||||
)
|
}
|
||||||
|
|
||||||
const handleChildrenToggle = useCallback(
|
// Sort by start time
|
||||||
(childSpanId: string) => toggleSet(setExpandedChildren, childSpanId),
|
return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime))
|
||||||
[toggleSet]
|
}, [span, spanId, spanStartTime])
|
||||||
)
|
|
||||||
|
|
||||||
const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name)
|
const hasChildren = allChildren.length > 0
|
||||||
|
const isExpanded = isRootWorkflow || expandedNodes.has(spanId)
|
||||||
|
const isToggleable = !isRootWorkflow
|
||||||
|
|
||||||
// Check if this card has expandable inline content
|
const hasInput = Boolean(span.input)
|
||||||
const hasInlineContent =
|
const hasOutput = Boolean(span.output)
|
||||||
(isWorkflowBlock && inlineChildren.length > 0) ||
|
|
||||||
(!isWorkflowBlock && (toolCallSpans.length > 0 || inlineChildren.length > 0))
|
|
||||||
|
|
||||||
const isExpandable = !isFirstSpan && hasInlineContent
|
// For progress bar - show child segments for workflow/iteration types
|
||||||
|
const lowerType = span.type?.toLowerCase() || ''
|
||||||
|
const showChildrenInProgressBar =
|
||||||
|
isIterationType(lowerType) || lowerType === 'workflow' || lowerType === 'workflow_input'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className='flex min-w-0 flex-col'>
|
||||||
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px]'>
|
{/* Node Header Row */}
|
||||||
<ExpandableRowHeader
|
<div
|
||||||
name={span.name}
|
className={cn(
|
||||||
duration={duration}
|
'group flex items-center justify-between gap-[8px] py-[6px]',
|
||||||
isError={isError}
|
isToggleable && 'cursor-pointer'
|
||||||
isExpanded={isCardExpanded}
|
)}
|
||||||
hasChildren={isExpandable}
|
onClick={isToggleable ? () => onToggleNode(spanId) : undefined}
|
||||||
showIcon={!isFirstSpan}
|
onKeyDown={
|
||||||
icon={BlockIcon}
|
isToggleable
|
||||||
bgColor={bgColor}
|
? (e) => {
|
||||||
onToggle={() => setIsCardExpanded((prev) => !prev)}
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
/>
|
e.preventDefault()
|
||||||
|
onToggleNode(spanId)
|
||||||
<SpanContent
|
}
|
||||||
span={span}
|
}
|
||||||
spanId={spanId}
|
: undefined
|
||||||
isError={isError}
|
}
|
||||||
workflowStartTime={workflowStartTime}
|
role={isToggleable ? 'button' : undefined}
|
||||||
totalDuration={totalDuration}
|
tabIndex={isToggleable ? 0 : undefined}
|
||||||
expandedSections={expandedSections}
|
aria-expanded={isToggleable ? isExpanded : undefined}
|
||||||
onToggle={handleSectionToggle}
|
aria-label={isToggleable ? (isExpanded ? 'Collapse' : 'Expand') : undefined}
|
||||||
/>
|
>
|
||||||
|
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||||
{/* For workflow blocks, keep children nested within the card (not as separate cards) */}
|
{!isIterationType(span.type) && (
|
||||||
{!isFirstSpan && isWorkflowBlock && inlineChildren.length > 0 && isCardExpanded && (
|
<div
|
||||||
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l pl-[10px]'>
|
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
|
||||||
{inlineChildren.map((childSpan, index) => (
|
style={{ background: bgColor }}
|
||||||
<NestedBlockItem
|
>
|
||||||
key={childSpan.id || `${spanId}-nested-${index}`}
|
{BlockIcon && <BlockIcon className='h-[9px] w-[9px] text-white' />}
|
||||||
span={childSpan}
|
|
||||||
parentId={spanId}
|
|
||||||
index={index}
|
|
||||||
expandedSections={expandedSections}
|
|
||||||
onToggle={handleSectionToggle}
|
|
||||||
workflowStartTime={workflowStartTime}
|
|
||||||
totalDuration={totalDuration}
|
|
||||||
expandedChildren={expandedChildren}
|
|
||||||
onToggleChildren={handleChildrenToggle}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<span
|
||||||
{/* For non-workflow blocks, render inline children/tool calls */}
|
className='min-w-0 max-w-[180px] truncate font-medium text-[12px]'
|
||||||
{!isFirstSpan && !isWorkflowBlock && isCardExpanded && (
|
style={{ color: showErrorStyle ? 'var(--text-error)' : 'var(--text-secondary)' }}
|
||||||
<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
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`inline-${childId}`}
|
|
||||||
className='flex min-w-0 flex-col gap-[8px] overflow-hidden'
|
|
||||||
>
|
>
|
||||||
<ExpandableRowHeader
|
{span.name}
|
||||||
name={childSpan.name}
|
</span>
|
||||||
duration={childSpan.duration || 0}
|
{isToggleable && (
|
||||||
isError={childIsError}
|
<ChevronDown
|
||||||
isExpanded={isNestedExpanded}
|
className='h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-colors transition-transform duration-100 group-hover:text-[var(--text-primary)]'
|
||||||
hasChildren={hasNestedChildren}
|
style={{
|
||||||
showIcon={!isIterationType(childSpan.type)}
|
transform: `translateY(-0.25px) ${isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)'}`,
|
||||||
icon={ChildIcon}
|
}}
|
||||||
bgColor={childBgColor}
|
|
||||||
onToggle={() => handleChildrenToggle(childId)}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</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
|
<ProgressBar
|
||||||
span={childSpan}
|
span={span}
|
||||||
childSpans={showChildrenInProgressBar ? childSpan.children : undefined}
|
childSpans={showChildrenInProgressBar ? span.children : undefined}
|
||||||
workflowStartTime={workflowStartTime}
|
workflowStartTime={workflowStartTime}
|
||||||
totalDuration={totalDuration}
|
totalDuration={totalDuration}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{childSpan.input && (
|
{/* Input/Output Sections */}
|
||||||
|
{(hasInput || hasOutput) && (
|
||||||
|
<div className='flex min-w-0 flex-col gap-[6px] overflow-hidden py-[2px]'>
|
||||||
|
{hasInput && (
|
||||||
<InputOutputSection
|
<InputOutputSection
|
||||||
label='Input'
|
label='Input'
|
||||||
data={childSpan.input}
|
data={span.input}
|
||||||
isError={false}
|
isError={false}
|
||||||
spanId={childId}
|
spanId={spanId}
|
||||||
sectionType='input'
|
sectionType='input'
|
||||||
expandedSections={expandedSections}
|
expandedSections={expandedSections}
|
||||||
onToggle={handleSectionToggle}
|
onToggle={onToggleSection}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{childSpan.input && childSpan.output && (
|
{hasInput && hasOutput && (
|
||||||
<div className='border-[var(--border)] border-t border-dashed' />
|
<div className='border-[var(--border)] border-t border-dashed' />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{childSpan.output && (
|
{hasOutput && (
|
||||||
<InputOutputSection
|
<InputOutputSection
|
||||||
label={childIsError ? 'Error' : 'Output'}
|
label={isDirectError ? 'Error' : 'Output'}
|
||||||
data={childSpan.output}
|
data={span.output}
|
||||||
isError={childIsError}
|
isError={isDirectError}
|
||||||
spanId={childId}
|
spanId={spanId}
|
||||||
sectionType='output'
|
sectionType='output'
|
||||||
expandedSections={expandedSections}
|
expandedSections={expandedSections}
|
||||||
onToggle={handleSectionToggle}
|
onToggle={onToggleSection}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Nested children */}
|
{/* Nested Children */}
|
||||||
{showChildrenInProgressBar && hasNestedChildren && isNestedExpanded && (
|
{hasChildren && (
|
||||||
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l pl-[10px]'>
|
<div className='flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[10px]'>
|
||||||
{childSpan.children!.map((nestedChild, nestedIndex) => (
|
{allChildren.map((child, index) => (
|
||||||
<NestedBlockItem
|
<div key={child.id || `${spanId}-child-${index}`} className='pl-[6px]'>
|
||||||
key={nestedChild.id || `${childId}-nested-${nestedIndex}`}
|
<TraceSpanNode
|
||||||
span={nestedChild}
|
span={child}
|
||||||
parentId={childId}
|
workflowStartTime={workflowStartTime}
|
||||||
index={nestedIndex}
|
totalDuration={totalDuration}
|
||||||
|
depth={depth + 1}
|
||||||
|
expandedNodes={expandedNodes}
|
||||||
expandedSections={expandedSections}
|
expandedSections={expandedSections}
|
||||||
onToggle={handleSectionToggle}
|
onToggleNode={onToggleNode}
|
||||||
workflowStartTime={workflowStartTime}
|
onToggleSection={onToggleSection}
|
||||||
totalDuration={totalDuration}
|
|
||||||
expandedChildren={expandedChildren}
|
|
||||||
onToggleChildren={handleChildrenToggle}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</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 structure.
|
* Displays workflow execution trace spans with nested tree structure.
|
||||||
* Memoized to prevent re-renders when parent LogDetails updates.
|
* Memoized to prevent re-renders when parent LogDetails updates.
|
||||||
*/
|
*/
|
||||||
export const TraceSpans = memo(function TraceSpans({
|
export const TraceSpans = memo(function TraceSpans({ traceSpans }: TraceSpansProps) {
|
||||||
traceSpans,
|
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set())
|
||||||
totalDuration = 0,
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
|
||||||
}: TraceSpansProps) {
|
const toggleSet = useSetToggle()
|
||||||
|
|
||||||
const { workflowStartTime, actualTotalDuration, normalizedSpans } = useMemo(() => {
|
const { workflowStartTime, actualTotalDuration, normalizedSpans } = useMemo(() => {
|
||||||
if (!traceSpans || traceSpans.length === 0) {
|
if (!traceSpans || traceSpans.length === 0) {
|
||||||
return { workflowStartTime: 0, actualTotalDuration: totalDuration, normalizedSpans: [] }
|
return { workflowStartTime: 0, actualTotalDuration: 0, normalizedSpans: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
let earliest = Number.POSITIVE_INFINITY
|
let earliest = Number.POSITIVE_INFINITY
|
||||||
@@ -962,26 +736,37 @@ export const TraceSpans = memo(function TraceSpans({
|
|||||||
actualTotalDuration: latest - earliest,
|
actualTotalDuration: latest - earliest,
|
||||||
normalizedSpans: normalizeAndSortSpans(traceSpans),
|
normalizedSpans: normalizeAndSortSpans(traceSpans),
|
||||||
}
|
}
|
||||||
}, [traceSpans, totalDuration])
|
}, [traceSpans])
|
||||||
|
|
||||||
|
const handleToggleNode = useCallback(
|
||||||
|
(nodeId: string) => toggleSet(setExpandedNodes, nodeId),
|
||||||
|
[toggleSet]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleToggleSection = useCallback(
|
||||||
|
(section: string) => toggleSet(setExpandedSections, section),
|
||||||
|
[toggleSet]
|
||||||
|
)
|
||||||
|
|
||||||
if (!traceSpans || traceSpans.length === 0) {
|
if (!traceSpans || traceSpans.length === 0) {
|
||||||
return <div className='text-[12px] text-[var(--text-secondary)]'>No trace data available</div>
|
return <div className='text-[12px] text-[var(--text-secondary)]'>No trace data available</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex w-full min-w-0 flex-col gap-[6px] overflow-hidden rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
<div className='flex w-full min-w-0 flex-col overflow-hidden'>
|
||||||
<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) => (
|
{normalizedSpans.map((span, index) => (
|
||||||
<TraceSpanItem
|
<TraceSpanNode
|
||||||
key={span.id || index}
|
key={span.id || index}
|
||||||
span={span}
|
span={span}
|
||||||
totalDuration={actualTotalDuration}
|
|
||||||
workflowStartTime={workflowStartTime}
|
workflowStartTime={workflowStartTime}
|
||||||
isFirstSpan={index === 0}
|
totalDuration={actualTotalDuration}
|
||||||
|
depth={0}
|
||||||
|
expandedNodes={expandedNodes}
|
||||||
|
expandedSections={expandedSections}
|
||||||
|
onToggleNode={handleToggleNode}
|
||||||
|
onToggleSection={handleToggleSection}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { ChevronUp, X } from 'lucide-react'
|
import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Search, X } from 'lucide-react'
|
||||||
import { Button, Eye } from '@/components/emcn'
|
import { createPortal } from 'react-dom'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Code,
|
||||||
|
Eye,
|
||||||
|
Input,
|
||||||
|
Popover,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverDivider,
|
||||||
|
PopoverItem,
|
||||||
|
Tooltip,
|
||||||
|
} from '@/components/emcn'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||||
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import {
|
import {
|
||||||
ExecutionSnapshot,
|
ExecutionSnapshot,
|
||||||
FileCards,
|
FileCards,
|
||||||
@@ -17,11 +30,194 @@ import {
|
|||||||
StatusBadge,
|
StatusBadge,
|
||||||
TriggerBadge,
|
TriggerBadge,
|
||||||
} from '@/app/workspace/[workspaceId]/logs/utils'
|
} from '@/app/workspace/[workspaceId]/logs/utils'
|
||||||
|
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||||
import { formatCost } from '@/providers/utils'
|
import { formatCost } from '@/providers/utils'
|
||||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||||
import { useLogDetailsUIStore } from '@/stores/logs/store'
|
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 {
|
interface LogDetailsProps {
|
||||||
/** The log to display details for */
|
/** The log to display details for */
|
||||||
log: WorkflowLog | null
|
log: WorkflowLog | null
|
||||||
@@ -78,6 +274,18 @@ export const LogDetails = memo(function LogDetails({
|
|||||||
return isWorkflowExecutionLog && log?.cost
|
return isWorkflowExecutionLog && log?.cost
|
||||||
}, [log, isWorkflowExecutionLog])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape' && isOpen) {
|
if (e.key === 'Escape' && isOpen) {
|
||||||
@@ -87,12 +295,12 @@ export const LogDetails = memo(function LogDetails({
|
|||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
if (e.key === 'ArrowUp' && hasPrev && onNavigatePrev) {
|
if (e.key === 'ArrowUp' && hasPrev && onNavigatePrev) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleNavigate(onNavigatePrev)
|
onNavigatePrev()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'ArrowDown' && hasNext && onNavigateNext) {
|
if (e.key === 'ArrowDown' && hasNext && onNavigateNext) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleNavigate(onNavigateNext)
|
onNavigateNext()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,10 +309,6 @@ export const LogDetails = memo(function LogDetails({
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [isOpen, onClose, hasPrev, hasNext, onNavigatePrev, onNavigateNext])
|
}, [isOpen, onClose, hasPrev, hasNext, onNavigatePrev, onNavigateNext])
|
||||||
|
|
||||||
const handleNavigate = (navigateFunction: () => void) => {
|
|
||||||
navigateFunction()
|
|
||||||
}
|
|
||||||
|
|
||||||
const formattedTimestamp = useMemo(
|
const formattedTimestamp = useMemo(
|
||||||
() => (log ? formatDate(log.createdAt) : null),
|
() => (log ? formatDate(log.createdAt) : null),
|
||||||
[log?.createdAt]
|
[log?.createdAt]
|
||||||
@@ -142,7 +346,7 @@ export const LogDetails = memo(function LogDetails({
|
|||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
className='!p-[4px]'
|
className='!p-[4px]'
|
||||||
onClick={() => hasPrev && handleNavigate(onNavigatePrev!)}
|
onClick={() => hasPrev && onNavigatePrev?.()}
|
||||||
disabled={!hasPrev}
|
disabled={!hasPrev}
|
||||||
aria-label='Previous log'
|
aria-label='Previous log'
|
||||||
>
|
>
|
||||||
@@ -151,7 +355,7 @@ export const LogDetails = memo(function LogDetails({
|
|||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
className='!p-[4px]'
|
className='!p-[4px]'
|
||||||
onClick={() => hasNext && handleNavigate(onNavigateNext!)}
|
onClick={() => hasNext && onNavigateNext?.()}
|
||||||
disabled={!hasNext}
|
disabled={!hasNext}
|
||||||
aria-label='Next log'
|
aria-label='Next log'
|
||||||
>
|
>
|
||||||
@@ -204,7 +408,7 @@ export const LogDetails = memo(function LogDetails({
|
|||||||
|
|
||||||
{/* Execution ID */}
|
{/* Execution ID */}
|
||||||
{log.executionId && (
|
{log.executionId && (
|
||||||
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
<div className='flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
Execution ID
|
Execution ID
|
||||||
</span>
|
</span>
|
||||||
@@ -215,7 +419,7 @@ export const LogDetails = memo(function LogDetails({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Details Section */}
|
{/* Details Section */}
|
||||||
<div className='flex min-w-0 flex-col overflow-hidden'>
|
<div className='-my-[4px] flex min-w-0 flex-col overflow-hidden'>
|
||||||
{/* Level */}
|
{/* Level */}
|
||||||
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
|
<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)]'>
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
@@ -267,19 +471,35 @@ export const LogDetails = memo(function LogDetails({
|
|||||||
|
|
||||||
{/* Workflow State */}
|
{/* Workflow State */}
|
||||||
{isWorkflowExecutionLog && log.executionId && !permissionConfig.hideTraceSpans && (
|
{isWorkflowExecutionLog && log.executionId && !permissionConfig.hideTraceSpans && (
|
||||||
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
<div className='-mt-[8px] flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
Workflow State
|
Workflow State
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Button
|
||||||
|
variant='active'
|
||||||
onClick={() => setIsExecutionSnapshotOpen(true)}
|
onClick={() => setIsExecutionSnapshotOpen(true)}
|
||||||
className='flex items-center justify-between rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px] transition-colors hover:bg-[var(--surface-4)]'
|
className='flex w-full items-center justify-between px-[10px] py-[6px]'
|
||||||
>
|
>
|
||||||
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
<span className='font-medium text-[12px]'>View Snapshot</span>
|
||||||
View Snapshot
|
<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>
|
</span>
|
||||||
<Eye className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
|
<WorkflowOutputSection output={workflowOutput} />
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -287,10 +507,12 @@ export const LogDetails = memo(function LogDetails({
|
|||||||
{isWorkflowExecutionLog &&
|
{isWorkflowExecutionLog &&
|
||||||
log.executionData?.traceSpans &&
|
log.executionData?.traceSpans &&
|
||||||
!permissionConfig.hideTraceSpans && (
|
!permissionConfig.hideTraceSpans && (
|
||||||
<TraceSpans
|
<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'>
|
||||||
traceSpans={log.executionData.traceSpans}
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
totalDuration={log.executionData.totalDuration}
|
Trace Span
|
||||||
/>
|
</span>
|
||||||
|
<TraceSpans traceSpans={log.executionData.traceSpans} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Files */}
|
{/* Files */}
|
||||||
|
|||||||
@@ -94,7 +94,9 @@ export default function Logs() {
|
|||||||
const [previewLogId, setPreviewLogId] = useState<string | null>(null)
|
const [previewLogId, setPreviewLogId] = useState<string | null>(null)
|
||||||
|
|
||||||
const activeLogId = isPreviewOpen ? previewLogId : selectedLogId
|
const activeLogId = isPreviewOpen ? previewLogId : selectedLogId
|
||||||
const activeLogQuery = useLogDetail(activeLogId ?? undefined)
|
const activeLogQuery = useLogDetail(activeLogId ?? undefined, {
|
||||||
|
refetchInterval: isLive ? 3000 : false,
|
||||||
|
})
|
||||||
|
|
||||||
const logFilters = useMemo(
|
const logFilters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -113,7 +115,7 @@ export default function Logs() {
|
|||||||
|
|
||||||
const logsQuery = useLogsList(workspaceId, logFilters, {
|
const logsQuery = useLogsList(workspaceId, logFilters, {
|
||||||
enabled: Boolean(workspaceId) && isInitialized.current,
|
enabled: Boolean(workspaceId) && isInitialized.current,
|
||||||
refetchInterval: isLive ? 5000 : false,
|
refetchInterval: isLive ? 3000 : false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const dashboardFilters = useMemo(
|
const dashboardFilters = useMemo(
|
||||||
@@ -132,7 +134,7 @@ export default function Logs() {
|
|||||||
|
|
||||||
const dashboardStatsQuery = useDashboardStats(workspaceId, dashboardFilters, {
|
const dashboardStatsQuery = useDashboardStats(workspaceId, dashboardFilters, {
|
||||||
enabled: Boolean(workspaceId) && isInitialized.current,
|
enabled: Boolean(workspaceId) && isInitialized.current,
|
||||||
refetchInterval: isLive ? 5000 : false,
|
refetchInterval: isLive ? 3000 : false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const logs = useMemo(() => {
|
const logs = useMemo(() => {
|
||||||
@@ -160,12 +162,6 @@ export default function Logs() {
|
|||||||
}
|
}
|
||||||
}, [debouncedSearchQuery, setStoreSearchQuery])
|
}, [debouncedSearchQuery, setStoreSearchQuery])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLive || !selectedLogId) return
|
|
||||||
const interval = setInterval(() => activeLogQuery.refetch(), 5000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [isLive, selectedLogId, activeLogQuery])
|
|
||||||
|
|
||||||
const handleLogClick = useCallback(
|
const handleLogClick = useCallback(
|
||||||
(log: WorkflowLog) => {
|
(log: WorkflowLog) => {
|
||||||
if (selectedLogId === log.id && isSidebarOpen) {
|
if (selectedLogId === log.id && isSidebarOpen) {
|
||||||
@@ -279,8 +275,11 @@ export default function Logs() {
|
|||||||
setIsVisuallyRefreshing(true)
|
setIsVisuallyRefreshing(true)
|
||||||
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
|
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
|
||||||
logsQuery.refetch()
|
logsQuery.refetch()
|
||||||
|
if (selectedLogId) {
|
||||||
|
activeLogQuery.refetch()
|
||||||
}
|
}
|
||||||
}, [isLive, logsQuery])
|
}
|
||||||
|
}, [isLive, logsQuery, activeLogQuery, selectedLogId])
|
||||||
|
|
||||||
const prevIsFetchingRef = useRef(logsQuery.isFetching)
|
const prevIsFetchingRef = useRef(logsQuery.isFetching)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -128,7 +128,30 @@ export const ActionBar = memo(
|
|||||||
'dark:border-transparent dark:bg-[var(--surface-4)]'
|
'dark:border-transparent dark:bg-[var(--surface-4)]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!isNoteBlock && !isSubflowBlock && (
|
{!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 && (
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -222,29 +245,6 @@ export const ActionBar = memo(
|
|||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isSubflowBlock && (
|
|
||||||
<Tooltip.Root>
|
|
||||||
<Tooltip.Trigger asChild>
|
|
||||||
<Button
|
|
||||||
variant='ghost'
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (!disabled) {
|
|
||||||
collaborativeBatchToggleBlockEnabled([blockId])
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={ACTION_BUTTON_STYLES}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
|
|
||||||
</Button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Content side='top'>
|
|
||||||
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
isAborting,
|
isAborting,
|
||||||
} = useCopilotStore()
|
} = useCopilotStore()
|
||||||
|
|
||||||
|
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
|
||||||
|
|
||||||
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
|
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
|
||||||
const hasCheckpoints = messageCheckpoints.length > 0 && messageCheckpoints.some((cp) => cp?.id)
|
const hasCheckpoints = messageCheckpoints.length > 0 && messageCheckpoints.some((cp) => cp?.id)
|
||||||
|
|
||||||
@@ -210,7 +212,10 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
const isLastTextBlock =
|
const isLastTextBlock =
|
||||||
index === message.contentBlocks!.length - 1 && block.type === 'text'
|
index === message.contentBlocks!.length - 1 && block.type === 'text'
|
||||||
const parsed = parseSpecialTags(block.content)
|
const parsed = parseSpecialTags(block.content)
|
||||||
const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
|
// Mask credential IDs in the displayed content
|
||||||
|
const cleanBlockContent = maskCredentialValue(
|
||||||
|
parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
|
||||||
|
)
|
||||||
|
|
||||||
if (!cleanBlockContent.trim()) return null
|
if (!cleanBlockContent.trim()) return null
|
||||||
|
|
||||||
@@ -238,7 +243,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
return (
|
return (
|
||||||
<div key={blockKey} className='w-full'>
|
<div key={blockKey} className='w-full'>
|
||||||
<ThinkingBlock
|
<ThinkingBlock
|
||||||
content={block.content}
|
content={maskCredentialValue(block.content)}
|
||||||
isStreaming={isActivelyStreaming}
|
isStreaming={isActivelyStreaming}
|
||||||
hasFollowingContent={hasFollowingContent}
|
hasFollowingContent={hasFollowingContent}
|
||||||
hasSpecialTags={hasSpecialTags}
|
hasSpecialTags={hasSpecialTags}
|
||||||
|
|||||||
@@ -782,6 +782,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
|||||||
const [isExpanded, setIsExpanded] = useState(true)
|
const [isExpanded, setIsExpanded] = useState(true)
|
||||||
const [duration, setDuration] = useState(0)
|
const [duration, setDuration] = useState(0)
|
||||||
const startTimeRef = useRef<number>(Date.now())
|
const startTimeRef = useRef<number>(Date.now())
|
||||||
|
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
|
||||||
const wasStreamingRef = useRef(false)
|
const wasStreamingRef = useRef(false)
|
||||||
|
|
||||||
// Only show streaming animations for current message
|
// Only show streaming animations for current message
|
||||||
@@ -816,14 +817,16 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
|||||||
currentText += parsed.cleanContent
|
currentText += parsed.cleanContent
|
||||||
} else if (block.type === 'subagent_tool_call' && block.toolCall) {
|
} else if (block.type === 'subagent_tool_call' && block.toolCall) {
|
||||||
if (currentText.trim()) {
|
if (currentText.trim()) {
|
||||||
segments.push({ type: 'text', content: currentText })
|
// Mask any credential IDs in the accumulated text before displaying
|
||||||
|
segments.push({ type: 'text', content: maskCredentialValue(currentText) })
|
||||||
currentText = ''
|
currentText = ''
|
||||||
}
|
}
|
||||||
segments.push({ type: 'tool', block })
|
segments.push({ type: 'tool', block })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentText.trim()) {
|
if (currentText.trim()) {
|
||||||
segments.push({ type: 'text', content: currentText })
|
// Mask any credential IDs in the accumulated text before displaying
|
||||||
|
segments.push({ type: 'text', content: maskCredentialValue(currentText) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const allParsed = parseSpecialTags(allRawText)
|
const allParsed = parseSpecialTags(allRawText)
|
||||||
@@ -952,6 +955,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
toolCall: CopilotToolCall
|
toolCall: CopilotToolCall
|
||||||
}) {
|
}) {
|
||||||
const blocks = useWorkflowStore((s) => s.blocks)
|
const blocks = useWorkflowStore((s) => s.blocks)
|
||||||
|
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
|
||||||
|
|
||||||
const cachedBlockInfoRef = useRef<Record<string, { name: string; type: string }>>({})
|
const cachedBlockInfoRef = useRef<Record<string, { name: string; type: string }>>({})
|
||||||
|
|
||||||
@@ -983,6 +987,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
title: string
|
title: string
|
||||||
value: any
|
value: any
|
||||||
isPassword?: boolean
|
isPassword?: boolean
|
||||||
|
isCredential?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BlockChange {
|
interface BlockChange {
|
||||||
@@ -1091,6 +1096,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
title: subBlockConfig.title ?? subBlockConfig.id,
|
title: subBlockConfig.title ?? subBlockConfig.id,
|
||||||
value,
|
value,
|
||||||
isPassword: subBlockConfig.password === true,
|
isPassword: subBlockConfig.password === true,
|
||||||
|
isCredential: subBlockConfig.type === 'oauth-input',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1172,8 +1178,15 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
{subBlocksToShow && subBlocksToShow.length > 0 && (
|
{subBlocksToShow && subBlocksToShow.length > 0 && (
|
||||||
<div className='border-[var(--border-1)] border-t px-2.5 py-1.5'>
|
<div className='border-[var(--border-1)] border-t px-2.5 py-1.5'>
|
||||||
{subBlocksToShow.map((sb) => {
|
{subBlocksToShow.map((sb) => {
|
||||||
// Mask password fields like the canvas does
|
// Mask password fields and credential IDs
|
||||||
const displayValue = sb.isPassword ? '•••' : getDisplayValue(sb.value)
|
let displayValue: string
|
||||||
|
if (sb.isPassword) {
|
||||||
|
displayValue = '•••'
|
||||||
|
} else {
|
||||||
|
// Get display value first, then mask any credential IDs that might be in it
|
||||||
|
const rawValue = getDisplayValue(sb.value)
|
||||||
|
displayValue = maskCredentialValue(rawValue)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div key={sb.id} className='flex items-start gap-1.5 py-0.5 text-[11px]'>
|
<div key={sb.id} className='flex items-start gap-1.5 py-0.5 text-[11px]'>
|
||||||
<span
|
<span
|
||||||
@@ -1412,10 +1425,10 @@ function RunSkipButtons({
|
|||||||
setIsProcessing(true)
|
setIsProcessing(true)
|
||||||
setButtonsHidden(true)
|
setButtonsHidden(true)
|
||||||
try {
|
try {
|
||||||
// Add to auto-allowed list first
|
|
||||||
await addAutoAllowedTool(toolCall.name)
|
await addAutoAllowedTool(toolCall.name)
|
||||||
// Then execute
|
if (!isIntegrationTool(toolCall.name)) {
|
||||||
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
|
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false)
|
setIsProcessing(false)
|
||||||
actionInProgressRef.current = false
|
actionInProgressRef.current = false
|
||||||
@@ -1438,10 +1451,10 @@ function RunSkipButtons({
|
|||||||
|
|
||||||
if (buttonsHidden) return null
|
if (buttonsHidden) return null
|
||||||
|
|
||||||
// Hide "Always Allow" for integration tools (only show for client tools with interrupts)
|
// Show "Always Allow" for all tools that require confirmation
|
||||||
const showAlwaysAllow = !isIntegrationTool(toolCall.name)
|
const showAlwaysAllow = true
|
||||||
|
|
||||||
// Standardized buttons for all interrupt tools: Allow, (Always Allow for client tools only), Skip
|
// Standardized buttons for all interrupt tools: Allow, Always Allow, Skip
|
||||||
return (
|
return (
|
||||||
<div className='mt-[10px] flex gap-[6px]'>
|
<div className='mt-[10px] flex gap-[6px]'>
|
||||||
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
|
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
|
||||||
@@ -1510,7 +1523,11 @@ export function ToolCall({
|
|||||||
toolCall.name === 'user_memory' ||
|
toolCall.name === 'user_memory' ||
|
||||||
toolCall.name === 'edit_respond' ||
|
toolCall.name === 'edit_respond' ||
|
||||||
toolCall.name === 'debug_respond' ||
|
toolCall.name === 'debug_respond' ||
|
||||||
toolCall.name === 'plan_respond'
|
toolCall.name === 'plan_respond' ||
|
||||||
|
toolCall.name === 'research_respond' ||
|
||||||
|
toolCall.name === 'info_respond' ||
|
||||||
|
toolCall.name === 'deploy_respond' ||
|
||||||
|
toolCall.name === 'superagent_respond'
|
||||||
)
|
)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
|
|||||||
@@ -209,9 +209,20 @@ export interface SlashCommand {
|
|||||||
export const TOP_LEVEL_COMMANDS: readonly SlashCommand[] = [
|
export const TOP_LEVEL_COMMANDS: readonly SlashCommand[] = [
|
||||||
{ id: 'fast', label: 'Fast' },
|
{ id: 'fast', label: 'Fast' },
|
||||||
{ id: 'research', label: 'Research' },
|
{ id: 'research', label: 'Research' },
|
||||||
{ id: 'superagent', label: 'Actions' },
|
{ id: 'actions', label: 'Actions' },
|
||||||
] as const
|
] 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[] = [
|
export const WEB_COMMANDS: readonly SlashCommand[] = [
|
||||||
{ id: 'search', label: 'Search' },
|
{ id: 'search', label: 'Search' },
|
||||||
{ id: 'read', label: 'Read' },
|
{ id: 'read', label: 'Read' },
|
||||||
|
|||||||
@@ -105,10 +105,10 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
|||||||
isSendingMessage,
|
isSendingMessage,
|
||||||
])
|
])
|
||||||
|
|
||||||
/** Load auto-allowed tools once on mount */
|
/** Load auto-allowed tools once on mount - runs immediately, independent of workflow */
|
||||||
const hasLoadedAutoAllowedToolsRef = useRef(false)
|
const hasLoadedAutoAllowedToolsRef = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasMountedRef.current && !hasLoadedAutoAllowedToolsRef.current) {
|
if (!hasLoadedAutoAllowedToolsRef.current) {
|
||||||
hasLoadedAutoAllowedToolsRef.current = true
|
hasLoadedAutoAllowedToolsRef.current = true
|
||||||
loadAutoAllowedTools().catch((err) => {
|
loadAutoAllowedTools().catch((err) => {
|
||||||
logger.warn('[Copilot] Failed to load auto-allowed tools', err)
|
logger.warn('[Copilot] Failed to load auto-allowed tools', err)
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export function DeployModal({
|
|||||||
const [activeTab, setActiveTab] = useState<TabView>('general')
|
const [activeTab, setActiveTab] = useState<TabView>('general')
|
||||||
const [chatSubmitting, setChatSubmitting] = useState(false)
|
const [chatSubmitting, setChatSubmitting] = useState(false)
|
||||||
const [apiDeployError, setApiDeployError] = useState<string | null>(null)
|
const [apiDeployError, setApiDeployError] = useState<string | null>(null)
|
||||||
|
const [apiDeployWarnings, setApiDeployWarnings] = useState<string[]>([])
|
||||||
const [isChatFormValid, setIsChatFormValid] = useState(false)
|
const [isChatFormValid, setIsChatFormValid] = useState(false)
|
||||||
const [selectedStreamingOutputs, setSelectedStreamingOutputs] = useState<string[]>([])
|
const [selectedStreamingOutputs, setSelectedStreamingOutputs] = useState<string[]>([])
|
||||||
|
|
||||||
@@ -227,6 +228,7 @@ export function DeployModal({
|
|||||||
if (open && workflowId) {
|
if (open && workflowId) {
|
||||||
setActiveTab('general')
|
setActiveTab('general')
|
||||||
setApiDeployError(null)
|
setApiDeployError(null)
|
||||||
|
setApiDeployWarnings([])
|
||||||
}
|
}
|
||||||
}, [open, workflowId])
|
}, [open, workflowId])
|
||||||
|
|
||||||
@@ -282,9 +284,13 @@ export function DeployModal({
|
|||||||
if (!workflowId) return
|
if (!workflowId) return
|
||||||
|
|
||||||
setApiDeployError(null)
|
setApiDeployError(null)
|
||||||
|
setApiDeployWarnings([])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
|
const result = await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
|
||||||
|
if (result.warnings && result.warnings.length > 0) {
|
||||||
|
setApiDeployWarnings(result.warnings)
|
||||||
|
}
|
||||||
await refetchDeployedState()
|
await refetchDeployedState()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error('Error deploying workflow:', { error })
|
logger.error('Error deploying workflow:', { error })
|
||||||
@@ -297,8 +303,13 @@ export function DeployModal({
|
|||||||
async (version: number) => {
|
async (version: number) => {
|
||||||
if (!workflowId) return
|
if (!workflowId) return
|
||||||
|
|
||||||
|
setApiDeployWarnings([])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await activateVersionMutation.mutateAsync({ workflowId, version })
|
const result = await activateVersionMutation.mutateAsync({ workflowId, version })
|
||||||
|
if (result.warnings && result.warnings.length > 0) {
|
||||||
|
setApiDeployWarnings(result.warnings)
|
||||||
|
}
|
||||||
await refetchDeployedState()
|
await refetchDeployedState()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error promoting version:', { error })
|
logger.error('Error promoting version:', { error })
|
||||||
@@ -324,9 +335,13 @@ export function DeployModal({
|
|||||||
if (!workflowId) return
|
if (!workflowId) return
|
||||||
|
|
||||||
setApiDeployError(null)
|
setApiDeployError(null)
|
||||||
|
setApiDeployWarnings([])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
|
const result = await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
|
||||||
|
if (result.warnings && result.warnings.length > 0) {
|
||||||
|
setApiDeployWarnings(result.warnings)
|
||||||
|
}
|
||||||
await refetchDeployedState()
|
await refetchDeployedState()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error('Error redeploying workflow:', { error })
|
logger.error('Error redeploying workflow:', { error })
|
||||||
@@ -338,6 +353,7 @@ export function DeployModal({
|
|||||||
const handleCloseModal = useCallback(() => {
|
const handleCloseModal = useCallback(() => {
|
||||||
setChatSubmitting(false)
|
setChatSubmitting(false)
|
||||||
setApiDeployError(null)
|
setApiDeployError(null)
|
||||||
|
setApiDeployWarnings([])
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
}, [onOpenChange])
|
}, [onOpenChange])
|
||||||
|
|
||||||
@@ -479,6 +495,14 @@ export function DeployModal({
|
|||||||
<div>{apiDeployError}</div>
|
<div>{apiDeployError}</div>
|
||||||
</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'>
|
<ModalTabsContent value='general'>
|
||||||
<GeneralDeploy
|
<GeneralDeploy
|
||||||
workflowId={workflowId}
|
workflowId={workflowId}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import type { GenerationType } from '@/blocks/types'
|
|||||||
import { normalizeName } from '@/executor/constants'
|
import { normalizeName } from '@/executor/constants'
|
||||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||||
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
|
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
|
||||||
|
import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||||
|
|
||||||
const logger = createLogger('Code')
|
const logger = createLogger('Code')
|
||||||
|
|
||||||
@@ -88,21 +89,27 @@ interface CodePlaceholder {
|
|||||||
/**
|
/**
|
||||||
* Creates a syntax highlighter function with custom reference and environment variable highlighting.
|
* Creates a syntax highlighter function with custom reference and environment variable highlighting.
|
||||||
* @param effectiveLanguage - The language to use for syntax highlighting
|
* @param effectiveLanguage - The language to use for syntax highlighting
|
||||||
* @param shouldHighlightReference - Function to determine if a reference should be highlighted
|
* @param shouldHighlightReference - Function to determine if a block reference should be highlighted
|
||||||
|
* @param shouldHighlightEnvVar - Function to determine if an env var should be highlighted
|
||||||
* @returns A function that highlights code with syntax and custom highlights
|
* @returns A function that highlights code with syntax and custom highlights
|
||||||
*/
|
*/
|
||||||
const createHighlightFunction = (
|
const createHighlightFunction = (
|
||||||
effectiveLanguage: 'javascript' | 'python' | 'json',
|
effectiveLanguage: 'javascript' | 'python' | 'json',
|
||||||
shouldHighlightReference: (part: string) => boolean
|
shouldHighlightReference: (part: string) => boolean,
|
||||||
|
shouldHighlightEnvVar: (varName: string) => boolean
|
||||||
) => {
|
) => {
|
||||||
return (codeToHighlight: string): string => {
|
return (codeToHighlight: string): string => {
|
||||||
const placeholders: CodePlaceholder[] = []
|
const placeholders: CodePlaceholder[] = []
|
||||||
let processedCode = codeToHighlight
|
let processedCode = codeToHighlight
|
||||||
|
|
||||||
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
|
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
|
||||||
|
const varName = match.slice(2, -2).trim()
|
||||||
|
if (shouldHighlightEnvVar(varName)) {
|
||||||
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
||||||
placeholders.push({ placeholder, original: match, type: 'env' })
|
placeholders.push({ placeholder, original: match, type: 'env' })
|
||||||
return placeholder
|
return placeholder
|
||||||
|
}
|
||||||
|
return match
|
||||||
})
|
})
|
||||||
|
|
||||||
processedCode = processedCode.replace(createReferencePattern(), (match) => {
|
processedCode = processedCode.replace(createReferencePattern(), (match) => {
|
||||||
@@ -212,6 +219,7 @@ export const Code = memo(function Code({
|
|||||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||||
const emitTagSelection = useTagSelection(blockId, subBlockId)
|
const emitTagSelection = useTagSelection(blockId, subBlockId)
|
||||||
const [languageValue] = useSubBlockValue<string>(blockId, 'language')
|
const [languageValue] = useSubBlockValue<string>(blockId, 'language')
|
||||||
|
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||||
|
|
||||||
const effectiveLanguage = (languageValue as 'javascript' | 'python' | 'json') || language
|
const effectiveLanguage = (languageValue as 'javascript' | 'python' | 'json') || language
|
||||||
|
|
||||||
@@ -603,9 +611,15 @@ export const Code = memo(function Code({
|
|||||||
[generateCodeStream, isPromptVisible, isAiStreaming]
|
[generateCodeStream, isPromptVisible, isAiStreaming]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const shouldHighlightEnvVar = useMemo(
|
||||||
|
() => createShouldHighlightEnvVar(availableEnvVars),
|
||||||
|
[availableEnvVars]
|
||||||
|
)
|
||||||
|
|
||||||
const highlightCode = useMemo(
|
const highlightCode = useMemo(
|
||||||
() => createHighlightFunction(effectiveLanguage, shouldHighlightReference),
|
() =>
|
||||||
[effectiveLanguage, shouldHighlightReference]
|
createHighlightFunction(effectiveLanguage, shouldHighlightReference, shouldHighlightEnvVar),
|
||||||
|
[effectiveLanguage, shouldHighlightReference, shouldHighlightEnvVar]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleValueChange = useCallback(
|
const handleValueChange = useCallback(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
|
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
@@ -35,6 +35,7 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/
|
|||||||
import { normalizeName } from '@/executor/constants'
|
import { normalizeName } from '@/executor/constants'
|
||||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||||
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
|
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
|
||||||
|
import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
|
|
||||||
const logger = createLogger('ConditionInput')
|
const logger = createLogger('ConditionInput')
|
||||||
@@ -123,6 +124,11 @@ export function ConditionInput({
|
|||||||
|
|
||||||
const emitTagSelection = useTagSelection(blockId, subBlockId)
|
const emitTagSelection = useTagSelection(blockId, subBlockId)
|
||||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||||
|
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||||
|
const shouldHighlightEnvVar = useMemo(
|
||||||
|
() => createShouldHighlightEnvVar(availableEnvVars),
|
||||||
|
[availableEnvVars]
|
||||||
|
)
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const inputRefs = useRef<Map<string, HTMLTextAreaElement>>(new Map())
|
const inputRefs = useRef<Map<string, HTMLTextAreaElement>>(new Map())
|
||||||
@@ -1136,6 +1142,8 @@ export function ConditionInput({
|
|||||||
let processedCode = codeToHighlight
|
let processedCode = codeToHighlight
|
||||||
|
|
||||||
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
|
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
|
||||||
|
const varName = match.slice(2, -2).trim()
|
||||||
|
if (shouldHighlightEnvVar(varName)) {
|
||||||
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
||||||
placeholders.push({
|
placeholders.push({
|
||||||
placeholder,
|
placeholder,
|
||||||
@@ -1144,6 +1152,8 @@ export function ConditionInput({
|
|||||||
shouldHighlight: true,
|
shouldHighlight: true,
|
||||||
})
|
})
|
||||||
return placeholder
|
return placeholder
|
||||||
|
}
|
||||||
|
return match
|
||||||
})
|
})
|
||||||
|
|
||||||
processedCode = processedCode.replace(
|
processedCode = processedCode.replace(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createCombinedPattern } from '@/executor/utils/reference-validation'
|
|||||||
|
|
||||||
export interface HighlightContext {
|
export interface HighlightContext {
|
||||||
accessiblePrefixes?: Set<string>
|
accessiblePrefixes?: Set<string>
|
||||||
|
availableEnvVars?: Set<string>
|
||||||
highlightAll?: boolean
|
highlightAll?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,9 +44,17 @@ export function formatDisplayText(text: string, context?: HighlightContext): Rea
|
|||||||
return false
|
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[] = []
|
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()
|
const regex = createCombinedPattern()
|
||||||
let lastIndex = 0
|
let lastIndex = 0
|
||||||
let key = 0
|
let key = 0
|
||||||
@@ -65,11 +74,16 @@ export function formatDisplayText(text: string, context?: HighlightContext): Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (matchText.startsWith(REFERENCE.ENV_VAR_START)) {
|
if (matchText.startsWith(REFERENCE.ENV_VAR_START)) {
|
||||||
|
const varName = matchText.slice(2, -2).trim()
|
||||||
|
if (shouldHighlightEnvVar(varName)) {
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<span key={key++} className='text-[var(--brand-secondary)]'>
|
<span key={key++} className='text-[var(--brand-secondary)]'>
|
||||||
{matchText}
|
{matchText}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
nodes.push(<span key={key++}>{matchText}</span>)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const split = splitReferenceSegment(matchText)
|
const split = splitReferenceSegment(matchText)
|
||||||
|
|
||||||
|
|||||||
@@ -1312,15 +1312,16 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
|||||||
if (currentLoop && isLoopBlock) {
|
if (currentLoop && isLoopBlock) {
|
||||||
containingLoopBlockId = blockId
|
containingLoopBlockId = blockId
|
||||||
const loopType = currentLoop.loopType || 'for'
|
const loopType = currentLoop.loopType || 'for'
|
||||||
const contextualTags: string[] = ['index']
|
|
||||||
if (loopType === 'forEach') {
|
|
||||||
contextualTags.push('currentItem')
|
|
||||||
contextualTags.push('items')
|
|
||||||
}
|
|
||||||
|
|
||||||
const loopBlock = blocks[blockId]
|
const loopBlock = blocks[blockId]
|
||||||
if (loopBlock) {
|
if (loopBlock) {
|
||||||
const loopBlockName = loopBlock.name || loopBlock.type
|
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 = {
|
loopBlockGroup = {
|
||||||
blockName: loopBlockName,
|
blockName: loopBlockName,
|
||||||
@@ -1328,21 +1329,23 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
|||||||
blockType: 'loop',
|
blockType: 'loop',
|
||||||
tags: contextualTags,
|
tags: contextualTags,
|
||||||
distance: 0,
|
distance: 0,
|
||||||
|
isContextual: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (containingLoop) {
|
} else if (containingLoop) {
|
||||||
const [loopId, loop] = containingLoop
|
const [loopId, loop] = containingLoop
|
||||||
containingLoopBlockId = loopId
|
containingLoopBlockId = loopId
|
||||||
const loopType = loop.loopType || 'for'
|
const loopType = loop.loopType || 'for'
|
||||||
const contextualTags: string[] = ['index']
|
|
||||||
if (loopType === 'forEach') {
|
|
||||||
contextualTags.push('currentItem')
|
|
||||||
contextualTags.push('items')
|
|
||||||
}
|
|
||||||
|
|
||||||
const containingLoopBlock = blocks[loopId]
|
const containingLoopBlock = blocks[loopId]
|
||||||
if (containingLoopBlock) {
|
if (containingLoopBlock) {
|
||||||
const loopBlockName = containingLoopBlock.name || containingLoopBlock.type
|
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 = {
|
loopBlockGroup = {
|
||||||
blockName: loopBlockName,
|
blockName: loopBlockName,
|
||||||
@@ -1350,6 +1353,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
|||||||
blockType: 'loop',
|
blockType: 'loop',
|
||||||
tags: contextualTags,
|
tags: contextualTags,
|
||||||
distance: 0,
|
distance: 0,
|
||||||
|
isContextual: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1363,15 +1367,16 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
|||||||
const [parallelId, parallel] = containingParallel
|
const [parallelId, parallel] = containingParallel
|
||||||
containingParallelBlockId = parallelId
|
containingParallelBlockId = parallelId
|
||||||
const parallelType = parallel.parallelType || 'count'
|
const parallelType = parallel.parallelType || 'count'
|
||||||
const contextualTags: string[] = ['index']
|
|
||||||
if (parallelType === 'collection') {
|
|
||||||
contextualTags.push('currentItem')
|
|
||||||
contextualTags.push('items')
|
|
||||||
}
|
|
||||||
|
|
||||||
const containingParallelBlock = blocks[parallelId]
|
const containingParallelBlock = blocks[parallelId]
|
||||||
if (containingParallelBlock) {
|
if (containingParallelBlock) {
|
||||||
const parallelBlockName = containingParallelBlock.name || containingParallelBlock.type
|
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 = {
|
parallelBlockGroup = {
|
||||||
blockName: parallelBlockName,
|
blockName: parallelBlockName,
|
||||||
@@ -1379,6 +1384,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
|||||||
blockType: 'parallel',
|
blockType: 'parallel',
|
||||||
tags: contextualTags,
|
tags: contextualTags,
|
||||||
distance: 0,
|
distance: 0,
|
||||||
|
isContextual: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1645,38 +1651,29 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
|||||||
const nestedBlockTagGroups: NestedBlockTagGroup[] = useMemo(() => {
|
const nestedBlockTagGroups: NestedBlockTagGroup[] = useMemo(() => {
|
||||||
return filteredBlockTagGroups.map((group: BlockTagGroup) => {
|
return filteredBlockTagGroups.map((group: BlockTagGroup) => {
|
||||||
const normalizedBlockName = normalizeName(group.blockName)
|
const normalizedBlockName = normalizeName(group.blockName)
|
||||||
|
|
||||||
// Handle loop/parallel contextual tags (index, currentItem, items)
|
|
||||||
const directTags: NestedTag[] = []
|
const directTags: NestedTag[] = []
|
||||||
const tagsForTree: string[] = []
|
const tagsForTree: string[] = []
|
||||||
|
|
||||||
group.tags.forEach((tag: string) => {
|
group.tags.forEach((tag: string) => {
|
||||||
const tagParts = tag.split('.')
|
const tagParts = tag.split('.')
|
||||||
|
|
||||||
// Loop/parallel contextual tags without block prefix
|
if (tagParts.length === 1) {
|
||||||
if (
|
|
||||||
(group.blockType === 'loop' || group.blockType === 'parallel') &&
|
|
||||||
tagParts.length === 1
|
|
||||||
) {
|
|
||||||
directTags.push({
|
directTags.push({
|
||||||
key: tag,
|
key: tag,
|
||||||
display: tag,
|
display: tag,
|
||||||
fullTag: tag,
|
fullTag: tag,
|
||||||
})
|
})
|
||||||
} else if (tagParts.length === 2) {
|
} else if (tagParts.length === 2) {
|
||||||
// Direct property like blockname.property
|
|
||||||
directTags.push({
|
directTags.push({
|
||||||
key: tagParts[1],
|
key: tagParts[1],
|
||||||
display: tagParts[1],
|
display: tagParts[1],
|
||||||
fullTag: tag,
|
fullTag: tag,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Nested property - add to tree builder
|
|
||||||
tagsForTree.push(tag)
|
tagsForTree.push(tag)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Build recursive tree from nested tags
|
|
||||||
const nestedTags = [...directTags, ...buildNestedTagTree(tagsForTree, normalizedBlockName)]
|
const nestedTags = [...directTags, ...buildNestedTagTree(tagsForTree, normalizedBlockName)]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1800,15 +1797,21 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
|||||||
processedTag = tag
|
processedTag = tag
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
blockGroup &&
|
blockGroup?.isContextual &&
|
||||||
(blockGroup.blockType === 'loop' || blockGroup.blockType === 'parallel')
|
(blockGroup.blockType === 'loop' || blockGroup.blockType === 'parallel')
|
||||||
) {
|
) {
|
||||||
if (!tag.includes('.') && ['index', 'currentItem', 'items'].includes(tag)) {
|
const tagParts = tag.split('.')
|
||||||
processedTag = `${blockGroup.blockType}.${tag}`
|
if (tagParts.length === 1) {
|
||||||
|
processedTag = blockGroup.blockType
|
||||||
|
} else {
|
||||||
|
const lastPart = tagParts[tagParts.length - 1]
|
||||||
|
if (['index', 'currentItem', 'items'].includes(lastPart)) {
|
||||||
|
processedTag = `${blockGroup.blockType}.${lastPart}`
|
||||||
} else {
|
} else {
|
||||||
processedTag = tag
|
processedTag = tag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let newValue: string
|
let newValue: string
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface BlockTagGroup {
|
|||||||
blockType: string
|
blockType: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
distance: number
|
distance: number
|
||||||
|
/** True if this is a contextual group (loop/parallel iteration context available inside the subflow) */
|
||||||
|
isContextual?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ interface TextProps {
|
|||||||
* - Automatically detects and renders HTML content safely
|
* - Automatically detects and renders HTML content safely
|
||||||
* - Applies prose styling for HTML content (links, code, lists, etc.)
|
* - Applies prose styling for HTML content (links, code, lists, etc.)
|
||||||
* - Falls back to plain text rendering for non-HTML content
|
* - Falls back to plain text rendering for non-HTML content
|
||||||
|
*
|
||||||
|
* Note: This component renders trusted, internally-defined content only
|
||||||
|
* (e.g., trigger setup instructions). It is NOT used for user-generated content.
|
||||||
*/
|
*/
|
||||||
export function Text({ blockId, subBlockId, content, className }: TextProps) {
|
export function Text({ blockId, subBlockId, content, className }: TextProps) {
|
||||||
const containsHtml = /<[^>]+>/.test(content)
|
const containsHtml = /<[^>]+>/.test(content)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useRef, useState } from 'react'
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
import { highlight, languages } from '@/components/emcn'
|
import { highlight, languages } from '@/components/emcn'
|
||||||
import {
|
import {
|
||||||
isLikelyReferenceSegment,
|
isLikelyReferenceSegment,
|
||||||
@@ -9,6 +10,7 @@ import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/co
|
|||||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||||
import { normalizeName, REFERENCE } from '@/executor/constants'
|
import { normalizeName, REFERENCE } from '@/executor/constants'
|
||||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||||
|
import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||||
@@ -53,6 +55,9 @@ const SUBFLOW_CONFIG = {
|
|||||||
* @returns Subflow editor state and handlers
|
* @returns Subflow editor state and handlers
|
||||||
*/
|
*/
|
||||||
export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId: string | null) {
|
export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId: string | null) {
|
||||||
|
const params = useParams()
|
||||||
|
const workspaceId = params.workspaceId as string
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||||
const editorContainerRef = useRef<HTMLDivElement>(null)
|
const editorContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -81,6 +86,13 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId
|
|||||||
// Get accessible prefixes for tag dropdown
|
// Get accessible prefixes for tag dropdown
|
||||||
const accessiblePrefixes = useAccessibleReferencePrefixes(currentBlockId || '')
|
const accessiblePrefixes = useAccessibleReferencePrefixes(currentBlockId || '')
|
||||||
|
|
||||||
|
// Get available env vars for highlighting validation
|
||||||
|
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||||
|
const shouldHighlightEnvVar = useMemo(
|
||||||
|
() => createShouldHighlightEnvVar(availableEnvVars),
|
||||||
|
[availableEnvVars]
|
||||||
|
)
|
||||||
|
|
||||||
// Collaborative actions
|
// Collaborative actions
|
||||||
const {
|
const {
|
||||||
collaborativeUpdateLoopType,
|
collaborativeUpdateLoopType,
|
||||||
@@ -140,9 +152,13 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId
|
|||||||
let processedCode = code
|
let processedCode = code
|
||||||
|
|
||||||
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
|
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
|
||||||
|
const varName = match.slice(2, -2).trim()
|
||||||
|
if (shouldHighlightEnvVar(varName)) {
|
||||||
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
||||||
placeholders.push({ placeholder, original: match, type: 'env' })
|
placeholders.push({ placeholder, original: match, type: 'env' })
|
||||||
return placeholder
|
return placeholder
|
||||||
|
}
|
||||||
|
return match
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use [^<>]+ to prevent matching across nested brackets (e.g., "<3 <real.ref>" should match separately)
|
// Use [^<>]+ to prevent matching across nested brackets (e.g., "<3 <real.ref>" should match separately)
|
||||||
@@ -174,7 +190,7 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId
|
|||||||
|
|
||||||
return highlightedCode
|
return highlightedCode
|
||||||
},
|
},
|
||||||
[shouldHighlightReference]
|
[shouldHighlightReference, shouldHighlightEnvVar]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useStoreWithEqualityFn } from 'zustand/traditional'
|
|||||||
import { Badge, Tooltip } from '@/components/emcn'
|
import { Badge, Tooltip } from '@/components/emcn'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { createMcpToolId } from '@/lib/mcp/utils'
|
import { createMcpToolId } from '@/lib/mcp/shared'
|
||||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||||
import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@/lib/workflows/triggers/triggers'
|
} from '@/lib/workflows/triggers/triggers'
|
||||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||||
import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types'
|
import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types'
|
||||||
|
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||||
import { coerceValue } from '@/executor/utils/start-block'
|
import { coerceValue } from '@/executor/utils/start-block'
|
||||||
import { subscriptionKeys } from '@/hooks/queries/subscription'
|
import { subscriptionKeys } from '@/hooks/queries/subscription'
|
||||||
import { useExecutionStream } from '@/hooks/use-execution-stream'
|
import { useExecutionStream } from '@/hooks/use-execution-stream'
|
||||||
@@ -76,17 +77,6 @@ function normalizeErrorMessage(error: unknown): string {
|
|||||||
return WORKFLOW_EXECUTION_FAILURE_MESSAGE
|
return WORKFLOW_EXECUTION_FAILURE_MESSAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExecutionResult(value: unknown): value is ExecutionResult {
|
|
||||||
if (!isRecord(value)) return false
|
|
||||||
return typeof value.success === 'boolean' && isRecord(value.output)
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractExecutionResult(error: unknown): ExecutionResult | null {
|
|
||||||
if (!isRecord(error)) return null
|
|
||||||
const candidate = error.executionResult
|
|
||||||
return isExecutionResult(candidate) ? candidate : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkflowExecution() {
|
export function useWorkflowExecution() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const currentWorkflow = useCurrentWorkflow()
|
const currentWorkflow = useCurrentWorkflow()
|
||||||
@@ -1138,11 +1128,11 @@ export function useWorkflowExecution() {
|
|||||||
|
|
||||||
const handleExecutionError = (error: unknown, options?: { executionId?: string }) => {
|
const handleExecutionError = (error: unknown, options?: { executionId?: string }) => {
|
||||||
const normalizedMessage = normalizeErrorMessage(error)
|
const normalizedMessage = normalizeErrorMessage(error)
|
||||||
const executionResultFromError = extractExecutionResult(error)
|
|
||||||
|
|
||||||
let errorResult: ExecutionResult
|
let errorResult: ExecutionResult
|
||||||
|
|
||||||
if (executionResultFromError) {
|
if (hasExecutionResult(error)) {
|
||||||
|
const executionResultFromError = error.executionResult
|
||||||
const logs = Array.isArray(executionResultFromError.logs) ? executionResultFromError.logs : []
|
const logs = Array.isArray(executionResultFromError.logs) ? executionResultFromError.logs : []
|
||||||
|
|
||||||
errorResult = {
|
errorResult = {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { ReactFlowProvider } from 'reactflow'
|
import { ReactFlowProvider } from 'reactflow'
|
||||||
import { Badge, Button, ChevronDown, Code, Combobox, Input, Label } from '@/components/emcn'
|
import { Badge, Button, ChevronDown, Code, Combobox, Input, Label } from '@/components/emcn'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||||
import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
|
import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
|
||||||
import {
|
import {
|
||||||
buildCanonicalIndex,
|
buildCanonicalIndex,
|
||||||
@@ -704,14 +705,6 @@ interface PreviewEditorProps {
|
|||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format duration for display
|
|
||||||
*/
|
|
||||||
function formatDuration(ms: number): string {
|
|
||||||
if (ms < 1000) return `${ms}ms`
|
|
||||||
return `${(ms / 1000).toFixed(2)}s`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimum height for the connections section (header only) */
|
/** Minimum height for the connections section (header only) */
|
||||||
const MIN_CONNECTIONS_HEIGHT = 30
|
const MIN_CONNECTIONS_HEIGHT = 30
|
||||||
/** Maximum height for the connections section */
|
/** Maximum height for the connections section */
|
||||||
@@ -1180,7 +1173,7 @@ function PreviewEditorContent({
|
|||||||
)}
|
)}
|
||||||
{executionData.durationMs !== undefined && (
|
{executionData.durationMs !== undefined && (
|
||||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
{formatDuration(executionData.durationMs)}
|
{formatDuration(executionData.durationMs, { precision: 2 })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -448,7 +448,7 @@ export const SearchModal = memo(function SearchModal({
|
|||||||
}, [workspaces, workflows, pages, blocks, triggers, tools, toolOperations, docs])
|
}, [workspaces, workflows, pages, blocks, triggers, tools, toolOperations, docs])
|
||||||
|
|
||||||
const sectionOrder = useMemo<SearchItem['type'][]>(
|
const sectionOrder = useMemo<SearchItem['type'][]>(
|
||||||
() => ['block', 'tool', 'tool-operation', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
|
() => ['block', 'tool', 'trigger', 'doc', 'tool-operation', 'workflow', 'workspace', 'page'],
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,47 @@ function calculateAliasScore(
|
|||||||
return { score: 0, matchType: null }
|
return { score: 0, matchType: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate multi-word match score
|
||||||
|
* Each word in the query must appear somewhere in the field
|
||||||
|
* Returns a score based on how well the words match
|
||||||
|
*/
|
||||||
|
function calculateMultiWordScore(
|
||||||
|
queryWords: string[],
|
||||||
|
field: string
|
||||||
|
): { score: number; matchType: 'word-boundary' | 'substring' | null } {
|
||||||
|
const normalizedField = field.toLowerCase().trim()
|
||||||
|
const fieldWords = normalizedField.split(/[\s\-_/:]+/)
|
||||||
|
|
||||||
|
let allWordsMatch = true
|
||||||
|
let totalScore = 0
|
||||||
|
let hasWordBoundary = false
|
||||||
|
|
||||||
|
for (const queryWord of queryWords) {
|
||||||
|
const wordBoundaryMatch = fieldWords.some((fw) => fw.startsWith(queryWord))
|
||||||
|
const substringMatch = normalizedField.includes(queryWord)
|
||||||
|
|
||||||
|
if (wordBoundaryMatch) {
|
||||||
|
totalScore += SCORE_WORD_BOUNDARY
|
||||||
|
hasWordBoundary = true
|
||||||
|
} else if (substringMatch) {
|
||||||
|
totalScore += SCORE_SUBSTRING_MATCH
|
||||||
|
} else {
|
||||||
|
allWordsMatch = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allWordsMatch) {
|
||||||
|
return { score: 0, matchType: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
score: totalScore / queryWords.length,
|
||||||
|
matchType: hasWordBoundary ? 'word-boundary' : 'substring',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search items using tiered matching algorithm
|
* Search items using tiered matching algorithm
|
||||||
* Returns items sorted by relevance (highest score first)
|
* Returns items sorted by relevance (highest score first)
|
||||||
@@ -117,6 +158,8 @@ export function searchItems<T extends SearchableItem>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const results: SearchResult<T>[] = []
|
const results: SearchResult<T>[] = []
|
||||||
|
const queryWords = normalizedQuery.toLowerCase().split(/\s+/).filter(Boolean)
|
||||||
|
const isMultiWord = queryWords.length > 1
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const nameMatch = calculateFieldScore(normalizedQuery, item.name)
|
const nameMatch = calculateFieldScore(normalizedQuery, item.name)
|
||||||
@@ -127,16 +170,35 @@ export function searchItems<T extends SearchableItem>(
|
|||||||
|
|
||||||
const aliasMatch = calculateAliasScore(normalizedQuery, item.aliases)
|
const aliasMatch = calculateAliasScore(normalizedQuery, item.aliases)
|
||||||
|
|
||||||
const nameScore = nameMatch.score
|
let nameScore = nameMatch.score
|
||||||
const descScore = descMatch.score * DESCRIPTION_WEIGHT
|
let descScore = descMatch.score * DESCRIPTION_WEIGHT
|
||||||
const aliasScore = aliasMatch.score
|
const aliasScore = aliasMatch.score
|
||||||
|
|
||||||
|
let bestMatchType = nameMatch.matchType
|
||||||
|
|
||||||
|
// For multi-word queries, also try matching each word independently and take the better score
|
||||||
|
if (isMultiWord) {
|
||||||
|
const multiWordNameMatch = calculateMultiWordScore(queryWords, item.name)
|
||||||
|
if (multiWordNameMatch.score > nameScore) {
|
||||||
|
nameScore = multiWordNameMatch.score
|
||||||
|
bestMatchType = multiWordNameMatch.matchType
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.description) {
|
||||||
|
const multiWordDescMatch = calculateMultiWordScore(queryWords, item.description)
|
||||||
|
const multiWordDescScore = multiWordDescMatch.score * DESCRIPTION_WEIGHT
|
||||||
|
if (multiWordDescScore > descScore) {
|
||||||
|
descScore = multiWordDescScore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const bestScore = Math.max(nameScore, descScore, aliasScore)
|
const bestScore = Math.max(nameScore, descScore, aliasScore)
|
||||||
|
|
||||||
if (bestScore > 0) {
|
if (bestScore > 0) {
|
||||||
let matchType: SearchResult<T>['matchType'] = 'substring'
|
let matchType: SearchResult<T>['matchType'] = 'substring'
|
||||||
if (nameScore >= descScore && nameScore >= aliasScore) {
|
if (nameScore >= descScore && nameScore >= aliasScore) {
|
||||||
matchType = nameMatch.matchType || 'substring'
|
matchType = bestMatchType || 'substring'
|
||||||
} else if (aliasScore >= descScore) {
|
} else if (aliasScore >= descScore) {
|
||||||
matchType = 'alias'
|
matchType = 'alias'
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -688,7 +688,7 @@ export function AccessControl() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex items-center justify-between rounded-[8px] border border-[var(--border)] px-[12px] py-[10px]'>
|
<div className='flex items-center justify-between'>
|
||||||
<div className='flex flex-col gap-[2px]'>
|
<div className='flex flex-col gap-[2px]'>
|
||||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||||
Auto-add new members
|
Auto-add new members
|
||||||
@@ -705,7 +705,7 @@ export function AccessControl() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||||
<div className='flex flex-col gap-[16px]'>
|
<div className='flex flex-col gap-[8px]'>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||||
Members
|
Members
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import { Input, Skeleton } from '@/components/ui'
|
import { Input, Skeleton } from '@/components/ui'
|
||||||
import { useSession } from '@/lib/auth/auth-client'
|
import { useSession } from '@/lib/auth/auth-client'
|
||||||
|
import { formatDate } from '@/lib/core/utils/formatting'
|
||||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||||
import {
|
import {
|
||||||
type ApiKey,
|
type ApiKey,
|
||||||
@@ -133,13 +134,9 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
|||||||
}
|
}
|
||||||
}, [shouldScrollToBottom])
|
}, [shouldScrollToBottom])
|
||||||
|
|
||||||
const formatDate = (dateString?: string) => {
|
const formatLastUsed = (dateString?: string) => {
|
||||||
if (!dateString) return 'Never'
|
if (!dateString) return 'Never'
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
return formatDate(new Date(dateString))
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -216,7 +213,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
|||||||
{key.name}
|
{key.name}
|
||||||
</span>
|
</span>
|
||||||
<span className='text-[13px] text-[var(--text-secondary)]'>
|
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||||
(last used: {formatDate(key.lastUsed).toLowerCase()})
|
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className='truncate text-[13px] text-[var(--text-muted)]'>
|
<p className='truncate text-[13px] text-[var(--text-muted)]'>
|
||||||
@@ -251,7 +248,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
|||||||
{key.name}
|
{key.name}
|
||||||
</span>
|
</span>
|
||||||
<span className='text-[13px] text-[var(--text-secondary)]'>
|
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||||
(last used: {formatDate(key.lastUsed).toLowerCase()})
|
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className='truncate text-[13px] text-[var(--text-muted)]'>
|
<p className='truncate text-[13px] text-[var(--text-muted)]'>
|
||||||
@@ -291,7 +288,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
|||||||
{key.name}
|
{key.name}
|
||||||
</span>
|
</span>
|
||||||
<span className='text-[13px] text-[var(--text-secondary)]'>
|
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||||
(last used: {formatDate(key.lastUsed).toLowerCase()})
|
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className='truncate text-[13px] text-[var(--text-muted)]'>
|
<p className='truncate text-[13px] text-[var(--text-muted)]'>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ModalHeader,
|
ModalHeader,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import { Input, Skeleton } from '@/components/ui'
|
import { Input, Skeleton } from '@/components/ui'
|
||||||
|
import { formatDate } from '@/lib/core/utils/formatting'
|
||||||
import {
|
import {
|
||||||
type CopilotKey,
|
type CopilotKey,
|
||||||
useCopilotKeys,
|
useCopilotKeys,
|
||||||
@@ -115,13 +116,9 @@ export function Copilot() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateString?: string | null) => {
|
const formatLastUsed = (dateString?: string | null) => {
|
||||||
if (!dateString) return 'Never'
|
if (!dateString) return 'Never'
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
return formatDate(new Date(dateString))
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasKeys = keys.length > 0
|
const hasKeys = keys.length > 0
|
||||||
@@ -180,7 +177,7 @@ export function Copilot() {
|
|||||||
{key.name || 'Unnamed Key'}
|
{key.name || 'Unnamed Key'}
|
||||||
</span>
|
</span>
|
||||||
<span className='text-[13px] text-[var(--text-secondary)]'>
|
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||||
(last used: {formatDate(key.lastUsed).toLowerCase()})
|
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className='truncate text-[13px] text-[var(--text-muted)]'>
|
<p className='truncate text-[13px] text-[var(--text-muted)]'>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
useRefreshMcpServer,
|
useRefreshMcpServer,
|
||||||
useStoredMcpTools,
|
useStoredMcpTools,
|
||||||
} from '@/hooks/queries/mcp'
|
} from '@/hooks/queries/mcp'
|
||||||
|
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||||
import { FormField, McpServerSkeleton } from './components'
|
import { FormField, McpServerSkeleton } from './components'
|
||||||
@@ -157,6 +158,7 @@ interface FormattedInputProps {
|
|||||||
scrollLeft: number
|
scrollLeft: number
|
||||||
showEnvVars: boolean
|
showEnvVars: boolean
|
||||||
envVarProps: EnvVarDropdownConfig
|
envVarProps: EnvVarDropdownConfig
|
||||||
|
availableEnvVars?: Set<string>
|
||||||
className?: string
|
className?: string
|
||||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
onScroll: (scrollLeft: number) => void
|
onScroll: (scrollLeft: number) => void
|
||||||
@@ -169,6 +171,7 @@ function FormattedInput({
|
|||||||
scrollLeft,
|
scrollLeft,
|
||||||
showEnvVars,
|
showEnvVars,
|
||||||
envVarProps,
|
envVarProps,
|
||||||
|
availableEnvVars,
|
||||||
className,
|
className,
|
||||||
onChange,
|
onChange,
|
||||||
onScroll,
|
onScroll,
|
||||||
@@ -190,7 +193,7 @@ function FormattedInput({
|
|||||||
/>
|
/>
|
||||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-[8px] py-[6px] font-medium font-sans text-sm'>
|
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-[8px] py-[6px] font-medium font-sans text-sm'>
|
||||||
<div className='whitespace-nowrap' style={{ transform: `translateX(-${scrollLeft}px)` }}>
|
<div className='whitespace-nowrap' style={{ transform: `translateX(-${scrollLeft}px)` }}>
|
||||||
{formatDisplayText(value)}
|
{formatDisplayText(value, { availableEnvVars })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showEnvVars && (
|
{showEnvVars && (
|
||||||
@@ -221,6 +224,7 @@ interface HeaderRowProps {
|
|||||||
envSearchTerm: string
|
envSearchTerm: string
|
||||||
cursorPosition: number
|
cursorPosition: number
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
|
availableEnvVars?: Set<string>
|
||||||
onInputChange: (field: InputFieldType, value: string, index?: number) => void
|
onInputChange: (field: InputFieldType, value: string, index?: number) => void
|
||||||
onHeaderScroll: (key: string, scrollLeft: number) => void
|
onHeaderScroll: (key: string, scrollLeft: number) => void
|
||||||
onEnvVarSelect: (value: string) => void
|
onEnvVarSelect: (value: string) => void
|
||||||
@@ -238,6 +242,7 @@ function HeaderRow({
|
|||||||
envSearchTerm,
|
envSearchTerm,
|
||||||
cursorPosition,
|
cursorPosition,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
availableEnvVars,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onHeaderScroll,
|
onHeaderScroll,
|
||||||
onEnvVarSelect,
|
onEnvVarSelect,
|
||||||
@@ -265,6 +270,7 @@ function HeaderRow({
|
|||||||
scrollLeft={headerScrollLeft[`key-${index}`] || 0}
|
scrollLeft={headerScrollLeft[`key-${index}`] || 0}
|
||||||
showEnvVars={isKeyActive}
|
showEnvVars={isKeyActive}
|
||||||
envVarProps={envVarProps}
|
envVarProps={envVarProps}
|
||||||
|
availableEnvVars={availableEnvVars}
|
||||||
className='flex-1'
|
className='flex-1'
|
||||||
onChange={(e) => onInputChange('header-key', e.target.value, index)}
|
onChange={(e) => onInputChange('header-key', e.target.value, index)}
|
||||||
onScroll={(scrollLeft) => onHeaderScroll(`key-${index}`, scrollLeft)}
|
onScroll={(scrollLeft) => onHeaderScroll(`key-${index}`, scrollLeft)}
|
||||||
@@ -276,6 +282,7 @@ function HeaderRow({
|
|||||||
scrollLeft={headerScrollLeft[`value-${index}`] || 0}
|
scrollLeft={headerScrollLeft[`value-${index}`] || 0}
|
||||||
showEnvVars={isValueActive}
|
showEnvVars={isValueActive}
|
||||||
envVarProps={envVarProps}
|
envVarProps={envVarProps}
|
||||||
|
availableEnvVars={availableEnvVars}
|
||||||
className='flex-1'
|
className='flex-1'
|
||||||
onChange={(e) => onInputChange('header-value', e.target.value, index)}
|
onChange={(e) => onInputChange('header-value', e.target.value, index)}
|
||||||
onScroll={(scrollLeft) => onHeaderScroll(`value-${index}`, scrollLeft)}
|
onScroll={(scrollLeft) => onHeaderScroll(`value-${index}`, scrollLeft)}
|
||||||
@@ -371,6 +378,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
|||||||
const deleteServerMutation = useDeleteMcpServer()
|
const deleteServerMutation = useDeleteMcpServer()
|
||||||
const refreshServerMutation = useRefreshMcpServer()
|
const refreshServerMutation = useRefreshMcpServer()
|
||||||
const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest()
|
const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest()
|
||||||
|
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||||
|
|
||||||
const urlInputRef = useRef<HTMLInputElement>(null)
|
const urlInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -1061,6 +1069,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
|||||||
onSelect: handleEnvVarSelect,
|
onSelect: handleEnvVarSelect,
|
||||||
onClose: resetEnvVarState,
|
onClose: resetEnvVarState,
|
||||||
}}
|
}}
|
||||||
|
availableEnvVars={availableEnvVars}
|
||||||
onChange={(e) => handleInputChange('url', e.target.value)}
|
onChange={(e) => handleInputChange('url', e.target.value)}
|
||||||
onScroll={(scrollLeft) => handleUrlScroll(scrollLeft)}
|
onScroll={(scrollLeft) => handleUrlScroll(scrollLeft)}
|
||||||
/>
|
/>
|
||||||
@@ -1094,6 +1103,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
|||||||
envSearchTerm={envSearchTerm}
|
envSearchTerm={envSearchTerm}
|
||||||
cursorPosition={cursorPosition}
|
cursorPosition={cursorPosition}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
|
availableEnvVars={availableEnvVars}
|
||||||
onInputChange={handleInputChange}
|
onInputChange={handleInputChange}
|
||||||
onHeaderScroll={handleHeaderScroll}
|
onHeaderScroll={handleHeaderScroll}
|
||||||
onEnvVarSelect={handleEnvVarSelect}
|
onEnvVarSelect={handleEnvVarSelect}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { useParams } from 'next/navigation'
|
|||||||
import { Combobox, Label, Switch, Tooltip } from '@/components/emcn'
|
import { Combobox, Label, Switch, Tooltip } from '@/components/emcn'
|
||||||
import { Skeleton } from '@/components/ui'
|
import { Skeleton } from '@/components/ui'
|
||||||
import { useSession } from '@/lib/auth/auth-client'
|
import { useSession } from '@/lib/auth/auth-client'
|
||||||
|
import { USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
|
||||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||||
import { USAGE_THRESHOLDS } from '@/lib/billing/client/usage-visualization'
|
|
||||||
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
|
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
|
|||||||
@@ -2,11 +2,7 @@
|
|||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Badge } from '@/components/emcn'
|
import { Badge } from '@/components/emcn'
|
||||||
import {
|
import { getFilledPillColor, USAGE_PILL_COLORS, USAGE_THRESHOLDS } from '@/lib/billing/client'
|
||||||
getFilledPillColor,
|
|
||||||
USAGE_PILL_COLORS,
|
|
||||||
USAGE_THRESHOLDS,
|
|
||||||
} from '@/lib/billing/client/usage-visualization'
|
|
||||||
|
|
||||||
const PILL_COUNT = 5
|
const PILL_COUNT = 5
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Badge } from '@/components/emcn'
|
import { Badge } from '@/components/emcn'
|
||||||
import { Skeleton } from '@/components/ui'
|
import { Skeleton } from '@/components/ui'
|
||||||
|
import { USAGE_PILL_COLORS, USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
|
||||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||||
import {
|
import {
|
||||||
|
getBillingStatus,
|
||||||
getFilledPillColor,
|
getFilledPillColor,
|
||||||
USAGE_PILL_COLORS,
|
getSubscriptionStatus,
|
||||||
USAGE_THRESHOLDS,
|
getUsage,
|
||||||
} from '@/lib/billing/client/usage-visualization'
|
} from '@/lib/billing/client/utils'
|
||||||
import { getBillingStatus, getSubscriptionStatus, getUsage } from '@/lib/billing/client/utils'
|
|
||||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||||
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { task } from '@trigger.dev/sdk'
|
|||||||
import { Cron } from 'croner'
|
import { Cron } from 'croner'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import type { ZodRecord, ZodString } from 'zod'
|
|
||||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
|
||||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||||
@@ -23,7 +21,7 @@ import {
|
|||||||
} from '@/lib/workflows/schedules/utils'
|
} from '@/lib/workflows/schedules/utils'
|
||||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||||
import type { ExecutionMetadata } from '@/executor/execution/types'
|
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||||
import type { ExecutionResult } from '@/executor/types'
|
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||||
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
|
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
|
||||||
|
|
||||||
const logger = createLogger('TriggerScheduleExecution')
|
const logger = createLogger('TriggerScheduleExecution')
|
||||||
@@ -122,7 +120,6 @@ async function runWorkflowExecution({
|
|||||||
loggingSession,
|
loggingSession,
|
||||||
requestId,
|
requestId,
|
||||||
executionId,
|
executionId,
|
||||||
EnvVarsSchema,
|
|
||||||
}: {
|
}: {
|
||||||
payload: ScheduleExecutionPayload
|
payload: ScheduleExecutionPayload
|
||||||
workflowRecord: WorkflowRecord
|
workflowRecord: WorkflowRecord
|
||||||
@@ -130,7 +127,6 @@ async function runWorkflowExecution({
|
|||||||
loggingSession: LoggingSession
|
loggingSession: LoggingSession
|
||||||
requestId: string
|
requestId: string
|
||||||
executionId: string
|
executionId: string
|
||||||
EnvVarsSchema: ZodRecord<ZodString, ZodString>
|
|
||||||
}): Promise<RunWorkflowResult> {
|
}): Promise<RunWorkflowResult> {
|
||||||
try {
|
try {
|
||||||
logger.debug(`[${requestId}] Loading deployed workflow ${payload.workflowId}`)
|
logger.debug(`[${requestId}] Loading deployed workflow ${payload.workflowId}`)
|
||||||
@@ -156,31 +152,12 @@ async function runWorkflowExecution({
|
|||||||
throw new Error(`Workflow ${payload.workflowId} has no associated workspace`)
|
throw new Error(`Workflow ${payload.workflowId} has no associated workspace`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const personalEnvUserId = workflowRecord.userId
|
|
||||||
|
|
||||||
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
|
|
||||||
personalEnvUserId,
|
|
||||||
workspaceId
|
|
||||||
)
|
|
||||||
|
|
||||||
const variables = EnvVarsSchema.parse({
|
|
||||||
...personalEncrypted,
|
|
||||||
...workspaceEncrypted,
|
|
||||||
})
|
|
||||||
|
|
||||||
const input = {
|
const input = {
|
||||||
_context: {
|
_context: {
|
||||||
workflowId: payload.workflowId,
|
workflowId: payload.workflowId,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
await loggingSession.safeStart({
|
|
||||||
userId: actorUserId,
|
|
||||||
workspaceId,
|
|
||||||
variables: variables || {},
|
|
||||||
deploymentVersionId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const metadata: ExecutionMetadata = {
|
const metadata: ExecutionMetadata = {
|
||||||
requestId,
|
requestId,
|
||||||
executionId,
|
executionId,
|
||||||
@@ -254,8 +231,7 @@ async function runWorkflowExecution({
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error(`[${requestId}] Early failure in scheduled workflow ${payload.workflowId}`, error)
|
logger.error(`[${requestId}] Early failure in scheduled workflow ${payload.workflowId}`, error)
|
||||||
|
|
||||||
const errorWithResult = error as { executionResult?: ExecutionResult }
|
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
|
||||||
const executionResult = errorWithResult?.executionResult
|
|
||||||
const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] }
|
const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] }
|
||||||
|
|
||||||
await loggingSession.safeCompleteWithError({
|
await loggingSession.safeCompleteWithError({
|
||||||
@@ -279,7 +255,6 @@ export type ScheduleExecutionPayload = {
|
|||||||
failedCount?: number
|
failedCount?: number
|
||||||
now: string
|
now: string
|
||||||
scheduledFor?: string
|
scheduledFor?: string
|
||||||
preflighted?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateNextRunTime(
|
function calculateNextRunTime(
|
||||||
@@ -319,9 +294,6 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
|
|||||||
executionId,
|
executionId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const zod = await import('zod')
|
|
||||||
const EnvVarsSchema = zod.z.record(zod.z.string())
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const loggingSession = new LoggingSession(
|
const loggingSession = new LoggingSession(
|
||||||
payload.workflowId,
|
payload.workflowId,
|
||||||
@@ -339,7 +311,6 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
|
|||||||
checkRateLimit: true,
|
checkRateLimit: true,
|
||||||
checkDeployment: true,
|
checkDeployment: true,
|
||||||
loggingSession,
|
loggingSession,
|
||||||
preflightEnvVars: !payload.preflighted,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!preprocessResult.success) {
|
if (!preprocessResult.success) {
|
||||||
@@ -482,7 +453,6 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
|
|||||||
loggingSession,
|
loggingSession,
|
||||||
requestId,
|
requestId,
|
||||||
executionId,
|
executionId,
|
||||||
EnvVarsSchema,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (executionResult.status === 'skip') {
|
if (executionResult.status === 'skip') {
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
|
|||||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||||
import type { ExecutionMetadata } from '@/executor/execution/types'
|
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||||
import type { ExecutionResult } from '@/executor/types'
|
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||||
|
import { safeAssign } from '@/tools/safe-assign'
|
||||||
import { getTrigger, isTriggerValid } from '@/triggers'
|
import { getTrigger, isTriggerValid } from '@/triggers'
|
||||||
|
|
||||||
const logger = createLogger('TriggerWebhookExecution')
|
const logger = createLogger('TriggerWebhookExecution')
|
||||||
@@ -397,7 +398,7 @@ async function executeWebhookJobInternal(
|
|||||||
requestId,
|
requestId,
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
})
|
})
|
||||||
Object.assign(input, processedInput)
|
safeAssign(input, processedInput as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.debug(`[${requestId}] No valid triggerId found for block ${payload.blockId}`)
|
logger.debug(`[${requestId}] No valid triggerId found for block ${payload.blockId}`)
|
||||||
@@ -577,8 +578,9 @@ async function executeWebhookJobInternal(
|
|||||||
deploymentVersionId,
|
deploymentVersionId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const errorWithResult = error as { executionResult?: ExecutionResult }
|
const executionResult = hasExecutionResult(error)
|
||||||
const executionResult = errorWithResult?.executionResult || {
|
? error.executionResult
|
||||||
|
: {
|
||||||
success: false,
|
success: false,
|
||||||
output: {},
|
output: {},
|
||||||
logs: [],
|
logs: [],
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-m
|
|||||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||||
import type { ExecutionMetadata } from '@/executor/execution/types'
|
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||||
import type { ExecutionResult } from '@/executor/types'
|
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||||
import type { CoreTriggerType } from '@/stores/logs/filters/types'
|
import type { CoreTriggerType } from '@/stores/logs/filters/types'
|
||||||
|
|
||||||
const logger = createLogger('TriggerWorkflowExecution')
|
const logger = createLogger('TriggerWorkflowExecution')
|
||||||
@@ -20,7 +20,6 @@ export type WorkflowExecutionPayload = {
|
|||||||
input?: any
|
input?: any
|
||||||
triggerType?: CoreTriggerType
|
triggerType?: CoreTriggerType
|
||||||
metadata?: Record<string, any>
|
metadata?: Record<string, any>
|
||||||
preflighted?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,7 +51,6 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
|
|||||||
checkRateLimit: true,
|
checkRateLimit: true,
|
||||||
checkDeployment: true,
|
checkDeployment: true,
|
||||||
loggingSession: loggingSession,
|
loggingSession: loggingSession,
|
||||||
preflightEnvVars: !payload.preflighted,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!preprocessResult.success) {
|
if (!preprocessResult.success) {
|
||||||
@@ -162,8 +160,7 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
|
|||||||
executionId,
|
executionId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const errorWithResult = error as { executionResult?: ExecutionResult }
|
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
|
||||||
const executionResult = errorWithResult?.executionResult
|
|
||||||
const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] }
|
const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] }
|
||||||
|
|
||||||
await loggingSession.safeCompleteWithError({
|
await loggingSession.safeCompleteWithError({
|
||||||
|
|||||||
@@ -242,15 +242,9 @@ Return ONLY the email body - no explanations, no extra text.`,
|
|||||||
id: 'messageId',
|
id: 'messageId',
|
||||||
title: 'Message ID',
|
title: 'Message ID',
|
||||||
type: 'short-input',
|
type: 'short-input',
|
||||||
placeholder: 'Enter message ID to read (optional)',
|
placeholder: 'Read specific email by ID (overrides label/folder)',
|
||||||
condition: {
|
condition: { field: 'operation', value: 'read_gmail' },
|
||||||
field: 'operation',
|
mode: 'advanced',
|
||||||
value: 'read_gmail',
|
|
||||||
and: {
|
|
||||||
field: 'folder',
|
|
||||||
value: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// Search Fields
|
// Search Fields
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { McpIcon } from '@/components/icons'
|
import { McpIcon } from '@/components/icons'
|
||||||
import { createMcpToolId } from '@/lib/mcp/utils'
|
import { createMcpToolId } from '@/lib/mcp/shared'
|
||||||
import type { BlockConfig } from '@/blocks/types'
|
import type { BlockConfig } from '@/blocks/types'
|
||||||
import type { ToolResponse } from '@/tools/types'
|
import type { ToolResponse } from '@/tools/types'
|
||||||
|
|
||||||
|
|||||||
@@ -129,12 +129,9 @@ ROUTING RULES:
|
|||||||
3. If the context is even partially related to a route's description, select that route
|
3. If the context is even partially related to a route's description, select that route
|
||||||
4. ONLY output NO_MATCH if the context is completely unrelated to ALL route descriptions
|
4. ONLY output NO_MATCH if the context is completely unrelated to ALL route descriptions
|
||||||
|
|
||||||
OUTPUT FORMAT:
|
Respond with a JSON object containing:
|
||||||
- Output EXACTLY one route ID (copied exactly as shown above) OR "NO_MATCH"
|
- route: EXACTLY one route ID (copied exactly as shown above) OR "NO_MATCH"
|
||||||
- No explanation, no punctuation, no additional text
|
- reasoning: A brief explanation (1-2 sentences) of why you chose this route`
|
||||||
- Just the route ID or NO_MATCH
|
|
||||||
|
|
||||||
Your response:`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -272,6 +269,7 @@ interface RouterV2Response extends ToolResponse {
|
|||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
selectedRoute: string
|
selectedRoute: string
|
||||||
|
reasoning: string
|
||||||
selectedPath: {
|
selectedPath: {
|
||||||
blockId: string
|
blockId: string
|
||||||
blockType: string
|
blockType: string
|
||||||
@@ -355,6 +353,7 @@ export const RouterV2Block: BlockConfig<RouterV2Response> = {
|
|||||||
tokens: { type: 'json', description: 'Token usage' },
|
tokens: { type: 'json', description: 'Token usage' },
|
||||||
cost: { type: 'json', description: 'Cost information' },
|
cost: { type: 'json', description: 'Cost information' },
|
||||||
selectedRoute: { type: 'string', description: 'Selected route ID' },
|
selectedRoute: { type: 'string', description: 'Selected route ID' },
|
||||||
|
reasoning: { type: 'string', description: 'Explanation of why this route was chosen' },
|
||||||
selectedPath: { type: 'json', description: 'Selected routing path' },
|
selectedPath: { type: 'json', description: 'Selected routing path' },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ import { cn } from '@/lib/core/utils/cn'
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
const checkboxVariants = cva(
|
const checkboxVariants = cva(
|
||||||
'peer shrink-0 rounded-sm border border-[var(--border-1)] bg-[var(--surface-4)] ring-offset-background transition-colors hover:border-[var(--border-muted)] hover:bg-[var(--surface-7)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[state=checked]:border-[var(--text-muted)] data-[state=checked]:bg-[var(--text-muted)] data-[state=checked]:text-white dark:bg-[var(--surface-5)] dark:data-[state=checked]:border-[var(--surface-7)] dark:data-[state=checked]:bg-[var(--surface-7)] dark:data-[state=checked]:text-[var(--text-primary)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
|
[
|
||||||
|
'peer shrink-0 cursor-pointer rounded-[4px] border transition-colors',
|
||||||
|
'border-[var(--border-1)] bg-transparent',
|
||||||
|
'focus-visible:outline-none',
|
||||||
|
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
|
||||||
|
'data-[state=checked]:border-[var(--text-primary)] data-[state=checked]:bg-[var(--text-primary)]',
|
||||||
|
].join(' '),
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
@@ -83,7 +89,7 @@ const Checkbox = React.forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root
|
|||||||
className={cn(checkboxVariants({ size }), className)}
|
className={cn(checkboxVariants({ size }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
<CheckboxPrimitive.Indicator className='flex items-center justify-center text-[var(--white)]'>
|
||||||
<Check className={cn(checkboxIconVariants({ size }))} />
|
<Check className={cn(checkboxIconVariants({ size }))} />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
|
|||||||
51
apps/sim/content/blog/enterprise/components.tsx
Normal file
51
apps/sim/content/blog/enterprise/components.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ArrowRight, ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ContactButtonProps {
|
||||||
|
href: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactButton({ href, children }: ContactButtonProps) {
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
background: 'linear-gradient(to bottom, #8357ff, #6f3dfa)',
|
||||||
|
border: '1px solid #6f3dfa',
|
||||||
|
boxShadow: 'inset 0 2px 4px 0 #9b77ff',
|
||||||
|
paddingTop: '6px',
|
||||||
|
paddingBottom: '6px',
|
||||||
|
paddingLeft: '12px',
|
||||||
|
paddingRight: '10px',
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#ffffff',
|
||||||
|
textDecoration: 'none',
|
||||||
|
opacity: isHovered ? 0.9 : 1,
|
||||||
|
transition: 'opacity 200ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<span style={{ display: 'inline-flex' }}>
|
||||||
|
{isHovered ? (
|
||||||
|
<ArrowRight style={{ height: '16px', width: '16px' }} aria-hidden='true' />
|
||||||
|
) : (
|
||||||
|
<ChevronRight style={{ height: '16px', width: '16px' }} aria-hidden='true' />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
177
apps/sim/content/blog/enterprise/index.mdx
Normal file
177
apps/sim/content/blog/enterprise/index.mdx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
---
|
||||||
|
slug: enterprise
|
||||||
|
title: 'Build with Sim for Enterprise'
|
||||||
|
description: 'Access control, BYOK, self-hosted deployments, on-prem Copilot, SSO & SAML, whitelabeling, Admin API, and flexible data retention—enterprise features for teams with strict security and compliance requirements.'
|
||||||
|
date: 2026-01-23
|
||||||
|
updated: 2026-01-23
|
||||||
|
authors:
|
||||||
|
- vik
|
||||||
|
readingTime: 10
|
||||||
|
tags: [Enterprise, Security, Self-Hosted, SSO, SAML, Compliance, BYOK, Access Control, Copilot, Whitelabel, API, Import, Export]
|
||||||
|
ogImage: /studio/enterprise/cover.png
|
||||||
|
ogAlt: 'Sim Enterprise features overview'
|
||||||
|
about: ['Enterprise Software', 'Security', 'Compliance', 'Self-Hosting']
|
||||||
|
timeRequired: PT10M
|
||||||
|
canonical: https://sim.ai/studio/enterprise
|
||||||
|
featured: false
|
||||||
|
draft: true
|
||||||
|
---
|
||||||
|
|
||||||
|
We've been working with security teams at larger organizations to bring Sim into environments with strict compliance and data handling requirements. This post covers the enterprise capabilities we've built: granular access control, bring-your-own-keys, self-hosted deployments, on-prem Copilot, SSO & SAML, whitelabeling, compliance, and programmatic management via the Admin API.
|
||||||
|
|
||||||
|
## Access Control
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Permission groups let administrators control what features and integrations are available to different teams within an organization. This isn't just UI filtering—restrictions are enforced at the execution layer.
|
||||||
|
|
||||||
|
### Model Provider Restrictions
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Allowlist specific providers while blocking others. Users in a restricted group see only approved providers in the model selector. A workflow that tries to use an unapproved provider won't execute.
|
||||||
|
|
||||||
|
This is useful when you've approved certain providers for production use, negotiated enterprise agreements with specific vendors, or need to comply with data residency requirements that only certain providers meet.
|
||||||
|
|
||||||
|
### Integration Controls
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Restrict which workflow blocks appear in the editor. Disable the HTTP block to prevent arbitrary external API calls. Block access to integrations that haven't completed your security review.
|
||||||
|
|
||||||
|
### Platform Feature Toggles
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Control access to platform capabilities per permission group:
|
||||||
|
|
||||||
|
- **[Knowledge Base](https://docs.sim.ai/blocks/knowledge)** — Disable document uploads if RAG workflows aren't approved
|
||||||
|
- **[MCP Tools](https://docs.sim.ai/mcp)** — Block deployment of workflows as external tool endpoints
|
||||||
|
- **Custom Tools** — Prevent creation of arbitrary HTTP integrations
|
||||||
|
- **Invitations** — Disable self-service team invitations to maintain centralized control
|
||||||
|
|
||||||
|
Users not assigned to any permission group have full access, so restrictions are opt-in per team rather than requiring you to grant permissions to everyone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bring Your Own Keys
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
When you configure your own API keys for model providers—OpenAI, Anthropic, Google, Azure OpenAI, AWS Bedrock, or any supported provider—your prompts and completions route directly between Sim and that provider. The traffic doesn't pass through our infrastructure.
|
||||||
|
|
||||||
|
This matters because LLM requests contain the context you've assembled: customer data, internal documents, proprietary business logic. With your own keys, you maintain a direct relationship with your model provider. Their data handling policies and compliance certifications apply to your usage without an intermediary.
|
||||||
|
|
||||||
|
BYOK is available to everyone, not just enterprise plans. Connect your credentials in workspace settings, and all model calls use your keys. For self-hosted deployments, this is the default—there are no Sim-managed keys involved.
|
||||||
|
|
||||||
|
A healthcare organization can use Azure OpenAI with their BAA-covered subscription. A financial services firm can route through their approved API gateway with additional logging controls. The workflow builder stays the same; only the underlying data flow changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Hosted Deployments
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Run Sim entirely on your infrastructure. Deploy with [Docker Compose](https://docs.sim.ai/self-hosting/docker) or [Helm charts](https://docs.sim.ai/self-hosting/kubernetes) for Kubernetes—the application, WebSocket server, and PostgreSQL database all stay within your network.
|
||||||
|
|
||||||
|
**Single-node** — Docker Compose setup for smaller teams getting started.
|
||||||
|
|
||||||
|
**High availability** — Multi-replica Kubernetes deployments with horizontal pod autoscaling.
|
||||||
|
|
||||||
|
**Air-gapped** — No external network access required. Pair with [Ollama](https://docs.sim.ai/self-hosting/ollama) or [vLLM](https://docs.sim.ai/self-hosting/vllm) for local model inference.
|
||||||
|
|
||||||
|
Enterprise features like access control, SSO, and organization management are enabled through environment variables—no connection to our billing infrastructure required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## On-Prem Copilot
|
||||||
|
|
||||||
|
Copilot—our context-aware AI assistant for building and debugging workflows—can run entirely within your self-hosted deployment using your own LLM keys.
|
||||||
|
|
||||||
|
When you configure Copilot with your API credentials, all assistant interactions route directly to your chosen provider. The prompts Copilot generates—which include context from your workflows, execution logs, and workspace configuration—never leave your network. You get the same capabilities as the hosted version: natural language workflow generation, error diagnosis, documentation lookup, and iterative editing through diffs.
|
||||||
|
|
||||||
|
This is particularly relevant for organizations where the context Copilot needs to be helpful is also the context that can't leave the building. Your workflow definitions, block configurations, and execution traces stay within your infrastructure even when you're asking Copilot for help debugging a failure or generating a new integration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SSO & SAML
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Integrate with your existing identity provider through SAML 2.0 or OIDC. We support Okta, Azure AD (Entra ID), Google Workspace, OneLogin, Auth0, JumpCloud, Ping Identity, ADFS, and any compliant identity provider.
|
||||||
|
|
||||||
|
Once enabled, users authenticate through your IdP instead of Sim credentials. Your MFA policies apply automatically. Session management ties to your IdP—logout there terminates Sim sessions. Account deprovisioning immediately revokes access.
|
||||||
|
|
||||||
|
New users are provisioned on first SSO login based on IdP attributes. No invitation emails, no password setup, no manual account creation required.
|
||||||
|
|
||||||
|
This centralizes your authentication and audit trail. Your security team's policies apply to Sim access through the same system that tracks everything else.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Whitelabeling
|
||||||
|
|
||||||
|
Customize Sim's appearance to match your brand. For self-hosted deployments, whitelabeling is configured through environment variables—no code changes required.
|
||||||
|
|
||||||
|
**Brand name & logo** — Replace "Sim" with your company name and logo throughout the interface.
|
||||||
|
|
||||||
|
**Theme colors** — Set primary, accent, and background colors to align with your brand palette.
|
||||||
|
|
||||||
|
**Support & documentation links** — Point help links to your internal documentation and support channels instead of ours.
|
||||||
|
|
||||||
|
**Legal pages** — Redirect terms of service and privacy policy links to your own policies.
|
||||||
|
|
||||||
|
This is useful for internal platforms, customer-facing deployments, or any scenario where you want Sim to feel like a native part of your product rather than a third-party tool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compliance & Data Retention
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Sim maintains **SOC 2 Type II** certification with annual audits covering security, availability, and confidentiality controls. We share our SOC 2 report directly with prospective customers under NDA.
|
||||||
|
|
||||||
|
**HIPAA** — Business Associate Agreements available for healthcare organizations. Requires self-hosted deployment or dedicated infrastructure.
|
||||||
|
|
||||||
|
**Data Retention** — Configure how long workflow execution traces, inputs, and outputs are stored before automatic deletion. We work with enterprise customers to set retention policies that match their compliance requirements.
|
||||||
|
|
||||||
|
We provide penetration test reports, architecture documentation, and completed security questionnaires (SIG, CAIQ, and custom formats) for your vendor review process.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin API
|
||||||
|
|
||||||
|
Manage Sim programmatically through the Admin API. Every operation available in the UI has a corresponding API endpoint, enabling infrastructure-as-code workflows and integration with your existing tooling.
|
||||||
|
|
||||||
|
**User & Organization Management** — Provision users, create organizations, assign roles, and manage team membership. Integrate with your HR systems to automatically onboard and offboard employees.
|
||||||
|
|
||||||
|
**Workspace Administration** — Create workspaces, configure settings, and manage access. Useful for setting up isolated environments for different teams or clients.
|
||||||
|
|
||||||
|
**Workflow Lifecycle** — Deploy, undeploy, and manage workflow versions programmatically. Build CI/CD pipelines that promote workflows from development to staging to production.
|
||||||
|
|
||||||
|
The API uses standard REST conventions with JSON payloads. Authentication is via API keys scoped to your organization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Import & Export
|
||||||
|
|
||||||
|
Move workflows between environments, create backups, and maintain version control inside or outside of Sim.
|
||||||
|
|
||||||
|
**Workflow Export** — Export individual workflows or entire folders as JSON. The export includes block configurations, connections, environment variable references, and metadata. Use this to back up critical workflows or move them between Sim instances.
|
||||||
|
|
||||||
|
**Workspace Export** — Export an entire workspace as a ZIP archive containing all workflows, folder structure, and configuration. Useful for disaster recovery or migrating to a self-hosted deployment.
|
||||||
|
|
||||||
|
**Import** — Import workflows into any workspace. Sim handles ID remapping and validates the structure before import. This enables workflow templates, sharing between teams, and restoring from backups.
|
||||||
|
|
||||||
|
**Version History** — Each deployment creates a version snapshot. Roll back to previous versions if a deployment causes issues. The Admin API exposes version history for integration with your change management processes.
|
||||||
|
|
||||||
|
For teams practicing GitOps, export workflows to your repository and use the Admin API to deploy from CI/CD pipelines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get Started
|
||||||
|
|
||||||
|
Enterprise features are available now. Check out our [self-hosting](https://docs.sim.ai/self-hosting) and [enterprise](https://docs.sim.ai/enterprise) docs to get started.
|
||||||
|
|
||||||
|
*Questions about enterprise deployments?*
|
||||||
|
|
||||||
|
<ContactButton href="https://form.typeform.com/to/jqCO12pF">Contact Us</ContactButton>
|
||||||
1
apps/sim/content/blog/v0-5/components.tsx
Normal file
1
apps/sim/content/blog/v0-5/components.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { DiffControlsDemo } from './components/diff-controls-demo'
|
||||||
111
apps/sim/content/blog/v0-5/components/diff-controls-demo.tsx
Normal file
111
apps/sim/content/blog/v0-5/components/diff-controls-demo.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function DiffControlsDemo() {
|
||||||
|
const [rejectHover, setRejectHover] = useState(false)
|
||||||
|
const [acceptHover, setAcceptHover] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', margin: '24px 0' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
height: '30px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: '4px',
|
||||||
|
isolation: 'isolate',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Reject button */}
|
||||||
|
<button
|
||||||
|
onClick={() => {}}
|
||||||
|
onMouseEnter={() => setRejectHover(true)}
|
||||||
|
onMouseLeave={() => setRejectHover(false)}
|
||||||
|
title='Reject changes'
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
height: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
backgroundColor: rejectHover ? '#f0f0f0' : '#f5f5f5',
|
||||||
|
paddingRight: '20px',
|
||||||
|
paddingLeft: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '13px',
|
||||||
|
color: rejectHover ? '#2d2d2d' : '#404040',
|
||||||
|
clipPath: 'polygon(0 0, calc(100% + 10px) 0, 100% 100%, 0 100%)',
|
||||||
|
borderRadius: '4px 0 0 4px',
|
||||||
|
cursor: 'default',
|
||||||
|
transition: 'color 150ms, background-color 150ms, border-color 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
{/* Slanted divider - split gray/green */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
pointerEvents: 'none',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: '66px',
|
||||||
|
width: '2px',
|
||||||
|
transform: 'skewX(-18.4deg)',
|
||||||
|
background: 'linear-gradient(to right, #e0e0e0 50%, #238458 50%)',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Accept button */}
|
||||||
|
<button
|
||||||
|
onClick={() => {}}
|
||||||
|
onMouseEnter={() => setAcceptHover(true)}
|
||||||
|
onMouseLeave={() => setAcceptHover(false)}
|
||||||
|
title='Accept changes (⇧⌘⏎)'
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
height: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.15)',
|
||||||
|
backgroundColor: '#32bd7e',
|
||||||
|
paddingRight: '12px',
|
||||||
|
paddingLeft: '20px',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#ffffff',
|
||||||
|
clipPath: 'polygon(10px 0, 100% 0, 100% 100%, 0 100%)',
|
||||||
|
borderRadius: '0 4px 4px 0',
|
||||||
|
marginLeft: '-10px',
|
||||||
|
cursor: 'default',
|
||||||
|
filter: acceptHover ? 'brightness(1.1)' : undefined,
|
||||||
|
transition: 'background-color 150ms, border-color 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
<kbd
|
||||||
|
style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
paddingLeft: '6px',
|
||||||
|
paddingRight: '6px',
|
||||||
|
paddingTop: '2px',
|
||||||
|
paddingBottom: '2px',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily:
|
||||||
|
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"',
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#ffffff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⇧⌘<span style={{ display: 'inline-block', transform: 'translateY(-1px)' }}>⏎</span>
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
204
apps/sim/content/blog/v0-5/index.mdx
Normal file
204
apps/sim/content/blog/v0-5/index.mdx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
---
|
||||||
|
slug: v0-5
|
||||||
|
title: 'Introducing Sim v0.5'
|
||||||
|
description: 'This new release brings a state of the art Copilot, seamless MCP server and tool deployment, 100+ integrations with 300+ tools, comprehensive execution logs, and realtime collaboration—built for teams shipping AI agents in production.'
|
||||||
|
date: 2026-01-22
|
||||||
|
updated: 2026-01-22
|
||||||
|
authors:
|
||||||
|
- waleed
|
||||||
|
readingTime: 8
|
||||||
|
tags: [Release, Copilot, MCP, Observability, Collaboration, Integrations, Sim]
|
||||||
|
ogImage: /studio/v0-5/cover.png
|
||||||
|
ogAlt: 'Sim v0.5 release announcement'
|
||||||
|
about: ['AI Agents', 'Workflow Automation', 'Developer Tools']
|
||||||
|
timeRequired: PT8M
|
||||||
|
canonical: https://sim.ai/studio/v0-5
|
||||||
|
featured: true
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
**Sim v0.5** is the next evolution of our agent workflow platform—built for teams shipping AI agents to production.
|
||||||
|
|
||||||
|
## Copilot
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Copilot is a context-aware assistant embedded in the Sim editor. Unlike general-purpose AI assistants, Copilot has direct access to your workspace: workflows, block configurations, execution logs, connected credentials, and documentation. It can also search the web to pull in external context when needed.
|
||||||
|
|
||||||
|
Your workspace is indexed for hybrid retrieval. When you ask a question, Copilot queries this index to ground its responses in your actual workflow state. Ask "why did my workflow fail at 3am?" and it retrieves the relevant execution trace, identifies the error, and explains what happened.
|
||||||
|
|
||||||
|
Copilot supports slash commands that trigger specialized capabilities:
|
||||||
|
|
||||||
|
- `/fast` — uses a faster model for quick responses when you need speed over depth
|
||||||
|
- `/research` — performs multi-step web research on a topic, synthesizing results from multiple sources
|
||||||
|
- `/actions` — enables agentic mode where Copilot can take actions on your behalf, like modifying blocks or creating workflows
|
||||||
|
- `/search` — searches the web for relevant information
|
||||||
|
- `/read` — reads and extracts content from a URL
|
||||||
|
- `/scrape` — scrapes structured data from web pages
|
||||||
|
- `/crawl` — crawls multiple pages from a website to gather comprehensive information
|
||||||
|
|
||||||
|
Use `@` commands to pull specific context into your conversation. `@block` references a specific block's configuration and recent outputs. `@workflow` includes the full workflow structure. `@logs` pulls in recent execution traces. This lets you ask targeted questions like "why is `@Slack1` returning an error?" and Copilot has the exact context it needs to diagnose the issue.
|
||||||
|
|
||||||
|
For complex tasks, Copilot uses subagents—breaking requests into discrete operations and executing them sequentially. Ask it to "add error handling to this workflow" and it will analyze your blocks, determine where failures could occur, add appropriate condition blocks, and wire up notification paths. Each change surfaces as a diff for your review before applying.
|
||||||
|
|
||||||
|
<DiffControlsDemo />
|
||||||
|
|
||||||
|
## MCP Deployment
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Deploy any workflow as an [MCP](https://modelcontextprotocol.io) server. Once deployed, the workflow becomes a callable tool for any MCP-compatible agent—[Claude Desktop](https://claude.ai/download), [Cursor](https://cursor.com), or your own applications.
|
||||||
|
|
||||||
|
Sim generates a tool definition from your workflow: the name and description you specify, plus a JSON schema derived from your Start block's input format. The MCP server uses Streamable HTTP transport, so agents connect via a single URL. Authentication is handled via API key headers or public access, depending on your configuration.
|
||||||
|
|
||||||
|
Consider a lead enrichment workflow: it queries Apollo for contact data, checks Salesforce for existing records, formats the output, and posts a summary to Slack. That's 8 blocks in Sim. Deploy it as MCP, and any agent can call `enrich_lead("jane@acme.com")` and receive structured data back. The agent treats it as a single tool call—it doesn't need to know about Apollo, Salesforce, or Slack.
|
||||||
|
|
||||||
|
This pattern scales to research pipelines, data processing workflows, approval chains, and internal tooling. Anything you build in Sim becomes a tool any agent can invoke.
|
||||||
|
|
||||||
|
## Logs & Dashboard
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Every workflow execution generates a full trace. Each block records its start time, end time, inputs, outputs, and any errors. For LLM blocks, we capture prompt tokens, completion tokens, and cost by model.
|
||||||
|
|
||||||
|
The dashboard aggregates this data into queryable views:
|
||||||
|
|
||||||
|
- **Trace spans**: Hierarchical view of block executions with timing waterfall
|
||||||
|
- **Cost attribution**: Token usage and spend broken down by model per execution
|
||||||
|
- **Error context**: Full stack traces with the block, input values, and failure reason
|
||||||
|
- **Filtering**: Query by time range, trigger type, workflow, or status
|
||||||
|
- **Execution snapshots**: Each run captures the workflow state at execution time—restore to see exactly what was running
|
||||||
|
|
||||||
|
This level of observability is necessary when workflows handle production traffic—sending customer emails, processing payments, or making API calls on behalf of users.
|
||||||
|
|
||||||
|
## Realtime Collaboration
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Multiple users can edit the same workflow simultaneously. Changes propagate in real time—you see teammates' cursors, block additions, and configuration updates as they happen.
|
||||||
|
|
||||||
|
The editor now supports full undo/redo history (Cmd+Z / Cmd+Shift+Z), so you can step back through changes without losing work. Copy and paste works for individual blocks, groups of blocks, or entire subflows—select what you need, Cmd+C, and paste into the same workflow or a different one. This makes it easy to duplicate patterns, share components across workflows, or quickly prototype variations.
|
||||||
|
|
||||||
|
This is particularly useful during development sessions where engineers, product managers, and domain experts need to iterate together. Everyone works on the same workflow state, and changes sync immediately across all connected clients.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Every deployment creates a new version. The version history shows who deployed what and when, with a preview of the workflow state at that point in time. Roll back to any previous version with one click—the live deployment updates immediately.
|
||||||
|
|
||||||
|
This matters when something breaks in production. You can instantly revert to the last known good version while you debug, rather than scrambling to fix forward. It also provides a clear audit trail: you can see exactly what changed between versions and who made the change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 100+ Integrations
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
v0.5 adds **100+ integrations** with **300+ actions**. These cover the specific operations you need—not just generic CRUD, but actions like "send Slack message to channel," "create Jira ticket with custom fields," "query Postgres with parameterized SQL," or "enrich contact via Apollo."
|
||||||
|
|
||||||
|
- **CRMs & Sales**: Salesforce, HubSpot, Pipedrive, Apollo, Wealthbox
|
||||||
|
- **Communication**: Slack, Discord, Microsoft Teams, Telegram, WhatsApp, Twilio
|
||||||
|
- **Productivity**: Notion, Confluence, Google Workspace, Microsoft 365, Airtable, Asana, Trello
|
||||||
|
- **Developer Tools**: GitHub, GitLab, Jira, Linear, Sentry, Datadog, Grafana
|
||||||
|
- **Databases**: PostgreSQL, MySQL, MongoDB, [Supabase](https://supabase.com), DynamoDB, Elasticsearch, [Pinecone](https://pinecone.io), [Qdrant](https://qdrant.tech), Neo4j
|
||||||
|
- **Finance**: Stripe, Kalshi, Polymarket
|
||||||
|
- **Web & Search**: [Firecrawl](https://firecrawl.dev), [Exa](https://exa.ai), [Tavily](https://tavily.com), [Jina](https://jina.ai), [Serper](https://serper.dev)
|
||||||
|
- **Cloud**: AWS (S3, RDS, SQS, Textract, Bedrock), [Browser Use](https://browser-use.com), [Stagehand](https://github.com/browserbase/stagehand)
|
||||||
|
|
||||||
|
Each integration handles OAuth or API key authentication. Connect once, and the credentials are available across all workflows in your workspace.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Triggers
|
||||||
|
|
||||||
|
Workflows can be triggered through multiple mechanisms:
|
||||||
|
|
||||||
|
**Webhooks**: Sim provisions a unique HTTPS endpoint for each workflow. Incoming POST requests are parsed and passed to the first block as input. Supports standard webhook patterns including signature verification for services that provide it.
|
||||||
|
|
||||||
|
**Schedules**: Cron-based scheduling with timezone support. Use the visual scheduler or write expressions directly. Execution locks prevent overlapping runs.
|
||||||
|
|
||||||
|
**Chat**: Deploy workflows as conversational interfaces. Messages stream to your workflow, responses stream back to the user. Supports multi-turn context.
|
||||||
|
|
||||||
|
**API**: REST endpoint with your workflow's input schema. Call it from any system that can make HTTP requests.
|
||||||
|
|
||||||
|
**Integration triggers**: Event-driven triggers for specific services—GitHub (PR opened, issue created, push), Stripe (payment succeeded, subscription updated), TypeForm (form submitted), RSS (new item), and more.
|
||||||
|
|
||||||
|
**Forms**: Coming soon—build custom input forms that trigger workflows directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Knowledge Base
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Upload documents—PDFs, text files, markdown, HTML—and make them queryable by your agents. This is [RAG](https://en.wikipedia.org/wiki/Retrieval-augmented_generation) (Retrieval Augmented Generation) built directly into Sim.
|
||||||
|
|
||||||
|
Documents are chunked, embedded, and indexed using hybrid search ([BM25](https://en.wikipedia.org/wiki/Okapi_BM25) + vector embeddings). Agent blocks can query the knowledge base as a tool, retrieving relevant passages based on semantic similarity and keyword matching. When documents are updated, they re-index automatically.
|
||||||
|
|
||||||
|
Use cases:
|
||||||
|
|
||||||
|
- **Customer support agents** that reference your help docs and troubleshooting guides to resolve tickets
|
||||||
|
- **Sales assistants** that pull from product specs, pricing sheets, and competitive intel
|
||||||
|
- **Internal Q&A bots** that answer questions about company policies, HR docs, or engineering runbooks
|
||||||
|
- **Research workflows** that synthesize information from uploaded papers, reports, or data exports
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Blocks
|
||||||
|
|
||||||
|
### Human in the Loop
|
||||||
|
|
||||||
|
Pause workflow execution pending human approval. The block sends a notification (email, Slack, or webhook) with approve/reject actions. Execution resumes only on approval—useful for high-stakes operations like customer-facing emails, financial transactions, or content publishing.
|
||||||
|
|
||||||
|
### Agent Block
|
||||||
|
|
||||||
|
The Agent block now supports three additional tool types:
|
||||||
|
|
||||||
|
- **Workflows as tools**: Agents can invoke other Sim workflows, enabling hierarchical architectures where a coordinator agent delegates to specialized sub-workflows
|
||||||
|
- **Knowledge base queries**: Agents search your indexed documents directly, retrieving relevant context for their responses
|
||||||
|
- **Custom functions**: Execute JavaScript or Python code in isolated sandboxes with configurable timeout and memory limits
|
||||||
|
|
||||||
|
### Subflows
|
||||||
|
|
||||||
|
Group blocks into collapsible subflows. Use them for loops (iterate over arrays), parallel execution (run branches concurrently), or logical organization. Subflows can be nested and keep complex workflows manageable.
|
||||||
|
|
||||||
|
### Router
|
||||||
|
|
||||||
|
Conditional branching based on data or LLM classification. Define rules or let the router use an LLM to determine intent and select the appropriate path.
|
||||||
|
|
||||||
|
The router now exposes its reasoning in execution logs—when debugging unexpected routing, you can see exactly why a particular branch was selected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model Providers
|
||||||
|
|
||||||
|
Sim supports 14 providers: [OpenAI](https://openai.com), [Anthropic](https://anthropic.com), [Google](https://ai.google.dev), [Azure OpenAI](https://azure.microsoft.com/en-us/products/ai-services/openai-service), [xAI](https://x.ai), [Mistral](https://mistral.ai), [Deepseek](https://deepseek.com), [Groq](https://groq.com), [Cerebras](https://cerebras.ai), [Ollama](https://ollama.com), and [OpenRouter](https://openrouter.ai).
|
||||||
|
|
||||||
|
New in v0.5:
|
||||||
|
|
||||||
|
- **[AWS Bedrock](https://aws.amazon.com/bedrock)**: Claude, Nova, Llama, Mistral, and Cohere models via your AWS account
|
||||||
|
- **[Google Vertex AI](https://cloud.google.com/vertex-ai)**: Gemini models through Google Cloud
|
||||||
|
- **[vLLM](https://github.com/vllm-project/vllm)**: Self-hosted models on your own infrastructure
|
||||||
|
|
||||||
|
Model selection is per-block, so you can use faster/cheaper models for simple tasks and more capable models where needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Developer Experience
|
||||||
|
|
||||||
|
**Custom Tools**: Define your own integrations with custom HTTP endpoints, authentication (API key, OAuth, Bearer token), and request/response schemas. Custom tools appear in the block palette alongside built-in integrations.
|
||||||
|
|
||||||
|
**Environment Variables**: Encrypted key-value storage for secrets and configuration. Variables are decrypted at runtime and can be referenced in any block configuration.
|
||||||
|
|
||||||
|
**Import/Export**: Export workflows or entire workspaces as JSON. Imports preserve all blocks, connections, configurations, and variable references.
|
||||||
|
|
||||||
|
**File Manager**: Upload files to your workspace for use in workflows—templates, seed data, static assets. Files are accessible via internal references or presigned URLs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get Started
|
||||||
|
|
||||||
|
Available now at [sim.ai](https://sim.ai). Check out the [docs](https://docs.sim.ai) to dive deeper.
|
||||||
|
|
||||||
|
*Questions? [help@sim.ai](mailto:help@sim.ai) · [Discord](https://sim.ai/discord)*
|
||||||
@@ -120,6 +120,12 @@ export const SPECIAL_REFERENCE_PREFIXES = [
|
|||||||
REFERENCE.PREFIX.VARIABLE,
|
REFERENCE.PREFIX.VARIABLE,
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
export const RESERVED_BLOCK_NAMES = [
|
||||||
|
REFERENCE.PREFIX.LOOP,
|
||||||
|
REFERENCE.PREFIX.PARALLEL,
|
||||||
|
REFERENCE.PREFIX.VARIABLE,
|
||||||
|
] as const
|
||||||
|
|
||||||
export const LOOP_REFERENCE = {
|
export const LOOP_REFERENCE = {
|
||||||
ITERATION: 'iteration',
|
ITERATION: 'iteration',
|
||||||
INDEX: 'index',
|
INDEX: 'index',
|
||||||
|
|||||||
@@ -24,6 +24,71 @@ function createBlock(id: string, metadataId: string): SerializedBlock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('DAGBuilder disabled subflow validation', () => {
|
||||||
|
it('skips validation for disabled loops with no blocks inside', () => {
|
||||||
|
const workflow: SerializedWorkflow = {
|
||||||
|
version: '1',
|
||||||
|
blocks: [
|
||||||
|
createBlock('start', BlockType.STARTER),
|
||||||
|
{ ...createBlock('loop-block', BlockType.FUNCTION), enabled: false },
|
||||||
|
],
|
||||||
|
connections: [],
|
||||||
|
loops: {
|
||||||
|
'loop-1': {
|
||||||
|
id: 'loop-1',
|
||||||
|
nodes: [], // Empty loop - would normally throw
|
||||||
|
iterations: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const builder = new DAGBuilder()
|
||||||
|
// Should not throw even though loop has no blocks inside
|
||||||
|
expect(() => builder.build(workflow)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips validation for disabled parallels with no blocks inside', () => {
|
||||||
|
const workflow: SerializedWorkflow = {
|
||||||
|
version: '1',
|
||||||
|
blocks: [createBlock('start', BlockType.STARTER)],
|
||||||
|
connections: [],
|
||||||
|
loops: {},
|
||||||
|
parallels: {
|
||||||
|
'parallel-1': {
|
||||||
|
id: 'parallel-1',
|
||||||
|
nodes: [], // Empty parallel - would normally throw
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const builder = new DAGBuilder()
|
||||||
|
// Should not throw even though parallel has no blocks inside
|
||||||
|
expect(() => builder.build(workflow)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips validation for loops where all inner blocks are disabled', () => {
|
||||||
|
const workflow: SerializedWorkflow = {
|
||||||
|
version: '1',
|
||||||
|
blocks: [
|
||||||
|
createBlock('start', BlockType.STARTER),
|
||||||
|
{ ...createBlock('inner-block', BlockType.FUNCTION), enabled: false },
|
||||||
|
],
|
||||||
|
connections: [],
|
||||||
|
loops: {
|
||||||
|
'loop-1': {
|
||||||
|
id: 'loop-1',
|
||||||
|
nodes: ['inner-block'], // Has node but it's disabled
|
||||||
|
iterations: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const builder = new DAGBuilder()
|
||||||
|
// Should not throw - loop is effectively disabled since all inner blocks are disabled
|
||||||
|
expect(() => builder.build(workflow)).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('DAGBuilder human-in-the-loop transformation', () => {
|
describe('DAGBuilder human-in-the-loop transformation', () => {
|
||||||
it('creates trigger nodes and rewires edges for pause blocks', () => {
|
it('creates trigger nodes and rewires edges for pause blocks', () => {
|
||||||
const workflow: SerializedWorkflow = {
|
const workflow: SerializedWorkflow = {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user