From 088495ba220f6f4736c84d6f78670b2a5a027a85 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 30 Jan 2026 00:11:57 -0800 Subject: [PATCH] feat(deployments): human-readable version descriptions --- apps/docs/components/icons.tsx | 4 +- apps/sim/components/icons.tsx | 2 +- apps/sim/hooks/queries/deployments.ts | 43 ++- apps/sim/lib/workflows/comparison/compare.ts | 144 ++++++- apps/sim/lib/workflows/comparison/index.ts | 1 + .../workflows/comparison/resolve-values.ts | 358 ++++++++++++++++++ 6 files changed, 516 insertions(+), 36 deletions(-) create mode 100644 apps/sim/lib/workflows/comparison/resolve-values.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 6d5cd6c48e..5645f1f9e1 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -5127,11 +5127,11 @@ export function SimilarwebIcon(props: SVGProps) { + /> ) } - + export function CalComIcon(props: SVGProps) { return ( ) { ) } - + export function CalComIcon(props: SVGProps) { return ( 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 => { - 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', diff --git a/apps/sim/lib/workflows/comparison/compare.ts b/apps/sim/lib/workflows/comparison/compare.ts index df70345866..da118fe1cc 100644 --- a/apps/sim/lib/workflows/comparison/compare.ts +++ b/apps/sim/lib/workflows/comparison/compare.ts @@ -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 { + 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') +} diff --git a/apps/sim/lib/workflows/comparison/index.ts b/apps/sim/lib/workflows/comparison/index.ts index 0957954711..6a5e57234f 100644 --- a/apps/sim/lib/workflows/comparison/index.ts +++ b/apps/sim/lib/workflows/comparison/index.ts @@ -1,6 +1,7 @@ export type { FieldChange, WorkflowDiffSummary } from './compare' export { formatDiffSummaryForDescription, + formatDiffSummaryForDescriptionAsync, generateWorkflowDiffSummary, hasWorkflowChanged, } from './compare' diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts new file mode 100644 index 0000000000..4912654023 --- /dev/null +++ b/apps/sim/lib/workflows/comparison/resolve-values.ts @@ -0,0 +1,358 @@ +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 + */ +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 + */ +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 getSemanticFallback(subBlockId: string, subBlockConfig?: SubBlockConfig): string { + if (subBlockConfig?.title) { + return subBlockConfig.title.toLowerCase() + } + + const patterns: Record = { + 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 { + 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 { + 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 { + 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 { + // Non-string or empty values can't be resolved + if (typeof value !== 'string' || !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) + : {} + + // Credential fields (oauth-input or credential subBlockId) + const isCredentialField = + subBlockConfig?.type === 'oauth-input' || context.subBlockId === 'credential' + + if (isCredentialField && (value.startsWith(CREDENTIAL_SET.PREFIX) || isUuid(value))) { + const label = await resolveCredential(value, context.workflowId) + if (label) { + return { original: value, displayLabel: label, resolved: true } + } + return { original: value, displayLabel: semanticFallback, resolved: true } + } + + // Workflow selector + if (subBlockConfig?.type === 'workflow-selector' && isUuid(value)) { + const label = await resolveWorkflow(value) + if (label) { + return { original: value, displayLabel: label, resolved: true } + } + return { original: value, displayLabel: semanticFallback, resolved: true } + } + + // MCP tool selector + if (subBlockConfig?.type === 'mcp-tool-selector') { + const toolName = extractMcpToolName(value) + return { original: value, displayLabel: toolName, resolved: true } + } + + // Selector types that require hydration (file-selector, sheet-selector, etc.) + // These support external service IDs like Google Drive file IDs + 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 resolution failed for a hydration-required type, use semantic fallback + return { original: value, displayLabel: semanticFallback, resolved: true } + } + + // For fields without specific subBlock types, use pattern matching + // UUID fallback + if (isUuid(value)) { + return { original: value, displayLabel: semanticFallback, resolved: true } + } + + // Slack-style IDs (channels: C..., users: U.../W...) get semantic fallback + if (/^C[A-Z0-9]{8,}$/.test(value) || /^[UW][A-Z0-9]{8,}$/.test(value)) { + return { original: value, displayLabel: semanticFallback, resolved: true } + } + + // Credential set prefix without credential field type + if (value.startsWith(CREDENTIAL_SET.PREFIX)) { + const label = await resolveCredential(value, context.workflowId) + if (label) { + return { original: value, displayLabel: label, resolved: true } + } + return { original: value, displayLabel: semanticFallback, resolved: true } + } + + return { + original: value, + displayLabel: formatValueForDisplay(value), + resolved: false, + } +}