Compare commits

...

4 Commits

Author SHA1 Message Date
Vikhyath Mondreti
526b7a64f6 update templates routes to use helper 2026-01-19 16:20:26 -08:00
Siddharth Ganesan
9da689bc8e Fix 2026-01-19 15:58:07 -08:00
Siddharth Ganesan
e1bea05de0 Superuser debug 2026-01-19 15:42:55 -08:00
Siddharth Ganesan
5f45db4343 improvement(copilot): variables, conditions, router (#2887)
* Temp

* Condition and router copilot syntax updates

* Plan respond plan
2026-01-19 15:24:50 -08:00
13 changed files with 598 additions and 135 deletions

View File

@@ -1,10 +1,11 @@
import { db } from '@sim/db'
import { templateCreators, user } from '@sim/db/schema'
import { templateCreators } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
const logger = createLogger('CreatorVerificationAPI')
@@ -23,9 +24,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
// Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (!currentUser[0]?.isSuperUser) {
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
if (!effectiveSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to verify creator: ${id}`)
return NextResponse.json({ error: 'Only super users can verify creators' }, { status: 403 })
}
@@ -76,9 +76,8 @@ export async function DELETE(
}
// Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (!currentUser[0]?.isSuperUser) {
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
if (!effectiveSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to unverify creator: ${id}`)
return NextResponse.json({ error: 'Only super users can unverify creators' }, { status: 403 })
}

View File

@@ -0,0 +1,193 @@
import { db } from '@sim/db'
import { copilotChats, workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
import {
loadWorkflowFromNormalizedTables,
saveWorkflowToNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
const logger = createLogger('SuperUserImportWorkflow')
interface ImportWorkflowRequest {
workflowId: string
targetWorkspaceId: string
}
/**
* POST /api/superuser/import-workflow
*
* Superuser endpoint to import a workflow by ID along with its copilot chats.
* This creates a copy of the workflow in the target workspace with new IDs.
* Only the workflow structure and copilot chats are copied - no deployments,
* webhooks, triggers, or other sensitive data.
*
* Requires both isSuperUser flag AND superUserModeEnabled setting.
*/
export async function POST(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { effectiveSuperUser, isSuperUser, superUserModeEnabled } =
await verifyEffectiveSuperUser(session.user.id)
if (!effectiveSuperUser) {
logger.warn('Non-effective-superuser attempted to access import-workflow endpoint', {
userId: session.user.id,
isSuperUser,
superUserModeEnabled,
})
return NextResponse.json({ error: 'Forbidden: Superuser access required' }, { status: 403 })
}
const body: ImportWorkflowRequest = await request.json()
const { workflowId, targetWorkspaceId } = body
if (!workflowId) {
return NextResponse.json({ error: 'workflowId is required' }, { status: 400 })
}
if (!targetWorkspaceId) {
return NextResponse.json({ error: 'targetWorkspaceId is required' }, { status: 400 })
}
// Verify target workspace exists
const [targetWorkspace] = await db
.select({ id: workspace.id, ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, targetWorkspaceId))
.limit(1)
if (!targetWorkspace) {
return NextResponse.json({ error: 'Target workspace not found' }, { status: 404 })
}
// Get the source workflow
const [sourceWorkflow] = await db
.select()
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!sourceWorkflow) {
return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 })
}
// Load the workflow state from normalized tables
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData) {
return NextResponse.json(
{ error: 'Workflow has no normalized data - cannot import' },
{ status: 400 }
)
}
// Use existing export logic to create export format
const workflowState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
metadata: {
name: sourceWorkflow.name,
description: sourceWorkflow.description ?? undefined,
color: sourceWorkflow.color,
},
}
const exportData = sanitizeForExport(workflowState)
// Use existing import logic (parseWorkflowJson regenerates IDs automatically)
const { data: importedData, errors } = parseWorkflowJson(JSON.stringify(exportData))
if (!importedData || errors.length > 0) {
return NextResponse.json(
{ error: `Failed to parse workflow: ${errors.join(', ')}` },
{ status: 400 }
)
}
// Create new workflow record
const newWorkflowId = crypto.randomUUID()
const now = new Date()
await db.insert(workflow).values({
id: newWorkflowId,
userId: session.user.id,
workspaceId: targetWorkspaceId,
folderId: null, // Don't copy folder association
name: `[Debug Import] ${sourceWorkflow.name}`,
description: sourceWorkflow.description,
color: sourceWorkflow.color,
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false, // Never copy deployment status
runCount: 0,
variables: sourceWorkflow.variables || {},
})
// Save using existing persistence logic
const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, importedData)
if (!saveResult.success) {
// Clean up the workflow record if save failed
await db.delete(workflow).where(eq(workflow.id, newWorkflowId))
return NextResponse.json(
{ error: `Failed to save workflow state: ${saveResult.error}` },
{ status: 500 }
)
}
// Copy copilot chats associated with the source workflow
const sourceCopilotChats = await db
.select()
.from(copilotChats)
.where(eq(copilotChats.workflowId, workflowId))
let copilotChatsImported = 0
for (const chat of sourceCopilotChats) {
await db.insert(copilotChats).values({
userId: session.user.id,
workflowId: newWorkflowId,
title: chat.title ? `[Import] ${chat.title}` : null,
messages: chat.messages,
model: chat.model,
conversationId: null, // Don't copy conversation ID
previewYaml: chat.previewYaml,
planArtifact: chat.planArtifact,
config: chat.config,
createdAt: new Date(),
updatedAt: new Date(),
})
copilotChatsImported++
}
logger.info('Superuser imported workflow', {
userId: session.user.id,
sourceWorkflowId: workflowId,
newWorkflowId,
targetWorkspaceId,
copilotChatsImported,
})
return NextResponse.json({
success: true,
newWorkflowId,
copilotChatsImported,
})
} catch (error) {
logger.error('Error importing workflow', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { verifySuperUser } from '@/lib/templates/permissions'
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
const logger = createLogger('TemplateApprovalAPI')
@@ -25,8 +25,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { isSuperUser } = await verifySuperUser(session.user.id)
if (!isSuperUser) {
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
if (!effectiveSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`)
return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 })
}
@@ -71,8 +71,8 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { isSuperUser } = await verifySuperUser(session.user.id)
if (!isSuperUser) {
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
if (!effectiveSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
}

View File

@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { verifySuperUser } from '@/lib/templates/permissions'
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
const logger = createLogger('TemplateRejectionAPI')
@@ -25,8 +25,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { isSuperUser } = await verifySuperUser(session.user.id)
if (!isSuperUser) {
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
if (!effectiveSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
}

View File

@@ -3,7 +3,6 @@ import {
templateCreators,
templateStars,
templates,
user,
workflow,
workflowDeploymentVersion,
} from '@sim/db/schema'
@@ -14,6 +13,7 @@ import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
import {
extractRequiredCredentials,
sanitizeCredentials,
@@ -70,8 +70,8 @@ export async function GET(request: NextRequest) {
logger.debug(`[${requestId}] Fetching templates with params:`, params)
// Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
const isSuperUser = currentUser[0]?.isSuperUser || false
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
const isSuperUser = effectiveSuperUser
// Build query conditions
const conditions = []

View File

@@ -26,9 +26,6 @@ import { CLASS_TOOL_METADATA } from '@/stores/panel/copilot/store'
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Parse special tags from content
*/
/**
* Plan step can be either a string or an object with title and plan
*/
@@ -47,6 +44,56 @@ interface ParsedTags {
cleanContent: string
}
/**
* Extract plan steps from plan_respond tool calls in subagent blocks.
* Returns { steps, isComplete } where steps is in the format expected by PlanSteps component.
*/
function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
steps: Record<string, PlanStep> | undefined
isComplete: boolean
} {
if (!blocks) return { steps: undefined, isComplete: false }
// Find the plan_respond tool call
const planRespondBlock = blocks.find(
(b) => b.type === 'subagent_tool_call' && b.toolCall?.name === 'plan_respond'
)
if (!planRespondBlock?.toolCall) {
return { steps: undefined, isComplete: false }
}
// Tool call arguments can be in different places depending on the source
// Also handle nested data.arguments structure from the schema
const tc = planRespondBlock.toolCall as any
const args = tc.params || tc.parameters || tc.input || tc.arguments || tc.data?.arguments || {}
const stepsArray = args.steps
if (!Array.isArray(stepsArray) || stepsArray.length === 0) {
return { steps: undefined, isComplete: false }
}
// Convert array format to Record<string, PlanStep> format
// From: [{ number: 1, title: "..." }, { number: 2, title: "..." }]
// To: { "1": "...", "2": "..." }
const steps: Record<string, PlanStep> = {}
for (const step of stepsArray) {
if (step.number !== undefined && step.title) {
steps[String(step.number)] = step.title
}
}
// Check if the tool call is complete (not pending/executing)
const isComplete =
planRespondBlock.toolCall.state === ClientToolCallState.success ||
planRespondBlock.toolCall.state === ClientToolCallState.error
return {
steps: Object.keys(steps).length > 0 ? steps : undefined,
isComplete,
}
}
/**
* Try to parse partial JSON for streaming options.
* Attempts to extract complete key-value pairs from incomplete JSON.
@@ -654,11 +701,20 @@ function SubAgentThinkingContent({
}
}
// Extract plan from plan_respond tool call (preferred) or fall back to <plan> tags
const { steps: planSteps, isComplete: planComplete } = extractPlanFromBlocks(blocks)
const allParsed = parseSpecialTags(allRawText)
if (!cleanText.trim() && !allParsed.plan) return null
// Prefer plan_respond tool data over <plan> tags
const hasPlan =
!!(planSteps && Object.keys(planSteps).length > 0) ||
!!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
const planToRender = planSteps || allParsed.plan
const isPlanStreaming = planSteps ? !planComplete : isStreaming
const hasSpecialTags = !!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
if (!cleanText.trim() && !hasPlan) return null
const hasSpecialTags = hasPlan
return (
<div className='space-y-1.5'>
@@ -670,9 +726,7 @@ function SubAgentThinkingContent({
hasSpecialTags={hasSpecialTags}
/>
)}
{allParsed.plan && Object.keys(allParsed.plan).length > 0 && (
<PlanSteps steps={allParsed.plan} streaming={isStreaming} />
)}
{hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
</div>
)
}
@@ -744,8 +798,19 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
}
const allParsed = parseSpecialTags(allRawText)
// Extract plan from plan_respond tool call (preferred) or fall back to <plan> tags
const { steps: planSteps, isComplete: planComplete } = extractPlanFromBlocks(
toolCall.subAgentBlocks
)
const hasPlan =
!!(planSteps && Object.keys(planSteps).length > 0) ||
!!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
const planToRender = planSteps || allParsed.plan
const isPlanStreaming = planSteps ? !planComplete : isStreaming
const hasSpecialTags = !!(
(allParsed.plan && Object.keys(allParsed.plan).length > 0) ||
hasPlan ||
(allParsed.options && Object.keys(allParsed.options).length > 0)
)
@@ -757,8 +822,6 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
const outerLabel = getSubagentCompletionLabel(toolCall.name)
const durationText = `${outerLabel} for ${formatDuration(duration)}`
const hasPlan = allParsed.plan && Object.keys(allParsed.plan).length > 0
const renderCollapsibleContent = () => (
<>
{segments.map((segment, index) => {
@@ -800,7 +863,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
return (
<div className='w-full space-y-1.5'>
{renderCollapsibleContent()}
{hasPlan && <PlanSteps steps={allParsed.plan!} streaming={isStreaming} />}
{hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
</div>
)
}
@@ -832,7 +895,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
</div>
{/* Plan stays outside the collapsible */}
{hasPlan && <PlanSteps steps={allParsed.plan!} />}
{hasPlan && planToRender && <PlanSteps steps={planToRender} />}
</div>
)
})
@@ -1412,7 +1475,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
if (
toolCall.name === 'checkoff_todo' ||
toolCall.name === 'mark_todo_in_progress' ||
toolCall.name === 'tool_search_tool_regex'
toolCall.name === 'tool_search_tool_regex' ||
toolCall.name === 'user_memory' ||
toolCall.name === 'edit_respond' ||
toolCall.name === 'debug_respond' ||
toolCall.name === 'plan_respond'
)
return null

View File

@@ -0,0 +1,80 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { Button, Input as EmcnInput } from '@/components/emcn'
import { workflowKeys } from '@/hooks/queries/workflows'
const logger = createLogger('DebugSettings')
/**
* Debug settings component for superusers.
* Allows importing workflows by ID for debugging purposes.
*/
export function Debug() {
const params = useParams()
const queryClient = useQueryClient()
const workspaceId = params?.workspaceId as string
const [workflowId, setWorkflowId] = useState('')
const [isImporting, setIsImporting] = useState(false)
const handleImport = async () => {
if (!workflowId.trim()) return
setIsImporting(true)
try {
const response = await fetch('/api/superuser/import-workflow', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId: workflowId.trim(),
targetWorkspaceId: workspaceId,
}),
})
const data = await response.json()
if (response.ok) {
await queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) })
setWorkflowId('')
logger.info('Workflow imported successfully', {
originalWorkflowId: workflowId.trim(),
newWorkflowId: data.newWorkflowId,
copilotChatsImported: data.copilotChatsImported,
})
}
} catch (error) {
logger.error('Failed to import workflow', error)
} finally {
setIsImporting(false)
}
}
return (
<div className='flex h-full flex-col gap-[16px]'>
<p className='text-[13px] text-[var(--text-secondary)]'>
Import a workflow by ID along with its associated copilot chats.
</p>
<div className='flex gap-[8px]'>
<EmcnInput
value={workflowId}
onChange={(e) => setWorkflowId(e.target.value)}
placeholder='Enter workflow ID'
disabled={isImporting}
/>
<Button
variant='tertiary'
onClick={handleImport}
disabled={isImporting || !workflowId.trim()}
>
{isImporting ? 'Importing...' : 'Import'}
</Button>
</div>
</div>
)
}

View File

@@ -4,6 +4,7 @@ export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot'
export { CredentialSets } from './credential-sets/credential-sets'
export { CustomTools } from './custom-tools/custom-tools'
export { Debug } from './debug/debug'
export { EnvironmentVariables } from './environment/environment'
export { Files as FileUploads } from './files/files'
export { General } from './general/general'

View File

@@ -5,6 +5,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { useQueryClient } from '@tanstack/react-query'
import {
Bug,
Files,
KeySquare,
LogIn,
@@ -46,6 +47,7 @@ import {
Copilot,
CredentialSets,
CustomTools,
Debug,
EnvironmentVariables,
FileUploads,
General,
@@ -91,8 +93,15 @@ type SettingsSection =
| 'mcp'
| 'custom-tools'
| 'workflow-mcp-servers'
| 'debug'
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system' | 'enterprise'
type NavigationSection =
| 'account'
| 'subscription'
| 'tools'
| 'system'
| 'enterprise'
| 'superuser'
type NavigationItem = {
id: SettingsSection
@@ -104,6 +113,7 @@ type NavigationItem = {
requiresEnterprise?: boolean
requiresHosted?: boolean
selfHostedOverride?: boolean
requiresSuperUser?: boolean
}
const sectionConfig: { key: NavigationSection; title: string }[] = [
@@ -112,6 +122,7 @@ const sectionConfig: { key: NavigationSection; title: string }[] = [
{ key: 'subscription', title: 'Subscription' },
{ key: 'system', title: 'System' },
{ key: 'enterprise', title: 'Enterprise' },
{ key: 'superuser', title: 'Superuser' },
]
const allNavigationItems: NavigationItem[] = [
@@ -180,15 +191,24 @@ const allNavigationItems: NavigationItem[] = [
requiresEnterprise: true,
selfHostedOverride: isSSOEnabled,
},
{
id: 'debug',
label: 'Debug',
icon: Bug,
section: 'superuser',
requiresSuperUser: true,
},
]
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
const [isSuperUser, setIsSuperUser] = useState(false)
const { data: session } = useSession()
const queryClient = useQueryClient()
const { data: organizationsData } = useOrganizations()
const { data: generalSettings } = useGeneralSettings()
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
@@ -209,6 +229,23 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const hasEnterprisePlan = subscriptionStatus.isEnterprise
const hasOrganization = !!activeOrganization?.id
// Fetch superuser status
useEffect(() => {
const fetchSuperUserStatus = async () => {
if (!userId) return
try {
const response = await fetch('/api/user/super-user')
if (response.ok) {
const data = await response.json()
setIsSuperUser(data.isSuperUser)
}
} catch {
setIsSuperUser(false)
}
}
fetchSuperUserStatus()
}, [userId])
// Memoize SSO provider ownership check
const isSSOProviderOwner = useMemo(() => {
if (isHosted) return null
@@ -268,6 +305,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
return false
}
// requiresSuperUser: only show if user is a superuser AND has superuser mode enabled
const superUserModeEnabled = generalSettings?.superUserModeEnabled ?? false
const effectiveSuperUser = isSuperUser && superUserModeEnabled
if (item.requiresSuperUser && !effectiveSuperUser) {
return false
}
return true
})
}, [
@@ -280,6 +324,8 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
isOwner,
isAdmin,
permissionConfig,
isSuperUser,
generalSettings?.superUserModeEnabled,
])
// Memoized callbacks to prevent infinite loops in child components
@@ -308,9 +354,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
[activeSection]
)
// React Query hook automatically loads and syncs settings
useGeneralSettings()
// Apply initial section from store when modal opens
useEffect(() => {
if (open && initialSection) {
@@ -523,6 +566,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
{activeSection === 'custom-tools' && <CustomTools />}
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
{activeSection === 'debug' && <Debug />}
</SModalMainBody>
</SModalMain>
</SModalContent>

View File

@@ -209,13 +209,17 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
}
}
const variablesArray = Object.values(byName)
// Convert byName (keyed by name) to record keyed by ID for the API
const variablesRecord: Record<string, any> = {}
for (const v of Object.values(byName)) {
variablesRecord[v.id] = v
}
// POST full variables array to persist
// POST full variables record to persist
const res = await fetch(`/api/workflows/${payload.workflowId}/variables`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variables: variablesArray }),
body: JSON.stringify({ variables: variablesRecord }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')

View File

@@ -817,6 +817,8 @@ function normalizeResponseFormat(value: any): string {
interface EdgeHandleValidationResult {
valid: boolean
error?: string
/** The normalized handle to use (e.g., simple 'if' normalized to 'condition-{uuid}') */
normalizedHandle?: string
}
/**
@@ -851,13 +853,6 @@ function validateSourceHandleForBlock(
}
case 'condition': {
if (!sourceHandle.startsWith(EDGE.CONDITION_PREFIX)) {
return {
valid: false,
error: `Invalid source handle "${sourceHandle}" for condition block. Must start with "${EDGE.CONDITION_PREFIX}"`,
}
}
const conditionsValue = sourceBlock?.subBlocks?.conditions?.value
if (!conditionsValue) {
return {
@@ -866,6 +861,8 @@ function validateSourceHandleForBlock(
}
}
// validateConditionHandle accepts simple format (if, else-if-0, else),
// legacy format (condition-{blockId}-if), and internal ID format (condition-{uuid})
return validateConditionHandle(sourceHandle, sourceBlock.id, conditionsValue)
}
@@ -879,13 +876,6 @@ function validateSourceHandleForBlock(
}
case 'router_v2': {
if (!sourceHandle.startsWith(EDGE.ROUTER_PREFIX)) {
return {
valid: false,
error: `Invalid source handle "${sourceHandle}" for router_v2 block. Must start with "${EDGE.ROUTER_PREFIX}"`,
}
}
const routesValue = sourceBlock?.subBlocks?.routes?.value
if (!routesValue) {
return {
@@ -894,6 +884,8 @@ function validateSourceHandleForBlock(
}
}
// validateRouterHandle accepts simple format (route-0, route-1),
// legacy format (router-{blockId}-route-1), and internal ID format (router-{uuid})
return validateRouterHandle(sourceHandle, sourceBlock.id, routesValue)
}
@@ -910,7 +902,12 @@ function validateSourceHandleForBlock(
/**
* Validates condition handle references a valid condition in the block.
* Accepts both internal IDs (condition-blockId-if) and semantic keys (condition-blockId-else-if)
* Accepts multiple formats:
* - Simple format: "if", "else-if-0", "else-if-1", "else"
* - Legacy semantic format: "condition-{blockId}-if", "condition-{blockId}-else-if"
* - Internal ID format: "condition-{conditionId}"
*
* Returns the normalized handle (condition-{conditionId}) for storage.
*/
function validateConditionHandle(
sourceHandle: string,
@@ -943,48 +940,80 @@ function validateConditionHandle(
}
}
const validHandles = new Set<string>()
const semanticPrefix = `condition-${blockId}-`
let elseIfCount = 0
// Build a map of all valid handle formats -> normalized handle (condition-{conditionId})
const handleToNormalized = new Map<string, string>()
const legacySemanticPrefix = `condition-${blockId}-`
let elseIfIndex = 0
for (const condition of conditions) {
if (condition.id) {
validHandles.add(`condition-${condition.id}`)
}
if (!condition.id) continue
const normalizedHandle = `condition-${condition.id}`
const title = condition.title?.toLowerCase()
// Always accept internal ID format
handleToNormalized.set(normalizedHandle, normalizedHandle)
if (title === 'if') {
// Simple format: "if"
handleToNormalized.set('if', normalizedHandle)
// Legacy format: "condition-{blockId}-if"
handleToNormalized.set(`${legacySemanticPrefix}if`, normalizedHandle)
} else if (title === 'else if') {
// Simple format: "else-if-0", "else-if-1", etc. (0-indexed)
handleToNormalized.set(`else-if-${elseIfIndex}`, normalizedHandle)
// Legacy format: "condition-{blockId}-else-if" for first, "condition-{blockId}-else-if-2" for second
if (elseIfIndex === 0) {
handleToNormalized.set(`${legacySemanticPrefix}else-if`, normalizedHandle)
} else {
handleToNormalized.set(
`${legacySemanticPrefix}else-if-${elseIfIndex + 1}`,
normalizedHandle
)
}
elseIfIndex++
} else if (title === 'else') {
// Simple format: "else"
handleToNormalized.set('else', normalizedHandle)
// Legacy format: "condition-{blockId}-else"
handleToNormalized.set(`${legacySemanticPrefix}else`, normalizedHandle)
}
}
const normalizedHandle = handleToNormalized.get(sourceHandle)
if (normalizedHandle) {
return { valid: true, normalizedHandle }
}
// Build list of valid simple format options for error message
const simpleOptions: string[] = []
elseIfIndex = 0
for (const condition of conditions) {
const title = condition.title?.toLowerCase()
if (title === 'if') {
validHandles.add(`${semanticPrefix}if`)
simpleOptions.push('if')
} else if (title === 'else if') {
elseIfCount++
validHandles.add(
elseIfCount === 1 ? `${semanticPrefix}else-if` : `${semanticPrefix}else-if-${elseIfCount}`
)
simpleOptions.push(`else-if-${elseIfIndex}`)
elseIfIndex++
} else if (title === 'else') {
validHandles.add(`${semanticPrefix}else`)
simpleOptions.push('else')
}
}
if (validHandles.has(sourceHandle)) {
return { valid: true }
}
const validOptions = Array.from(validHandles).slice(0, 5)
const moreCount = validHandles.size - validOptions.length
let validOptionsStr = validOptions.join(', ')
if (moreCount > 0) {
validOptionsStr += `, ... and ${moreCount} more`
}
return {
valid: false,
error: `Invalid condition handle "${sourceHandle}". Valid handles: ${validOptionsStr}`,
error: `Invalid condition handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`,
}
}
/**
* Validates router handle references a valid route in the block.
* Accepts both internal IDs (router-{routeId}) and semantic keys (router-{blockId}-route-1)
* Accepts multiple formats:
* - Simple format: "route-0", "route-1", "route-2" (0-indexed)
* - Legacy semantic format: "router-{blockId}-route-1" (1-indexed)
* - Internal ID format: "router-{routeId}"
*
* Returns the normalized handle (router-{routeId}) for storage.
*/
function validateRouterHandle(
sourceHandle: string,
@@ -1017,47 +1046,48 @@ function validateRouterHandle(
}
}
const validHandles = new Set<string>()
const semanticPrefix = `router-${blockId}-`
// Build a map of all valid handle formats -> normalized handle (router-{routeId})
const handleToNormalized = new Map<string, string>()
const legacySemanticPrefix = `router-${blockId}-`
for (let i = 0; i < routes.length; i++) {
const route = routes[i]
if (!route.id) continue
// Accept internal ID format: router-{uuid}
if (route.id) {
validHandles.add(`router-${route.id}`)
}
const normalizedHandle = `router-${route.id}`
// Accept 1-indexed route number format: router-{blockId}-route-1, router-{blockId}-route-2, etc.
validHandles.add(`${semanticPrefix}route-${i + 1}`)
// Always accept internal ID format: router-{uuid}
handleToNormalized.set(normalizedHandle, normalizedHandle)
// Simple format: route-0, route-1, etc. (0-indexed)
handleToNormalized.set(`route-${i}`, normalizedHandle)
// Legacy 1-indexed route number format: router-{blockId}-route-1
handleToNormalized.set(`${legacySemanticPrefix}route-${i + 1}`, normalizedHandle)
// Accept normalized title format: router-{blockId}-{normalized-title}
// Normalize: lowercase, replace spaces with dashes, remove special chars
if (route.title && typeof route.title === 'string') {
const normalizedTitle = route.title
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
if (normalizedTitle) {
validHandles.add(`${semanticPrefix}${normalizedTitle}`)
handleToNormalized.set(`${legacySemanticPrefix}${normalizedTitle}`, normalizedHandle)
}
}
}
if (validHandles.has(sourceHandle)) {
return { valid: true }
const normalizedHandle = handleToNormalized.get(sourceHandle)
if (normalizedHandle) {
return { valid: true, normalizedHandle }
}
const validOptions = Array.from(validHandles).slice(0, 5)
const moreCount = validHandles.size - validOptions.length
let validOptionsStr = validOptions.join(', ')
if (moreCount > 0) {
validOptionsStr += `, ... and ${moreCount} more`
}
// Build list of valid simple format options for error message
const simpleOptions = routes.map((_, i) => `route-${i}`)
return {
valid: false,
error: `Invalid router handle "${sourceHandle}". Valid handles: ${validOptionsStr}`,
error: `Invalid router handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`,
}
}
@@ -1172,10 +1202,13 @@ function createValidatedEdge(
return false
}
// Use normalized handle if available (e.g., 'if' -> 'condition-{uuid}')
const finalSourceHandle = sourceValidation.normalizedHandle || sourceHandle
modifiedState.edges.push({
id: crypto.randomUUID(),
source: sourceBlockId,
sourceHandle,
sourceHandle: finalSourceHandle,
target: targetBlockId,
targetHandle,
type: 'default',
@@ -1184,7 +1217,11 @@ function createValidatedEdge(
}
/**
* Adds connections as edges for a block
* Adds connections as edges for a block.
* Supports multiple target formats:
* - String: "target-block-id"
* - Object: { block: "target-block-id", handle?: "custom-target-handle" }
* - Array of strings or objects
*/
function addConnectionsAsEdges(
modifiedState: any,
@@ -1194,19 +1231,34 @@ function addConnectionsAsEdges(
skippedItems?: SkippedItem[]
): void {
Object.entries(connections).forEach(([sourceHandle, targets]) => {
const targetArray = Array.isArray(targets) ? targets : [targets]
targetArray.forEach((targetId: string) => {
if (targets === null) return
const addEdgeForTarget = (targetBlock: string, targetHandle?: string) => {
createValidatedEdge(
modifiedState,
blockId,
targetId,
targetBlock,
sourceHandle,
'target',
targetHandle || 'target',
'add_edge',
logger,
skippedItems
)
})
}
if (typeof targets === 'string') {
addEdgeForTarget(targets)
} else if (Array.isArray(targets)) {
targets.forEach((target: any) => {
if (typeof target === 'string') {
addEdgeForTarget(target)
} else if (target?.block) {
addEdgeForTarget(target.block, target.handle)
}
})
} else if (typeof targets === 'object' && targets?.block) {
addEdgeForTarget(targets.block, targets.handle)
}
})
}

View File

@@ -1,18 +1,41 @@
import { db } from '@sim/db'
import { member, templateCreators, templates, user } from '@sim/db/schema'
import { member, settings, templateCreators, templates, user } from '@sim/db/schema'
import { and, eq, or } from 'drizzle-orm'
export type CreatorPermissionLevel = 'member' | 'admin'
/**
* Verifies if a user is a super user.
* Verifies if a user is an effective super user (database flag AND settings toggle).
* This should be used for features that can be disabled by the user's settings toggle.
*
* @param userId - The ID of the user to check
* @returns Object with isSuperUser boolean
* @returns Object with effectiveSuperUser boolean and component values
*/
export async function verifySuperUser(userId: string): Promise<{ isSuperUser: boolean }> {
const [currentUser] = await db.select().from(user).where(eq(user.id, userId)).limit(1)
return { isSuperUser: currentUser?.isSuperUser || false }
export async function verifyEffectiveSuperUser(userId: string): Promise<{
effectiveSuperUser: boolean
isSuperUser: boolean
superUserModeEnabled: boolean
}> {
const [currentUser] = await db
.select({ isSuperUser: user.isSuperUser })
.from(user)
.where(eq(user.id, userId))
.limit(1)
const [userSettings] = await db
.select({ superUserModeEnabled: settings.superUserModeEnabled })
.from(settings)
.where(eq(settings.userId, userId))
.limit(1)
const isSuperUser = currentUser?.isSuperUser || false
const superUserModeEnabled = userSettings?.superUserModeEnabled ?? false
return {
effectiveSuperUser: isSuperUser && superUserModeEnabled,
isSuperUser,
superUserModeEnabled,
}
}
/**

View File

@@ -269,11 +269,12 @@ function sanitizeSubBlocks(
}
/**
* Convert internal condition handle (condition-{uuid}) to semantic format (condition-{blockId}-if)
* Convert internal condition handle (condition-{uuid}) to simple format (if, else-if-0, else)
* Uses 0-indexed numbering for else-if conditions
*/
function convertConditionHandleToSemantic(
function convertConditionHandleToSimple(
handle: string,
blockId: string,
_blockId: string,
block: BlockState
): string {
if (!handle.startsWith('condition-')) {
@@ -300,27 +301,24 @@ function convertConditionHandleToSemantic(
return handle
}
// Find the condition by ID and generate semantic handle
let elseIfCount = 0
// Find the condition by ID and generate simple handle
let elseIfIndex = 0
for (const condition of conditions) {
const title = condition.title?.toLowerCase()
if (condition.id === conditionId) {
if (title === 'if') {
return `condition-${blockId}-if`
return 'if'
}
if (title === 'else if') {
elseIfCount++
return elseIfCount === 1
? `condition-${blockId}-else-if`
: `condition-${blockId}-else-if-${elseIfCount}`
return `else-if-${elseIfIndex}`
}
if (title === 'else') {
return `condition-${blockId}-else`
return 'else'
}
}
// Count else-ifs as we iterate
// Count else-ifs as we iterate (for index tracking)
if (title === 'else if') {
elseIfCount++
elseIfIndex++
}
}
@@ -329,9 +327,10 @@ function convertConditionHandleToSemantic(
}
/**
* Convert internal router handle (router-{uuid}) to semantic format (router-{blockId}-route-N)
* Convert internal router handle (router-{uuid}) to simple format (route-0, route-1)
* Uses 0-indexed numbering for routes
*/
function convertRouterHandleToSemantic(handle: string, blockId: string, block: BlockState): string {
function convertRouterHandleToSimple(handle: string, _blockId: string, block: BlockState): string {
if (!handle.startsWith('router-')) {
return handle
}
@@ -356,10 +355,10 @@ function convertRouterHandleToSemantic(handle: string, blockId: string, block: B
return handle
}
// Find the route by ID and generate semantic handle (1-indexed)
// Find the route by ID and generate simple handle (0-indexed)
for (let i = 0; i < routes.length; i++) {
if (routes[i].id === routeId) {
return `router-${blockId}-route-${i + 1}`
return `route-${i}`
}
}
@@ -368,15 +367,16 @@ function convertRouterHandleToSemantic(handle: string, blockId: string, block: B
}
/**
* Convert source handle to semantic format for condition and router blocks
* Convert source handle to simple format for condition and router blocks
* Outputs: if, else-if-0, else (for conditions) and route-0, route-1 (for routers)
*/
function convertToSemanticHandle(handle: string, blockId: string, block: BlockState): string {
function convertToSimpleHandle(handle: string, blockId: string, block: BlockState): string {
if (handle.startsWith('condition-') && block.type === 'condition') {
return convertConditionHandleToSemantic(handle, blockId, block)
return convertConditionHandleToSimple(handle, blockId, block)
}
if (handle.startsWith('router-') && block.type === 'router_v2') {
return convertRouterHandleToSemantic(handle, blockId, block)
return convertRouterHandleToSimple(handle, blockId, block)
}
return handle
@@ -400,12 +400,12 @@ function extractConnectionsForBlock(
return undefined
}
// Group by source handle (converting to semantic format)
// Group by source handle (converting to simple format)
for (const edge of outgoingEdges) {
let handle = edge.sourceHandle || 'source'
// Convert internal UUID handles to semantic format
handle = convertToSemanticHandle(handle, blockId, block)
// Convert internal UUID handles to simple format (if, else-if-0, route-0, etc.)
handle = convertToSimpleHandle(handle, blockId, block)
if (!connections[handle]) {
connections[handle] = []