mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-30 09:18:01 -05:00
Compare commits
1 Commits
cursor/sta
...
feat/deplo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74c0ba4ec8 |
@@ -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} />
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -71,7 +71,7 @@ export class BlockExecutor {
|
||||
this.callOnBlockStart(ctx, node, block)
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
const startTime = Date.now()
|
||||
let resolvedInputs: Record<string, any> = {}
|
||||
|
||||
const nodeMetadata = this.buildNodeMetadata(node)
|
||||
@@ -145,7 +145,7 @@ export class BlockExecutor {
|
||||
})) as NormalizedBlockOutput
|
||||
}
|
||||
|
||||
const duration = performance.now() - startTime
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
if (blockLog) {
|
||||
blockLog.endedAt = new Date().toISOString()
|
||||
@@ -221,7 +221,7 @@ export class BlockExecutor {
|
||||
isSentinel: boolean,
|
||||
phase: 'input_resolution' | 'execution'
|
||||
): NormalizedBlockOutput {
|
||||
const duration = performance.now() - startTime
|
||||
const duration = Date.now() - startTime
|
||||
const errorMessage = normalizeError(error)
|
||||
const hasResolvedInputs =
|
||||
resolvedInputs && typeof resolvedInputs === 'object' && Object.keys(resolvedInputs).length > 0
|
||||
|
||||
@@ -101,7 +101,7 @@ export class ExecutionEngine {
|
||||
}
|
||||
|
||||
async run(triggerBlockId?: string): Promise<ExecutionResult> {
|
||||
const startTime = performance.now()
|
||||
const startTime = Date.now()
|
||||
try {
|
||||
this.initializeQueue(triggerBlockId)
|
||||
|
||||
@@ -125,8 +125,8 @@ export class ExecutionEngine {
|
||||
return this.buildPausedResult(startTime)
|
||||
}
|
||||
|
||||
const endTime = performance.now()
|
||||
this.context.metadata.endTime = new Date().toISOString()
|
||||
const endTime = Date.now()
|
||||
this.context.metadata.endTime = new Date(endTime).toISOString()
|
||||
this.context.metadata.duration = endTime - startTime
|
||||
|
||||
if (this.cancelledFlag) {
|
||||
@@ -146,8 +146,8 @@ export class ExecutionEngine {
|
||||
metadata: this.context.metadata,
|
||||
}
|
||||
} catch (error) {
|
||||
const endTime = performance.now()
|
||||
this.context.metadata.endTime = new Date().toISOString()
|
||||
const endTime = Date.now()
|
||||
this.context.metadata.endTime = new Date(endTime).toISOString()
|
||||
this.context.metadata.duration = endTime - startTime
|
||||
|
||||
if (this.cancelledFlag) {
|
||||
@@ -433,8 +433,8 @@ export class ExecutionEngine {
|
||||
}
|
||||
|
||||
private buildPausedResult(startTime: number): ExecutionResult {
|
||||
const endTime = performance.now()
|
||||
this.context.metadata.endTime = new Date().toISOString()
|
||||
const endTime = Date.now()
|
||||
this.context.metadata.endTime = new Date(endTime).toISOString()
|
||||
this.context.metadata.duration = endTime - startTime
|
||||
this.context.metadata.status = 'paused'
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type { FieldChange, WorkflowDiffSummary } from './compare'
|
||||
export {
|
||||
formatDiffSummaryForDescription,
|
||||
formatDiffSummaryForDescriptionAsync,
|
||||
generateWorkflowDiffSummary,
|
||||
hasWorkflowChanged,
|
||||
} from './compare'
|
||||
|
||||
343
apps/sim/lib/workflows/comparison/resolve-values.ts
Normal file
343
apps/sim/lib/workflows/comparison/resolve-values.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user