Compare commits

..

41 Commits

Author SHA1 Message Date
Vikhyath Mondreti
11dc18a80d v0.5.74: autolayout improvements, clerk integration, auth enforcements 2026-01-27 20:37:39 -08:00
Waleed
ab4e9dc72f v0.5.73: ci, helm updates, kb, ui fixes, note block enhancements 2026-01-26 22:04:35 -08:00
Vikhyath Mondreti
1c58c35bd8 v0.5.72: azure connection string, supabase improvement, multitrigger resolution, docs quick reference 2026-01-25 23:42:27 -08:00
Waleed
d63a5cb504 v0.5.71: ux, ci improvements, docs updates 2026-01-25 03:08:08 -08:00
Waleed
8bd5d41723 v0.5.70: router fix, anthropic agent response format adherence 2026-01-24 20:57:02 -08:00
Waleed
c12931bc50 v0.5.69: kb upgrades, blog, copilot improvements, auth consolidation (#2973)
* fix(subflows): tag dropdown + resolution logic (#2949)

* fix(subflows): tag dropdown + resolution logic

* fixes;

* revert parallel change

* chore(deps): bump posthog-js to 1.334.1 (#2948)

* fix(idempotency): add conflict target to atomicallyClaimDb query + remove redundant db namespace tracking (#2950)

* fix(idempotency): add conflict target to atomicallyClaimDb query

* delete needs to account for namespace

* simplify namespace filtering logic

* fix cleanup

* consistent target

* improvement(kb): add document filtering, select all, and React Query migration (#2951)

* improvement(kb): add document filtering, select all, and React Query migration

* test(kb): update tests for enabledFilter and removed userId params

* fix(kb): remove non-null assertion, add explicit guard

* improvement(logs): trace span, details (#2952)

* improvement(action-bar): ordering

* improvement(logs): details, trace span

* feat(blog): v0.5 release post (#2953)

* feat(blog): v0.5 post

* improvement(blog): simplify title and remove code block header

- Simplified blog title from Introducing Sim Studio v0.5 to Introducing Sim v0.5
- Removed language label header and copy button from code blocks for cleaner appearance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ack PR comments

* small styling improvements

* created system to create post-specific components

* updated componnet

* cache invalidation

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(admin): add credits endpoint to issue credits to users (#2954)

* feat(admin): add credits endpoint to issue credits to users

* fix(admin): use existing credit functions and handle enterprise seats

* fix(admin): reject NaN and Infinity in amount validation

* styling

* fix(admin): validate userId and email are strings

* improvement(copilot): fast mode, subagent tool responses and allow preferences (#2955)

* Improvements

* Fix actions mapping

* Remove console logs

* fix(billing): handle missing userStats and prevent crashes (#2956)

* fix(billing): handle missing userStats and prevent crashes

* fix(billing): correct import path for getFilledPillColor

* fix(billing): add Number.isFinite check to lastPeriodCost

* fix(logs): refresh logic to refresh logs details (#2958)

* fix(security): add authentication and input validation to API routes (#2959)

* fix(security): add authentication and input validation to API routes

* moved utils

* remove extraneous commetns

* removed unused dep

* improvement(helm): add internal ingress support and same-host path consolidation (#2960)

* improvement(helm): add internal ingress support and same-host path consolidation

* improvement(helm): clean up ingress template comments

Simplify verbose inline Helm comments and section dividers to match the
minimal style used in services.yaml.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(helm): add missing copilot path consolidation for realtime host

When copilot.host equals realtime.host but differs from app.host,
copilot paths were not being routed. Added logic to consolidate
copilot paths into the realtime rule for this scenario.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* improvement(helm): follow ingress best practices

- Remove orphan comments that appeared when services were disabled
- Add documentation about path ordering requirements
- Paths rendered in order: realtime, copilot, app (specific before catch-all)
- Clean template output matching industry Helm chart standards

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(blog): enterprise post (#2961)

* feat(blog): enterprise post

* added more images, styling

* more content

* updated v0-5 post

* remove unused transition

---------

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

* fix(envvars): resolution standardized (#2957)

* fix(envvars): resolution standardized

* remove comments

* address bugbot

* fix highlighting for env vars

* remove comments

* address greptile

* address bugbot

* fix(copilot): mask credentials fix (#2963)

* Fix copilot masking

* Clean up

* Lint

* improvement(webhooks): remove dead code (#2965)

* fix(webhooks): subscription recreation path

* improvement(webhooks): remove dead code

* fix tests

* address bugbot comments

* fix restoration edge case

* fix more edge cases

* address bugbot comments

* fix gmail polling

* add warnings for UI indication for credential sets

* fix(preview): subblock values (#2969)

* fix(child-workflow): nested spans handoff (#2966)

* fix(child-workflow): nested spans handoff

* remove overly defensive programming

* update type check

* type more code

* remove more dead code

* address bugbot comments

* fix(security): restrict API key access on internal-only routes (#2964)

* fix(security): restrict API key access on internal-only routes

* test(security): update function execute tests for checkInternalAuth

* updated agent handler

* move session check higher in checkSessionOrInternalAuth

* extracted duplicate code into helper for resolving user from jwt

* fix(copilot): update copilot chat title (#2968)

* fix(hitl): fix condition blocks after hitl (#2967)

* fix(notes): ghost edges (#2970)

* fix(notes): ghost edges

* fix deployed state fallback

* fallback

* remove UI level checks

* annotation missing from autoconnect source check

* improvement(docs): loop and parallel var reference syntax (#2975)

* fix(blog): slash actions description (#2976)

* improvement(docs): loop and parallel var reference syntax

* fix(blog): slash actions description

* fix(auth): copilot routes (#2977)

* Fix copilot auth

* Fix

* Fix

* Fix

* fix(copilot): fix edit summary for loops/parallels (#2978)

* fix(integrations): hide from tool bar (#2544)

* fix(landing): ui (#2979)

* fix(edge-validation): race condition on collaborative add (#2980)

* fix(variables): boolean type support and input improvements (#2981)

* fix(variables): boolean type support and input improvements

* fix formatting

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-24 14:29:53 -08:00
Waleed
e9c4251c1c v0.5.68: router block reasoning, executor improvements, variable resolution consolidation, helm updates (#2946)
* improvement(workflow-item): stabilize avatar layout and fix name truncation (#2939)

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

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

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

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

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

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

* fix(resolver): consolidate code to resolve references

* fix edge cases

* use already formatted error

* fix multi index

* fix backwards compat reachability

* handle backwards compatibility accurately

* use shared constant correctly

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

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

* Fix always allow, credential validation

* Credential masking

* Autoload

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

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
2026-01-22 13:48:15 -08:00
Waleed
cc2be33d6b v0.5.67: loading, password reset, ui improvements, helm updates (#2928)
* fix(zustand): updated to useShallow from deprecated createWithEqualityFn (#2919)

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

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

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

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

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

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

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

* chore(auth): fix import order per lint

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

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

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

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

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

* fix response block initial seeding

* fix tests

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

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

* fixed remaining zustand warnings

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

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

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

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

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

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

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

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

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

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

* fix(null-statuses): empty bodies handling

* address bugbot comment

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

* fix(microsoft): proactive refresh needed

* fix(x): missing token refresh flag

* notion and linear missing flag too

* address bugbot comment

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

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback

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

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

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

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

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

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-21 22:53:25 -08:00
Vikhyath Mondreti
45371e521e v0.5.66: external http requests fix, ring highlighting 2026-01-21 02:55:39 -08:00
Waleed
0ce0f98aa5 v0.5.65: gemini updates, textract integration, ui updates (#2909)
* fix(google): wrap primitive tool responses for Gemini API compatibility (#2900)

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

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

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

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

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

* improvement(browseruse): add profile id param

* make request a stub since we have directExec

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

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

* comments

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

* progress

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

* fix types

* cleanup comments

* path security vuln

* reject promise correctly

* fix redirect case

* remove proxy routes

* fix tests

* use ipaddr

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

* feat(tools): added textract

* cleanup

* ack pr comments

* reorder

* removed upload for textract async version

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

* added mistral v2, files v2, and finalized textract

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

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

* updated extension finder

* cleanup

* added description for inputs to workflow

* use helper for internal route check

* fix tag dropdown merge conflict change

* remove duplicate code

---------

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

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

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

* fix(canvas): removed invite to workspace

* removed unused props

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

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

* fix canonical merge

* fix empty array case

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

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

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

* added duplicate to action bar for subflows

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

---------

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

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

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

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

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

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

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

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

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

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

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

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

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

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

* feat(admin): routes to manage deployments

* fix naming fo deployed by

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

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

* removed unused params, cleaned up redundant utils

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

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

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

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

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

File diff suppressed because one or more lines are too long

View File

@@ -9,24 +9,13 @@ 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')
.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',
})
const patchBodySchema = z.object({
name: z
.string()
.trim()
.min(1, 'Name cannot be empty')
.max(100, 'Name must be 100 characters or less'),
})
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
@@ -99,46 +88,33 @@ export async function PATCH(
return createErrorResponse(validation.error.errors[0]?.message || 'Invalid request body', 400)
}
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 { name } = validation.data
const [updated] = await db
.update(workflowDeploymentVersion)
.set(updateData)
.set({ name })
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.returning({
id: workflowDeploymentVersion.id,
name: workflowDeploymentVersion.name,
description: workflowDeploymentVersion.description,
})
.returning({ id: workflowDeploymentVersion.id, name: workflowDeploymentVersion.name })
if (!updated) {
return createErrorResponse('Deployment version not found', 404)
}
logger.info(`[${requestId}] Updated deployment version ${version} for workflow ${id}`, {
name: updateData.name,
description: updateData.description,
})
logger.info(
`[${requestId}] Renamed deployment version ${version} for workflow ${id} to "${name}"`
)
return createSuccessResponse({ name: updated.name, description: updated.description })
return createSuccessResponse({ name: updated.name })
} catch (error: any) {
logger.error(
`[${requestId}] Error updating deployment version ${version} for workflow ${id}`,
`[${requestId}] Error renaming deployment version ${version} for workflow ${id}`,
error
)
return createErrorResponse(error.message || 'Failed to update deployment version', 500)
return createErrorResponse(error.message || 'Failed to rename deployment version', 500)
}
}

View File

@@ -26,7 +26,6 @@ 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,

View File

@@ -1,170 +0,0 @@
'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>
</>
)
}

View File

@@ -1,31 +1,26 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx'
import { FileText, MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
import {
Button,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } 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-[56px]',
ACTIONS: 'w-[32px]',
} as const
interface VersionsProps {
@@ -36,6 +31,34 @@ 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}`
}
/**
@@ -50,15 +73,14 @@ 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()
@@ -72,8 +94,7 @@ export function Versions({
setEditValue(currentName || `v${version}`)
}
const handleSaveRename = (version: number) => {
if (renameMutation.isPending) return
const handleSaveRename = async (version: number) => {
if (!workflowId || !editValue.trim()) {
setEditingVersion(null)
return
@@ -87,21 +108,25 @@ export function Versions({
return
}
renameMutation.mutate(
{
workflowId,
version,
name: editValue.trim(),
},
{
onSuccess: () => {
setEditingVersion(null)
},
onError: () => {
// Keep editing state open on error so user can retry
},
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')
}
)
} catch (error) {
logger.error('Error renaming version:', error)
} finally {
setIsRenaming(false)
}
}
const handleCancelRename = () => {
@@ -124,16 +149,6 @@ 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)]'>
@@ -164,14 +179,7 @@ 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 gap-[2px]'
)}
>
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
<div className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end')}>
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
</div>
</div>
@@ -249,7 +257,7 @@ export function Versions({
'text-[var(--text-primary)] focus:outline-none focus:ring-0'
)}
maxLength={100}
disabled={renameMutation.isPending}
disabled={isRenaming}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
@@ -281,40 +289,14 @@ export function Versions({
<span
className={clsx('block truncate text-[var(--text-tertiary)]', ROW_TEXT_CLASS)}
>
{formatDateTime(new Date(v.createdAt))}
{formatDate(v.createdAt)}
</span>
</div>
<div
className={clsx(
COLUMN_WIDTHS.ACTIONS,
COLUMN_BASE_CLASS,
'flex items-center justify-end gap-[2px]'
)}
className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end')}
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)}
@@ -329,10 +311,6 @@ 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' />
@@ -350,20 +328,6 @@ 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>
)
}

View File

@@ -32,6 +32,7 @@ interface GeneralDeployProps {
versionsLoading: boolean
onPromoteToLive: (version: number) => Promise<void>
onLoadDeploymentComplete: () => void
fetchVersions: () => Promise<void>
}
type PreviewMode = 'active' | 'selected'
@@ -47,6 +48,7 @@ export function GeneralDeploy({
versionsLoading,
onPromoteToLive,
onLoadDeploymentComplete,
fetchVersions,
}: GeneralDeployProps) {
const [selectedVersion, setSelectedVersion] = useState<number | null>(null)
const [previewMode, setPreviewMode] = useState<PreviewMode>('active')
@@ -227,6 +229,7 @@ export function GeneralDeploy({
onSelectVersion={handleSelectVersion}
onPromoteToLive={handlePromoteToLive}
onLoadDeployment={handleLoadDeployment}
fetchVersions={fetchVersions}
/>
</div>
</div>

View File

@@ -135,9 +135,11 @@ export function DeployModal({
refetch: refetchDeploymentInfo,
} = useDeploymentInfo(workflowId, { enabled: open && isDeployed })
const { data: versionsData, isLoading: versionsLoading } = useDeploymentVersions(workflowId, {
enabled: open,
})
const {
data: versionsData,
isLoading: versionsLoading,
refetch: refetchVersions,
} = useDeploymentVersions(workflowId, { enabled: open })
const {
isLoading: isLoadingChat,
@@ -448,6 +450,10 @@ export function DeployModal({
deleteTrigger?.click()
}, [])
const handleFetchVersions = useCallback(async () => {
await refetchVersions()
}, [refetchVersions])
const isSubmitting = deployMutation.isPending
const isUndeploying = undeployMutation.isPending
@@ -506,6 +512,7 @@ export function DeployModal({
versionsLoading={versionsLoading}
onPromoteToLive={handlePromoteToLive}
onLoadDeploymentComplete={handleCloseModal}
fetchVersions={handleFetchVersions}
/>
</ModalTabsContent>

View File

@@ -0,0 +1,241 @@
/**
* 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'
}
}

View File

@@ -176,7 +176,7 @@ function FormattedInput({
onChange,
onScroll,
}: FormattedInputProps) {
const handleScroll = (e: { currentTarget: HTMLInputElement }) => {
const handleScroll = (e: React.UIEvent<HTMLInputElement>) => {
onScroll(e.currentTarget.scrollLeft)
}

View File

@@ -73,12 +73,7 @@ export const Sidebar = memo(function Sidebar() {
const { data: sessionData, isPending: sessionLoading } = useSession()
const { canEdit } = useUserPermissionsContext()
const { config: permissionConfig, filterBlocks } = usePermissionConfig()
const initializeSearchData = useSearchModalStore((state) => state.initializeData)
useEffect(() => {
initializeSearchData(filterBlocks)
}, [initializeSearchData, filterBlocks])
const { config: permissionConfig } = usePermissionConfig()
/**
* Sidebar state from store with hydration tracking to prevent SSR mismatch.

File diff suppressed because one or more lines are too long

View File

@@ -348,210 +348,6 @@ 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
*/

View File

@@ -7,11 +7,7 @@ import {
} from '@sim/testing'
import { describe, expect, it } from 'vitest'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import {
formatDiffSummaryForDescription,
generateWorkflowDiffSummary,
hasWorkflowChanged,
} from './compare'
import { hasWorkflowChanged } from './compare'
/**
* Type helper for converting test workflow state to app workflow state.
@@ -2739,299 +2735,3 @@ 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')
})
})

View File

@@ -10,6 +10,7 @@ import {
sanitizeInputFormat,
sanitizeTools,
sanitizeVariable,
sortEdges,
} from './normalize'
/** Block with optional diff markers added by copilot */
@@ -27,109 +28,60 @@ type SubBlockWithDiffMarker = {
}
/**
* Compare the current workflow state with the deployed state to detect meaningful changes.
* Uses generateWorkflowDiffSummary internally to ensure consistent change detection.
* 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
*/
export function hasWorkflowChanged(
currentState: WorkflowState,
deployedState: WorkflowState | null
): boolean {
return generateWorkflowDiffSummary(currentState, deployedState).hasChanges
}
// If no deployed state exists, then the workflow has changed
if (!deployedState) return true
/**
* Represents a single field change with old and new values
*/
export interface FieldChange {
field: string
oldValue: unknown
newValue: unknown
}
// 1. Compare edges (connections between blocks)
const currentEdges = currentState.edges || []
const deployedEdges = deployedState.edges || []
/**
* 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
}
const normalizedCurrentEdges = sortEdges(currentEdges.map(normalizeEdge))
const normalizedDeployedEdges = sortEdges(deployedEdges.map(normalizeEdge))
/**
* 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,
if (
normalizedStringify(normalizedCurrentEdges) !== normalizedStringify(normalizedDeployedEdges)
) {
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
// 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
}
const currentBlocks = currentState.blocks || {}
const previousBlocks = previousState.blocks || {}
const currentBlockIds = new Set(Object.keys(currentBlocks))
const previousBlockIds = new Set(Object.keys(previousBlocks))
// 3. Build normalized representations of blocks for comparison
const normalizedCurrentBlocks: Record<string, unknown> = {}
const normalizedDeployedBlocks: Record<string, unknown> = {}
for (const id of currentBlockIds) {
if (!previousBlockIds.has(id)) {
const block = currentBlocks[id]
result.addedBlocks.push({
id,
type: block.type,
name: block.name,
})
}
}
for (const blockId of currentBlockIds) {
const currentBlock = currentState.blocks[blockId]
const deployedBlock = deployedState.blocks[blockId]
for (const id of previousBlockIds) {
if (!currentBlockIds.has(id)) {
const block = previousBlocks[id]
result.removedBlocks.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 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 = {},
@@ -139,308 +91,187 @@ export function generateWorkflowDiffSummary(
is_diff: _currentIsDiff,
field_diffs: _currentFieldDiffs,
...currentRest
} = currentBlock
} = currentBlockWithDiff
const {
position: _previousPos,
subBlocks: previousSubBlocks = {},
layout: _previousLayout,
height: _previousHeight,
outputs: _previousOutputs,
is_diff: _previousIsDiff,
field_diffs: _previousFieldDiffs,
...previousRest
} = previousBlock
position: _deployedPos,
subBlocks: deployedSubBlocks = {},
layout: _deployedLayout,
height: _deployedHeight,
outputs: _deployedOutputs,
is_diff: _deployedIsDiff,
field_diffs: _deployedFieldDiffs,
...deployedRest
} = deployedBlockWithDiff
// 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 || {}
// 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 || {}
const normalizedCurrentBlock = { ...currentRest, data: currentDataRest, subBlocks: undefined }
const normalizedPreviousBlock = {
...previousRest,
data: previousDataRest,
normalizedCurrentBlocks[blockId] = {
...currentRest,
data: currentDataRest,
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 })
}
normalizedDeployedBlocks[blockId] = {
...deployedRest,
data: deployedDataRest,
subBlocks: undefined,
}
// Compare subBlocks
// Get all subBlock IDs from both states, excluding runtime metadata and UI-only elements
const allSubBlockIds = [
...new Set([...Object.keys(currentSubBlocks), ...Object.keys(previousSubBlocks)]),
...new Set([...Object.keys(currentSubBlocks), ...Object.keys(deployedSubBlocks)]),
]
.filter(
(subId) =>
!TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subId) && !SYSTEM_SUBBLOCK_IDS.includes(subId)
(id) => !TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id) && !SYSTEM_SUBBLOCK_IDS.includes(id)
)
.sort()
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
// 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
}
// Compare subBlock values with sanitization
let currentValue: unknown = currentSub.value ?? null
let previousValue: unknown = previousSub.value ?? null
// 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
if (subId === 'tools' && Array.isArray(currentValue) && Array.isArray(previousValue)) {
if (subBlockId === 'tools' && Array.isArray(currentValue) && Array.isArray(deployedValue)) {
currentValue = sanitizeTools(currentValue)
previousValue = sanitizeTools(previousValue)
deployedValue = sanitizeTools(deployedValue)
}
if (subId === 'inputFormat' && Array.isArray(currentValue) && Array.isArray(previousValue)) {
if (
subBlockId === 'inputFormat' &&
Array.isArray(currentValue) &&
Array.isArray(deployedValue)
) {
currentValue = sanitizeInputFormat(currentValue)
previousValue = sanitizeInputFormat(previousValue)
deployedValue = sanitizeInputFormat(deployedValue)
}
// For string values, compare directly to catch even small text changes
if (typeof currentValue === 'string' && typeof previousValue === 'string') {
if (currentValue !== previousValue) {
changes.push({ field: subId, oldValue: previousSub.value, newValue: currentSub.value })
if (typeof currentValue === 'string' && typeof deployedValue === 'string') {
if (currentValue !== deployedValue) {
return true
}
} else {
const normalizedCurrent = normalizeValue(currentValue)
const normalizedPrevious = normalizeValue(previousValue)
if (normalizedStringify(normalizedCurrent) !== normalizedStringify(normalizedPrevious)) {
changes.push({ field: subId, oldValue: previousSub.value, newValue: currentSub.value })
// For other types, use normalized comparison
const normalizedCurrentValue = normalizeValue(currentValue)
const normalizedDeployedValue = normalizeValue(deployedValue)
if (
normalizedStringify(normalizedCurrentValue) !==
normalizedStringify(normalizedDeployedValue)
) {
return true
}
}
// 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
// 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
if (normalizedStringify(currentSubRest) !== normalizedStringify(previousSubRest)) {
changes.push({
field: `${subId}.properties`,
oldValue: previousSubRest,
newValue: currentSubRest,
})
if (normalizedStringify(currentSubBlockRest) !== normalizedStringify(deployedSubBlockRest)) {
return true
}
}
if (changes.length > 0) {
result.modifiedBlocks.push({
id,
type: currentBlock.type,
name: currentBlock.name,
changes,
})
const blocksEqual =
normalizedStringify(normalizedCurrentBlocks[blockId]) ===
normalizedStringify(normalizedDeployedBlocks[blockId])
if (!blocksEqual) {
return true
}
}
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++
}
// 4. Compare loops
const currentLoops = currentState.loops || {}
const previousLoops = previousState.loops || {}
const currentLoopIds = Object.keys(currentLoops)
const previousLoopIds = Object.keys(previousLoops)
const deployedLoops = deployedState.loops || {}
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++
}
}
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 previousLoopIds) {
if (!currentLoopIds.includes(id)) {
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
}
}
// 5. Compare parallels
const currentParallels = currentState.parallels || {}
const previousParallels = previousState.parallels || {}
const currentParallelIds = Object.keys(currentParallels)
const previousParallelIds = Object.keys(previousParallels)
const deployedParallels = deployedState.parallels || {}
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++
}
}
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 previousParallelIds) {
if (!currentParallelIds.includes(id)) {
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
}
}
const currentVars = normalizeVariables(currentState.variables)
const previousVars = normalizeVariables(previousState.variables)
const currentVarIds = Object.keys(currentVars)
const previousVarIds = Object.keys(previousVars)
// 6. Compare variables
const currentVariables = normalizeVariables(currentState.variables)
const deployedVariables = normalizeVariables(deployedState.variables)
result.variableChanges.added = currentVarIds.filter((id) => !previousVarIds.includes(id)).length
result.variableChanges.removed = previousVarIds.filter((id) => !currentVarIds.includes(id)).length
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)])
)
)
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++
}
if (normalizedStringify(normalizedCurrentVars) !== normalizedStringify(normalizedDeployedVars)) {
return true
}
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')
return false
}

View File

@@ -27,7 +27,6 @@ export interface WorkflowDeploymentVersionResponse {
id: string
version: number
name?: string | null
description?: string | null
isActive: boolean
createdAt: string
createdBy?: string | null

View File

@@ -1,155 +1,15 @@
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { create } from 'zustand'
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'
import type { SearchModalState } from './types'
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' }
)
)
export const useSearchModalStore = create<SearchModalState>((set) => ({
isOpen: false,
setOpen: (open: boolean) => {
set({ isOpen: open })
},
open: () => {
set({ isOpen: true })
},
close: () => {
set({ isOpen: false })
},
}))

View File

@@ -1,55 +1,3 @@
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.
*
@@ -60,27 +8,18 @@ export interface SearchData {
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
}

View File

@@ -1 +0,0 @@
ALTER TABLE "workflow_deployment_version" ADD COLUMN "description" text;

File diff suppressed because it is too large Load Diff

View File

@@ -1030,13 +1030,6 @@
"when": 1769134350805,
"tag": "0147_rare_firebrand",
"breakpoints": true
},
{
"idx": 148,
"version": "7",
"when": 1769626313827,
"tag": "0148_aberrant_venom",
"breakpoints": true
}
]
}

View File

@@ -1634,7 +1634,6 @@ 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(),