Compare commits

..

1 Commits

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

View File

@@ -1,6 +1,5 @@
import type React from 'react'
import { findNeighbour } from 'fumadocs-core/page-tree'
import { Pre } from 'fumadocs-ui/components/codeblock'
import defaultMdxComponents from 'fumadocs-ui/mdx'
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/page'
import { ChevronLeft, ChevronRight } from 'lucide-react'
@@ -22,7 +21,6 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
const data = page.data as PageData
const MDX = data.body
const baseUrl = 'https://docs.sim.ai'
const markdownContent = await data.getText('processed')
const pageTreeRecord = source.pageTree as Record<string, any>
const pageTree =
@@ -202,7 +200,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
<div className='relative mt-6 sm:mt-0'>
<div className='absolute top-1 right-0 flex items-center gap-2'>
<div className='hidden sm:flex'>
<LLMCopyButton content={markdownContent} />
<LLMCopyButton markdownUrl={`${page.url}.mdx`} />
</div>
<PageNavigationArrows previous={neighbours?.previous} next={neighbours?.next} />
</div>
@@ -213,11 +211,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
<MDX
components={{
...defaultMdxComponents,
pre: (props: React.HTMLAttributes<HTMLPreElement>) => (
<CodeBlock {...props}>
<Pre>{props.children}</Pre>
</CodeBlock>
),
CodeBlock,
h1: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
<Heading as='h1' {...props} />
),

View File

@@ -3,7 +3,6 @@ import { defineI18nUI } from 'fumadocs-ui/i18n'
import { DocsLayout } from 'fumadocs-ui/layouts/docs'
import { RootProvider } from 'fumadocs-ui/provider/next'
import { Geist_Mono, Inter } from 'next/font/google'
import Script from 'next/script'
import {
SidebarFolder,
SidebarItem,
@@ -18,13 +17,11 @@ import '../global.css'
const inter = Inter({
subsets: ['latin'],
variable: '--font-geist-sans',
display: 'swap',
})
const geistMono = Geist_Mono({
subsets: ['latin'],
variable: '--font-geist-mono',
display: 'swap',
})
const { provider } = defineI18nUI(i18n, {
@@ -96,9 +93,10 @@ export default async function Layout({ children, params }: LayoutProps) {
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
{/* OneDollarStats Analytics - CDN script handles everything automatically */}
<script defer src='https://assets.onedollarstats.com/stonks.js' />
</head>
<body className='flex min-h-screen flex-col font-sans'>
<Script src='https://assets.onedollarstats.com/stonks.js' strategy='lazyOnload' />
<RootProvider i18n={provider(lang)}>
<Navbar />
<DocsLayout

View File

@@ -9,7 +9,7 @@ export default function NotFound() {
<DocsPage>
<DocsBody>
<div className='flex min-h-[60vh] flex-col items-center justify-center text-center'>
<h1 className='mb-4 bg-gradient-to-b from-[#47d991] to-[#33c482] bg-clip-text font-bold text-8xl text-transparent'>
<h1 className='mb-4 bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] bg-clip-text font-bold text-8xl text-transparent'>
404
</h1>
<h2 className='mb-2 font-semibold text-2xl text-foreground'>Page Not Found</h2>

View File

@@ -8,7 +8,13 @@ import { ThemeToggle } from '@/components/ui/theme-toggle'
export function Navbar() {
return (
<nav className='sticky top-0 z-50 border-border/50 border-b bg-background/80 backdrop-blur-md backdrop-saturate-150'>
<nav
className='sticky top-0 z-50 border-border/50 border-b'
style={{
backdropFilter: 'blur(25px) saturate(180%)',
WebkitBackdropFilter: 'blur(25px) saturate(180%)',
}}
>
{/* Desktop: Single row layout */}
<div className='hidden h-16 w-full items-center lg:flex'>
<div

View File

@@ -1,13 +1,45 @@
'use client'
import { useState } from 'react'
import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button'
import { Check, Copy } from 'lucide-react'
export function LLMCopyButton({ content }: { content: string }) {
const [checked, onClick] = useCopyButton(() => navigator.clipboard.writeText(content))
const cache = new Map<string, string>()
export function LLMCopyButton({
markdownUrl,
}: {
/**
* A URL to fetch the raw Markdown/MDX content of page
*/
markdownUrl: string
}) {
const [isLoading, setLoading] = useState(false)
const [checked, onClick] = useCopyButton(async () => {
const cached = cache.get(markdownUrl)
if (cached) return navigator.clipboard.writeText(cached)
setLoading(true)
try {
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': fetch(markdownUrl).then(async (res) => {
const content = await res.text()
cache.set(markdownUrl, content)
return content
}),
}),
])
} finally {
setLoading(false)
}
})
return (
<button
disabled={isLoading}
onClick={onClick}
className='flex cursor-pointer items-center gap-1.5 rounded-lg border border-border/40 bg-background px-2.5 py-2 text-muted-foreground/60 text-sm leading-none transition-all hover:border-border hover:bg-accent/50 hover:text-muted-foreground'
aria-label={checked ? 'Copied to clipboard' : 'Copy page content'}

View File

@@ -17,16 +17,23 @@ export function CodeBlock(props: React.ComponentProps<typeof FumadocsCodeBlock>)
return (
<FumadocsCodeBlock
{...props}
Actions={({ className }) => (
Actions={({ children, className }) => (
<div className={cn('empty:hidden', className)}>
{/* Custom copy button */}
<button
type='button'
aria-label={copied ? 'Copied Text' : 'Copy Text'}
onClick={(e) => {
const pre = (e.currentTarget as HTMLElement).closest('figure')?.querySelector('pre')
const pre = (e.currentTarget as HTMLElement)
.closest('.nd-codeblock')
?.querySelector('pre')
if (pre) handleCopy(pre.textContent || '')
}}
className='cursor-pointer rounded-md p-2 text-muted-foreground transition-colors hover:text-foreground'
className={cn(
'cursor-pointer rounded-md p-2 transition-all',
'border border-border bg-background/80 hover:bg-muted',
'backdrop-blur-sm'
)}
>
<span className='flex items-center justify-center'>
{copied ? (

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,
}
}