Compare commits

...

1 Commits

Author SHA1 Message Date
waleed
74c0ba4ec8 feat(deployments): human-readable version descriptions 2026-01-30 00:39:13 -08:00
6 changed files with 501 additions and 36 deletions

View File

@@ -5127,11 +5127,11 @@ export function SimilarwebIcon(props: SVGProps<SVGSVGElement>) {
<path
d='M22.099 5.781c-1.283 -2 -3.14 -3.67 -5.27 -4.52l-0.63 -0.213a7.433 7.433 0 0 0 -2.15 -0.331c-2.307 0.01 -4.175 1.92 -4.175 4.275a4.3 4.3 0 0 0 0.867 2.602l-0.26 -0.342c0.124 0.186 0.26 0.37 0.417 0.556 0.663 0.802 1.604 1.635 2.822 2.58 2.999 2.32 4.943 4.378 5.104 6.93 0.038 0.344 0.062 0.696 0.062 1.051 0 1.297 -0.283 2.67 -0.764 3.635h0.005s-0.207 0.377 -0.077 0.487c0.066 0.057 0.21 0.1 0.46 -0.053a12.104 12.104 0 0 0 3.4 -3.33 12.111 12.111 0 0 0 2.088 -6.635 12.098 12.098 0 0 0 -1.9 -6.692zm-9.096 8.718 -1.878 -1.55c-3.934 -2.87 -5.98 -5.966 -4.859 -9.783a8.73 8.73 0 0 1 0.37 -1.016v-0.004s0.278 -0.583 -0.327 -0.295a12.067 12.067 0 0 0 -6.292 9.975 12.11 12.11 0 0 0 2.053 7.421 9.394 9.394 0 0 0 2.154 2.168H4.22c4.148 3.053 7.706 1.446 7.706 1.446h0.003a4.847 4.847 0 0 0 2.962 -4.492 4.855 4.855 0 0 0 -1.889 -3.87z'
fill='currentColor'
/>
/>
</svg>
)
}
export function CalComIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -5131,7 +5131,7 @@ export function SimilarwebIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function CalComIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -421,28 +421,27 @@ interface GenerateVersionDescriptionVariables {
onStreamChunk?: (accumulated: string) => void
}
const VERSION_DESCRIPTION_SYSTEM_PROMPT = `You are a technical writer generating concise deployment version descriptions.
const VERSION_DESCRIPTION_SYSTEM_PROMPT = `You are writing deployment version descriptions for a workflow automation platform.
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.
Write a brief, factual description (1-3 sentences, under 400 characters) that states what changed between versions.
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
Guidelines:
- Use the specific values provided (credential names, channel names, model names)
- Be precise: "Changes Slack channel from #general to #alerts" not "Updates channel configuration"
- Combine related changes: "Updates Agent model to claude-sonnet-4-5 and increases temperature to 0.8"
- For added/removed blocks, mention their purpose if clear from the type
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.
Format rules:
- Plain text only, no quotes around the response
- No markdown formatting
- No filler phrases ("for improved efficiency", "streamlining the workflow")
- No version numbers or "This version" prefixes
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.`
Examples:
- Switches Agent model from gpt-4o to claude-sonnet-4-5. Changes Slack credential to Production OAuth.
- Adds Gmail notification block for sending alerts. Removes unused Function block. Updates Router conditions.
- Updates system prompt for more concise responses. Reduces temperature from 0.7 to 0.3.
- Connects Slack block to Router. Adds 2 new workflow connections. Configures error handling path.`
/**
* Hook for generating a version description using AI based on workflow diff
@@ -454,7 +453,7 @@ export function useGenerateVersionDescription() {
version,
onStreamChunk,
}: GenerateVersionDescriptionVariables): Promise<string> => {
const { generateWorkflowDiffSummary, formatDiffSummaryForDescription } = await import(
const { generateWorkflowDiffSummary, formatDiffSummaryForDescriptionAsync } = await import(
'@/lib/workflows/comparison/compare'
)
@@ -470,7 +469,11 @@ export function useGenerateVersionDescription() {
}
const diffSummary = generateWorkflowDiffSummary(currentState, previousState)
const diffText = formatDiffSummaryForDescription(diffSummary)
const diffText = await formatDiffSummaryForDescriptionAsync(
diffSummary,
currentState,
workflowId
)
const wandResponse = await fetch('/api/wand', {
method: 'POST',

View File

@@ -1,3 +1,4 @@
import { createLogger } from '@sim/logger'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import {
extractBlockFieldsForComparison,
@@ -12,6 +13,9 @@ import {
normalizeVariables,
sanitizeVariable,
} from './normalize'
import { formatValueForDisplay, resolveValueForDisplay } from './resolve-values'
const logger = createLogger('WorkflowComparison')
/**
* Compare the current workflow state with the deployed state to detect meaningful changes.
@@ -318,19 +322,6 @@ export function generateWorkflowDiffSummary(
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
*/
@@ -406,3 +397,130 @@ export function formatDiffSummaryForDescription(summary: WorkflowDiffSummary): s
return changes.join('\n')
}
/**
* Converts a WorkflowDiffSummary to a human-readable string with resolved display names.
* Resolves IDs (credentials, channels, workflows, etc.) to human-readable names using
* the selector registry infrastructure.
*
* @param summary - The diff summary to format
* @param currentState - The current workflow state for context extraction
* @param workflowId - The workflow ID for API calls
* @returns A formatted string describing the changes with resolved names
*/
export async function formatDiffSummaryForDescriptionAsync(
summary: WorkflowDiffSummary,
currentState: WorkflowState,
workflowId: string
): Promise<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})`)
}
const modifiedBlockPromises = summary.modifiedBlocks.map(async (block) => {
const name = block.name || block.type
const blockChanges: string[] = []
const changesToProcess = block.changes.slice(0, 3)
const resolvedChanges = await Promise.all(
changesToProcess.map(async (change) => {
const context = {
blockType: block.type,
subBlockId: change.field,
workflowId,
currentState,
blockId: block.id,
}
const [oldResolved, newResolved] = await Promise.all([
resolveValueForDisplay(change.oldValue, context),
resolveValueForDisplay(change.newValue, context),
])
return {
field: change.field,
oldLabel: oldResolved.displayLabel,
newLabel: newResolved.displayLabel,
}
})
)
for (const resolved of resolvedChanges) {
blockChanges.push(
`Modified ${name}: ${resolved.field} changed from "${resolved.oldLabel}" to "${resolved.newLabel}"`
)
}
if (block.changes.length > 3) {
blockChanges.push(` ...and ${block.changes.length - 3} more changes in ${name}`)
}
return blockChanges
})
const allModifiedBlockChanges = await Promise.all(modifiedBlockPromises)
for (const blockChanges of allModifiedBlockChanges) {
changes.push(...blockChanges)
}
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.loopChanges.modified > 0) {
changes.push(`Modified ${summary.loopChanges.modified} 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)`)
}
if (summary.parallelChanges.modified > 0) {
changes.push(`Modified ${summary.parallelChanges.modified} 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(', ')}`)
}
logger.info('Generated async diff description', {
workflowId,
changeCount: changes.length,
modifiedBlocks: summary.modifiedBlocks.length,
})
return changes.join('\n')
}

View File

@@ -1,6 +1,7 @@
export type { FieldChange, WorkflowDiffSummary } from './compare'
export {
formatDiffSummaryForDescription,
formatDiffSummaryForDescriptionAsync,
generateWorkflowDiffSummary,
hasWorkflowChanged,
} from './compare'

View File

@@ -0,0 +1,343 @@
import { createLogger } from '@sim/logger'
import { getBlock } from '@/blocks/registry'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { CREDENTIAL_SET, isUuid } from '@/executor/constants'
import { fetchCredentialSetById } from '@/hooks/queries/credential-sets'
import { fetchOAuthCredentialDetail } from '@/hooks/queries/oauth-credentials'
import { getSelectorDefinition } from '@/hooks/selectors/registry'
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
import type { SelectorKey } from '@/hooks/selectors/types'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('ResolveValues')
/**
* Result of resolving a value for display
*/
export interface ResolvedValue {
/** The original value before resolution */
original: unknown
/** Human-readable label for display */
displayLabel: string
/** Whether the value was successfully resolved to a name */
resolved: boolean
}
/**
* Context needed to resolve values for display
*/
export interface ResolutionContext {
/** The block type (e.g., 'slack', 'gmail') */
blockType: string
/** The subBlock field ID (e.g., 'channel', 'credential') */
subBlockId: string
/** The workflow ID for API calls */
workflowId: string
/** The current workflow state for extracting additional context */
currentState: WorkflowState
/** The block ID being resolved */
blockId?: string
}
/**
* Extended context extracted from block subBlocks for selector resolution
*/
interface ExtendedSelectorContext {
credentialId?: string
domain?: string
projectId?: string
planId?: string
teamId?: string
knowledgeBaseId?: string
siteId?: string
collectionId?: string
spreadsheetId?: string
}
function isResolvableValue(value: unknown): value is string {
if (typeof value !== 'string' || !value) return false
if (value.startsWith(CREDENTIAL_SET.PREFIX)) return true
if (isUuid(value)) return true
if (/^C[A-Z0-9]{8,}$/.test(value)) return true
if (/^[UW][A-Z0-9]{8,}$/.test(value)) return true
return false
}
function getSemanticFallback(subBlockId: string, subBlockConfig?: SubBlockConfig): string {
if (subBlockConfig?.title) {
return subBlockConfig.title.toLowerCase()
}
const patterns: Record<string, string> = {
credential: 'credential',
channel: 'channel',
channelId: 'channel',
user: 'user',
userId: 'user',
workflow: 'workflow',
workflowId: 'workflow',
file: 'file',
fileId: 'file',
folder: 'folder',
folderId: 'folder',
project: 'project',
projectId: 'project',
team: 'team',
teamId: 'team',
sheet: 'sheet',
sheetId: 'sheet',
document: 'document',
documentId: 'document',
knowledgeBase: 'knowledge base',
knowledgeBaseId: 'knowledge base',
server: 'server',
serverId: 'server',
tool: 'tool',
toolId: 'tool',
calendar: 'calendar',
calendarId: 'calendar',
label: 'label',
labelId: 'label',
site: 'site',
siteId: 'site',
collection: 'collection',
collectionId: 'collection',
item: 'item',
itemId: 'item',
contact: 'contact',
contactId: 'contact',
task: 'task',
taskId: 'task',
chat: 'chat',
chatId: 'chat',
}
return patterns[subBlockId] || 'value'
}
async function resolveCredential(credentialId: string, workflowId: string): Promise<string | null> {
try {
if (credentialId.startsWith(CREDENTIAL_SET.PREFIX)) {
const setId = credentialId.slice(CREDENTIAL_SET.PREFIX.length)
const credentialSet = await fetchCredentialSetById(setId)
return credentialSet?.name ?? null
}
const credentials = await fetchOAuthCredentialDetail(credentialId, workflowId)
if (credentials.length > 0) {
return credentials[0].name ?? null
}
return null
} catch (error) {
logger.warn('Failed to resolve credential', { credentialId, error })
return null
}
}
async function resolveWorkflow(workflowId: string): Promise<string | null> {
try {
const definition = getSelectorDefinition('sim.workflows')
if (definition.fetchById) {
const result = await definition.fetchById({
key: 'sim.workflows',
context: {},
detailId: workflowId,
})
return result?.label ?? null
}
return null
} catch (error) {
logger.warn('Failed to resolve workflow', { workflowId, error })
return null
}
}
async function resolveSelectorValue(
value: string,
selectorKey: SelectorKey,
extendedContext: ExtendedSelectorContext,
workflowId: string
): Promise<string | null> {
try {
const definition = getSelectorDefinition(selectorKey)
const selectorContext = {
workflowId,
credentialId: extendedContext.credentialId,
domain: extendedContext.domain,
projectId: extendedContext.projectId,
planId: extendedContext.planId,
teamId: extendedContext.teamId,
knowledgeBaseId: extendedContext.knowledgeBaseId,
siteId: extendedContext.siteId,
collectionId: extendedContext.collectionId,
spreadsheetId: extendedContext.spreadsheetId,
}
if (definition.fetchById) {
const result = await definition.fetchById({
key: selectorKey,
context: selectorContext,
detailId: value,
})
if (result?.label) {
return result.label
}
}
const options = await definition.fetchList({
key: selectorKey,
context: selectorContext,
})
const match = options.find((opt) => opt.id === value)
return match?.label ?? null
} catch (error) {
logger.warn('Failed to resolve selector value', { value, selectorKey, error })
return null
}
}
function extractMcpToolName(toolId: string): string {
const withoutPrefix = toolId.startsWith('mcp-') ? toolId.slice(4) : toolId
const parts = withoutPrefix.split('_')
if (parts.length >= 2) {
return parts[parts.length - 1]
}
return withoutPrefix
}
/**
* Formats a value for display in diff descriptions.
*/
export 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)
}
/**
* Extracts extended context from a block's subBlocks for selector resolution.
* This mirrors the context extraction done in the UI components.
*/
function extractExtendedContext(
blockId: string,
currentState: WorkflowState
): ExtendedSelectorContext {
const block = currentState.blocks?.[blockId]
if (!block?.subBlocks) return {}
const getStringValue = (id: string): string | undefined => {
const subBlock = block.subBlocks[id] as { value?: unknown } | undefined
const val = subBlock?.value
return typeof val === 'string' ? val : undefined
}
return {
credentialId: getStringValue('credential'),
domain: getStringValue('domain'),
projectId: getStringValue('projectId'),
planId: getStringValue('planId'),
teamId: getStringValue('teamId'),
knowledgeBaseId: getStringValue('knowledgeBaseId'),
siteId: getStringValue('siteId'),
collectionId: getStringValue('collectionId'),
spreadsheetId: getStringValue('spreadsheetId') || getStringValue('fileId'),
}
}
/**
* Resolves a value to a human-readable display label.
* Uses the selector registry infrastructure to resolve IDs to names.
*
* @param value - The value to resolve (credential ID, channel ID, UUID, etc.)
* @param context - Context needed for resolution (block type, subBlock ID, workflow state)
* @returns ResolvedValue with the display label and resolution status
*/
export async function resolveValueForDisplay(
value: unknown,
context: ResolutionContext
): Promise<ResolvedValue> {
if (!isResolvableValue(value)) {
return {
original: value,
displayLabel: formatValueForDisplay(value),
resolved: false,
}
}
const blockConfig = getBlock(context.blockType)
const subBlockConfig = blockConfig?.subBlocks.find((sb) => sb.id === context.subBlockId)
const semanticFallback = getSemanticFallback(context.subBlockId, subBlockConfig)
const extendedContext = context.blockId
? extractExtendedContext(context.blockId, context.currentState)
: {}
const isCredentialField =
subBlockConfig?.type === 'oauth-input' || context.subBlockId === 'credential'
if (isCredentialField) {
const label = await resolveCredential(value, context.workflowId)
if (label) {
return { original: value, displayLabel: label, resolved: true }
}
return { original: value, displayLabel: semanticFallback, resolved: true }
}
if (subBlockConfig?.type === 'workflow-selector') {
const label = await resolveWorkflow(value)
if (label) {
return { original: value, displayLabel: label, resolved: true }
}
return { original: value, displayLabel: semanticFallback, resolved: true }
}
if (subBlockConfig?.type === 'mcp-tool-selector') {
const toolName = extractMcpToolName(value)
return { original: value, displayLabel: toolName, resolved: true }
}
if (subBlockConfig && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlockConfig.type)) {
const resolution = resolveSelectorForSubBlock(subBlockConfig, {
workflowId: context.workflowId,
credentialId: extendedContext.credentialId,
domain: extendedContext.domain,
projectId: extendedContext.projectId,
planId: extendedContext.planId,
teamId: extendedContext.teamId,
knowledgeBaseId: extendedContext.knowledgeBaseId,
siteId: extendedContext.siteId,
collectionId: extendedContext.collectionId,
spreadsheetId: extendedContext.spreadsheetId,
})
if (resolution?.key) {
const label = await resolveSelectorValue(
value,
resolution.key,
extendedContext,
context.workflowId
)
if (label) {
return { original: value, displayLabel: label, resolved: true }
}
}
}
if (isUuid(value)) {
return { original: value, displayLabel: semanticFallback, resolved: true }
}
return {
original: value,
displayLabel: formatValueForDisplay(value),
resolved: false,
}
}