mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-28 16:27:55 -05:00
Compare commits
6 Commits
main
...
feat/descr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a1f3e87e7 | ||
|
|
8aaaefcd72 | ||
|
|
242710f4d7 | ||
|
|
43f7f3bc00 | ||
|
|
d4af644159 | ||
|
|
304cf717a4 |
File diff suppressed because one or more lines are too long
@@ -9,13 +9,24 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/
|
||||
|
||||
const logger = createLogger('WorkflowDeploymentVersionAPI')
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Name cannot be empty')
|
||||
.max(100, 'Name must be 100 characters or less'),
|
||||
})
|
||||
const patchBodySchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Name cannot be empty')
|
||||
.max(100, 'Name must be 100 characters or less')
|
||||
.optional(),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(500, 'Description must be 500 characters or less')
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.refine((data) => data.name !== undefined || data.description !== undefined, {
|
||||
message: 'At least one of name or description must be provided',
|
||||
})
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
@@ -88,33 +99,46 @@ export async function PATCH(
|
||||
return createErrorResponse(validation.error.errors[0]?.message || 'Invalid request body', 400)
|
||||
}
|
||||
|
||||
const { name } = validation.data
|
||||
const { name, description } = validation.data
|
||||
|
||||
const updateData: { name?: string; description?: string | null } = {}
|
||||
if (name !== undefined) {
|
||||
updateData.name = name
|
||||
}
|
||||
if (description !== undefined) {
|
||||
updateData.description = description
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(workflowDeploymentVersion)
|
||||
.set({ name })
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, id),
|
||||
eq(workflowDeploymentVersion.version, versionNum)
|
||||
)
|
||||
)
|
||||
.returning({ id: workflowDeploymentVersion.id, name: workflowDeploymentVersion.name })
|
||||
.returning({
|
||||
id: workflowDeploymentVersion.id,
|
||||
name: workflowDeploymentVersion.name,
|
||||
description: workflowDeploymentVersion.description,
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
return createErrorResponse('Deployment version not found', 404)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Renamed deployment version ${version} for workflow ${id} to "${name}"`
|
||||
)
|
||||
logger.info(`[${requestId}] Updated deployment version ${version} for workflow ${id}`, {
|
||||
name: updateData.name,
|
||||
description: updateData.description,
|
||||
})
|
||||
|
||||
return createSuccessResponse({ name: updated.name })
|
||||
return createSuccessResponse({ name: updated.name, description: updated.description })
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`[${requestId}] Error renaming deployment version ${version} for workflow ${id}`,
|
||||
`[${requestId}] Error updating deployment version ${version} for workflow ${id}`,
|
||||
error
|
||||
)
|
||||
return createErrorResponse(error.message || 'Failed to rename deployment version', 500)
|
||||
return createErrorResponse(error.message || 'Failed to update deployment version', 500)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
id: workflowDeploymentVersion.id,
|
||||
version: workflowDeploymentVersion.version,
|
||||
name: workflowDeploymentVersion.name,
|
||||
description: workflowDeploymentVersion.description,
|
||||
isActive: workflowDeploymentVersion.isActive,
|
||||
createdAt: workflowDeploymentVersion.createdAt,
|
||||
createdBy: workflowDeploymentVersion.createdBy,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import {
|
||||
useGenerateVersionDescription,
|
||||
useUpdateDeploymentVersion,
|
||||
} from '@/hooks/queries/deployments'
|
||||
|
||||
interface VersionDescriptionModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
workflowId: string
|
||||
version: number
|
||||
versionName: string
|
||||
currentDescription: string | null | undefined
|
||||
}
|
||||
|
||||
export function VersionDescriptionModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
workflowId,
|
||||
version,
|
||||
versionName,
|
||||
currentDescription,
|
||||
}: VersionDescriptionModalProps) {
|
||||
const initialDescriptionRef = useRef(currentDescription || '')
|
||||
const [description, setDescription] = useState(initialDescriptionRef.current)
|
||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||
|
||||
const updateMutation = useUpdateDeploymentVersion()
|
||||
const generateMutation = useGenerateVersionDescription()
|
||||
|
||||
const hasChanges = description.trim() !== initialDescriptionRef.current.trim()
|
||||
const isGenerating = generateMutation.isPending
|
||||
|
||||
const handleCloseAttempt = useCallback(() => {
|
||||
if (updateMutation.isPending || isGenerating) {
|
||||
return
|
||||
}
|
||||
if (hasChanges) {
|
||||
setShowUnsavedChangesAlert(true)
|
||||
} else {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}, [hasChanges, updateMutation.isPending, isGenerating, onOpenChange])
|
||||
|
||||
const handleDiscardChanges = useCallback(() => {
|
||||
setShowUnsavedChangesAlert(false)
|
||||
setDescription(initialDescriptionRef.current)
|
||||
onOpenChange(false)
|
||||
}, [onOpenChange])
|
||||
|
||||
const handleGenerateDescription = useCallback(() => {
|
||||
generateMutation.mutate({
|
||||
workflowId,
|
||||
version,
|
||||
onStreamChunk: (accumulated) => {
|
||||
setDescription(accumulated)
|
||||
},
|
||||
})
|
||||
}, [workflowId, version, generateMutation])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!workflowId) return
|
||||
|
||||
updateMutation.mutate(
|
||||
{
|
||||
workflowId,
|
||||
version,
|
||||
description: description.trim() || null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [workflowId, version, description, updateMutation, onOpenChange])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={(openState) => !openState && handleCloseAttempt()}>
|
||||
<ModalContent className='max-w-[480px]'>
|
||||
<ModalHeader>
|
||||
<span>Version Description</span>
|
||||
</ModalHeader>
|
||||
<ModalBody className='space-y-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
{currentDescription ? 'Edit the' : 'Add a'} description for{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{versionName}</span>
|
||||
</p>
|
||||
<Button
|
||||
variant='active'
|
||||
className='-my-1 h-5 px-2 py-0 text-[11px]'
|
||||
onClick={handleGenerateDescription}
|
||||
disabled={isGenerating || updateMutation.isPending}
|
||||
>
|
||||
{isGenerating ? 'Generating...' : 'Generate'}
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder='Describe the changes in this deployment version...'
|
||||
className='min-h-[120px] resize-none'
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={500}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<div className='flex items-center justify-between'>
|
||||
{(updateMutation.error || generateMutation.error) && (
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{updateMutation.error?.message || generateMutation.error?.message}
|
||||
</p>
|
||||
)}
|
||||
{!updateMutation.error && !generateMutation.error && <div />}
|
||||
<p className='text-[11px] text-[var(--text-tertiary)]'>{description.length}/500</p>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleCloseAttempt}
|
||||
disabled={updateMutation.isPending || isGenerating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending || isGenerating || !hasChanges}
|
||||
>
|
||||
{updateMutation.isPending ? '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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import clsx from 'clsx'
|
||||
import { MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
|
||||
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
||||
import { FileText, MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { formatDateTime } from '@/lib/core/utils/formatting'
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||
import { useUpdateDeploymentVersion } from '@/hooks/queries/deployments'
|
||||
import { VersionDescriptionModal } from './version-description-modal'
|
||||
|
||||
const logger = createLogger('Versions')
|
||||
|
||||
/** Shared styling constants aligned with terminal component */
|
||||
const HEADER_TEXT_CLASS = 'font-medium text-[var(--text-tertiary)] text-[12px]'
|
||||
const ROW_TEXT_CLASS = 'font-medium text-[var(--text-primary)] text-[12px]'
|
||||
const COLUMN_BASE_CLASS = 'flex-shrink-0'
|
||||
|
||||
/** Column width configuration */
|
||||
const COLUMN_WIDTHS = {
|
||||
VERSION: 'w-[180px]',
|
||||
DEPLOYED_BY: 'w-[140px]',
|
||||
TIMESTAMP: 'flex-1',
|
||||
ACTIONS: 'w-[32px]',
|
||||
ACTIONS: 'w-[56px]',
|
||||
} as const
|
||||
|
||||
interface VersionsProps {
|
||||
@@ -31,34 +36,6 @@ interface VersionsProps {
|
||||
onSelectVersion: (version: number | null) => void
|
||||
onPromoteToLive: (version: number) => void
|
||||
onLoadDeployment: (version: number) => void
|
||||
fetchVersions: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp into a readable string.
|
||||
* @param value - The date string or Date object to format
|
||||
* @returns Formatted string like "8:36 PM PT on Oct 11, 2025"
|
||||
*/
|
||||
const formatDate = (value: string | Date): string => {
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const timePart = date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
timeZoneName: 'short',
|
||||
})
|
||||
|
||||
const datePart = date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
return `${timePart} on ${datePart}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,14 +50,15 @@ export function Versions({
|
||||
onSelectVersion,
|
||||
onPromoteToLive,
|
||||
onLoadDeployment,
|
||||
fetchVersions,
|
||||
}: VersionsProps) {
|
||||
const [editingVersion, setEditingVersion] = useState<number | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [openDropdown, setOpenDropdown] = useState<number | null>(null)
|
||||
const [descriptionModalVersion, setDescriptionModalVersion] = useState<number | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const renameMutation = useUpdateDeploymentVersion()
|
||||
|
||||
useEffect(() => {
|
||||
if (editingVersion !== null && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
@@ -94,7 +72,8 @@ export function Versions({
|
||||
setEditValue(currentName || `v${version}`)
|
||||
}
|
||||
|
||||
const handleSaveRename = async (version: number) => {
|
||||
const handleSaveRename = (version: number) => {
|
||||
if (renameMutation.isPending) return
|
||||
if (!workflowId || !editValue.trim()) {
|
||||
setEditingVersion(null)
|
||||
return
|
||||
@@ -108,25 +87,21 @@ export function Versions({
|
||||
return
|
||||
}
|
||||
|
||||
setIsRenaming(true)
|
||||
try {
|
||||
const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: editValue.trim() }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
await fetchVersions()
|
||||
setEditingVersion(null)
|
||||
} else {
|
||||
logger.error('Failed to rename version')
|
||||
renameMutation.mutate(
|
||||
{
|
||||
workflowId,
|
||||
version,
|
||||
name: editValue.trim(),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditingVersion(null)
|
||||
},
|
||||
onError: () => {
|
||||
// Keep editing state open on error so user can retry
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error renaming version:', error)
|
||||
} finally {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleCancelRename = () => {
|
||||
@@ -149,6 +124,16 @@ export function Versions({
|
||||
onLoadDeployment(version)
|
||||
}
|
||||
|
||||
const handleOpenDescriptionModal = (version: number) => {
|
||||
setOpenDropdown(null)
|
||||
setDescriptionModalVersion(version)
|
||||
}
|
||||
|
||||
const descriptionModalVersionData =
|
||||
descriptionModalVersion !== null
|
||||
? versions.find((v) => v.version === descriptionModalVersion)
|
||||
: null
|
||||
|
||||
if (versionsLoading && versions.length === 0) {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-[4px] border border-[var(--border)]'>
|
||||
@@ -179,7 +164,14 @@ export function Versions({
|
||||
<div className={clsx(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
|
||||
<Skeleton className='h-[12px] w-[160px]' />
|
||||
</div>
|
||||
<div className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end')}>
|
||||
<div
|
||||
className={clsx(
|
||||
COLUMN_WIDTHS.ACTIONS,
|
||||
COLUMN_BASE_CLASS,
|
||||
'flex justify-end gap-[2px]'
|
||||
)}
|
||||
>
|
||||
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
|
||||
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,7 +249,7 @@ export function Versions({
|
||||
'text-[var(--text-primary)] focus:outline-none focus:ring-0'
|
||||
)}
|
||||
maxLength={100}
|
||||
disabled={isRenaming}
|
||||
disabled={renameMutation.isPending}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
@@ -289,14 +281,40 @@ export function Versions({
|
||||
<span
|
||||
className={clsx('block truncate text-[var(--text-tertiary)]', ROW_TEXT_CLASS)}
|
||||
>
|
||||
{formatDate(v.createdAt)}
|
||||
{formatDateTime(new Date(v.createdAt))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end')}
|
||||
className={clsx(
|
||||
COLUMN_WIDTHS.ACTIONS,
|
||||
COLUMN_BASE_CLASS,
|
||||
'flex items-center justify-end gap-[2px]'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className={clsx(
|
||||
'!p-1',
|
||||
!v.description &&
|
||||
'text-[var(--text-quaternary)] hover:text-[var(--text-tertiary)]'
|
||||
)}
|
||||
onClick={() => handleOpenDescriptionModal(v.version)}
|
||||
>
|
||||
<FileText className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[240px]'>
|
||||
{v.description ? (
|
||||
<p className='line-clamp-3 text-[12px]'>{v.description}</p>
|
||||
) : (
|
||||
<p className='text-[12px]'>Add description</p>
|
||||
)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Popover
|
||||
open={openDropdown === v.version}
|
||||
onOpenChange={(open) => setOpenDropdown(open ? v.version : null)}
|
||||
@@ -311,6 +329,10 @@ export function Versions({
|
||||
<Pencil className='h-3 w-3' />
|
||||
<span>Rename</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem onClick={() => handleOpenDescriptionModal(v.version)}>
|
||||
<FileText className='h-3 w-3' />
|
||||
<span>{v.description ? 'Edit description' : 'Add description'}</span>
|
||||
</PopoverItem>
|
||||
{!v.isActive && (
|
||||
<PopoverItem onClick={() => handlePromote(v.version)}>
|
||||
<RotateCcw className='h-3 w-3' />
|
||||
@@ -328,6 +350,20 @@ export function Versions({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{workflowId && descriptionModalVersionData && (
|
||||
<VersionDescriptionModal
|
||||
key={descriptionModalVersionData.version}
|
||||
open={descriptionModalVersion !== null}
|
||||
onOpenChange={(open) => !open && setDescriptionModalVersion(null)}
|
||||
workflowId={workflowId}
|
||||
version={descriptionModalVersionData.version}
|
||||
versionName={
|
||||
descriptionModalVersionData.name || `v${descriptionModalVersionData.version}`
|
||||
}
|
||||
currentDescription={descriptionModalVersionData.description}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ interface GeneralDeployProps {
|
||||
versionsLoading: boolean
|
||||
onPromoteToLive: (version: number) => Promise<void>
|
||||
onLoadDeploymentComplete: () => void
|
||||
fetchVersions: () => Promise<void>
|
||||
}
|
||||
|
||||
type PreviewMode = 'active' | 'selected'
|
||||
@@ -48,7 +47,6 @@ export function GeneralDeploy({
|
||||
versionsLoading,
|
||||
onPromoteToLive,
|
||||
onLoadDeploymentComplete,
|
||||
fetchVersions,
|
||||
}: GeneralDeployProps) {
|
||||
const [selectedVersion, setSelectedVersion] = useState<number | null>(null)
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>('active')
|
||||
@@ -229,7 +227,6 @@ export function GeneralDeploy({
|
||||
onSelectVersion={handleSelectVersion}
|
||||
onPromoteToLive={handlePromoteToLive}
|
||||
onLoadDeployment={handleLoadDeployment}
|
||||
fetchVersions={fetchVersions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,11 +135,9 @@ export function DeployModal({
|
||||
refetch: refetchDeploymentInfo,
|
||||
} = useDeploymentInfo(workflowId, { enabled: open && isDeployed })
|
||||
|
||||
const {
|
||||
data: versionsData,
|
||||
isLoading: versionsLoading,
|
||||
refetch: refetchVersions,
|
||||
} = useDeploymentVersions(workflowId, { enabled: open })
|
||||
const { data: versionsData, isLoading: versionsLoading } = useDeploymentVersions(workflowId, {
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const {
|
||||
isLoading: isLoadingChat,
|
||||
@@ -450,10 +448,6 @@ export function DeployModal({
|
||||
deleteTrigger?.click()
|
||||
}, [])
|
||||
|
||||
const handleFetchVersions = useCallback(async () => {
|
||||
await refetchVersions()
|
||||
}, [refetchVersions])
|
||||
|
||||
const isSubmitting = deployMutation.isPending
|
||||
const isUndeploying = undeployMutation.isPending
|
||||
|
||||
@@ -512,7 +506,6 @@ export function DeployModal({
|
||||
versionsLoading={versionsLoading}
|
||||
onPromoteToLive={handlePromoteToLive}
|
||||
onLoadDeploymentComplete={handleCloseModal}
|
||||
fetchVersions={handleFetchVersions}
|
||||
/>
|
||||
</ModalTabsContent>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,241 +0,0 @@
|
||||
/**
|
||||
* Search utility functions for tiered matching algorithm
|
||||
* Provides predictable search results prioritizing exact matches over fuzzy matches
|
||||
*/
|
||||
|
||||
export interface SearchableItem {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
type: string
|
||||
aliases?: string[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface SearchResult<T extends SearchableItem> {
|
||||
item: T
|
||||
score: number
|
||||
matchType: 'exact' | 'prefix' | 'alias' | 'word-boundary' | 'substring' | 'description'
|
||||
}
|
||||
|
||||
const SCORE_EXACT_MATCH = 10000
|
||||
const SCORE_PREFIX_MATCH = 5000
|
||||
const SCORE_ALIAS_MATCH = 3000
|
||||
const SCORE_WORD_BOUNDARY = 1000
|
||||
const SCORE_SUBSTRING_MATCH = 100
|
||||
const DESCRIPTION_WEIGHT = 0.3
|
||||
|
||||
/**
|
||||
* Calculate match score for a single field
|
||||
* Returns 0 if no match found
|
||||
*/
|
||||
function calculateFieldScore(
|
||||
query: string,
|
||||
field: string
|
||||
): {
|
||||
score: number
|
||||
matchType: 'exact' | 'prefix' | 'word-boundary' | 'substring' | null
|
||||
} {
|
||||
const normalizedQuery = query.toLowerCase().trim()
|
||||
const normalizedField = field.toLowerCase().trim()
|
||||
|
||||
if (!normalizedQuery || !normalizedField) {
|
||||
return { score: 0, matchType: null }
|
||||
}
|
||||
|
||||
// Tier 1: Exact match
|
||||
if (normalizedField === normalizedQuery) {
|
||||
return { score: SCORE_EXACT_MATCH, matchType: 'exact' }
|
||||
}
|
||||
|
||||
// Tier 2: Prefix match (starts with query)
|
||||
if (normalizedField.startsWith(normalizedQuery)) {
|
||||
return { score: SCORE_PREFIX_MATCH, matchType: 'prefix' }
|
||||
}
|
||||
|
||||
// Tier 3: Word boundary match (query matches start of a word)
|
||||
const words = normalizedField.split(/[\s-_/]+/)
|
||||
const hasWordBoundaryMatch = words.some((word) => word.startsWith(normalizedQuery))
|
||||
if (hasWordBoundaryMatch) {
|
||||
return { score: SCORE_WORD_BOUNDARY, matchType: 'word-boundary' }
|
||||
}
|
||||
|
||||
// Tier 4: Substring match (query appears anywhere)
|
||||
if (normalizedField.includes(normalizedQuery)) {
|
||||
return { score: SCORE_SUBSTRING_MATCH, matchType: 'substring' }
|
||||
}
|
||||
|
||||
// No match
|
||||
return { score: 0, matchType: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if query matches any alias in the item's aliases array
|
||||
* Returns the alias score if a match is found, 0 otherwise
|
||||
*/
|
||||
function calculateAliasScore(
|
||||
query: string,
|
||||
aliases?: string[]
|
||||
): { score: number; matchType: 'alias' | null } {
|
||||
if (!aliases || aliases.length === 0) {
|
||||
return { score: 0, matchType: null }
|
||||
}
|
||||
|
||||
const normalizedQuery = query.toLowerCase().trim()
|
||||
|
||||
for (const alias of aliases) {
|
||||
const normalizedAlias = alias.toLowerCase().trim()
|
||||
|
||||
if (normalizedAlias === normalizedQuery) {
|
||||
return { score: SCORE_ALIAS_MATCH, matchType: 'alias' }
|
||||
}
|
||||
|
||||
if (normalizedAlias.startsWith(normalizedQuery)) {
|
||||
return { score: SCORE_ALIAS_MATCH * 0.8, matchType: 'alias' }
|
||||
}
|
||||
|
||||
if (normalizedQuery.includes(normalizedAlias) || normalizedAlias.includes(normalizedQuery)) {
|
||||
return { score: SCORE_ALIAS_MATCH * 0.6, matchType: 'alias' }
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
* Returns items sorted by relevance (highest score first)
|
||||
*/
|
||||
export function searchItems<T extends SearchableItem>(
|
||||
query: string,
|
||||
items: T[]
|
||||
): SearchResult<T>[] {
|
||||
const normalizedQuery = query.trim()
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return []
|
||||
}
|
||||
|
||||
const results: SearchResult<T>[] = []
|
||||
const queryWords = normalizedQuery.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
const isMultiWord = queryWords.length > 1
|
||||
|
||||
for (const item of items) {
|
||||
const nameMatch = calculateFieldScore(normalizedQuery, item.name)
|
||||
|
||||
const descMatch = item.description
|
||||
? calculateFieldScore(normalizedQuery, item.description)
|
||||
: { score: 0, matchType: null }
|
||||
|
||||
const aliasMatch = calculateAliasScore(normalizedQuery, item.aliases)
|
||||
|
||||
let nameScore = nameMatch.score
|
||||
let descScore = descMatch.score * DESCRIPTION_WEIGHT
|
||||
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)
|
||||
|
||||
if (bestScore > 0) {
|
||||
let matchType: SearchResult<T>['matchType'] = 'substring'
|
||||
if (nameScore >= descScore && nameScore >= aliasScore) {
|
||||
matchType = bestMatchType || 'substring'
|
||||
} else if (aliasScore >= descScore) {
|
||||
matchType = 'alias'
|
||||
} else {
|
||||
matchType = 'description'
|
||||
}
|
||||
|
||||
results.push({
|
||||
item,
|
||||
score: bestScore,
|
||||
matchType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.score - a.score)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable match type label
|
||||
*/
|
||||
export function getMatchTypeLabel(matchType: SearchResult<any>['matchType']): string {
|
||||
switch (matchType) {
|
||||
case 'exact':
|
||||
return 'Exact match'
|
||||
case 'prefix':
|
||||
return 'Starts with'
|
||||
case 'alias':
|
||||
return 'Similar to'
|
||||
case 'word-boundary':
|
||||
return 'Word match'
|
||||
case 'substring':
|
||||
return 'Contains'
|
||||
case 'description':
|
||||
return 'In description'
|
||||
default:
|
||||
return 'Match'
|
||||
}
|
||||
}
|
||||
@@ -176,7 +176,7 @@ function FormattedInput({
|
||||
onChange,
|
||||
onScroll,
|
||||
}: FormattedInputProps) {
|
||||
const handleScroll = (e: React.UIEvent<HTMLInputElement>) => {
|
||||
const handleScroll = (e: { currentTarget: HTMLInputElement }) => {
|
||||
onScroll(e.currentTarget.scrollLeft)
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,12 @@ export const Sidebar = memo(function Sidebar() {
|
||||
|
||||
const { data: sessionData, isPending: sessionLoading } = useSession()
|
||||
const { canEdit } = useUserPermissionsContext()
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
const { config: permissionConfig, filterBlocks } = usePermissionConfig()
|
||||
const initializeSearchData = useSearchModalStore((state) => state.initializeData)
|
||||
|
||||
useEffect(() => {
|
||||
initializeSearchData(filterBlocks)
|
||||
}, [initializeSearchData, filterBlocks])
|
||||
|
||||
/**
|
||||
* Sidebar state from store with hydration tracking to prevent SSR mismatch.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -348,6 +348,210 @@ export function useUndeployWorkflow() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables for update deployment version mutation
|
||||
*/
|
||||
interface UpdateDeploymentVersionVariables {
|
||||
workflowId: string
|
||||
version: number
|
||||
name?: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from update deployment version mutation
|
||||
*/
|
||||
interface UpdateDeploymentVersionResult {
|
||||
name: string | null
|
||||
description: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation hook for updating a deployment version's name or description.
|
||||
* Invalidates versions query on success.
|
||||
*/
|
||||
export function useUpdateDeploymentVersion() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workflowId,
|
||||
version,
|
||||
name,
|
||||
description,
|
||||
}: UpdateDeploymentVersionVariables): Promise<UpdateDeploymentVersionResult> => {
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name, description }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to update deployment version')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
logger.info('Deployment version updated', {
|
||||
workflowId: variables.workflowId,
|
||||
version: variables.version,
|
||||
})
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.versions(variables.workflowId),
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to update deployment version', { error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables for generating a version description
|
||||
*/
|
||||
interface GenerateVersionDescriptionVariables {
|
||||
workflowId: string
|
||||
version: number
|
||||
onStreamChunk?: (accumulated: string) => void
|
||||
}
|
||||
|
||||
const VERSION_DESCRIPTION_SYSTEM_PROMPT = `You are a technical writer generating concise deployment version descriptions.
|
||||
|
||||
Given a diff of changes between two workflow versions, write a brief, factual description (1-2 sentences, under 300 characters) that states ONLY what changed.
|
||||
|
||||
RULES:
|
||||
- State specific values when provided (e.g. "model changed from X to Y")
|
||||
- Do NOT wrap your response in quotes
|
||||
- Do NOT add filler phrases like "streamlining the workflow", "for improved efficiency"
|
||||
- Do NOT use markdown formatting
|
||||
- Do NOT include version numbers
|
||||
- Do NOT start with "This version" or similar phrases
|
||||
|
||||
Good examples:
|
||||
- Changes model in Agent 1 from gpt-4o to claude-sonnet-4-20250514.
|
||||
- Adds Slack notification block. Updates webhook URL to production endpoint.
|
||||
- Removes Function block and its connection to Router.
|
||||
|
||||
Bad examples:
|
||||
- "Changes model..." (NO - don't wrap in quotes)
|
||||
- Changes model, streamlining the workflow. (NO - don't add filler)
|
||||
|
||||
Respond with ONLY the plain text description.`
|
||||
|
||||
/**
|
||||
* Hook for generating a version description using AI based on workflow diff
|
||||
*/
|
||||
export function useGenerateVersionDescription() {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workflowId,
|
||||
version,
|
||||
onStreamChunk,
|
||||
}: GenerateVersionDescriptionVariables): Promise<string> => {
|
||||
const { generateWorkflowDiffSummary, formatDiffSummaryForDescription } = await import(
|
||||
'@/lib/workflows/comparison/compare'
|
||||
)
|
||||
|
||||
const currentResponse = await fetch(`/api/workflows/${workflowId}/deployments/${version}`)
|
||||
if (!currentResponse.ok) {
|
||||
throw new Error('Failed to fetch current version state')
|
||||
}
|
||||
const currentData = await currentResponse.json()
|
||||
const currentState = currentData.deployedState
|
||||
|
||||
let previousState = null
|
||||
if (version > 1) {
|
||||
const previousResponse = await fetch(
|
||||
`/api/workflows/${workflowId}/deployments/${version - 1}`
|
||||
)
|
||||
if (previousResponse.ok) {
|
||||
const previousData = await previousResponse.json()
|
||||
previousState = previousData.deployedState
|
||||
}
|
||||
}
|
||||
|
||||
const diffSummary = generateWorkflowDiffSummary(currentState, previousState)
|
||||
const diffText = formatDiffSummaryForDescription(diffSummary)
|
||||
|
||||
const wandResponse = await fetch('/api/wand', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: `Generate a deployment version description based on these changes:\n\n${diffText}`,
|
||||
systemPrompt: VERSION_DESCRIPTION_SYSTEM_PROMPT,
|
||||
stream: true,
|
||||
workflowId,
|
||||
}),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!wandResponse.ok) {
|
||||
const errorText = await wandResponse.text()
|
||||
throw new Error(errorText || 'Failed to generate description')
|
||||
}
|
||||
|
||||
if (!wandResponse.body) {
|
||||
throw new Error('Response body is null')
|
||||
}
|
||||
|
||||
const reader = wandResponse.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let accumulatedContent = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value)
|
||||
const lines = chunk.split('\n\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const lineData = line.substring(6)
|
||||
if (lineData === '[DONE]') continue
|
||||
|
||||
try {
|
||||
const data = JSON.parse(lineData)
|
||||
if (data.error) throw new Error(data.error)
|
||||
if (data.chunk) {
|
||||
accumulatedContent += data.chunk
|
||||
onStreamChunk?.(accumulatedContent)
|
||||
}
|
||||
if (data.done) break
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
if (!accumulatedContent) {
|
||||
throw new Error('Failed to generate description')
|
||||
}
|
||||
|
||||
return accumulatedContent.trim()
|
||||
},
|
||||
onSuccess: (content) => {
|
||||
logger.info('Generated version description', { length: content.length })
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to generate version description', { error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables for activate version mutation
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
} from '@sim/testing'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { hasWorkflowChanged } from './compare'
|
||||
import {
|
||||
formatDiffSummaryForDescription,
|
||||
generateWorkflowDiffSummary,
|
||||
hasWorkflowChanged,
|
||||
} from './compare'
|
||||
|
||||
/**
|
||||
* Type helper for converting test workflow state to app workflow state.
|
||||
@@ -2735,3 +2739,299 @@ describe('hasWorkflowChanged', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateWorkflowDiffSummary', () => {
|
||||
describe('Basic Cases', () => {
|
||||
it.concurrent('should return hasChanges=true when previousState is null', () => {
|
||||
const currentState = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1') },
|
||||
})
|
||||
const result = generateWorkflowDiffSummary(currentState, null)
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.addedBlocks).toHaveLength(1)
|
||||
expect(result.addedBlocks[0].id).toBe('block1')
|
||||
})
|
||||
|
||||
it.concurrent('should return hasChanges=false for identical states', () => {
|
||||
const state = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1') },
|
||||
})
|
||||
const result = generateWorkflowDiffSummary(state, state)
|
||||
expect(result.hasChanges).toBe(false)
|
||||
expect(result.addedBlocks).toHaveLength(0)
|
||||
expect(result.removedBlocks).toHaveLength(0)
|
||||
expect(result.modifiedBlocks).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Block Changes', () => {
|
||||
it.concurrent('should detect added blocks', () => {
|
||||
const previousState = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1') },
|
||||
})
|
||||
const currentState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
block2: createBlock('block2'),
|
||||
},
|
||||
})
|
||||
const result = generateWorkflowDiffSummary(currentState, previousState)
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.addedBlocks).toHaveLength(1)
|
||||
expect(result.addedBlocks[0].id).toBe('block2')
|
||||
})
|
||||
|
||||
it.concurrent('should detect removed blocks', () => {
|
||||
const previousState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
block2: createBlock('block2'),
|
||||
},
|
||||
})
|
||||
const currentState = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1') },
|
||||
})
|
||||
const result = generateWorkflowDiffSummary(currentState, previousState)
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.removedBlocks).toHaveLength(1)
|
||||
expect(result.removedBlocks[0].id).toBe('block2')
|
||||
})
|
||||
|
||||
it.concurrent('should detect modified blocks with field changes', () => {
|
||||
const previousState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1', {
|
||||
subBlocks: { model: { id: 'model', type: 'dropdown', value: 'gpt-4o' } },
|
||||
}),
|
||||
},
|
||||
})
|
||||
const currentState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1', {
|
||||
subBlocks: { model: { id: 'model', type: 'dropdown', value: 'claude-sonnet' } },
|
||||
}),
|
||||
},
|
||||
})
|
||||
const result = generateWorkflowDiffSummary(currentState, previousState)
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.modifiedBlocks).toHaveLength(1)
|
||||
expect(result.modifiedBlocks[0].id).toBe('block1')
|
||||
expect(result.modifiedBlocks[0].changes.length).toBeGreaterThan(0)
|
||||
const modelChange = result.modifiedBlocks[0].changes.find((c) => c.field === 'model')
|
||||
expect(modelChange).toBeDefined()
|
||||
expect(modelChange?.oldValue).toBe('gpt-4o')
|
||||
expect(modelChange?.newValue).toBe('claude-sonnet')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Changes', () => {
|
||||
it.concurrent('should detect added edges', () => {
|
||||
const previousState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
block2: createBlock('block2'),
|
||||
},
|
||||
edges: [],
|
||||
})
|
||||
const currentState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
block2: createBlock('block2'),
|
||||
},
|
||||
edges: [{ id: 'e1', source: 'block1', target: 'block2' }],
|
||||
})
|
||||
const result = generateWorkflowDiffSummary(currentState, previousState)
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.edgeChanges.added).toBe(1)
|
||||
expect(result.edgeChanges.removed).toBe(0)
|
||||
})
|
||||
|
||||
it.concurrent('should detect removed edges', () => {
|
||||
const previousState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
block2: createBlock('block2'),
|
||||
},
|
||||
edges: [{ id: 'e1', source: 'block1', target: 'block2' }],
|
||||
})
|
||||
const currentState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
block2: createBlock('block2'),
|
||||
},
|
||||
edges: [],
|
||||
})
|
||||
const result = generateWorkflowDiffSummary(currentState, previousState)
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.edgeChanges.added).toBe(0)
|
||||
expect(result.edgeChanges.removed).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Variable Changes', () => {
|
||||
it.concurrent('should detect added variables', () => {
|
||||
const previousState = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: {},
|
||||
})
|
||||
const currentState = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: { var1: { id: 'var1', name: 'test', type: 'string', value: 'hello' } },
|
||||
})
|
||||
const result = generateWorkflowDiffSummary(currentState, previousState)
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.variableChanges.added).toBe(1)
|
||||
})
|
||||
|
||||
it.concurrent('should detect modified variables', () => {
|
||||
const previousState = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: { var1: { id: 'var1', name: 'test', type: 'string', value: 'hello' } },
|
||||
})
|
||||
const currentState = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: { var1: { id: 'var1', name: 'test', type: 'string', value: 'world' } },
|
||||
})
|
||||
const result = generateWorkflowDiffSummary(currentState, previousState)
|
||||
expect(result.hasChanges).toBe(true)
|
||||
expect(result.variableChanges.modified).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Consistency with hasWorkflowChanged', () => {
|
||||
it.concurrent('hasChanges should match hasWorkflowChanged result', () => {
|
||||
const state1 = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1') },
|
||||
})
|
||||
const state2 = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1', {
|
||||
subBlocks: { prompt: { id: 'prompt', type: 'long-input', value: 'new value' } },
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const diffResult = generateWorkflowDiffSummary(state2, state1)
|
||||
const hasChangedResult = hasWorkflowChanged(state2, state1)
|
||||
|
||||
expect(diffResult.hasChanges).toBe(hasChangedResult)
|
||||
})
|
||||
|
||||
it.concurrent('should return same result as hasWorkflowChanged for no changes', () => {
|
||||
const state = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1') },
|
||||
})
|
||||
|
||||
const diffResult = generateWorkflowDiffSummary(state, state)
|
||||
const hasChangedResult = hasWorkflowChanged(state, state)
|
||||
|
||||
expect(diffResult.hasChanges).toBe(hasChangedResult)
|
||||
expect(diffResult.hasChanges).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDiffSummaryForDescription', () => {
|
||||
it.concurrent('should return no changes message when hasChanges is false', () => {
|
||||
const summary = {
|
||||
addedBlocks: [],
|
||||
removedBlocks: [],
|
||||
modifiedBlocks: [],
|
||||
edgeChanges: { added: 0, removed: 0 },
|
||||
loopChanges: { added: 0, removed: 0 },
|
||||
parallelChanges: { added: 0, removed: 0 },
|
||||
variableChanges: { added: 0, removed: 0, modified: 0 },
|
||||
hasChanges: false,
|
||||
}
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
expect(result).toContain('No structural changes')
|
||||
})
|
||||
|
||||
it.concurrent('should format added blocks', () => {
|
||||
const summary = {
|
||||
addedBlocks: [{ id: 'block1', type: 'agent', name: 'My Agent' }],
|
||||
removedBlocks: [],
|
||||
modifiedBlocks: [],
|
||||
edgeChanges: { added: 0, removed: 0 },
|
||||
loopChanges: { added: 0, removed: 0 },
|
||||
parallelChanges: { added: 0, removed: 0 },
|
||||
variableChanges: { added: 0, removed: 0, modified: 0 },
|
||||
hasChanges: true,
|
||||
}
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
expect(result).toContain('Added block: My Agent (agent)')
|
||||
})
|
||||
|
||||
it.concurrent('should format removed blocks', () => {
|
||||
const summary = {
|
||||
addedBlocks: [],
|
||||
removedBlocks: [{ id: 'block1', type: 'function', name: 'Old Function' }],
|
||||
modifiedBlocks: [],
|
||||
edgeChanges: { added: 0, removed: 0 },
|
||||
loopChanges: { added: 0, removed: 0 },
|
||||
parallelChanges: { added: 0, removed: 0 },
|
||||
variableChanges: { added: 0, removed: 0, modified: 0 },
|
||||
hasChanges: true,
|
||||
}
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
expect(result).toContain('Removed block: Old Function (function)')
|
||||
})
|
||||
|
||||
it.concurrent('should format modified blocks with field changes', () => {
|
||||
const summary = {
|
||||
addedBlocks: [],
|
||||
removedBlocks: [],
|
||||
modifiedBlocks: [
|
||||
{
|
||||
id: 'block1',
|
||||
type: 'agent',
|
||||
name: 'Agent 1',
|
||||
changes: [{ field: 'model', oldValue: 'gpt-4o', newValue: 'claude-sonnet' }],
|
||||
},
|
||||
],
|
||||
edgeChanges: { added: 0, removed: 0 },
|
||||
loopChanges: { added: 0, removed: 0 },
|
||||
parallelChanges: { added: 0, removed: 0 },
|
||||
variableChanges: { added: 0, removed: 0, modified: 0 },
|
||||
hasChanges: true,
|
||||
}
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
expect(result).toContain('Modified Agent 1')
|
||||
expect(result).toContain('model')
|
||||
expect(result).toContain('gpt-4o')
|
||||
expect(result).toContain('claude-sonnet')
|
||||
})
|
||||
|
||||
it.concurrent('should format edge changes', () => {
|
||||
const summary = {
|
||||
addedBlocks: [],
|
||||
removedBlocks: [],
|
||||
modifiedBlocks: [],
|
||||
edgeChanges: { added: 2, removed: 1 },
|
||||
loopChanges: { added: 0, removed: 0 },
|
||||
parallelChanges: { added: 0, removed: 0 },
|
||||
variableChanges: { added: 0, removed: 0, modified: 0 },
|
||||
hasChanges: true,
|
||||
}
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
expect(result).toContain('Added 2 connection(s)')
|
||||
expect(result).toContain('Removed 1 connection(s)')
|
||||
})
|
||||
|
||||
it.concurrent('should format variable changes', () => {
|
||||
const summary = {
|
||||
addedBlocks: [],
|
||||
removedBlocks: [],
|
||||
modifiedBlocks: [],
|
||||
edgeChanges: { added: 0, removed: 0 },
|
||||
loopChanges: { added: 0, removed: 0 },
|
||||
parallelChanges: { added: 0, removed: 0 },
|
||||
variableChanges: { added: 1, removed: 0, modified: 2 },
|
||||
hasChanges: true,
|
||||
}
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
expect(result).toContain('Variables:')
|
||||
expect(result).toContain('1 added')
|
||||
expect(result).toContain('2 modified')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
sanitizeInputFormat,
|
||||
sanitizeTools,
|
||||
sanitizeVariable,
|
||||
sortEdges,
|
||||
} from './normalize'
|
||||
|
||||
/** Block with optional diff markers added by copilot */
|
||||
@@ -28,60 +27,109 @@ type SubBlockWithDiffMarker = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the current workflow state with the deployed state to detect meaningful changes
|
||||
* @param currentState - The current workflow state
|
||||
* @param deployedState - The deployed workflow state
|
||||
* @returns True if there are meaningful changes, false if only position changes or no changes
|
||||
* Compare the current workflow state with the deployed state to detect meaningful changes.
|
||||
* Uses generateWorkflowDiffSummary internally to ensure consistent change detection.
|
||||
*/
|
||||
export function hasWorkflowChanged(
|
||||
currentState: WorkflowState,
|
||||
deployedState: WorkflowState | null
|
||||
): boolean {
|
||||
// If no deployed state exists, then the workflow has changed
|
||||
if (!deployedState) return true
|
||||
return generateWorkflowDiffSummary(currentState, deployedState).hasChanges
|
||||
}
|
||||
|
||||
// 1. Compare edges (connections between blocks)
|
||||
const currentEdges = currentState.edges || []
|
||||
const deployedEdges = deployedState.edges || []
|
||||
/**
|
||||
* Represents a single field change with old and new values
|
||||
*/
|
||||
export interface FieldChange {
|
||||
field: string
|
||||
oldValue: unknown
|
||||
newValue: unknown
|
||||
}
|
||||
|
||||
const normalizedCurrentEdges = sortEdges(currentEdges.map(normalizeEdge))
|
||||
const normalizedDeployedEdges = sortEdges(deployedEdges.map(normalizeEdge))
|
||||
/**
|
||||
* Result of workflow diff analysis between two workflow states
|
||||
*/
|
||||
export interface WorkflowDiffSummary {
|
||||
addedBlocks: Array<{ id: string; type: string; name?: string }>
|
||||
removedBlocks: Array<{ id: string; type: string; name?: string }>
|
||||
modifiedBlocks: Array<{ id: string; type: string; name?: string; changes: FieldChange[] }>
|
||||
edgeChanges: { added: number; removed: number }
|
||||
loopChanges: { added: number; removed: number }
|
||||
parallelChanges: { added: number; removed: number }
|
||||
variableChanges: { added: number; removed: number; modified: number }
|
||||
hasChanges: boolean
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedStringify(normalizedCurrentEdges) !== normalizedStringify(normalizedDeployedEdges)
|
||||
) {
|
||||
return true
|
||||
/**
|
||||
* Generate a detailed diff summary between two workflow states
|
||||
*/
|
||||
export function generateWorkflowDiffSummary(
|
||||
currentState: WorkflowState,
|
||||
previousState: WorkflowState | null
|
||||
): WorkflowDiffSummary {
|
||||
const result: WorkflowDiffSummary = {
|
||||
addedBlocks: [],
|
||||
removedBlocks: [],
|
||||
modifiedBlocks: [],
|
||||
edgeChanges: { added: 0, removed: 0 },
|
||||
loopChanges: { added: 0, removed: 0 },
|
||||
parallelChanges: { added: 0, removed: 0 },
|
||||
variableChanges: { added: 0, removed: 0, modified: 0 },
|
||||
hasChanges: false,
|
||||
}
|
||||
|
||||
// 2. Compare blocks and their configurations
|
||||
const currentBlockIds = Object.keys(currentState.blocks || {}).sort()
|
||||
const deployedBlockIds = Object.keys(deployedState.blocks || {}).sort()
|
||||
|
||||
if (
|
||||
currentBlockIds.length !== deployedBlockIds.length ||
|
||||
normalizedStringify(currentBlockIds) !== normalizedStringify(deployedBlockIds)
|
||||
) {
|
||||
return true
|
||||
if (!previousState) {
|
||||
const currentBlocks = currentState.blocks || {}
|
||||
for (const [id, block] of Object.entries(currentBlocks)) {
|
||||
result.addedBlocks.push({
|
||||
id,
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
})
|
||||
}
|
||||
result.edgeChanges.added = (currentState.edges || []).length
|
||||
result.loopChanges.added = Object.keys(currentState.loops || {}).length
|
||||
result.parallelChanges.added = Object.keys(currentState.parallels || {}).length
|
||||
result.variableChanges.added = Object.keys(currentState.variables || {}).length
|
||||
result.hasChanges = true
|
||||
return result
|
||||
}
|
||||
|
||||
// 3. Build normalized representations of blocks for comparison
|
||||
const normalizedCurrentBlocks: Record<string, unknown> = {}
|
||||
const normalizedDeployedBlocks: Record<string, unknown> = {}
|
||||
const currentBlocks = currentState.blocks || {}
|
||||
const previousBlocks = previousState.blocks || {}
|
||||
const currentBlockIds = new Set(Object.keys(currentBlocks))
|
||||
const previousBlockIds = new Set(Object.keys(previousBlocks))
|
||||
|
||||
for (const blockId of currentBlockIds) {
|
||||
const currentBlock = currentState.blocks[blockId]
|
||||
const deployedBlock = deployedState.blocks[blockId]
|
||||
for (const id of currentBlockIds) {
|
||||
if (!previousBlockIds.has(id)) {
|
||||
const block = currentBlocks[id]
|
||||
result.addedBlocks.push({
|
||||
id,
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Destructure and exclude non-functional fields:
|
||||
// - position: visual positioning only
|
||||
// - subBlocks: handled separately below
|
||||
// - layout: contains measuredWidth/measuredHeight from autolayout
|
||||
// - height: block height measurement from autolayout
|
||||
// - outputs: derived from subBlocks (e.g., inputFormat), already compared via subBlocks
|
||||
// - is_diff, field_diffs: diff markers from copilot edits
|
||||
const currentBlockWithDiff = currentBlock as BlockWithDiffMarkers
|
||||
const deployedBlockWithDiff = deployedBlock as BlockWithDiffMarkers
|
||||
for (const id of previousBlockIds) {
|
||||
if (!currentBlockIds.has(id)) {
|
||||
const block = previousBlocks[id]
|
||||
result.removedBlocks.push({
|
||||
id,
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of currentBlockIds) {
|
||||
if (!previousBlockIds.has(id)) continue
|
||||
|
||||
const currentBlock = currentBlocks[id] as BlockWithDiffMarkers
|
||||
const previousBlock = previousBlocks[id] as BlockWithDiffMarkers
|
||||
const changes: FieldChange[] = []
|
||||
|
||||
// Compare block-level properties (excluding visual-only fields)
|
||||
const {
|
||||
position: _currentPos,
|
||||
subBlocks: currentSubBlocks = {},
|
||||
@@ -91,187 +139,308 @@ export function hasWorkflowChanged(
|
||||
is_diff: _currentIsDiff,
|
||||
field_diffs: _currentFieldDiffs,
|
||||
...currentRest
|
||||
} = currentBlockWithDiff
|
||||
} = currentBlock
|
||||
|
||||
const {
|
||||
position: _deployedPos,
|
||||
subBlocks: deployedSubBlocks = {},
|
||||
layout: _deployedLayout,
|
||||
height: _deployedHeight,
|
||||
outputs: _deployedOutputs,
|
||||
is_diff: _deployedIsDiff,
|
||||
field_diffs: _deployedFieldDiffs,
|
||||
...deployedRest
|
||||
} = deployedBlockWithDiff
|
||||
position: _previousPos,
|
||||
subBlocks: previousSubBlocks = {},
|
||||
layout: _previousLayout,
|
||||
height: _previousHeight,
|
||||
outputs: _previousOutputs,
|
||||
is_diff: _previousIsDiff,
|
||||
field_diffs: _previousFieldDiffs,
|
||||
...previousRest
|
||||
} = previousBlock
|
||||
|
||||
// Also exclude width/height from data object (container dimensions from autolayout)
|
||||
const {
|
||||
width: _currentDataWidth,
|
||||
height: _currentDataHeight,
|
||||
...currentDataRest
|
||||
} = currentRest.data || {}
|
||||
const {
|
||||
width: _deployedDataWidth,
|
||||
height: _deployedDataHeight,
|
||||
...deployedDataRest
|
||||
} = deployedRest.data || {}
|
||||
// Exclude width/height from data object (container dimensions from autolayout)
|
||||
const { width: _cw, height: _ch, ...currentDataRest } = currentRest.data || {}
|
||||
const { width: _pw, height: _ph, ...previousDataRest } = previousRest.data || {}
|
||||
|
||||
normalizedCurrentBlocks[blockId] = {
|
||||
...currentRest,
|
||||
data: currentDataRest,
|
||||
const normalizedCurrentBlock = { ...currentRest, data: currentDataRest, subBlocks: undefined }
|
||||
const normalizedPreviousBlock = {
|
||||
...previousRest,
|
||||
data: previousDataRest,
|
||||
subBlocks: undefined,
|
||||
}
|
||||
|
||||
normalizedDeployedBlocks[blockId] = {
|
||||
...deployedRest,
|
||||
data: deployedDataRest,
|
||||
subBlocks: undefined,
|
||||
if (
|
||||
normalizedStringify(normalizedCurrentBlock) !== normalizedStringify(normalizedPreviousBlock)
|
||||
) {
|
||||
if (currentBlock.type !== previousBlock.type) {
|
||||
changes.push({ field: 'type', oldValue: previousBlock.type, newValue: currentBlock.type })
|
||||
}
|
||||
if (currentBlock.name !== previousBlock.name) {
|
||||
changes.push({ field: 'name', oldValue: previousBlock.name, newValue: currentBlock.name })
|
||||
}
|
||||
if (currentBlock.enabled !== previousBlock.enabled) {
|
||||
changes.push({
|
||||
field: 'enabled',
|
||||
oldValue: previousBlock.enabled,
|
||||
newValue: currentBlock.enabled,
|
||||
})
|
||||
}
|
||||
// Check other block properties
|
||||
const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode'] as const
|
||||
for (const field of blockFields) {
|
||||
if (currentBlock[field] !== previousBlock[field]) {
|
||||
changes.push({
|
||||
field,
|
||||
oldValue: previousBlock[field],
|
||||
newValue: currentBlock[field],
|
||||
})
|
||||
}
|
||||
}
|
||||
if (normalizedStringify(currentDataRest) !== normalizedStringify(previousDataRest)) {
|
||||
changes.push({ field: 'data', oldValue: previousDataRest, newValue: currentDataRest })
|
||||
}
|
||||
}
|
||||
|
||||
// Get all subBlock IDs from both states, excluding runtime metadata and UI-only elements
|
||||
// Compare subBlocks
|
||||
const allSubBlockIds = [
|
||||
...new Set([...Object.keys(currentSubBlocks), ...Object.keys(deployedSubBlocks)]),
|
||||
...new Set([...Object.keys(currentSubBlocks), ...Object.keys(previousSubBlocks)]),
|
||||
]
|
||||
.filter(
|
||||
(id) => !TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id) && !SYSTEM_SUBBLOCK_IDS.includes(id)
|
||||
(subId) =>
|
||||
!TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subId) && !SYSTEM_SUBBLOCK_IDS.includes(subId)
|
||||
)
|
||||
.sort()
|
||||
|
||||
// Normalize and compare each subBlock
|
||||
for (const subBlockId of allSubBlockIds) {
|
||||
// If the subBlock doesn't exist in either state, there's a difference
|
||||
if (!currentSubBlocks[subBlockId] || !deployedSubBlocks[subBlockId]) {
|
||||
return true
|
||||
for (const subId of allSubBlockIds) {
|
||||
const currentSub = currentSubBlocks[subId]
|
||||
const previousSub = previousSubBlocks[subId]
|
||||
|
||||
if (!currentSub || !previousSub) {
|
||||
changes.push({
|
||||
field: subId,
|
||||
oldValue: previousSub?.value ?? null,
|
||||
newValue: currentSub?.value ?? null,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Get values with special handling for null/undefined
|
||||
// Using unknown type since sanitization functions return different types
|
||||
let currentValue: unknown = currentSubBlocks[subBlockId].value ?? null
|
||||
let deployedValue: unknown = deployedSubBlocks[subBlockId].value ?? null
|
||||
// Compare subBlock values with sanitization
|
||||
let currentValue: unknown = currentSub.value ?? null
|
||||
let previousValue: unknown = previousSub.value ?? null
|
||||
|
||||
if (subBlockId === 'tools' && Array.isArray(currentValue) && Array.isArray(deployedValue)) {
|
||||
if (subId === 'tools' && Array.isArray(currentValue) && Array.isArray(previousValue)) {
|
||||
currentValue = sanitizeTools(currentValue)
|
||||
deployedValue = sanitizeTools(deployedValue)
|
||||
previousValue = sanitizeTools(previousValue)
|
||||
}
|
||||
|
||||
if (
|
||||
subBlockId === 'inputFormat' &&
|
||||
Array.isArray(currentValue) &&
|
||||
Array.isArray(deployedValue)
|
||||
) {
|
||||
if (subId === 'inputFormat' && Array.isArray(currentValue) && Array.isArray(previousValue)) {
|
||||
currentValue = sanitizeInputFormat(currentValue)
|
||||
deployedValue = sanitizeInputFormat(deployedValue)
|
||||
previousValue = sanitizeInputFormat(previousValue)
|
||||
}
|
||||
|
||||
// For string values, compare directly to catch even small text changes
|
||||
if (typeof currentValue === 'string' && typeof deployedValue === 'string') {
|
||||
if (currentValue !== deployedValue) {
|
||||
return true
|
||||
if (typeof currentValue === 'string' && typeof previousValue === 'string') {
|
||||
if (currentValue !== previousValue) {
|
||||
changes.push({ field: subId, oldValue: previousSub.value, newValue: currentSub.value })
|
||||
}
|
||||
} else {
|
||||
// For other types, use normalized comparison
|
||||
const normalizedCurrentValue = normalizeValue(currentValue)
|
||||
const normalizedDeployedValue = normalizeValue(deployedValue)
|
||||
|
||||
if (
|
||||
normalizedStringify(normalizedCurrentValue) !==
|
||||
normalizedStringify(normalizedDeployedValue)
|
||||
) {
|
||||
return true
|
||||
const normalizedCurrent = normalizeValue(currentValue)
|
||||
const normalizedPrevious = normalizeValue(previousValue)
|
||||
if (normalizedStringify(normalizedCurrent) !== normalizedStringify(normalizedPrevious)) {
|
||||
changes.push({ field: subId, oldValue: previousSub.value, newValue: currentSub.value })
|
||||
}
|
||||
}
|
||||
|
||||
// Compare type and other properties (excluding diff markers and value)
|
||||
const currentSubBlockWithDiff = currentSubBlocks[subBlockId] as SubBlockWithDiffMarker
|
||||
const deployedSubBlockWithDiff = deployedSubBlocks[subBlockId] as SubBlockWithDiffMarker
|
||||
const { value: _cv, is_diff: _cd, ...currentSubBlockRest } = currentSubBlockWithDiff
|
||||
const { value: _dv, is_diff: _dd, ...deployedSubBlockRest } = deployedSubBlockWithDiff
|
||||
// Compare subBlock REST properties (type, id, etc. - excluding value and is_diff)
|
||||
const currentSubWithDiff = currentSub as SubBlockWithDiffMarker
|
||||
const previousSubWithDiff = previousSub as SubBlockWithDiffMarker
|
||||
const { value: _cv, is_diff: _cd, ...currentSubRest } = currentSubWithDiff
|
||||
const { value: _pv, is_diff: _pd, ...previousSubRest } = previousSubWithDiff
|
||||
|
||||
if (normalizedStringify(currentSubBlockRest) !== normalizedStringify(deployedSubBlockRest)) {
|
||||
return true
|
||||
if (normalizedStringify(currentSubRest) !== normalizedStringify(previousSubRest)) {
|
||||
changes.push({
|
||||
field: `${subId}.properties`,
|
||||
oldValue: previousSubRest,
|
||||
newValue: currentSubRest,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const blocksEqual =
|
||||
normalizedStringify(normalizedCurrentBlocks[blockId]) ===
|
||||
normalizedStringify(normalizedDeployedBlocks[blockId])
|
||||
|
||||
if (!blocksEqual) {
|
||||
return true
|
||||
if (changes.length > 0) {
|
||||
result.modifiedBlocks.push({
|
||||
id,
|
||||
type: currentBlock.type,
|
||||
name: currentBlock.name,
|
||||
changes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Compare loops
|
||||
const currentEdges = (currentState.edges || []).map(normalizeEdge)
|
||||
const previousEdges = (previousState.edges || []).map(normalizeEdge)
|
||||
const currentEdgeSet = new Set(currentEdges.map(normalizedStringify))
|
||||
const previousEdgeSet = new Set(previousEdges.map(normalizedStringify))
|
||||
|
||||
for (const edge of currentEdgeSet) {
|
||||
if (!previousEdgeSet.has(edge)) result.edgeChanges.added++
|
||||
}
|
||||
for (const edge of previousEdgeSet) {
|
||||
if (!currentEdgeSet.has(edge)) result.edgeChanges.removed++
|
||||
}
|
||||
|
||||
const currentLoops = currentState.loops || {}
|
||||
const deployedLoops = deployedState.loops || {}
|
||||
const previousLoops = previousState.loops || {}
|
||||
const currentLoopIds = Object.keys(currentLoops)
|
||||
const previousLoopIds = Object.keys(previousLoops)
|
||||
|
||||
const currentLoopIds = Object.keys(currentLoops).sort()
|
||||
const deployedLoopIds = Object.keys(deployedLoops).sort()
|
||||
|
||||
if (
|
||||
currentLoopIds.length !== deployedLoopIds.length ||
|
||||
normalizedStringify(currentLoopIds) !== normalizedStringify(deployedLoopIds)
|
||||
) {
|
||||
return true
|
||||
for (const id of currentLoopIds) {
|
||||
if (!previousLoopIds.includes(id)) {
|
||||
result.loopChanges.added++
|
||||
} else {
|
||||
const normalizedCurrent = normalizeValue(normalizeLoop(currentLoops[id]))
|
||||
const normalizedPrevious = normalizeValue(normalizeLoop(previousLoops[id]))
|
||||
if (normalizedStringify(normalizedCurrent) !== normalizedStringify(normalizedPrevious)) {
|
||||
result.loopChanges.added++
|
||||
result.loopChanges.removed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const loopId of currentLoopIds) {
|
||||
const normalizedCurrentLoop = normalizeValue(normalizeLoop(currentLoops[loopId]))
|
||||
const normalizedDeployedLoop = normalizeValue(normalizeLoop(deployedLoops[loopId]))
|
||||
|
||||
if (
|
||||
normalizedStringify(normalizedCurrentLoop) !== normalizedStringify(normalizedDeployedLoop)
|
||||
) {
|
||||
return true
|
||||
for (const id of previousLoopIds) {
|
||||
if (!currentLoopIds.includes(id)) {
|
||||
result.loopChanges.removed++
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Compare parallels
|
||||
const currentParallels = currentState.parallels || {}
|
||||
const deployedParallels = deployedState.parallels || {}
|
||||
const previousParallels = previousState.parallels || {}
|
||||
const currentParallelIds = Object.keys(currentParallels)
|
||||
const previousParallelIds = Object.keys(previousParallels)
|
||||
|
||||
const currentParallelIds = Object.keys(currentParallels).sort()
|
||||
const deployedParallelIds = Object.keys(deployedParallels).sort()
|
||||
|
||||
if (
|
||||
currentParallelIds.length !== deployedParallelIds.length ||
|
||||
normalizedStringify(currentParallelIds) !== normalizedStringify(deployedParallelIds)
|
||||
) {
|
||||
return true
|
||||
for (const id of currentParallelIds) {
|
||||
if (!previousParallelIds.includes(id)) {
|
||||
result.parallelChanges.added++
|
||||
} else {
|
||||
const normalizedCurrent = normalizeValue(normalizeParallel(currentParallels[id]))
|
||||
const normalizedPrevious = normalizeValue(normalizeParallel(previousParallels[id]))
|
||||
if (normalizedStringify(normalizedCurrent) !== normalizedStringify(normalizedPrevious)) {
|
||||
result.parallelChanges.added++
|
||||
result.parallelChanges.removed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const parallelId of currentParallelIds) {
|
||||
const normalizedCurrentParallel = normalizeValue(
|
||||
normalizeParallel(currentParallels[parallelId])
|
||||
)
|
||||
const normalizedDeployedParallel = normalizeValue(
|
||||
normalizeParallel(deployedParallels[parallelId])
|
||||
)
|
||||
|
||||
if (
|
||||
normalizedStringify(normalizedCurrentParallel) !==
|
||||
normalizedStringify(normalizedDeployedParallel)
|
||||
) {
|
||||
return true
|
||||
for (const id of previousParallelIds) {
|
||||
if (!currentParallelIds.includes(id)) {
|
||||
result.parallelChanges.removed++
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Compare variables
|
||||
const currentVariables = normalizeVariables(currentState.variables)
|
||||
const deployedVariables = normalizeVariables(deployedState.variables)
|
||||
const currentVars = normalizeVariables(currentState.variables)
|
||||
const previousVars = normalizeVariables(previousState.variables)
|
||||
const currentVarIds = Object.keys(currentVars)
|
||||
const previousVarIds = Object.keys(previousVars)
|
||||
|
||||
const normalizedCurrentVars = normalizeValue(
|
||||
Object.fromEntries(Object.entries(currentVariables).map(([id, v]) => [id, sanitizeVariable(v)]))
|
||||
)
|
||||
const normalizedDeployedVars = normalizeValue(
|
||||
Object.fromEntries(
|
||||
Object.entries(deployedVariables).map(([id, v]) => [id, sanitizeVariable(v)])
|
||||
)
|
||||
)
|
||||
result.variableChanges.added = currentVarIds.filter((id) => !previousVarIds.includes(id)).length
|
||||
result.variableChanges.removed = previousVarIds.filter((id) => !currentVarIds.includes(id)).length
|
||||
|
||||
if (normalizedStringify(normalizedCurrentVars) !== normalizedStringify(normalizedDeployedVars)) {
|
||||
return true
|
||||
for (const id of currentVarIds) {
|
||||
if (!previousVarIds.includes(id)) continue
|
||||
const currentVar = normalizeValue(sanitizeVariable(currentVars[id]))
|
||||
const previousVar = normalizeValue(sanitizeVariable(previousVars[id]))
|
||||
if (normalizedStringify(currentVar) !== normalizedStringify(previousVar)) {
|
||||
result.variableChanges.modified++
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
result.hasChanges =
|
||||
result.addedBlocks.length > 0 ||
|
||||
result.removedBlocks.length > 0 ||
|
||||
result.modifiedBlocks.length > 0 ||
|
||||
result.edgeChanges.added > 0 ||
|
||||
result.edgeChanges.removed > 0 ||
|
||||
result.loopChanges.added > 0 ||
|
||||
result.loopChanges.removed > 0 ||
|
||||
result.parallelChanges.added > 0 ||
|
||||
result.parallelChanges.removed > 0 ||
|
||||
result.variableChanges.added > 0 ||
|
||||
result.variableChanges.removed > 0 ||
|
||||
result.variableChanges.modified > 0
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function formatValueForDisplay(value: unknown): string {
|
||||
if (value === null || value === undefined) return '(none)'
|
||||
if (typeof value === 'string') {
|
||||
if (value.length > 50) return `${value.slice(0, 50)}...`
|
||||
return value || '(empty)'
|
||||
}
|
||||
if (typeof value === 'boolean') return value ? 'enabled' : 'disabled'
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (Array.isArray(value)) return `[${value.length} items]`
|
||||
if (typeof value === 'object') return `${JSON.stringify(value).slice(0, 50)}...`
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a WorkflowDiffSummary to a human-readable string for AI description generation
|
||||
*/
|
||||
export function formatDiffSummaryForDescription(summary: WorkflowDiffSummary): string {
|
||||
if (!summary.hasChanges) {
|
||||
return 'No structural changes detected (configuration may have changed)'
|
||||
}
|
||||
|
||||
const changes: string[] = []
|
||||
|
||||
for (const block of summary.addedBlocks) {
|
||||
const name = block.name || block.type
|
||||
changes.push(`Added block: ${name} (${block.type})`)
|
||||
}
|
||||
|
||||
for (const block of summary.removedBlocks) {
|
||||
const name = block.name || block.type
|
||||
changes.push(`Removed block: ${name} (${block.type})`)
|
||||
}
|
||||
|
||||
for (const block of summary.modifiedBlocks) {
|
||||
const name = block.name || block.type
|
||||
for (const change of block.changes.slice(0, 3)) {
|
||||
const oldStr = formatValueForDisplay(change.oldValue)
|
||||
const newStr = formatValueForDisplay(change.newValue)
|
||||
changes.push(`Modified ${name}: ${change.field} changed from "${oldStr}" to "${newStr}"`)
|
||||
}
|
||||
if (block.changes.length > 3) {
|
||||
changes.push(` ...and ${block.changes.length - 3} more changes in ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.edgeChanges.added > 0) {
|
||||
changes.push(`Added ${summary.edgeChanges.added} connection(s)`)
|
||||
}
|
||||
if (summary.edgeChanges.removed > 0) {
|
||||
changes.push(`Removed ${summary.edgeChanges.removed} connection(s)`)
|
||||
}
|
||||
|
||||
if (summary.loopChanges.added > 0) {
|
||||
changes.push(`Added ${summary.loopChanges.added} loop(s)`)
|
||||
}
|
||||
if (summary.loopChanges.removed > 0) {
|
||||
changes.push(`Removed ${summary.loopChanges.removed} loop(s)`)
|
||||
}
|
||||
|
||||
if (summary.parallelChanges.added > 0) {
|
||||
changes.push(`Added ${summary.parallelChanges.added} parallel group(s)`)
|
||||
}
|
||||
if (summary.parallelChanges.removed > 0) {
|
||||
changes.push(`Removed ${summary.parallelChanges.removed} parallel group(s)`)
|
||||
}
|
||||
|
||||
const varChanges: string[] = []
|
||||
if (summary.variableChanges.added > 0) {
|
||||
varChanges.push(`${summary.variableChanges.added} added`)
|
||||
}
|
||||
if (summary.variableChanges.removed > 0) {
|
||||
varChanges.push(`${summary.variableChanges.removed} removed`)
|
||||
}
|
||||
if (summary.variableChanges.modified > 0) {
|
||||
varChanges.push(`${summary.variableChanges.modified} modified`)
|
||||
}
|
||||
if (varChanges.length > 0) {
|
||||
changes.push(`Variables: ${varChanges.join(', ')}`)
|
||||
}
|
||||
|
||||
return changes.join('\n')
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface WorkflowDeploymentVersionResponse {
|
||||
id: string
|
||||
version: number
|
||||
name?: string | null
|
||||
description?: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
createdBy?: string | null
|
||||
|
||||
@@ -1,15 +1,155 @@
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { create } from 'zustand'
|
||||
import type { SearchModalState } from './types'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { getToolOperationsIndex } from '@/lib/search/tool-operations'
|
||||
import { getTriggersForSidebar } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import type {
|
||||
SearchBlockItem,
|
||||
SearchData,
|
||||
SearchDocItem,
|
||||
SearchModalState,
|
||||
SearchToolOperationItem,
|
||||
} from './types'
|
||||
|
||||
export const useSearchModalStore = create<SearchModalState>((set) => ({
|
||||
isOpen: false,
|
||||
setOpen: (open: boolean) => {
|
||||
set({ isOpen: open })
|
||||
},
|
||||
open: () => {
|
||||
set({ isOpen: true })
|
||||
},
|
||||
close: () => {
|
||||
set({ isOpen: false })
|
||||
},
|
||||
}))
|
||||
const initialData: SearchData = {
|
||||
blocks: [],
|
||||
tools: [],
|
||||
triggers: [],
|
||||
toolOperations: [],
|
||||
docs: [],
|
||||
isInitialized: false,
|
||||
}
|
||||
|
||||
export const useSearchModalStore = create<SearchModalState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
isOpen: false,
|
||||
data: initialData,
|
||||
|
||||
setOpen: (open: boolean) => {
|
||||
set({ isOpen: open })
|
||||
},
|
||||
|
||||
open: () => {
|
||||
set({ isOpen: true })
|
||||
},
|
||||
|
||||
close: () => {
|
||||
set({ isOpen: false })
|
||||
},
|
||||
|
||||
initializeData: (filterBlocks) => {
|
||||
const allBlocks = getAllBlocks()
|
||||
const filteredAllBlocks = filterBlocks(allBlocks) as typeof allBlocks
|
||||
|
||||
const regularBlocks: SearchBlockItem[] = []
|
||||
const tools: SearchBlockItem[] = []
|
||||
const docs: SearchDocItem[] = []
|
||||
|
||||
for (const block of filteredAllBlocks) {
|
||||
if (block.hideFromToolbar) continue
|
||||
|
||||
const searchItem: SearchBlockItem = {
|
||||
id: block.type,
|
||||
name: block.name,
|
||||
description: block.description || '',
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor || '#6B7280',
|
||||
type: block.type,
|
||||
}
|
||||
|
||||
if (block.category === 'blocks' && block.type !== 'starter') {
|
||||
regularBlocks.push(searchItem)
|
||||
} else if (block.category === 'tools') {
|
||||
tools.push(searchItem)
|
||||
}
|
||||
|
||||
if (block.docsLink) {
|
||||
docs.push({
|
||||
id: `docs-${block.type}`,
|
||||
name: block.name,
|
||||
icon: block.icon,
|
||||
href: block.docsLink,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const specialBlocks: SearchBlockItem[] = [
|
||||
{
|
||||
id: 'loop',
|
||||
name: 'Loop',
|
||||
description: 'Create a Loop',
|
||||
icon: RepeatIcon,
|
||||
bgColor: '#2FB3FF',
|
||||
type: 'loop',
|
||||
},
|
||||
{
|
||||
id: 'parallel',
|
||||
name: 'Parallel',
|
||||
description: 'Parallel Execution',
|
||||
icon: SplitIcon,
|
||||
bgColor: '#FEE12B',
|
||||
type: 'parallel',
|
||||
},
|
||||
]
|
||||
|
||||
const blocks = [...regularBlocks, ...(filterBlocks(specialBlocks) as SearchBlockItem[])]
|
||||
|
||||
const allTriggers = getTriggersForSidebar()
|
||||
const filteredTriggers = filterBlocks(allTriggers) as typeof allTriggers
|
||||
const priorityOrder = ['Start', 'Schedule', 'Webhook']
|
||||
|
||||
const sortedTriggers = [...filteredTriggers].sort((a, b) => {
|
||||
const aIndex = priorityOrder.indexOf(a.name)
|
||||
const bIndex = priorityOrder.indexOf(b.name)
|
||||
const aHasPriority = aIndex !== -1
|
||||
const bHasPriority = bIndex !== -1
|
||||
|
||||
if (aHasPriority && bHasPriority) return aIndex - bIndex
|
||||
if (aHasPriority) return -1
|
||||
if (bHasPriority) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
const triggers = sortedTriggers.map(
|
||||
(block): SearchBlockItem => ({
|
||||
id: block.type,
|
||||
name: block.name,
|
||||
description: block.description || '',
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor || '#6B7280',
|
||||
type: block.type,
|
||||
config: block,
|
||||
})
|
||||
)
|
||||
|
||||
const allowedBlockTypes = new Set(tools.map((t) => t.type))
|
||||
const toolOperations: SearchToolOperationItem[] = getToolOperationsIndex()
|
||||
.filter((op) => allowedBlockTypes.has(op.blockType))
|
||||
.map((op) => ({
|
||||
id: op.id,
|
||||
name: `${op.serviceName}: ${op.operationName}`,
|
||||
searchValue: `${op.serviceName} ${op.operationName}`,
|
||||
icon: op.icon,
|
||||
bgColor: op.bgColor,
|
||||
blockType: op.blockType,
|
||||
operationId: op.operationId,
|
||||
keywords: op.aliases,
|
||||
}))
|
||||
|
||||
set({
|
||||
data: {
|
||||
blocks,
|
||||
tools,
|
||||
triggers,
|
||||
toolOperations,
|
||||
docs,
|
||||
isInitialized: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
{ name: 'search-modal-store' }
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,3 +1,55 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
|
||||
/**
|
||||
* Represents a block item in the search results.
|
||||
*/
|
||||
export interface SearchBlockItem {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
bgColor: string
|
||||
type: string
|
||||
config?: BlockConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a tool operation item in the search results.
|
||||
*/
|
||||
export interface SearchToolOperationItem {
|
||||
id: string
|
||||
name: string
|
||||
searchValue: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
bgColor: string
|
||||
blockType: string
|
||||
operationId: string
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a doc item in the search results.
|
||||
*/
|
||||
export interface SearchDocItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
href: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-computed search data that is initialized on app load.
|
||||
*/
|
||||
export interface SearchData {
|
||||
blocks: SearchBlockItem[]
|
||||
tools: SearchBlockItem[]
|
||||
triggers: SearchBlockItem[]
|
||||
toolOperations: SearchToolOperationItem[]
|
||||
docs: SearchDocItem[]
|
||||
isInitialized: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Global state for the universal search modal.
|
||||
*
|
||||
@@ -8,18 +60,27 @@
|
||||
export interface SearchModalState {
|
||||
/** Whether the search modal is currently open. */
|
||||
isOpen: boolean
|
||||
|
||||
/** Pre-computed search data. */
|
||||
data: SearchData
|
||||
|
||||
/**
|
||||
* Explicitly set the open state of the modal.
|
||||
*
|
||||
* @param open - New open state.
|
||||
*/
|
||||
setOpen: (open: boolean) => void
|
||||
|
||||
/**
|
||||
* Convenience method to open the modal.
|
||||
*/
|
||||
open: () => void
|
||||
|
||||
/**
|
||||
* Convenience method to close the modal.
|
||||
*/
|
||||
close: () => void
|
||||
|
||||
/**
|
||||
* Initialize search data. Called once on app load.
|
||||
*/
|
||||
initializeData: (filterBlocks: <T extends { type: string }>(blocks: T[]) => T[]) => void
|
||||
}
|
||||
|
||||
1
packages/db/migrations/0148_aberrant_venom.sql
Normal file
1
packages/db/migrations/0148_aberrant_venom.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "workflow_deployment_version" ADD COLUMN "description" text;
|
||||
10347
packages/db/migrations/meta/0148_snapshot.json
Normal file
10347
packages/db/migrations/meta/0148_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1030,6 +1030,13 @@
|
||||
"when": 1769134350805,
|
||||
"tag": "0147_rare_firebrand",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 148,
|
||||
"version": "7",
|
||||
"when": 1769626313827,
|
||||
"tag": "0148_aberrant_venom",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1634,6 +1634,7 @@ export const workflowDeploymentVersion = pgTable(
|
||||
.references(() => workflow.id, { onDelete: 'cascade' }),
|
||||
version: integer('version').notNull(),
|
||||
name: text('name'),
|
||||
description: text('description'),
|
||||
state: json('state').notNull(),
|
||||
isActive: boolean('is_active').notNull().default(false),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
|
||||
Reference in New Issue
Block a user