Compare commits

..

4 Commits

Author SHA1 Message Date
waleed
609a8a53b0 fix hasChanges logic 2026-01-19 16:39:10 -08:00
waleed
b46f760247 added new rate limit category, ack PR comments 2026-01-19 16:05:51 -08:00
waleed
a2c794a77e added ability to edit parameter and workflow descriptions 2026-01-19 15:45:30 -08:00
waleed
530a3292a3 feat(api): added workflows api route for dynamic discovery 2026-01-19 15:10:47 -08:00
11 changed files with 670 additions and 268 deletions

View File

@@ -19,7 +19,7 @@ export interface RateLimitResult {
export async function checkRateLimit(
request: NextRequest,
endpoint: 'logs' | 'logs-detail' = 'logs'
endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' = 'logs'
): Promise<RateLimitResult> {
try {
const auth = await authenticateV1Request(request)

View File

@@ -0,0 +1,102 @@
import { db } from '@sim/db'
import { permissions, workflow, workflowBlocks } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
const logger = createLogger('V1WorkflowDetailsAPI')
export const revalidate = 0
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const rateLimit = await checkRateLimit(request, 'workflow-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { id } = await params
logger.info(`[${requestId}] Fetching workflow details for ${id}`, { userId })
const rows = await db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
color: workflow.color,
folderId: workflow.folderId,
workspaceId: workflow.workspaceId,
isDeployed: workflow.isDeployed,
deployedAt: workflow.deployedAt,
runCount: workflow.runCount,
lastRunAt: workflow.lastRunAt,
variables: workflow.variables,
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
})
.from(workflow)
.innerJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId),
eq(permissions.userId, userId)
)
)
.where(eq(workflow.id, id))
.limit(1)
const workflowData = rows[0]
if (!workflowData) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const blockRows = await db
.select({
id: workflowBlocks.id,
type: workflowBlocks.type,
subBlocks: workflowBlocks.subBlocks,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, id))
const blocksRecord = Object.fromEntries(
blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }])
)
const inputs = extractInputFieldsFromBlocks(blocksRecord)
const response = {
id: workflowData.id,
name: workflowData.name,
description: workflowData.description,
color: workflowData.color,
folderId: workflowData.folderId,
workspaceId: workflowData.workspaceId,
isDeployed: workflowData.isDeployed,
deployedAt: workflowData.deployedAt?.toISOString() || null,
runCount: workflowData.runCount,
lastRunAt: workflowData.lastRunAt?.toISOString() || null,
variables: workflowData.variables || {},
inputs,
createdAt: workflowData.createdAt.toISOString(),
updatedAt: workflowData.updatedAt.toISOString(),
}
const limits = await getUserLimits(userId)
const apiResponse = createApiResponse({ data: response }, limits, rateLimit)
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Workflow details fetch error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,184 @@
import { db } from '@sim/db'
import { permissions, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, gt, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
const logger = createLogger('V1WorkflowsAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
const QueryParamsSchema = z.object({
workspaceId: z.string(),
folderId: z.string().optional(),
deployedOnly: z.coerce.boolean().optional().default(false),
limit: z.coerce.number().min(1).max(100).optional().default(50),
cursor: z.string().optional(),
})
interface CursorData {
sortOrder: number
createdAt: string
id: string
}
function encodeCursor(data: CursorData): string {
return Buffer.from(JSON.stringify(data)).toString('base64')
}
function decodeCursor(cursor: string): CursorData | null {
try {
return JSON.parse(Buffer.from(cursor, 'base64').toString())
} catch {
return null
}
}
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const rateLimit = await checkRateLimit(request, 'workflows')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { searchParams } = new URL(request.url)
const rawParams = Object.fromEntries(searchParams.entries())
const validationResult = QueryParamsSchema.safeParse(rawParams)
if (!validationResult.success) {
return NextResponse.json(
{ error: 'Invalid parameters', details: validationResult.error.errors },
{ status: 400 }
)
}
const params = validationResult.data
logger.info(`[${requestId}] Fetching workflows for workspace ${params.workspaceId}`, {
userId,
filters: {
folderId: params.folderId,
deployedOnly: params.deployedOnly,
},
})
const conditions = [
eq(workflow.workspaceId, params.workspaceId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, params.workspaceId),
eq(permissions.userId, userId),
]
if (params.folderId) {
conditions.push(eq(workflow.folderId, params.folderId))
}
if (params.deployedOnly) {
conditions.push(eq(workflow.isDeployed, true))
}
if (params.cursor) {
const cursorData = decodeCursor(params.cursor)
if (cursorData) {
const cursorCondition = or(
gt(workflow.sortOrder, cursorData.sortOrder),
and(
eq(workflow.sortOrder, cursorData.sortOrder),
gt(workflow.createdAt, new Date(cursorData.createdAt))
),
and(
eq(workflow.sortOrder, cursorData.sortOrder),
eq(workflow.createdAt, new Date(cursorData.createdAt)),
gt(workflow.id, cursorData.id)
)
)
if (cursorCondition) {
conditions.push(cursorCondition)
}
}
}
const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)]
const rows = await db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
color: workflow.color,
folderId: workflow.folderId,
workspaceId: workflow.workspaceId,
isDeployed: workflow.isDeployed,
deployedAt: workflow.deployedAt,
runCount: workflow.runCount,
lastRunAt: workflow.lastRunAt,
sortOrder: workflow.sortOrder,
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
})
.from(workflow)
.innerJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, params.workspaceId),
eq(permissions.userId, userId)
)
)
.where(and(...conditions))
.orderBy(...orderByClause)
.limit(params.limit + 1)
const hasMore = rows.length > params.limit
const data = rows.slice(0, params.limit)
let nextCursor: string | undefined
if (hasMore && data.length > 0) {
const lastWorkflow = data[data.length - 1]
nextCursor = encodeCursor({
sortOrder: lastWorkflow.sortOrder,
createdAt: lastWorkflow.createdAt.toISOString(),
id: lastWorkflow.id,
})
}
const formattedWorkflows = data.map((w) => ({
id: w.id,
name: w.name,
description: w.description,
color: w.color,
folderId: w.folderId,
workspaceId: w.workspaceId,
isDeployed: w.isDeployed,
deployedAt: w.deployedAt?.toISOString() || null,
runCount: w.runCount,
lastRunAt: w.lastRunAt?.toISOString() || null,
createdAt: w.createdAt.toISOString(),
updatedAt: w.updatedAt.toISOString(),
}))
const limits = await getUserLimits(userId)
const response = createApiResponse(
{
data: formattedWorkflows,
nextCursor,
},
limits,
rateLimit
)
return NextResponse.json(response.body, { headers: response.headers })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Workflows fetch error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -26,6 +26,9 @@ import { CLASS_TOOL_METADATA } from '@/stores/panel/copilot/store'
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Parse special tags from content
*/
/**
* Plan step can be either a string or an object with title and plan
*/
@@ -44,56 +47,6 @@ interface ParsedTags {
cleanContent: string
}
/**
* Extract plan steps from plan_respond tool calls in subagent blocks.
* Returns { steps, isComplete } where steps is in the format expected by PlanSteps component.
*/
function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
steps: Record<string, PlanStep> | undefined
isComplete: boolean
} {
if (!blocks) return { steps: undefined, isComplete: false }
// Find the plan_respond tool call
const planRespondBlock = blocks.find(
(b) => b.type === 'subagent_tool_call' && b.toolCall?.name === 'plan_respond'
)
if (!planRespondBlock?.toolCall) {
return { steps: undefined, isComplete: false }
}
// Tool call arguments can be in different places depending on the source
// Also handle nested data.arguments structure from the schema
const tc = planRespondBlock.toolCall as any
const args = tc.params || tc.parameters || tc.input || tc.arguments || tc.data?.arguments || {}
const stepsArray = args.steps
if (!Array.isArray(stepsArray) || stepsArray.length === 0) {
return { steps: undefined, isComplete: false }
}
// Convert array format to Record<string, PlanStep> format
// From: [{ number: 1, title: "..." }, { number: 2, title: "..." }]
// To: { "1": "...", "2": "..." }
const steps: Record<string, PlanStep> = {}
for (const step of stepsArray) {
if (step.number !== undefined && step.title) {
steps[String(step.number)] = step.title
}
}
// Check if the tool call is complete (not pending/executing)
const isComplete =
planRespondBlock.toolCall.state === ClientToolCallState.success ||
planRespondBlock.toolCall.state === ClientToolCallState.error
return {
steps: Object.keys(steps).length > 0 ? steps : undefined,
isComplete,
}
}
/**
* Try to parse partial JSON for streaming options.
* Attempts to extract complete key-value pairs from incomplete JSON.
@@ -701,20 +654,11 @@ function SubAgentThinkingContent({
}
}
// Extract plan from plan_respond tool call (preferred) or fall back to <plan> tags
const { steps: planSteps, isComplete: planComplete } = extractPlanFromBlocks(blocks)
const allParsed = parseSpecialTags(allRawText)
// Prefer plan_respond tool data over <plan> tags
const hasPlan =
!!(planSteps && Object.keys(planSteps).length > 0) ||
!!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
const planToRender = planSteps || allParsed.plan
const isPlanStreaming = planSteps ? !planComplete : isStreaming
if (!cleanText.trim() && !allParsed.plan) return null
if (!cleanText.trim() && !hasPlan) return null
const hasSpecialTags = hasPlan
const hasSpecialTags = !!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
return (
<div className='space-y-1.5'>
@@ -726,7 +670,9 @@ function SubAgentThinkingContent({
hasSpecialTags={hasSpecialTags}
/>
)}
{hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
{allParsed.plan && Object.keys(allParsed.plan).length > 0 && (
<PlanSteps steps={allParsed.plan} streaming={isStreaming} />
)}
</div>
)
}
@@ -798,19 +744,8 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
}
const allParsed = parseSpecialTags(allRawText)
// Extract plan from plan_respond tool call (preferred) or fall back to <plan> tags
const { steps: planSteps, isComplete: planComplete } = extractPlanFromBlocks(
toolCall.subAgentBlocks
)
const hasPlan =
!!(planSteps && Object.keys(planSteps).length > 0) ||
!!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
const planToRender = planSteps || allParsed.plan
const isPlanStreaming = planSteps ? !planComplete : isStreaming
const hasSpecialTags = !!(
hasPlan ||
(allParsed.plan && Object.keys(allParsed.plan).length > 0) ||
(allParsed.options && Object.keys(allParsed.options).length > 0)
)
@@ -822,6 +757,8 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
const outerLabel = getSubagentCompletionLabel(toolCall.name)
const durationText = `${outerLabel} for ${formatDuration(duration)}`
const hasPlan = allParsed.plan && Object.keys(allParsed.plan).length > 0
const renderCollapsibleContent = () => (
<>
{segments.map((segment, index) => {
@@ -863,7 +800,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
return (
<div className='w-full space-y-1.5'>
{renderCollapsibleContent()}
{hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
{hasPlan && <PlanSteps steps={allParsed.plan!} streaming={isStreaming} />}
</div>
)
}
@@ -895,7 +832,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
</div>
{/* Plan stays outside the collapsible */}
{hasPlan && planToRender && <PlanSteps steps={planToRender} />}
{hasPlan && <PlanSteps steps={allParsed.plan!} />}
</div>
)
})
@@ -1475,11 +1412,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
if (
toolCall.name === 'checkoff_todo' ||
toolCall.name === 'mark_todo_in_progress' ||
toolCall.name === 'tool_search_tool_regex' ||
toolCall.name === 'user_memory' ||
toolCall.name === 'edit_responsd' ||
toolCall.name === 'debug_respond' ||
toolCall.name === 'plan_respond'
toolCall.name === 'tool_search_tool_regex'
)
return null

View File

@@ -452,39 +452,6 @@ console.log(limits);`
</div>
)}
{/* <div>
<div className='mb-[6.5px] flex items-center justify-between'>
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
URL
</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => handleCopy('endpoint', info.endpoint)}
aria-label='Copy endpoint'
className='!p-1.5 -my-1.5'
>
{copied.endpoint ? (
<Check className='h-3 w-3' />
) : (
<Clipboard className='h-3 w-3' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{copied.endpoint ? 'Copied' : 'Copy'}</span>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Code.Viewer
code={info.endpoint}
language='javascript'
wrapText
className='!min-h-0 rounded-[4px] border border-[var(--border-1)]'
/>
</div> */}
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>

View File

@@ -0,0 +1,260 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
Button,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import type { InputFormatField } from '@/lib/workflows/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
type NormalizedField = InputFormatField & { name: string }
interface ApiInfoModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflowId: string
}
export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalProps) {
const blocks = useWorkflowStore((state) => state.blocks)
const setValue = useSubBlockStore((state) => state.setValue)
const subBlockValues = useSubBlockStore((state) =>
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
)
const workflowMetadata = useWorkflowRegistry((state) =>
workflowId ? state.workflows[workflowId] : undefined
)
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
const [description, setDescription] = useState('')
const [paramDescriptions, setParamDescriptions] = useState<Record<string, string>>({})
const [isSaving, setIsSaving] = useState(false)
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const initialDescriptionRef = useRef('')
const initialParamDescriptionsRef = useRef<Record<string, string>>({})
const starterBlockId = useMemo(() => {
for (const [blockId, block] of Object.entries(blocks)) {
if (!block || typeof block !== 'object') continue
const blockType = (block as { type?: string }).type
if (blockType && isValidStartBlockType(blockType)) {
return blockId
}
}
return null
}, [blocks])
const inputFormat = useMemo((): NormalizedField[] => {
if (!starterBlockId) return []
const storeValue = subBlockValues[starterBlockId]?.inputFormat
const normalized = normalizeInputFormatValue(storeValue) as NormalizedField[]
if (normalized.length > 0) return normalized
const startBlock = blocks[starterBlockId]
const blockValue = startBlock?.subBlocks?.inputFormat?.value
return normalizeInputFormatValue(blockValue) as NormalizedField[]
}, [starterBlockId, subBlockValues, blocks])
useEffect(() => {
if (open) {
const normalizedDesc = workflowMetadata?.description?.toLowerCase().trim()
const isDefaultDescription =
!workflowMetadata?.description ||
workflowMetadata.description === workflowMetadata.name ||
normalizedDesc === 'new workflow' ||
normalizedDesc === 'your first workflow - start building here!'
const initialDescription = isDefaultDescription ? '' : workflowMetadata?.description || ''
setDescription(initialDescription)
initialDescriptionRef.current = initialDescription
const descriptions: Record<string, string> = {}
for (const field of inputFormat) {
if (field.description) {
descriptions[field.name] = field.description
}
}
setParamDescriptions(descriptions)
initialParamDescriptionsRef.current = { ...descriptions }
}
}, [open, workflowMetadata, inputFormat])
const hasChanges = useMemo(() => {
if (description !== initialDescriptionRef.current) return true
for (const field of inputFormat) {
const currentValue = (paramDescriptions[field.name] || '').trim()
const initialValue = (initialParamDescriptionsRef.current[field.name] || '').trim()
if (currentValue !== initialValue) return true
}
return false
}, [description, paramDescriptions, inputFormat])
const handleParamDescriptionChange = (fieldName: string, value: string) => {
setParamDescriptions((prev) => ({
...prev,
[fieldName]: value,
}))
}
const handleCloseAttempt = useCallback(() => {
if (hasChanges && !isSaving) {
setShowUnsavedChangesAlert(true)
} else {
onOpenChange(false)
}
}, [hasChanges, isSaving, onOpenChange])
const handleDiscardChanges = useCallback(() => {
setShowUnsavedChangesAlert(false)
setDescription(initialDescriptionRef.current)
setParamDescriptions({ ...initialParamDescriptionsRef.current })
onOpenChange(false)
}, [onOpenChange])
const handleSave = useCallback(async () => {
if (!workflowId) return
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId !== workflowId) {
return
}
setIsSaving(true)
try {
if (description.trim() !== (workflowMetadata?.description || '')) {
updateWorkflow(workflowId, { description: description.trim() || 'New workflow' })
}
if (starterBlockId) {
const updatedValue = inputFormat.map((field) => ({
...field,
description: paramDescriptions[field.name]?.trim() || undefined,
}))
setValue(starterBlockId, 'inputFormat', updatedValue)
}
onOpenChange(false)
} finally {
setIsSaving(false)
}
}, [
workflowId,
description,
workflowMetadata,
updateWorkflow,
starterBlockId,
inputFormat,
paramDescriptions,
setValue,
onOpenChange,
])
return (
<>
<Modal open={open} onOpenChange={(openState) => !openState && handleCloseAttempt()}>
<ModalContent className='max-w-[480px]'>
<ModalHeader>
<span>Edit API Info</span>
</ModalHeader>
<ModalBody className='space-y-[12px]'>
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Description
</Label>
<Textarea
placeholder='Describe what this workflow API does...'
className='min-h-[80px] resize-none'
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{inputFormat.length > 0 && (
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Parameters ({inputFormat.length})
</Label>
<div className='flex flex-col gap-[8px]'>
{inputFormat.map((field) => (
<div
key={field.name}
className='overflow-hidden rounded-[4px] border border-[var(--border-1)]'
>
<div className='flex items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{field.name}
</span>
<Badge size='sm'>{field.type || 'string'}</Badge>
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Description</Label>
<Input
value={paramDescriptions[field.name] || ''}
onChange={(e) =>
handleParamDescriptionChange(field.name, e.target.value)
}
placeholder={`Enter description for ${field.name}`}
/>
</div>
</div>
</div>
))}
</div>
</div>
)}
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleCloseAttempt} disabled={isSaving}>
Cancel
</Button>
<Button variant='tertiary' onClick={handleSave} disabled={isSaving || !hasChanges}>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
<ModalContent className='max-w-[400px]'>
<ModalHeader>
<span>Unsaved Changes</span>
</ModalHeader>
<ModalBody>
<p className='text-[14px] text-[var(--text-secondary)]'>
You have unsaved changes. Are you sure you want to discard them?
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowUnsavedChangesAlert(false)}>
Keep Editing
</Button>
<Button variant='destructive' onClick={handleDiscardChanges}>
Discard Changes
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -43,6 +43,7 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { A2aDeploy } from './components/a2a/a2a'
import { ApiDeploy } from './components/api/api'
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
import { ApiInfoModal } from './components/general/components/api-info-modal'
import { GeneralDeploy } from './components/general/general'
import { McpDeploy } from './components/mcp/mcp'
import { TemplateDeploy } from './components/template/template'
@@ -110,6 +111,7 @@ export function DeployModal({
const [chatSuccess, setChatSuccess] = useState(false)
const [isCreateKeyModalOpen, setIsCreateKeyModalOpen] = useState(false)
const [isApiInfoModalOpen, setIsApiInfoModalOpen] = useState(false)
const userPermissions = useUserPermissionsContext()
const canManageWorkspaceKeys = userPermissions.canAdmin
const { config: permissionConfig } = usePermissionConfig()
@@ -389,11 +391,6 @@ export function DeployModal({
form?.requestSubmit()
}, [])
const handleA2aFormSubmit = useCallback(() => {
const form = document.getElementById('a2a-deploy-form') as HTMLFormElement
form?.requestSubmit()
}, [])
const handleA2aPublish = useCallback(() => {
const form = document.getElementById('a2a-deploy-form')
const publishTrigger = form?.querySelector('[data-a2a-publish-trigger]') as HTMLButtonElement
@@ -594,7 +591,11 @@ export function DeployModal({
)}
{activeTab === 'api' && (
<ModalFooter className='items-center justify-between'>
<div />
<div>
<Button variant='default' onClick={() => setIsApiInfoModalOpen(true)}>
Edit API Info
</Button>
</div>
<div className='flex items-center gap-2'>
<Button
variant='tertiary'
@@ -880,6 +881,14 @@ export function DeployModal({
canManageWorkspaceKeys={canManageWorkspaceKeys}
defaultKeyType={defaultKeyType}
/>
{workflowId && (
<ApiInfoModal
open={isApiInfoModalOpen}
onOpenChange={setIsApiInfoModalOpen}
workflowId={workflowId}
/>
)}
</>
)
}

View File

@@ -209,17 +209,13 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
}
}
// Convert byName (keyed by name) to record keyed by ID for the API
const variablesRecord: Record<string, any> = {}
for (const v of Object.values(byName)) {
variablesRecord[v.id] = v
}
const variablesArray = Object.values(byName)
// POST full variables record to persist
// POST full variables array to persist
const res = await fetch(`/api/workflows/${payload.workflowId}/variables`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variables: variablesRecord }),
body: JSON.stringify({ variables: variablesArray }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')

View File

@@ -817,8 +817,6 @@ function normalizeResponseFormat(value: any): string {
interface EdgeHandleValidationResult {
valid: boolean
error?: string
/** The normalized handle to use (e.g., simple 'if' normalized to 'condition-{uuid}') */
normalizedHandle?: string
}
/**
@@ -853,6 +851,13 @@ function validateSourceHandleForBlock(
}
case 'condition': {
if (!sourceHandle.startsWith(EDGE.CONDITION_PREFIX)) {
return {
valid: false,
error: `Invalid source handle "${sourceHandle}" for condition block. Must start with "${EDGE.CONDITION_PREFIX}"`,
}
}
const conditionsValue = sourceBlock?.subBlocks?.conditions?.value
if (!conditionsValue) {
return {
@@ -861,8 +866,6 @@ function validateSourceHandleForBlock(
}
}
// validateConditionHandle accepts simple format (if, else-if-0, else),
// legacy format (condition-{blockId}-if), and internal ID format (condition-{uuid})
return validateConditionHandle(sourceHandle, sourceBlock.id, conditionsValue)
}
@@ -876,6 +879,13 @@ function validateSourceHandleForBlock(
}
case 'router_v2': {
if (!sourceHandle.startsWith(EDGE.ROUTER_PREFIX)) {
return {
valid: false,
error: `Invalid source handle "${sourceHandle}" for router_v2 block. Must start with "${EDGE.ROUTER_PREFIX}"`,
}
}
const routesValue = sourceBlock?.subBlocks?.routes?.value
if (!routesValue) {
return {
@@ -884,8 +894,6 @@ function validateSourceHandleForBlock(
}
}
// validateRouterHandle accepts simple format (route-0, route-1),
// legacy format (router-{blockId}-route-1), and internal ID format (router-{uuid})
return validateRouterHandle(sourceHandle, sourceBlock.id, routesValue)
}
@@ -902,12 +910,7 @@ function validateSourceHandleForBlock(
/**
* Validates condition handle references a valid condition in the block.
* Accepts multiple formats:
* - Simple format: "if", "else-if-0", "else-if-1", "else"
* - Legacy semantic format: "condition-{blockId}-if", "condition-{blockId}-else-if"
* - Internal ID format: "condition-{conditionId}"
*
* Returns the normalized handle (condition-{conditionId}) for storage.
* Accepts both internal IDs (condition-blockId-if) and semantic keys (condition-blockId-else-if)
*/
function validateConditionHandle(
sourceHandle: string,
@@ -940,80 +943,48 @@ function validateConditionHandle(
}
}
// Build a map of all valid handle formats -> normalized handle (condition-{conditionId})
const handleToNormalized = new Map<string, string>()
const legacySemanticPrefix = `condition-${blockId}-`
let elseIfIndex = 0
const validHandles = new Set<string>()
const semanticPrefix = `condition-${blockId}-`
let elseIfCount = 0
for (const condition of conditions) {
if (!condition.id) continue
if (condition.id) {
validHandles.add(`condition-${condition.id}`)
}
const normalizedHandle = `condition-${condition.id}`
const title = condition.title?.toLowerCase()
// Always accept internal ID format
handleToNormalized.set(normalizedHandle, normalizedHandle)
if (title === 'if') {
// Simple format: "if"
handleToNormalized.set('if', normalizedHandle)
// Legacy format: "condition-{blockId}-if"
handleToNormalized.set(`${legacySemanticPrefix}if`, normalizedHandle)
validHandles.add(`${semanticPrefix}if`)
} else if (title === 'else if') {
// Simple format: "else-if-0", "else-if-1", etc. (0-indexed)
handleToNormalized.set(`else-if-${elseIfIndex}`, normalizedHandle)
// Legacy format: "condition-{blockId}-else-if" for first, "condition-{blockId}-else-if-2" for second
if (elseIfIndex === 0) {
handleToNormalized.set(`${legacySemanticPrefix}else-if`, normalizedHandle)
} else {
handleToNormalized.set(
`${legacySemanticPrefix}else-if-${elseIfIndex + 1}`,
normalizedHandle
)
}
elseIfIndex++
elseIfCount++
validHandles.add(
elseIfCount === 1 ? `${semanticPrefix}else-if` : `${semanticPrefix}else-if-${elseIfCount}`
)
} else if (title === 'else') {
// Simple format: "else"
handleToNormalized.set('else', normalizedHandle)
// Legacy format: "condition-{blockId}-else"
handleToNormalized.set(`${legacySemanticPrefix}else`, normalizedHandle)
validHandles.add(`${semanticPrefix}else`)
}
}
const normalizedHandle = handleToNormalized.get(sourceHandle)
if (normalizedHandle) {
return { valid: true, normalizedHandle }
if (validHandles.has(sourceHandle)) {
return { valid: true }
}
// Build list of valid simple format options for error message
const simpleOptions: string[] = []
elseIfIndex = 0
for (const condition of conditions) {
const title = condition.title?.toLowerCase()
if (title === 'if') {
simpleOptions.push('if')
} else if (title === 'else if') {
simpleOptions.push(`else-if-${elseIfIndex}`)
elseIfIndex++
} else if (title === 'else') {
simpleOptions.push('else')
}
const validOptions = Array.from(validHandles).slice(0, 5)
const moreCount = validHandles.size - validOptions.length
let validOptionsStr = validOptions.join(', ')
if (moreCount > 0) {
validOptionsStr += `, ... and ${moreCount} more`
}
return {
valid: false,
error: `Invalid condition handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`,
error: `Invalid condition handle "${sourceHandle}". Valid handles: ${validOptionsStr}`,
}
}
/**
* Validates router handle references a valid route in the block.
* Accepts multiple formats:
* - Simple format: "route-0", "route-1", "route-2" (0-indexed)
* - Legacy semantic format: "router-{blockId}-route-1" (1-indexed)
* - Internal ID format: "router-{routeId}"
*
* Returns the normalized handle (router-{routeId}) for storage.
* Accepts both internal IDs (router-{routeId}) and semantic keys (router-{blockId}-route-1)
*/
function validateRouterHandle(
sourceHandle: string,
@@ -1046,48 +1017,47 @@ function validateRouterHandle(
}
}
// Build a map of all valid handle formats -> normalized handle (router-{routeId})
const handleToNormalized = new Map<string, string>()
const legacySemanticPrefix = `router-${blockId}-`
const validHandles = new Set<string>()
const semanticPrefix = `router-${blockId}-`
for (let i = 0; i < routes.length; i++) {
const route = routes[i]
if (!route.id) continue
const normalizedHandle = `router-${route.id}`
// Accept internal ID format: router-{uuid}
if (route.id) {
validHandles.add(`router-${route.id}`)
}
// Always accept internal ID format: router-{uuid}
handleToNormalized.set(normalizedHandle, normalizedHandle)
// Simple format: route-0, route-1, etc. (0-indexed)
handleToNormalized.set(`route-${i}`, normalizedHandle)
// Legacy 1-indexed route number format: router-{blockId}-route-1
handleToNormalized.set(`${legacySemanticPrefix}route-${i + 1}`, normalizedHandle)
// Accept 1-indexed route number format: router-{blockId}-route-1, router-{blockId}-route-2, etc.
validHandles.add(`${semanticPrefix}route-${i + 1}`)
// Accept normalized title format: router-{blockId}-{normalized-title}
// Normalize: lowercase, replace spaces with dashes, remove special chars
if (route.title && typeof route.title === 'string') {
const normalizedTitle = route.title
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
if (normalizedTitle) {
handleToNormalized.set(`${legacySemanticPrefix}${normalizedTitle}`, normalizedHandle)
validHandles.add(`${semanticPrefix}${normalizedTitle}`)
}
}
}
const normalizedHandle = handleToNormalized.get(sourceHandle)
if (normalizedHandle) {
return { valid: true, normalizedHandle }
if (validHandles.has(sourceHandle)) {
return { valid: true }
}
// Build list of valid simple format options for error message
const simpleOptions = routes.map((_, i) => `route-${i}`)
const validOptions = Array.from(validHandles).slice(0, 5)
const moreCount = validHandles.size - validOptions.length
let validOptionsStr = validOptions.join(', ')
if (moreCount > 0) {
validOptionsStr += `, ... and ${moreCount} more`
}
return {
valid: false,
error: `Invalid router handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`,
error: `Invalid router handle "${sourceHandle}". Valid handles: ${validOptionsStr}`,
}
}
@@ -1202,13 +1172,10 @@ function createValidatedEdge(
return false
}
// Use normalized handle if available (e.g., 'if' -> 'condition-{uuid}')
const finalSourceHandle = sourceValidation.normalizedHandle || sourceHandle
modifiedState.edges.push({
id: crypto.randomUUID(),
source: sourceBlockId,
sourceHandle: finalSourceHandle,
sourceHandle,
target: targetBlockId,
targetHandle,
type: 'default',
@@ -1217,11 +1184,7 @@ function createValidatedEdge(
}
/**
* Adds connections as edges for a block.
* Supports multiple target formats:
* - String: "target-block-id"
* - Object: { block: "target-block-id", handle?: "custom-target-handle" }
* - Array of strings or objects
* Adds connections as edges for a block
*/
function addConnectionsAsEdges(
modifiedState: any,
@@ -1231,34 +1194,19 @@ function addConnectionsAsEdges(
skippedItems?: SkippedItem[]
): void {
Object.entries(connections).forEach(([sourceHandle, targets]) => {
if (targets === null) return
const addEdgeForTarget = (targetBlock: string, targetHandle?: string) => {
const targetArray = Array.isArray(targets) ? targets : [targets]
targetArray.forEach((targetId: string) => {
createValidatedEdge(
modifiedState,
blockId,
targetBlock,
targetId,
sourceHandle,
targetHandle || 'target',
'target',
'add_edge',
logger,
skippedItems
)
}
if (typeof targets === 'string') {
addEdgeForTarget(targets)
} else if (Array.isArray(targets)) {
targets.forEach((target: any) => {
if (typeof target === 'string') {
addEdgeForTarget(target)
} else if (target?.block) {
addEdgeForTarget(target.block, target.handle)
}
})
} else if (typeof targets === 'object' && targets?.block) {
addEdgeForTarget(targets.block, targets.handle)
}
})
})
}

View File

@@ -7,6 +7,7 @@ import type { InputFormatField } from '@/lib/workflows/types'
export interface WorkflowInputField {
name: string
type: string
description?: string
}
/**
@@ -37,7 +38,7 @@ export function extractInputFieldsFromBlocks(
if (Array.isArray(inputFormat)) {
return inputFormat
.filter(
(field: unknown): field is { name: string; type?: string } =>
(field: unknown): field is { name: string; type?: string; description?: string } =>
typeof field === 'object' &&
field !== null &&
'name' in field &&
@@ -47,6 +48,7 @@ export function extractInputFieldsFromBlocks(
.map((field) => ({
name: field.name,
type: field.type || 'string',
...(field.description && { description: field.description }),
}))
}
@@ -57,7 +59,7 @@ export function extractInputFieldsFromBlocks(
if (Array.isArray(legacyFormat)) {
return legacyFormat
.filter(
(field: unknown): field is { name: string; type?: string } =>
(field: unknown): field is { name: string; type?: string; description?: string } =>
typeof field === 'object' &&
field !== null &&
'name' in field &&
@@ -67,6 +69,7 @@ export function extractInputFieldsFromBlocks(
.map((field) => ({
name: field.name,
type: field.type || 'string',
...(field.description && { description: field.description }),
}))
}

View File

@@ -269,12 +269,11 @@ function sanitizeSubBlocks(
}
/**
* Convert internal condition handle (condition-{uuid}) to simple format (if, else-if-0, else)
* Uses 0-indexed numbering for else-if conditions
* Convert internal condition handle (condition-{uuid}) to semantic format (condition-{blockId}-if)
*/
function convertConditionHandleToSimple(
function convertConditionHandleToSemantic(
handle: string,
_blockId: string,
blockId: string,
block: BlockState
): string {
if (!handle.startsWith('condition-')) {
@@ -301,24 +300,27 @@ function convertConditionHandleToSimple(
return handle
}
// Find the condition by ID and generate simple handle
let elseIfIndex = 0
// Find the condition by ID and generate semantic handle
let elseIfCount = 0
for (const condition of conditions) {
const title = condition.title?.toLowerCase()
if (condition.id === conditionId) {
if (title === 'if') {
return 'if'
return `condition-${blockId}-if`
}
if (title === 'else if') {
return `else-if-${elseIfIndex}`
elseIfCount++
return elseIfCount === 1
? `condition-${blockId}-else-if`
: `condition-${blockId}-else-if-${elseIfCount}`
}
if (title === 'else') {
return 'else'
return `condition-${blockId}-else`
}
}
// Count else-ifs as we iterate (for index tracking)
// Count else-ifs as we iterate
if (title === 'else if') {
elseIfIndex++
elseIfCount++
}
}
@@ -327,10 +329,9 @@ function convertConditionHandleToSimple(
}
/**
* Convert internal router handle (router-{uuid}) to simple format (route-0, route-1)
* Uses 0-indexed numbering for routes
* Convert internal router handle (router-{uuid}) to semantic format (router-{blockId}-route-N)
*/
function convertRouterHandleToSimple(handle: string, _blockId: string, block: BlockState): string {
function convertRouterHandleToSemantic(handle: string, blockId: string, block: BlockState): string {
if (!handle.startsWith('router-')) {
return handle
}
@@ -355,10 +356,10 @@ function convertRouterHandleToSimple(handle: string, _blockId: string, block: Bl
return handle
}
// Find the route by ID and generate simple handle (0-indexed)
// Find the route by ID and generate semantic handle (1-indexed)
for (let i = 0; i < routes.length; i++) {
if (routes[i].id === routeId) {
return `route-${i}`
return `router-${blockId}-route-${i + 1}`
}
}
@@ -367,16 +368,15 @@ function convertRouterHandleToSimple(handle: string, _blockId: string, block: Bl
}
/**
* Convert source handle to simple format for condition and router blocks
* Outputs: if, else-if-0, else (for conditions) and route-0, route-1 (for routers)
* Convert source handle to semantic format for condition and router blocks
*/
function convertToSimpleHandle(handle: string, blockId: string, block: BlockState): string {
function convertToSemanticHandle(handle: string, blockId: string, block: BlockState): string {
if (handle.startsWith('condition-') && block.type === 'condition') {
return convertConditionHandleToSimple(handle, blockId, block)
return convertConditionHandleToSemantic(handle, blockId, block)
}
if (handle.startsWith('router-') && block.type === 'router_v2') {
return convertRouterHandleToSimple(handle, blockId, block)
return convertRouterHandleToSemantic(handle, blockId, block)
}
return handle
@@ -400,12 +400,12 @@ function extractConnectionsForBlock(
return undefined
}
// Group by source handle (converting to simple format)
// Group by source handle (converting to semantic format)
for (const edge of outgoingEdges) {
let handle = edge.sourceHandle || 'source'
// Convert internal UUID handles to simple format (if, else-if-0, route-0, etc.)
handle = convertToSimpleHandle(handle, blockId, block)
// Convert internal UUID handles to semantic format
handle = convertToSemanticHandle(handle, blockId, block)
if (!connections[handle]) {
connections[handle] = []