mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-04 03:35:04 -05:00
Merge remote-tracking branch 'origin/staging' into feat/timeout-lims
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import SSOForm from '@/app/(auth)/sso/sso-form'
|
||||
import SSOForm from '@/ee/sso/components/sso-form'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
InvitationsNotAllowedError,
|
||||
validateInvitationsAllowed,
|
||||
} from '@/executor/utils/permission-check'
|
||||
} from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
const logger = createLogger('OrganizationInvitations')
|
||||
|
||||
|
||||
@@ -612,6 +612,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
blockType: string,
|
||||
executionOrder: number,
|
||||
iterationContext?: IterationContext
|
||||
) => {
|
||||
logger.info(`[${requestId}] 🔷 onBlockStart called:`, { blockId, blockName, blockType })
|
||||
@@ -624,6 +625,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
blockId,
|
||||
blockName,
|
||||
blockType,
|
||||
executionOrder,
|
||||
...(iterationContext && {
|
||||
iterationCurrent: iterationContext.iterationCurrent,
|
||||
iterationTotal: iterationContext.iterationTotal,
|
||||
@@ -662,6 +664,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
error: callbackData.output.error,
|
||||
durationMs: callbackData.executionTime || 0,
|
||||
startedAt: callbackData.startedAt,
|
||||
executionOrder: callbackData.executionOrder,
|
||||
endedAt: callbackData.endedAt,
|
||||
...(iterationContext && {
|
||||
iterationCurrent: iterationContext.iterationCurrent,
|
||||
@@ -689,6 +692,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
output: callbackData.output,
|
||||
durationMs: callbackData.executionTime || 0,
|
||||
startedAt: callbackData.startedAt,
|
||||
executionOrder: callbackData.executionOrder,
|
||||
endedAt: callbackData.endedAt,
|
||||
...(iterationContext && {
|
||||
iterationCurrent: iterationContext.iterationCurrent,
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('Workspace Invitations API Route', () => {
|
||||
inArray: vi.fn().mockImplementation((field, values) => ({ type: 'inArray', field, values })),
|
||||
}))
|
||||
|
||||
vi.doMock('@/executor/utils/permission-check', () => ({
|
||||
vi.doMock('@/ee/access-control/utils/permission-check', () => ({
|
||||
validateInvitationsAllowed: vi.fn().mockResolvedValue(undefined),
|
||||
InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error {
|
||||
constructor() {
|
||||
|
||||
@@ -21,7 +21,7 @@ import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
||||
import {
|
||||
InvitationsNotAllowedError,
|
||||
validateInvitationsAllowed,
|
||||
} from '@/executor/utils/permission-check'
|
||||
} from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -38,7 +38,6 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all workspaces where the user has permissions
|
||||
const userWorkspaces = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
@@ -55,10 +54,8 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ invitations: [] })
|
||||
}
|
||||
|
||||
// Get all workspaceIds where the user is a member
|
||||
const workspaceIds = userWorkspaces.map((w) => w.id)
|
||||
|
||||
// Find all invitations for those workspaces
|
||||
const invitations = await db
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
ChatMessageContainer,
|
||||
EmailAuth,
|
||||
PasswordAuth,
|
||||
SSOAuth,
|
||||
VoiceInterface,
|
||||
} from '@/app/chat/components'
|
||||
import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants'
|
||||
import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks'
|
||||
import SSOAuth from '@/ee/sso/components/sso-auth'
|
||||
|
||||
const logger = createLogger('ChatClient')
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export { default as EmailAuth } from './auth/email/email-auth'
|
||||
export { default as PasswordAuth } from './auth/password/password-auth'
|
||||
export { default as SSOAuth } from './auth/sso/sso-auth'
|
||||
export { ChatErrorState } from './error-state/error-state'
|
||||
export { ChatHeader } from './header/header'
|
||||
export { ChatInput } from './input/input'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { Knowledge } from './knowledge'
|
||||
|
||||
interface KnowledgePageProps {
|
||||
@@ -23,7 +23,6 @@ export default async function KnowledgePage({ params }: KnowledgePageProps) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
// Check permission group restrictions
|
||||
const permissionConfig = await getUserPermissionConfig(session.user.id)
|
||||
if (permissionConfig?.hideKnowledgeBaseTab) {
|
||||
redirect(`/workspace/${workspaceId}`)
|
||||
|
||||
@@ -104,14 +104,12 @@ function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[8px] rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span className='truncate font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
<div className='flex flex-col gap-[4px] rounded-[6px] bg-[var(--surface-1)] px-[8px] py-[6px]'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-[8px]'>
|
||||
<span className='min-w-0 flex-1 truncate font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -142,20 +140,18 @@ export function FileCards({ files, isExecutionFile = false, workspaceId }: FileC
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||
<div className='mt-[4px] flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px] dark:bg-transparent'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Files ({files.length})
|
||||
</span>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{files.map((file, index) => (
|
||||
<FileCard
|
||||
key={file.id || `file-${index}`}
|
||||
file={file}
|
||||
isExecutionFile={isExecutionFile}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{files.map((file, index) => (
|
||||
<FileCard
|
||||
key={file.id || `file-${index}`}
|
||||
file={file}
|
||||
isExecutionFile={isExecutionFile}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import {
|
||||
ExecutionSnapshot,
|
||||
@@ -453,7 +454,7 @@ export const LogDetails = memo(function LogDetails({
|
||||
Duration
|
||||
</span>
|
||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
{log.duration || '—'}
|
||||
{formatDuration(log.duration, { precision: 2 }) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ import Link from 'next/link'
|
||||
import { List, type RowComponentProps, useListRef } from 'react-window'
|
||||
import { Badge, buttonVariants } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import {
|
||||
DELETED_WORKFLOW_COLOR,
|
||||
DELETED_WORKFLOW_LABEL,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
getDisplayStatus,
|
||||
LOG_COLUMNS,
|
||||
StatusBadge,
|
||||
@@ -113,7 +113,7 @@ const LogRow = memo(
|
||||
|
||||
<div className={`${LOG_COLUMNS.duration.width} ${LOG_COLUMNS.duration.minWidth}`}>
|
||||
<Badge variant='default' className='rounded-[6px] px-[9px] py-[2px] text-[12px]'>
|
||||
{formatDuration(log.duration) || '—'}
|
||||
{formatDuration(log.duration, { precision: 2 }) || '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
@@ -362,47 +363,14 @@ export function mapToExecutionLogAlt(log: RawLogResponse): ExecutionLog {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration for display in logs UI
|
||||
* If duration is under 1 second, displays as milliseconds (e.g., "500ms")
|
||||
* If duration is 1 second or more, displays as seconds (e.g., "1.23s")
|
||||
* @param duration - Duration string (e.g., "500ms") or null
|
||||
* @returns Formatted duration string or null
|
||||
*/
|
||||
export function formatDuration(duration: string | null): string | null {
|
||||
if (!duration) return null
|
||||
|
||||
// Extract numeric value from duration string (e.g., "500ms" -> 500)
|
||||
const ms = Number.parseInt(duration.replace(/[^0-9]/g, ''), 10)
|
||||
|
||||
if (!Number.isFinite(ms)) return duration
|
||||
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`
|
||||
}
|
||||
|
||||
// Convert to seconds with up to 2 decimal places
|
||||
const seconds = ms / 1000
|
||||
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format latency value for display in dashboard UI
|
||||
* If latency is under 1 second, displays as milliseconds (e.g., "500ms")
|
||||
* If latency is 1 second or more, displays as seconds (e.g., "1.23s")
|
||||
* @param ms - Latency in milliseconds (number)
|
||||
* @returns Formatted latency string
|
||||
*/
|
||||
export function formatLatency(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
||||
|
||||
if (ms < 1000) {
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
|
||||
// Convert to seconds with up to 2 decimal places
|
||||
const seconds = ms / 1000
|
||||
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
|
||||
return formatDuration(ms, { precision: 2 }) ?? '—'
|
||||
}
|
||||
|
||||
export const formatDate = (dateString: string) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
interface TemplatesPageProps {
|
||||
params: Promise<{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { CopilotMarkdownRenderer } from '../markdown-renderer'
|
||||
|
||||
/** Removes thinking tags (raw or escaped) and special tags from streamed content */
|
||||
@@ -241,15 +242,11 @@ export function ThinkingBlock({
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [isStreaming, isExpanded, userHasScrolledAway])
|
||||
|
||||
/** Formats duration in milliseconds to seconds (minimum 1s) */
|
||||
const formatDuration = (ms: number) => {
|
||||
const seconds = Math.max(1, Math.round(ms / 1000))
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
const hasContent = cleanContent.length > 0
|
||||
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
|
||||
const durationText = `${label} for ${formatDuration(duration)}`
|
||||
// Round to nearest second (minimum 1s) to match original behavior
|
||||
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
|
||||
const durationText = `${label} for ${formatDuration(roundedMs)}`
|
||||
|
||||
const getStreamingLabel = (lbl: string) => {
|
||||
if (lbl === 'Thought') return 'Thinking'
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
hasInterrupt as hasInterruptFromConfig,
|
||||
isSpecialTool as isSpecialToolFromConfig,
|
||||
} from '@/lib/copilot/tools/client/ui-config'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
|
||||
import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block'
|
||||
@@ -848,13 +849,10 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
||||
(allParsed.options && Object.keys(allParsed.options).length > 0)
|
||||
)
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
const seconds = Math.max(1, Math.round(ms / 1000))
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
const outerLabel = getSubagentCompletionLabel(toolCall.name)
|
||||
const durationText = `${outerLabel} for ${formatDuration(duration)}`
|
||||
// Round to nearest second (minimum 1s) to match original behavior
|
||||
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
|
||||
const durationText = `${outerLabel} for ${formatDuration(roundedMs)}`
|
||||
|
||||
const renderCollapsibleContent = () => (
|
||||
<>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function CredentialSelector({
|
||||
previewValue,
|
||||
}: CredentialSelectorProps) {
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [editingValue, setEditingValue] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
|
||||
@@ -128,11 +128,7 @@ export function CredentialSelector({
|
||||
return ''
|
||||
}, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setInputValue(resolvedLabel)
|
||||
}
|
||||
}, [resolvedLabel, isEditing])
|
||||
const displayValue = isEditing ? editingValue : resolvedLabel
|
||||
|
||||
const invalidSelection =
|
||||
!isPreview &&
|
||||
@@ -295,7 +291,7 @@ export function CredentialSelector({
|
||||
const selectedCredentialProvider = selectedCredential?.provider ?? provider
|
||||
|
||||
const overlayContent = useMemo(() => {
|
||||
if (!inputValue) return null
|
||||
if (!displayValue) return null
|
||||
|
||||
if (isCredentialSetSelected && selectedCredentialSet) {
|
||||
return (
|
||||
@@ -303,7 +299,7 @@ export function CredentialSelector({
|
||||
<div className='mr-2 flex-shrink-0 opacity-90'>
|
||||
<Users className='h-3 w-3' />
|
||||
</div>
|
||||
<span className='truncate'>{inputValue}</span>
|
||||
<span className='truncate'>{displayValue}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -313,12 +309,12 @@ export function CredentialSelector({
|
||||
<div className='mr-2 flex-shrink-0 opacity-90'>
|
||||
{getProviderIcon(selectedCredentialProvider)}
|
||||
</div>
|
||||
<span className='truncate'>{inputValue}</span>
|
||||
<span className='truncate'>{displayValue}</span>
|
||||
</div>
|
||||
)
|
||||
}, [
|
||||
getProviderIcon,
|
||||
inputValue,
|
||||
displayValue,
|
||||
selectedCredentialProvider,
|
||||
isCredentialSetSelected,
|
||||
selectedCredentialSet,
|
||||
@@ -335,7 +331,6 @@ export function CredentialSelector({
|
||||
const credentialSetId = value.slice(CREDENTIAL_SET.PREFIX.length)
|
||||
const matchedSet = credentialSets.find((cs) => cs.id === credentialSetId)
|
||||
if (matchedSet) {
|
||||
setInputValue(matchedSet.name)
|
||||
handleCredentialSetSelect(credentialSetId)
|
||||
return
|
||||
}
|
||||
@@ -343,13 +338,12 @@ export function CredentialSelector({
|
||||
|
||||
const matchedCred = credentials.find((c) => c.id === value)
|
||||
if (matchedCred) {
|
||||
setInputValue(matchedCred.name)
|
||||
handleSelect(value)
|
||||
return
|
||||
}
|
||||
|
||||
setIsEditing(true)
|
||||
setInputValue(value)
|
||||
setEditingValue(value)
|
||||
},
|
||||
[credentials, credentialSets, handleAddCredential, handleSelect, handleCredentialSetSelect]
|
||||
)
|
||||
@@ -359,7 +353,7 @@ export function CredentialSelector({
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
groups={comboboxGroups}
|
||||
value={inputValue}
|
||||
value={displayValue}
|
||||
selectedValue={rawSelectedId}
|
||||
onChange={handleComboboxChange}
|
||||
onOpenChange={handleOpenChange}
|
||||
|
||||
@@ -908,8 +908,10 @@ const PopoverContextCapture: React.FC<{
|
||||
* When in nested folders, goes back one level at a time.
|
||||
* At the root folder level, closes the folder.
|
||||
*/
|
||||
const TagDropdownBackButton: React.FC = () => {
|
||||
const { isInFolder, closeFolder, colorScheme, size } = usePopoverContext()
|
||||
const TagDropdownBackButton: React.FC<{ setSelectedIndex: (index: number) => void }> = ({
|
||||
setSelectedIndex,
|
||||
}) => {
|
||||
const { isInFolder, closeFolder, size, isKeyboardNav, setKeyboardNav } = usePopoverContext()
|
||||
const nestedNav = useNestedNavigation()
|
||||
|
||||
if (!isInFolder) return null
|
||||
@@ -922,28 +924,31 @@ const TagDropdownBackButton: React.FC = () => {
|
||||
closeFolder()
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (isKeyboardNav) return
|
||||
setKeyboardNav(false)
|
||||
setSelectedIndex(-1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base',
|
||||
size === 'sm' ? 'h-[22px] text-[11px]' : 'h-[26px] text-[13px]',
|
||||
colorScheme === 'inverted'
|
||||
? 'text-white hover:bg-[#363636] hover:text-white dark:text-[var(--text-primary)] dark:hover:bg-[var(--surface-5)]'
|
||||
: 'text-[var(--text-primary)] hover:bg-[var(--border-1)]'
|
||||
)}
|
||||
role='button'
|
||||
onClick={handleBackClick}
|
||||
<PopoverItem
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleBackClick(e)
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
>
|
||||
<svg
|
||||
className={size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5'}
|
||||
className={cn('shrink-0', size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5')}
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M15 19l-7-7 7-7' />
|
||||
</svg>
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<span className='shrink-0'>Back</span>
|
||||
</PopoverItem>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1961,8 +1966,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<TagDropdownBackButton />
|
||||
<PopoverScrollArea ref={scrollAreaRef}>
|
||||
<TagDropdownBackButton setSelectedIndex={setSelectedIndex} />
|
||||
{flatTagList.length === 0 ? (
|
||||
<div className='px-[6px] py-[8px] text-[12px] text-[var(--white)]/60'>
|
||||
No matching tags found
|
||||
|
||||
@@ -105,11 +105,9 @@ export function useTerminalFilters() {
|
||||
})
|
||||
}
|
||||
|
||||
// Apply sorting by timestamp
|
||||
// Sort by executionOrder (monotonically increasing integer from server)
|
||||
result = [...result].sort((a, b) => {
|
||||
const timeA = new Date(a.timestamp).getTime()
|
||||
const timeB = new Date(b.timestamp).getTime()
|
||||
const comparison = timeA - timeB
|
||||
const comparison = a.executionOrder - b.executionOrder
|
||||
return sortConfig.direction === 'asc' ? comparison : -comparison
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
import {
|
||||
@@ -43,7 +44,6 @@ import {
|
||||
type EntryNode,
|
||||
type ExecutionGroup,
|
||||
flattenBlockEntriesOnly,
|
||||
formatDuration,
|
||||
getBlockColor,
|
||||
getBlockIcon,
|
||||
groupEntriesByExecution,
|
||||
@@ -128,7 +128,7 @@ const BlockRow = memo(function BlockRow({
|
||||
<StatusDisplay
|
||||
isRunning={isRunning}
|
||||
isCanceled={isCanceled}
|
||||
formattedDuration={formatDuration(entry.durationMs)}
|
||||
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -201,7 +201,7 @@ const IterationNodeRow = memo(function IterationNodeRow({
|
||||
<StatusDisplay
|
||||
isRunning={hasRunningChild}
|
||||
isCanceled={hasCanceledChild}
|
||||
formattedDuration={formatDuration(entry.durationMs)}
|
||||
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -314,7 +314,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
|
||||
<StatusDisplay
|
||||
isRunning={hasRunningDescendant}
|
||||
isCanceled={hasCanceledDescendant}
|
||||
formattedDuration={formatDuration(entry.durationMs)}
|
||||
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -53,17 +53,6 @@ export function getBlockColor(blockType: string): string {
|
||||
return '#6b7280'
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats duration from milliseconds to readable format
|
||||
*/
|
||||
export function formatDuration(ms?: number): string {
|
||||
if (ms === undefined || ms === null) return '-'
|
||||
if (ms < 1000) {
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a keyboard event originated from a text-editable element
|
||||
*/
|
||||
@@ -195,13 +184,9 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
group.blocks.push(entry)
|
||||
}
|
||||
|
||||
// Sort blocks within each iteration by start time ascending (oldest first, top-down)
|
||||
// Sort blocks within each iteration by executionOrder ascending (oldest first, top-down)
|
||||
for (const group of iterationGroupsMap.values()) {
|
||||
group.blocks.sort((a, b) => {
|
||||
const aStart = new Date(a.startedAt || a.timestamp).getTime()
|
||||
const bStart = new Date(b.startedAt || b.timestamp).getTime()
|
||||
return aStart - bStart
|
||||
})
|
||||
group.blocks.sort((a, b) => a.executionOrder - b.executionOrder)
|
||||
}
|
||||
|
||||
// Group iterations by iterationType to create subflow parents
|
||||
@@ -236,6 +221,8 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
const totalDuration = allBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
|
||||
|
||||
// Create synthetic subflow parent entry
|
||||
// Use the minimum executionOrder from all child blocks for proper ordering
|
||||
const subflowExecutionOrder = Math.min(...allBlocks.map((b) => b.executionOrder))
|
||||
const syntheticSubflow: ConsoleEntry = {
|
||||
id: `subflow-${iterationType}-${firstIteration.blocks[0]?.executionId || 'unknown'}`,
|
||||
timestamp: new Date(subflowStartMs).toISOString(),
|
||||
@@ -245,6 +232,7 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
blockType: iterationType,
|
||||
executionId: firstIteration.blocks[0]?.executionId,
|
||||
startedAt: new Date(subflowStartMs).toISOString(),
|
||||
executionOrder: subflowExecutionOrder,
|
||||
endedAt: new Date(subflowEndMs).toISOString(),
|
||||
durationMs: totalDuration,
|
||||
success: !allBlocks.some((b) => b.error),
|
||||
@@ -262,6 +250,8 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
)
|
||||
const iterDuration = iterBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
|
||||
|
||||
// Use the minimum executionOrder from blocks in this iteration
|
||||
const iterExecutionOrder = Math.min(...iterBlocks.map((b) => b.executionOrder))
|
||||
const syntheticIteration: ConsoleEntry = {
|
||||
id: `iteration-${iterationType}-${iterGroup.iterationCurrent}-${iterBlocks[0]?.executionId || 'unknown'}`,
|
||||
timestamp: new Date(iterStartMs).toISOString(),
|
||||
@@ -271,6 +261,7 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
blockType: iterationType,
|
||||
executionId: iterBlocks[0]?.executionId,
|
||||
startedAt: new Date(iterStartMs).toISOString(),
|
||||
executionOrder: iterExecutionOrder,
|
||||
endedAt: new Date(iterEndMs).toISOString(),
|
||||
durationMs: iterDuration,
|
||||
success: !iterBlocks.some((b) => b.error),
|
||||
@@ -311,14 +302,9 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
nodeType: 'block' as const,
|
||||
}))
|
||||
|
||||
// Combine all nodes and sort by start time ascending (oldest first, top-down)
|
||||
// Combine all nodes and sort by executionOrder ascending (oldest first, top-down)
|
||||
const allNodes = [...subflowNodes, ...regularNodes]
|
||||
allNodes.sort((a, b) => {
|
||||
const aStart = new Date(a.entry.startedAt || a.entry.timestamp).getTime()
|
||||
const bStart = new Date(b.entry.startedAt || b.entry.timestamp).getTime()
|
||||
return aStart - bStart
|
||||
})
|
||||
|
||||
allNodes.sort((a, b) => a.entry.executionOrder - b.entry.executionOrder)
|
||||
return allNodes
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
|
||||
import { formatEditSequence } from '@/lib/workflows/training/compute-edit-sequence'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||
@@ -575,7 +576,9 @@ export function TrainingModal() {
|
||||
<span className='text-[var(--text-muted)]'>Duration:</span>{' '}
|
||||
<span className='text-[var(--text-secondary)]'>
|
||||
{dataset.metadata?.duration
|
||||
? `${(dataset.metadata.duration / 1000).toFixed(1)}s`
|
||||
? formatDuration(dataset.metadata.duration, {
|
||||
precision: 1,
|
||||
})
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -926,6 +926,7 @@ export function useWorkflowExecution() {
|
||||
})
|
||||
|
||||
// Add entry to terminal immediately with isRunning=true
|
||||
// Use server-provided executionOrder to ensure correct sort order
|
||||
const startedAt = new Date().toISOString()
|
||||
addConsole({
|
||||
input: {},
|
||||
@@ -933,6 +934,7 @@ export function useWorkflowExecution() {
|
||||
success: undefined,
|
||||
durationMs: undefined,
|
||||
startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt: undefined,
|
||||
workflowId: activeWorkflowId,
|
||||
blockId: data.blockId,
|
||||
@@ -948,8 +950,6 @@ export function useWorkflowExecution() {
|
||||
},
|
||||
|
||||
onBlockCompleted: (data) => {
|
||||
logger.info('onBlockCompleted received:', { data })
|
||||
|
||||
activeBlocksSet.delete(data.blockId)
|
||||
setActiveBlocks(new Set(activeBlocksSet))
|
||||
setBlockRunStatus(data.blockId, 'success')
|
||||
@@ -976,6 +976,7 @@ export function useWorkflowExecution() {
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt,
|
||||
})
|
||||
|
||||
@@ -987,6 +988,7 @@ export function useWorkflowExecution() {
|
||||
replaceOutput: data.output,
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
isRunning: false,
|
||||
// Pass through iteration context for subflow grouping
|
||||
@@ -1027,6 +1029,7 @@ export function useWorkflowExecution() {
|
||||
error: data.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt,
|
||||
})
|
||||
|
||||
@@ -1039,6 +1042,7 @@ export function useWorkflowExecution() {
|
||||
success: false,
|
||||
error: data.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
isRunning: false,
|
||||
// Pass through iteration context for subflow grouping
|
||||
@@ -1157,20 +1161,25 @@ export function useWorkflowExecution() {
|
||||
cancelRunningEntries(activeWorkflowId)
|
||||
}
|
||||
|
||||
addConsole({
|
||||
input: {},
|
||||
output: {},
|
||||
success: false,
|
||||
error: data.error,
|
||||
durationMs: data.duration || 0,
|
||||
startedAt: new Date(Date.now() - (data.duration || 0)).toISOString(),
|
||||
endedAt: new Date().toISOString(),
|
||||
workflowId: activeWorkflowId,
|
||||
blockId: 'workflow-error',
|
||||
executionId,
|
||||
blockName: 'Workflow Error',
|
||||
blockType: 'error',
|
||||
})
|
||||
if (accumulatedBlockLogs.length === 0) {
|
||||
// No blocks executed yet - this is a pre-execution error
|
||||
// Use 0 for executionOrder so validation errors appear first
|
||||
addConsole({
|
||||
input: {},
|
||||
output: {},
|
||||
success: false,
|
||||
error: data.error,
|
||||
durationMs: data.duration || 0,
|
||||
startedAt: new Date(Date.now() - (data.duration || 0)).toISOString(),
|
||||
executionOrder: 0,
|
||||
endedAt: new Date().toISOString(),
|
||||
workflowId: activeWorkflowId,
|
||||
blockId: 'validation',
|
||||
executionId,
|
||||
blockName: 'Workflow Validation',
|
||||
blockType: 'validation',
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
onExecutionCancelled: () => {
|
||||
@@ -1236,6 +1245,7 @@ export function useWorkflowExecution() {
|
||||
blockType = error.blockType || blockType
|
||||
}
|
||||
|
||||
// Use MAX_SAFE_INTEGER so execution errors appear at the end of the log
|
||||
useTerminalConsoleStore.getState().addConsole({
|
||||
input: {},
|
||||
output: {},
|
||||
@@ -1243,6 +1253,7 @@ export function useWorkflowExecution() {
|
||||
error: normalizedMessage,
|
||||
durationMs: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
executionOrder: Number.MAX_SAFE_INTEGER,
|
||||
endedAt: new Date().toISOString(),
|
||||
workflowId: activeWorkflowId || '',
|
||||
blockId,
|
||||
@@ -1614,6 +1625,7 @@ export function useWorkflowExecution() {
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt,
|
||||
})
|
||||
|
||||
@@ -1623,6 +1635,7 @@ export function useWorkflowExecution() {
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt,
|
||||
workflowId,
|
||||
blockId: data.blockId,
|
||||
@@ -1652,6 +1665,7 @@ export function useWorkflowExecution() {
|
||||
output: {},
|
||||
success: false,
|
||||
error: data.error,
|
||||
executionOrder: data.executionOrder,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
@@ -1664,6 +1678,7 @@ export function useWorkflowExecution() {
|
||||
error: data.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt,
|
||||
workflowId,
|
||||
blockId: data.blockId,
|
||||
@@ -1728,6 +1743,7 @@ export function useWorkflowExecution() {
|
||||
error: data.error,
|
||||
durationMs: data.duration || 0,
|
||||
startedAt: new Date(Date.now() - (data.duration || 0)).toISOString(),
|
||||
executionOrder: Number.MAX_SAFE_INTEGER,
|
||||
endedAt: new Date().toISOString(),
|
||||
workflowId,
|
||||
blockId: 'workflow-error',
|
||||
|
||||
@@ -111,6 +111,7 @@ export async function executeWorkflowWithFullLogging(
|
||||
success: true,
|
||||
durationMs: event.data.durationMs,
|
||||
startedAt: new Date(Date.now() - event.data.durationMs).toISOString(),
|
||||
executionOrder: event.data.executionOrder,
|
||||
endedAt: new Date().toISOString(),
|
||||
workflowId: activeWorkflowId,
|
||||
blockId: event.data.blockId,
|
||||
@@ -140,6 +141,7 @@ export async function executeWorkflowWithFullLogging(
|
||||
error: event.data.error,
|
||||
durationMs: event.data.durationMs,
|
||||
startedAt: new Date(Date.now() - event.data.durationMs).toISOString(),
|
||||
executionOrder: event.data.executionOrder,
|
||||
endedAt: new Date().toISOString(),
|
||||
workflowId: activeWorkflowId,
|
||||
blockId: event.data.blockId,
|
||||
|
||||
@@ -246,7 +246,6 @@ export function CredentialSets() {
|
||||
setNewSetDescription('')
|
||||
setNewSetProvider('google-email')
|
||||
|
||||
// Open detail view for the newly created group
|
||||
if (result?.credentialSet) {
|
||||
setViewingSet(result.credentialSet)
|
||||
}
|
||||
@@ -336,7 +335,6 @@ export function CredentialSets() {
|
||||
email,
|
||||
})
|
||||
|
||||
// Start 60s cooldown
|
||||
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
|
||||
const interval = setInterval(() => {
|
||||
setResendCooldowns((prev) => {
|
||||
@@ -393,7 +391,6 @@ export function CredentialSets() {
|
||||
return <GmailIcon className='h-4 w-4' />
|
||||
}
|
||||
|
||||
// All hooks must be called before any early returns
|
||||
const activeMemberships = useMemo(
|
||||
() => memberships.filter((m) => m.status === 'active'),
|
||||
[memberships]
|
||||
@@ -447,7 +444,6 @@ export function CredentialSets() {
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
{/* Group Info */}
|
||||
<div className='flex items-center gap-[16px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
@@ -471,7 +467,6 @@ export function CredentialSets() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invite Section - Email Tags Input */}
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<TagInput
|
||||
@@ -495,7 +490,6 @@ export function CredentialSets() {
|
||||
{emailError && <p className='text-[12px] text-[var(--text-error)]'>{emailError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Members List - styled like team members */}
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
<h4 className='font-medium text-[14px] text-[var(--text-primary)]'>Members</h4>
|
||||
|
||||
@@ -519,7 +513,6 @@ export function CredentialSets() {
|
||||
</p>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
{/* Active Members */}
|
||||
{activeMembers.map((member) => {
|
||||
const name = member.userName || 'Unknown'
|
||||
const avatarInitial = name.charAt(0).toUpperCase()
|
||||
@@ -572,7 +565,6 @@ export function CredentialSets() {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Pending Invitations */}
|
||||
{pendingInvitations.map((invitation) => {
|
||||
const email = invitation.email || 'Unknown'
|
||||
const emailPrefix = email.split('@')[0]
|
||||
@@ -641,7 +633,6 @@ export function CredentialSets() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className='mt-auto flex items-center justify-end'>
|
||||
<Button onClick={handleBackToList} variant='tertiary'>
|
||||
Back
|
||||
@@ -822,7 +813,6 @@ export function CredentialSets() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Polling Group Modal */}
|
||||
<Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Create Polling Group</ModalHeader>
|
||||
@@ -895,7 +885,6 @@ export function CredentialSets() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Leave Confirmation Modal */}
|
||||
<Modal open={!!leavingMembership} onOpenChange={() => setLeavingMembership(null)}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Leave Polling Group</ModalHeader>
|
||||
@@ -923,7 +912,6 @@ export function CredentialSets() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal open={!!deletingSet} onOpenChange={() => setDeletingSet(null)}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Polling Group</ModalHeader>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { AccessControl } from './access-control/access-control'
|
||||
export { ApiKeys } from './api-keys/api-keys'
|
||||
export { BYOK } from './byok/byok'
|
||||
export { Copilot } from './copilot/copilot'
|
||||
@@ -10,7 +9,6 @@ export { Files as FileUploads } from './files/files'
|
||||
export { General } from './general/general'
|
||||
export { Integrations } from './integrations/integrations'
|
||||
export { MCP } from './mcp/mcp'
|
||||
export { SSO } from './sso/sso'
|
||||
export { Subscription } from './subscription/subscription'
|
||||
export { TeamManagement } from './team-management/team-management'
|
||||
export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers'
|
||||
|
||||
@@ -407,14 +407,12 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
|
||||
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
|
||||
|
||||
// Auto-select server when initialServerId is provided
|
||||
useEffect(() => {
|
||||
if (initialServerId && servers.some((s) => s.id === initialServerId)) {
|
||||
setSelectedServerId(initialServerId)
|
||||
}
|
||||
}, [initialServerId, servers])
|
||||
|
||||
// Force refresh tools when entering server detail view to detect stale schemas
|
||||
useEffect(() => {
|
||||
if (selectedServerId) {
|
||||
forceRefreshTools(workspaceId)
|
||||
@@ -675,6 +673,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
|
||||
/**
|
||||
* Opens the detail view for a specific server.
|
||||
* Note: Tool refresh is handled by the useEffect that watches selectedServerId
|
||||
*/
|
||||
const handleViewDetails = useCallback((serverId: string) => {
|
||||
setSelectedServerId(serverId)
|
||||
@@ -717,7 +716,6 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
`Refreshed MCP server: ${serverId}, workflows updated: ${result.workflowsUpdated}`
|
||||
)
|
||||
|
||||
// If the active workflow was updated, reload its subblock values from DB
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) {
|
||||
logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`)
|
||||
|
||||
@@ -41,7 +41,6 @@ import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { getUserRole } from '@/lib/workspaces/organization'
|
||||
import {
|
||||
AccessControl,
|
||||
ApiKeys,
|
||||
BYOK,
|
||||
Copilot,
|
||||
@@ -53,16 +52,18 @@ import {
|
||||
General,
|
||||
Integrations,
|
||||
MCP,
|
||||
SSO,
|
||||
Subscription,
|
||||
TeamManagement,
|
||||
WorkflowMcpServers,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components'
|
||||
import { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile'
|
||||
import { AccessControl } from '@/ee/access-control/components/access-control'
|
||||
import { SSO } from '@/ee/sso/components/sso-settings'
|
||||
import { ssoKeys, useSSOProviders } from '@/ee/sso/hooks/sso'
|
||||
import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings'
|
||||
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
|
||||
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
|
||||
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { useSuperUserStatus } from '@/hooks/queries/user-profile'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSettingsModalStore } from '@/stores/modals/settings/store'
|
||||
|
||||
@@ -204,13 +205,13 @@ 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()
|
||||
const { data: superUserData } = useSuperUserStatus(session?.user?.id)
|
||||
|
||||
const activeOrganization = organizationsData?.activeOrganization
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
@@ -229,22 +230,7 @@ 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])
|
||||
const isSuperUser = superUserData?.isSuperUser ?? false
|
||||
|
||||
// Memoize SSO provider ownership check
|
||||
const isSSOProviderOwner = useMemo(() => {
|
||||
@@ -328,7 +314,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
generalSettings?.superUserModeEnabled,
|
||||
])
|
||||
|
||||
// Memoized callbacks to prevent infinite loops in child components
|
||||
const effectiveActiveSection = useMemo(() => {
|
||||
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
|
||||
return 'general'
|
||||
}
|
||||
return activeSection
|
||||
}, [activeSection])
|
||||
|
||||
const registerEnvironmentBeforeLeaveHandler = useCallback(
|
||||
(handler: (onProceed: () => void) => void) => {
|
||||
environmentBeforeLeaveHandler.current = handler
|
||||
@@ -342,19 +334,18 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
|
||||
const handleSectionChange = useCallback(
|
||||
(sectionId: SettingsSection) => {
|
||||
if (sectionId === activeSection) return
|
||||
if (sectionId === effectiveActiveSection) return
|
||||
|
||||
if (activeSection === 'environment' && environmentBeforeLeaveHandler.current) {
|
||||
if (effectiveActiveSection === 'environment' && environmentBeforeLeaveHandler.current) {
|
||||
environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId))
|
||||
return
|
||||
}
|
||||
|
||||
setActiveSection(sectionId)
|
||||
},
|
||||
[activeSection]
|
||||
[effectiveActiveSection]
|
||||
)
|
||||
|
||||
// Apply initial section from store when modal opens
|
||||
useEffect(() => {
|
||||
if (open && initialSection) {
|
||||
setActiveSection(initialSection)
|
||||
@@ -365,7 +356,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
}
|
||||
}, [open, initialSection, mcpServerId, clearInitialState])
|
||||
|
||||
// Clear pending server ID when section changes away from MCP
|
||||
useEffect(() => {
|
||||
if (activeSection !== 'mcp') {
|
||||
setPendingMcpServerId(null)
|
||||
@@ -391,14 +381,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
}
|
||||
}, [onOpenChange])
|
||||
|
||||
// Redirect away from billing tabs if billing is disabled
|
||||
useEffect(() => {
|
||||
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
|
||||
setActiveSection('general')
|
||||
}
|
||||
}, [activeSection])
|
||||
|
||||
// Prefetch functions for React Query
|
||||
const prefetchGeneral = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: generalSettingsKeys.settings(),
|
||||
@@ -489,9 +471,17 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
|
||||
// Handle dialog close - delegate to environment component if it's active
|
||||
const handleDialogOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen && activeSection === 'environment' && environmentBeforeLeaveHandler.current) {
|
||||
if (
|
||||
!newOpen &&
|
||||
effectiveActiveSection === 'environment' &&
|
||||
environmentBeforeLeaveHandler.current
|
||||
) {
|
||||
environmentBeforeLeaveHandler.current(() => onOpenChange(false))
|
||||
} else if (!newOpen && activeSection === 'integrations' && integrationsCloseHandler.current) {
|
||||
} else if (
|
||||
!newOpen &&
|
||||
effectiveActiveSection === 'integrations' &&
|
||||
integrationsCloseHandler.current
|
||||
) {
|
||||
integrationsCloseHandler.current(newOpen)
|
||||
} else {
|
||||
onOpenChange(newOpen)
|
||||
@@ -522,7 +512,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
{sectionItems.map((item) => (
|
||||
<SModalSidebarItem
|
||||
key={item.id}
|
||||
active={activeSection === item.id}
|
||||
active={effectiveActiveSection === item.id}
|
||||
icon={<item.icon />}
|
||||
onMouseEnter={() => handlePrefetch(item.id)}
|
||||
onClick={() => handleSectionChange(item.id)}
|
||||
@@ -538,35 +528,36 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
|
||||
<SModalMain>
|
||||
<SModalMainHeader>
|
||||
{navigationItems.find((item) => item.id === activeSection)?.label || activeSection}
|
||||
{navigationItems.find((item) => item.id === effectiveActiveSection)?.label ||
|
||||
effectiveActiveSection}
|
||||
</SModalMainHeader>
|
||||
<SModalMainBody>
|
||||
{activeSection === 'general' && <General onOpenChange={onOpenChange} />}
|
||||
{activeSection === 'environment' && (
|
||||
{effectiveActiveSection === 'general' && <General onOpenChange={onOpenChange} />}
|
||||
{effectiveActiveSection === 'environment' && (
|
||||
<EnvironmentVariables
|
||||
registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'template-profile' && <TemplateProfile />}
|
||||
{activeSection === 'integrations' && (
|
||||
{effectiveActiveSection === 'template-profile' && <TemplateProfile />}
|
||||
{effectiveActiveSection === 'integrations' && (
|
||||
<Integrations
|
||||
onOpenChange={onOpenChange}
|
||||
registerCloseHandler={registerIntegrationsCloseHandler}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'credential-sets' && <CredentialSets />}
|
||||
{activeSection === 'access-control' && <AccessControl />}
|
||||
{activeSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
|
||||
{activeSection === 'files' && <FileUploads />}
|
||||
{isBillingEnabled && activeSection === 'subscription' && <Subscription />}
|
||||
{isBillingEnabled && activeSection === 'team' && <TeamManagement />}
|
||||
{activeSection === 'sso' && <SSO />}
|
||||
{activeSection === 'byok' && <BYOK />}
|
||||
{activeSection === 'copilot' && <Copilot />}
|
||||
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
||||
{activeSection === 'custom-tools' && <CustomTools />}
|
||||
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
||||
{activeSection === 'debug' && <Debug />}
|
||||
{effectiveActiveSection === 'credential-sets' && <CredentialSets />}
|
||||
{effectiveActiveSection === 'access-control' && <AccessControl />}
|
||||
{effectiveActiveSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
|
||||
{effectiveActiveSection === 'files' && <FileUploads />}
|
||||
{isBillingEnabled && effectiveActiveSection === 'subscription' && <Subscription />}
|
||||
{isBillingEnabled && effectiveActiveSection === 'team' && <TeamManagement />}
|
||||
{effectiveActiveSection === 'sso' && <SSO />}
|
||||
{effectiveActiveSection === 'byok' && <BYOK />}
|
||||
{effectiveActiveSection === 'copilot' && <Copilot />}
|
||||
{effectiveActiveSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
||||
{effectiveActiveSection === 'custom-tools' && <CustomTools />}
|
||||
{effectiveActiveSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
||||
{effectiveActiveSection === 'debug' && <Debug />}
|
||||
</SModalMainBody>
|
||||
</SModalMain>
|
||||
</SModalContent>
|
||||
|
||||
@@ -231,6 +231,8 @@ export function FolderItem({
|
||||
const isFolderSelected = store.selectedFolders.has(folder.id)
|
||||
|
||||
if (!isFolderSelected) {
|
||||
// Replace selection with just this folder (Finder/Explorer pattern)
|
||||
store.clearAllSelection()
|
||||
store.selectFolder(folder.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -189,6 +189,9 @@ export function WorkflowItem({
|
||||
const isCurrentlySelected = store.selectedWorkflows.has(workflow.id)
|
||||
|
||||
if (!isCurrentlySelected) {
|
||||
// Replace selection with just this item (Finder/Explorer pattern)
|
||||
// This clears both workflow and folder selections
|
||||
store.clearAllSelection()
|
||||
store.selectWorkflow(workflow.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
@@ -227,12 +228,6 @@ async function deliverWebhook(
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
return `${(ms / 60000).toFixed(1)}m`
|
||||
}
|
||||
|
||||
function formatCost(cost?: Record<string, unknown>): string {
|
||||
if (!cost?.total) return 'N/A'
|
||||
const total = cost.total as number
|
||||
@@ -302,7 +297,7 @@ async function deliverEmail(
|
||||
workflowName: payload.data.workflowName || 'Unknown Workflow',
|
||||
status: payload.data.status,
|
||||
trigger: payload.data.trigger,
|
||||
duration: formatDuration(payload.data.totalDurationMs),
|
||||
duration: formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-',
|
||||
cost: formatCost(payload.data.cost),
|
||||
logUrl,
|
||||
alertReason,
|
||||
@@ -315,7 +310,7 @@ async function deliverEmail(
|
||||
to: subscription.emailRecipients,
|
||||
subject,
|
||||
html,
|
||||
text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs)}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
|
||||
text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
|
||||
emailType: 'notifications',
|
||||
})
|
||||
|
||||
@@ -373,7 +368,10 @@ async function deliverSlack(
|
||||
fields: [
|
||||
{ type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` },
|
||||
{ type: 'mrkdwn', text: `*Trigger:*\n${payload.data.trigger}` },
|
||||
{ type: 'mrkdwn', text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs)}` },
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}`,
|
||||
},
|
||||
{ type: 'mrkdwn', text: `*Cost:*\n${formatCost(payload.data.cost)}` },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -260,6 +260,9 @@ const Popover: React.FC<PopoverProps> = ({
|
||||
setIsKeyboardNav(false)
|
||||
setSelectedIndex(-1)
|
||||
registeredItemsRef.current = []
|
||||
} else {
|
||||
// Reset hover state when opening to prevent stale submenu from previous menu
|
||||
setLastHoveredItem(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import type { ToolCallGroup, ToolCallState } from '@/lib/copilot/types'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
|
||||
interface ToolCallProps {
|
||||
toolCall: ToolCallState
|
||||
@@ -225,11 +226,6 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
|
||||
const isError = toolCall.state === 'error'
|
||||
const isAborted = toolCall.state === 'aborted'
|
||||
|
||||
const formatDuration = (duration?: number) => {
|
||||
if (!duration) return ''
|
||||
return duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -279,7 +275,7 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
|
||||
)}
|
||||
style={{ fontSize: '0.625rem' }}
|
||||
>
|
||||
{formatDuration(toolCall.duration)}
|
||||
{toolCall.duration ? formatDuration(toolCall.duration, { precision: 1 }) : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
43
apps/sim/ee/LICENSE
Normal file
43
apps/sim/ee/LICENSE
Normal file
@@ -0,0 +1,43 @@
|
||||
Sim Enterprise License
|
||||
|
||||
Copyright (c) 2025-present Sim Studio, Inc.
|
||||
|
||||
This software and associated documentation files (the "Software") are licensed
|
||||
under the following terms:
|
||||
|
||||
1. LICENSE GRANT
|
||||
|
||||
Subject to the terms of this license, Sim Studio, Inc. grants you a limited,
|
||||
non-exclusive, non-transferable license to use the Software for:
|
||||
|
||||
- Development, testing, and evaluation purposes
|
||||
- Internal non-production use
|
||||
|
||||
Production use of the Software requires a valid Sim Enterprise subscription.
|
||||
|
||||
2. RESTRICTIONS
|
||||
|
||||
You may not:
|
||||
|
||||
- Use the Software in production without a valid Enterprise subscription
|
||||
- Modify, adapt, or create derivative works of the Software
|
||||
- Redistribute, sublicense, or transfer the Software
|
||||
- Remove or alter any proprietary notices in the Software
|
||||
|
||||
3. ENTERPRISE SUBSCRIPTION
|
||||
|
||||
Production deployment of enterprise features requires an active Sim Enterprise
|
||||
subscription. Contact sales@simstudio.ai for licensing information.
|
||||
|
||||
4. DISCLAIMER
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
|
||||
5. LIMITATION OF LIABILITY
|
||||
|
||||
IN NO EVENT SHALL SIM STUDIO, INC. BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
|
||||
|
||||
For questions about enterprise licensing, contact: sales@simstudio.ai
|
||||
21
apps/sim/ee/README.md
Normal file
21
apps/sim/ee/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Sim Enterprise Edition
|
||||
|
||||
This directory contains enterprise features that require a Sim Enterprise subscription
|
||||
for production use.
|
||||
|
||||
## Features
|
||||
|
||||
- **SSO (Single Sign-On)**: OIDC and SAML authentication integration
|
||||
- **Access Control**: Permission groups for fine-grained user access management
|
||||
- **Credential Sets**: Shared credential pools for email polling workflows
|
||||
|
||||
## Licensing
|
||||
|
||||
See [LICENSE](./LICENSE) for terms. Development and testing use is permitted.
|
||||
Production deployment requires an active Enterprise subscription.
|
||||
|
||||
## Architecture
|
||||
|
||||
Enterprise features are imported directly throughout the codebase. The `ee/` directory
|
||||
is required at build time. Feature visibility is controlled at runtime via environment
|
||||
variables (e.g., `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED`).
|
||||
@@ -29,7 +29,6 @@ import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
import { getUserRole } from '@/lib/workspaces/organization'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
|
||||
import {
|
||||
type PermissionGroup,
|
||||
useBulkAddPermissionGroupMembers,
|
||||
@@ -39,7 +38,8 @@ import {
|
||||
usePermissionGroups,
|
||||
useRemovePermissionGroupMember,
|
||||
useUpdatePermissionGroup,
|
||||
} from '@/hooks/queries/permission-groups'
|
||||
} from '@/ee/access-control/hooks/permission-groups'
|
||||
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
import { getAllProviderIds } from '@/providers/utils'
|
||||
@@ -255,7 +255,6 @@ export function AccessControl() {
|
||||
queryEnabled
|
||||
)
|
||||
|
||||
// Show loading while dependencies load, or while permission groups query is pending
|
||||
const isLoading = orgsLoading || subLoading || (queryEnabled && groupsLoading)
|
||||
const { data: organization } = useOrganization(activeOrganization?.id || '')
|
||||
|
||||
@@ -410,10 +409,8 @@ export function AccessControl() {
|
||||
}, [viewingGroup, editingConfig])
|
||||
|
||||
const allBlocks = useMemo(() => {
|
||||
// Filter out hidden blocks and start_trigger (which should never be disabled)
|
||||
const blocks = getAllBlocks().filter((b) => !b.hideFromToolbar && b.type !== 'start_trigger')
|
||||
return blocks.sort((a, b) => {
|
||||
// Group by category: triggers first, then blocks, then tools
|
||||
const categoryOrder = { triggers: 0, blocks: 1, tools: 2 }
|
||||
const catA = categoryOrder[a.category] ?? 3
|
||||
const catB = categoryOrder[b.category] ?? 3
|
||||
@@ -555,10 +552,9 @@ export function AccessControl() {
|
||||
}, [viewingGroup, editingConfig, activeOrganization?.id, updatePermissionGroup])
|
||||
|
||||
const handleOpenAddMembersModal = useCallback(() => {
|
||||
const existingMemberUserIds = new Set(members.map((m) => m.userId))
|
||||
setSelectedMemberIds(new Set())
|
||||
setShowAddMembersModal(true)
|
||||
}, [members])
|
||||
}, [])
|
||||
|
||||
const handleAddSelectedMembers = useCallback(async () => {
|
||||
if (!viewingGroup || selectedMemberIds.size === 0) return
|
||||
@@ -891,7 +887,6 @@ export function AccessControl() {
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
// When deselecting all, keep start_trigger allowed (it should never be disabled)
|
||||
allowedIntegrations: allAllowed ? ['start_trigger'] : null,
|
||||
}
|
||||
: prev
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
import { fetchJson } from '@/hooks/selectors/helpers'
|
||||
@@ -11,55 +11,13 @@ import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getUserRole } from '@/lib/workspaces/organization/utils'
|
||||
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
|
||||
import { useConfigureSSO, useSSOProviders } from '@/ee/sso/hooks/sso'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useConfigureSSO, useSSOProviders } from '@/hooks/queries/sso'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
const logger = createLogger('SSO')
|
||||
|
||||
const TRUSTED_SSO_PROVIDERS = [
|
||||
'okta',
|
||||
'okta-saml',
|
||||
'okta-prod',
|
||||
'okta-dev',
|
||||
'okta-staging',
|
||||
'okta-test',
|
||||
'azure-ad',
|
||||
'azure-active-directory',
|
||||
'azure-corp',
|
||||
'azure-enterprise',
|
||||
'adfs',
|
||||
'adfs-company',
|
||||
'adfs-corp',
|
||||
'adfs-enterprise',
|
||||
'auth0',
|
||||
'auth0-prod',
|
||||
'auth0-dev',
|
||||
'auth0-staging',
|
||||
'onelogin',
|
||||
'onelogin-prod',
|
||||
'onelogin-corp',
|
||||
'jumpcloud',
|
||||
'jumpcloud-prod',
|
||||
'jumpcloud-corp',
|
||||
'ping-identity',
|
||||
'ping-federate',
|
||||
'pingone',
|
||||
'shibboleth',
|
||||
'shibboleth-idp',
|
||||
'google-workspace',
|
||||
'google-sso',
|
||||
'saml',
|
||||
'saml2',
|
||||
'saml-sso',
|
||||
'oidc',
|
||||
'oidc-sso',
|
||||
'openid-connect',
|
||||
'custom-sso',
|
||||
'enterprise-sso',
|
||||
'company-sso',
|
||||
]
|
||||
|
||||
interface SSOProvider {
|
||||
id: string
|
||||
providerId: string
|
||||
@@ -565,7 +523,7 @@ export function SSO() {
|
||||
<Combobox
|
||||
value={formData.providerId}
|
||||
onChange={(value: string) => handleInputChange('providerId', value)}
|
||||
options={TRUSTED_SSO_PROVIDERS.map((id) => ({
|
||||
options={SSO_TRUSTED_PROVIDERS.map((id) => ({
|
||||
label: id,
|
||||
value: id,
|
||||
}))}
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* List of trusted SSO provider identifiers.
|
||||
* Used for validation and autocomplete in SSO configuration.
|
||||
*/
|
||||
export const SSO_TRUSTED_PROVIDERS = [
|
||||
'okta',
|
||||
'okta-saml',
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { organizationKeys } from '@/hooks/queries/organization'
|
||||
|
||||
@@ -75,39 +77,3 @@ export function useConfigureSSO() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete SSO provider mutation
|
||||
*/
|
||||
interface DeleteSSOParams {
|
||||
providerId: string
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export function useDeleteSSO() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ providerId }: DeleteSSOParams) => {
|
||||
const response = await fetch(`/api/auth/sso/providers/${providerId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to delete SSO provider')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ssoKeys.providers() })
|
||||
|
||||
if (variables.orgId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.detail(variables.orgId),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
hydrateUserFilesWithBase64,
|
||||
} from '@/lib/uploads/utils/user-file-base64.server'
|
||||
import { sanitizeInputFormat, sanitizeTools } from '@/lib/workflows/comparison/normalize'
|
||||
import { validateBlockType } from '@/ee/access-control/utils/permission-check'
|
||||
import {
|
||||
BlockType,
|
||||
buildResumeApiUrl,
|
||||
@@ -20,18 +21,18 @@ import {
|
||||
generatePauseContextId,
|
||||
mapNodeMetadataToPauseScopes,
|
||||
} from '@/executor/human-in-the-loop/utils.ts'
|
||||
import type {
|
||||
BlockHandler,
|
||||
BlockLog,
|
||||
BlockState,
|
||||
ExecutionContext,
|
||||
NormalizedBlockOutput,
|
||||
import {
|
||||
type BlockHandler,
|
||||
type BlockLog,
|
||||
type BlockState,
|
||||
type ExecutionContext,
|
||||
getNextExecutionOrder,
|
||||
type NormalizedBlockOutput,
|
||||
} from '@/executor/types'
|
||||
import { streamingResponseFormatProcessor } from '@/executor/utils'
|
||||
import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors'
|
||||
import { isJSONString } from '@/executor/utils/json'
|
||||
import { filterOutputForLog } from '@/executor/utils/output-filter'
|
||||
import { validateBlockType } from '@/executor/utils/permission-check'
|
||||
import type { VariableResolver } from '@/executor/variables/resolver'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import type { SubflowType } from '@/stores/workflows/workflow/types'
|
||||
@@ -68,7 +69,7 @@ export class BlockExecutor {
|
||||
if (!isSentinel) {
|
||||
blockLog = this.createBlockLog(ctx, node.id, block, node)
|
||||
ctx.blockLogs.push(blockLog)
|
||||
this.callOnBlockStart(ctx, node, block)
|
||||
this.callOnBlockStart(ctx, node, block, blockLog.executionOrder)
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
@@ -159,7 +160,7 @@ export class BlockExecutor {
|
||||
|
||||
this.state.setBlockOutput(node.id, normalizedOutput, duration)
|
||||
|
||||
if (!isSentinel) {
|
||||
if (!isSentinel && blockLog) {
|
||||
const displayOutput = filterOutputForLog(block.metadata?.id || '', normalizedOutput, {
|
||||
block,
|
||||
})
|
||||
@@ -170,8 +171,9 @@ export class BlockExecutor {
|
||||
this.sanitizeInputsForLog(resolvedInputs),
|
||||
displayOutput,
|
||||
duration,
|
||||
blockLog!.startedAt,
|
||||
blockLog!.endedAt
|
||||
blockLog.startedAt,
|
||||
blockLog.executionOrder,
|
||||
blockLog.endedAt
|
||||
)
|
||||
}
|
||||
|
||||
@@ -268,7 +270,7 @@ export class BlockExecutor {
|
||||
}
|
||||
)
|
||||
|
||||
if (!isSentinel) {
|
||||
if (!isSentinel && blockLog) {
|
||||
const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
|
||||
this.callOnBlockComplete(
|
||||
ctx,
|
||||
@@ -277,8 +279,9 @@ export class BlockExecutor {
|
||||
this.sanitizeInputsForLog(input),
|
||||
displayOutput,
|
||||
duration,
|
||||
blockLog!.startedAt,
|
||||
blockLog!.endedAt
|
||||
blockLog.startedAt,
|
||||
blockLog.executionOrder,
|
||||
blockLog.endedAt
|
||||
)
|
||||
}
|
||||
|
||||
@@ -346,6 +349,7 @@ export class BlockExecutor {
|
||||
blockName,
|
||||
blockType: block.metadata?.id ?? DEFAULTS.BLOCK_TYPE,
|
||||
startedAt: new Date().toISOString(),
|
||||
executionOrder: getNextExecutionOrder(ctx),
|
||||
endedAt: '',
|
||||
durationMs: 0,
|
||||
success: false,
|
||||
@@ -409,7 +413,12 @@ export class BlockExecutor {
|
||||
return result
|
||||
}
|
||||
|
||||
private callOnBlockStart(ctx: ExecutionContext, node: DAGNode, block: SerializedBlock): void {
|
||||
private callOnBlockStart(
|
||||
ctx: ExecutionContext,
|
||||
node: DAGNode,
|
||||
block: SerializedBlock,
|
||||
executionOrder: number
|
||||
): void {
|
||||
const blockId = node.id
|
||||
const blockName = block.metadata?.name ?? blockId
|
||||
const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE
|
||||
@@ -417,7 +426,13 @@ export class BlockExecutor {
|
||||
const iterationContext = this.getIterationContext(ctx, node)
|
||||
|
||||
if (this.contextExtensions.onBlockStart) {
|
||||
this.contextExtensions.onBlockStart(blockId, blockName, blockType, iterationContext)
|
||||
this.contextExtensions.onBlockStart(
|
||||
blockId,
|
||||
blockName,
|
||||
blockType,
|
||||
executionOrder,
|
||||
iterationContext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +444,7 @@ export class BlockExecutor {
|
||||
output: NormalizedBlockOutput,
|
||||
duration: number,
|
||||
startedAt: string,
|
||||
executionOrder: number,
|
||||
endedAt: string
|
||||
): void {
|
||||
const blockId = node.id
|
||||
@@ -447,6 +463,7 @@ export class BlockExecutor {
|
||||
output,
|
||||
executionTime: duration,
|
||||
startedAt,
|
||||
executionOrder,
|
||||
endedAt,
|
||||
},
|
||||
iterationContext
|
||||
|
||||
@@ -55,7 +55,13 @@ export interface IterationContext {
|
||||
|
||||
export interface ExecutionCallbacks {
|
||||
onStream?: (streamingExec: any) => Promise<void>
|
||||
onBlockStart?: (blockId: string, blockName: string, blockType: string) => Promise<void>
|
||||
onBlockStart?: (
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
blockType: string,
|
||||
executionOrder: number,
|
||||
iterationContext?: IterationContext
|
||||
) => Promise<void>
|
||||
onBlockComplete?: (
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
@@ -97,6 +103,7 @@ export interface ContextExtensions {
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
blockType: string,
|
||||
executionOrder: number,
|
||||
iterationContext?: IterationContext
|
||||
) => Promise<void>
|
||||
onBlockComplete?: (
|
||||
@@ -108,6 +115,7 @@ export interface ContextExtensions {
|
||||
output: NormalizedBlockOutput
|
||||
executionTime: number
|
||||
startedAt: string
|
||||
executionOrder: number
|
||||
endedAt: string
|
||||
},
|
||||
iterationContext?: IterationContext
|
||||
|
||||
@@ -6,6 +6,12 @@ import { createMcpToolId } from '@/lib/mcp/utils'
|
||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import {
|
||||
validateBlockType,
|
||||
validateCustomToolsAllowed,
|
||||
validateMcpToolsAllowed,
|
||||
validateModelProvider,
|
||||
} from '@/ee/access-control/utils/permission-check'
|
||||
import { AGENT, BlockType, DEFAULTS, REFERENCE, stripCustomToolPrefix } from '@/executor/constants'
|
||||
import { memoryService } from '@/executor/handlers/agent/memory'
|
||||
import type {
|
||||
@@ -18,12 +24,6 @@ import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/execu
|
||||
import { collectBlockData } from '@/executor/utils/block-data'
|
||||
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
|
||||
import { stringifyJSON } from '@/executor/utils/json'
|
||||
import {
|
||||
validateBlockType,
|
||||
validateCustomToolsAllowed,
|
||||
validateMcpToolsAllowed,
|
||||
validateModelProvider,
|
||||
} from '@/executor/utils/permission-check'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
@@ -4,11 +4,11 @@ import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
|
||||
import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
|
||||
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
|
||||
import { validateModelProvider } from '@/executor/utils/permission-check'
|
||||
import { calculateCost, getProviderFromModel } from '@/providers/utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
|
||||
import {
|
||||
BlockType,
|
||||
DEFAULTS,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
} from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { buildAuthHeaders } from '@/executor/utils/http'
|
||||
import { validateModelProvider } from '@/executor/utils/permission-check'
|
||||
import { calculateCost, getProviderFromModel } from '@/providers/utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
|
||||
@@ -7,7 +7,11 @@ import type { DAG } from '@/executor/dag/builder'
|
||||
import type { EdgeManager } from '@/executor/execution/edge-manager'
|
||||
import type { LoopScope } from '@/executor/execution/state'
|
||||
import type { BlockStateController, ContextExtensions } from '@/executor/execution/types'
|
||||
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
|
||||
import {
|
||||
type ExecutionContext,
|
||||
getNextExecutionOrder,
|
||||
type NormalizedBlockOutput,
|
||||
} from '@/executor/types'
|
||||
import type { LoopConfigWithNodes } from '@/executor/types/loop'
|
||||
import { replaceValidReferences } from '@/executor/utils/reference-validation'
|
||||
import {
|
||||
@@ -286,6 +290,7 @@ export class LoopOrchestrator {
|
||||
output,
|
||||
executionTime: DEFAULTS.EXECUTION_TIME,
|
||||
startedAt: now,
|
||||
executionOrder: getNextExecutionOrder(ctx),
|
||||
endedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { DEFAULTS } from '@/executor/constants'
|
||||
import type { DAG } from '@/executor/dag/builder'
|
||||
import type { ParallelScope } from '@/executor/execution/state'
|
||||
import type { BlockStateWriter, ContextExtensions } from '@/executor/execution/types'
|
||||
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
|
||||
import {
|
||||
type ExecutionContext,
|
||||
getNextExecutionOrder,
|
||||
type NormalizedBlockOutput,
|
||||
} from '@/executor/types'
|
||||
import type { ParallelConfigWithNodes } from '@/executor/types/parallel'
|
||||
import { ParallelExpander } from '@/executor/utils/parallel-expansion'
|
||||
import {
|
||||
@@ -270,6 +274,7 @@ export class ParallelOrchestrator {
|
||||
output,
|
||||
executionTime: 0,
|
||||
startedAt: now,
|
||||
executionOrder: getNextExecutionOrder(ctx),
|
||||
endedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -114,6 +114,11 @@ export interface BlockLog {
|
||||
loopId?: string
|
||||
parallelId?: string
|
||||
iterationIndex?: number
|
||||
/**
|
||||
* Monotonically increasing integer (1, 2, 3, ...) for accurate block ordering.
|
||||
* Generated via getNextExecutionOrder() to ensure deterministic sorting.
|
||||
*/
|
||||
executionOrder: number
|
||||
/**
|
||||
* Child workflow trace spans for nested workflow execution.
|
||||
* Stored separately from output to keep output clean for display
|
||||
@@ -227,7 +232,12 @@ export interface ExecutionContext {
|
||||
edges?: Array<{ source: string; target: string }>
|
||||
|
||||
onStream?: (streamingExecution: StreamingExecution) => Promise<void>
|
||||
onBlockStart?: (blockId: string, blockName: string, blockType: string) => Promise<void>
|
||||
onBlockStart?: (
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
blockType: string,
|
||||
executionOrder: number
|
||||
) => Promise<void>
|
||||
onBlockComplete?: (
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
@@ -268,6 +278,23 @@ export interface ExecutionContext {
|
||||
* Stop execution after this block completes. Used for "run until block" feature.
|
||||
*/
|
||||
stopAfterBlockId?: string
|
||||
|
||||
/**
|
||||
* Counter for generating monotonically increasing execution order values.
|
||||
* Starts at 0 and increments for each block. Use getNextExecutionOrder() to access.
|
||||
*/
|
||||
executionOrderCounter?: { value: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the next execution order value for a block.
|
||||
* Returns a simple incrementing integer (1, 2, 3, ...) for clear ordering.
|
||||
*/
|
||||
export function getNextExecutionOrder(ctx: ExecutionContext): number {
|
||||
if (!ctx.executionOrderCounter) {
|
||||
ctx.executionOrderCounter = { value: 0 }
|
||||
}
|
||||
return ++ctx.executionOrderCounter.value
|
||||
}
|
||||
|
||||
export interface ExecutionResult {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { LOOP, PARALLEL, PARSING, REFERENCE } from '@/executor/constants'
|
||||
import type { ContextExtensions } from '@/executor/execution/types'
|
||||
import type { BlockLog, ExecutionContext } from '@/executor/types'
|
||||
import { type BlockLog, type ExecutionContext, getNextExecutionOrder } from '@/executor/types'
|
||||
import type { VariableResolver } from '@/executor/variables/resolver'
|
||||
|
||||
const logger = createLogger('SubflowUtils')
|
||||
@@ -208,6 +208,7 @@ export function addSubflowErrorLog(
|
||||
contextExtensions: ContextExtensions | null
|
||||
): void {
|
||||
const now = new Date().toISOString()
|
||||
const execOrder = getNextExecutionOrder(ctx)
|
||||
|
||||
const block = ctx.workflow?.blocks?.find((b) => b.id === blockId)
|
||||
const blockName = block?.metadata?.name || (blockType === 'loop' ? 'Loop' : 'Parallel')
|
||||
@@ -217,6 +218,7 @@ export function addSubflowErrorLog(
|
||||
blockName,
|
||||
blockType,
|
||||
startedAt: now,
|
||||
executionOrder: execOrder,
|
||||
endedAt: now,
|
||||
durationMs: 0,
|
||||
success: false,
|
||||
@@ -233,6 +235,7 @@ export function addSubflowErrorLog(
|
||||
output: { error: errorMessage },
|
||||
executionTime: 0,
|
||||
startedAt: now,
|
||||
executionOrder: execOrder,
|
||||
endedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { fetchJson } from '@/hooks/selectors/helpers'
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const logger = createLogger('UserProfileQuery')
|
||||
export const userProfileKeys = {
|
||||
all: ['userProfile'] as const,
|
||||
profile: () => [...userProfileKeys.all, 'profile'] as const,
|
||||
superUser: (userId?: string) => [...userProfileKeys.all, 'superUser', userId ?? ''] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,3 +110,37 @@ export function useUpdateUserProfile() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Superuser status response type
|
||||
*/
|
||||
interface SuperUserStatus {
|
||||
isSuperUser: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch superuser status from API
|
||||
*/
|
||||
async function fetchSuperUserStatus(): Promise<SuperUserStatus> {
|
||||
const response = await fetch('/api/user/super-user')
|
||||
|
||||
if (!response.ok) {
|
||||
return { isSuperUser: false }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return { isSuperUser: data.isSuperUser ?? false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch superuser status
|
||||
* @param userId - User ID for cache isolation (required for proper per-user caching)
|
||||
*/
|
||||
export function useSuperUserStatus(userId?: string) {
|
||||
return useQuery({
|
||||
queryKey: userProfileKeys.superUser(userId),
|
||||
queryFn: fetchSuperUserStatus,
|
||||
enabled: Boolean(userId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - superuser status rarely changes
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags'
|
||||
@@ -5,8 +7,8 @@ import {
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
type PermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useUserPermissionConfig } from '@/hooks/queries/permission-groups'
|
||||
|
||||
export interface PermissionConfigResult {
|
||||
config: PermissionGroupConfig
|
||||
|
||||
@@ -59,8 +59,8 @@ import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
|
||||
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
|
||||
import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
|
||||
|
||||
const logger = createLogger('Auth')
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
|
||||
import { isHiddenFromDisplay } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { escapeRegExp } from '@/executor/constants'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import type { ChatContext } from '@/stores/panel/copilot/types'
|
||||
|
||||
export type AgentContextType =
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@/lib/copilot/tools/shared/schemas'
|
||||
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
|
||||
import { isHiddenFromDisplay, type SubBlockConfig } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
import { tools as toolsRegistry } from '@/tools/registry'
|
||||
import { getTrigger, isTriggerValid } from '@/triggers'
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type GetBlockOptionsResultType,
|
||||
} from '@/lib/copilot/tools/shared/schemas'
|
||||
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { tools as toolsRegistry } from '@/tools/registry'
|
||||
|
||||
export const getBlockOptionsServerTool: BaseServerTool<
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@/lib/copilot/tools/shared/schemas'
|
||||
import { registry as blockRegistry } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
export const getBlocksAndToolsServerTool: BaseServerTool<
|
||||
ReturnType<typeof GetBlocksAndToolsInput.parse>,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@/lib/copilot/tools/shared/schemas'
|
||||
import { registry as blockRegistry } from '@/blocks/registry'
|
||||
import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
import { tools as toolsRegistry } from '@/tools/registry'
|
||||
import { getTrigger, isTriggerValid } from '@/triggers'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod'
|
||||
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
|
||||
import { registry as blockRegistry } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
export const GetTriggerBlocksInput = z.object({})
|
||||
export const GetTriggerBlocksResult = z.object({
|
||||
|
||||
@@ -15,8 +15,8 @@ import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { getAllBlocks, getBlock } from '@/blocks/registry'
|
||||
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
|
||||
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
|
||||
|
||||
|
||||
@@ -153,22 +153,50 @@ export function formatCompactTimestamp(iso: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration in milliseconds to a human-readable format
|
||||
* @param durationMs - The duration in milliseconds
|
||||
* Format a duration to a human-readable format
|
||||
* @param duration - Duration in milliseconds (number) or as string (e.g., "500ms")
|
||||
* @param options - Optional formatting options
|
||||
* @param options.precision - Number of decimal places for seconds (default: 0)
|
||||
* @returns A formatted duration string
|
||||
* @param options.precision - Number of decimal places for seconds (default: 0), trailing zeros are stripped
|
||||
* @returns A formatted duration string, or null if input is null/undefined
|
||||
*/
|
||||
export function formatDuration(durationMs: number, options?: { precision?: number }): string {
|
||||
const precision = options?.precision ?? 0
|
||||
|
||||
if (durationMs < 1000) {
|
||||
return `${durationMs}ms`
|
||||
export function formatDuration(
|
||||
duration: number | string | undefined | null,
|
||||
options?: { precision?: number }
|
||||
): string | null {
|
||||
if (duration === undefined || duration === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const seconds = durationMs / 1000
|
||||
// Parse string durations (e.g., "500ms", "0.44ms", "1234")
|
||||
let ms: number
|
||||
if (typeof duration === 'string') {
|
||||
ms = Number.parseFloat(duration.replace(/[^0-9.-]/g, ''))
|
||||
if (!Number.isFinite(ms)) {
|
||||
return duration
|
||||
}
|
||||
} else {
|
||||
ms = duration
|
||||
}
|
||||
|
||||
const precision = options?.precision ?? 0
|
||||
|
||||
if (ms < 1) {
|
||||
// Sub-millisecond: show with 2 decimal places
|
||||
return `${ms.toFixed(2)}ms`
|
||||
}
|
||||
|
||||
if (ms < 1000) {
|
||||
// Milliseconds: round to integer
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
|
||||
const seconds = ms / 1000
|
||||
if (seconds < 60) {
|
||||
return precision > 0 ? `${seconds.toFixed(precision)}s` : `${Math.floor(seconds)}s`
|
||||
if (precision > 0) {
|
||||
// Strip trailing zeros (e.g., "5.00s" -> "5s", "5.10s" -> "5.1s")
|
||||
return `${seconds.toFixed(precision).replace(/\.?0+$/, '')}s`
|
||||
}
|
||||
return `${Math.floor(seconds)}s`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* SSE Event types for workflow execution
|
||||
*/
|
||||
|
||||
import type { SubflowType } from '@/stores/workflows/workflow/types'
|
||||
|
||||
export type ExecutionEventType =
|
||||
@@ -80,7 +76,7 @@ export interface BlockStartedEvent extends BaseExecutionEvent {
|
||||
blockId: string
|
||||
blockName: string
|
||||
blockType: string
|
||||
// Iteration context for loops and parallels
|
||||
executionOrder: number
|
||||
iterationCurrent?: number
|
||||
iterationTotal?: number
|
||||
iterationType?: SubflowType
|
||||
@@ -101,8 +97,8 @@ export interface BlockCompletedEvent extends BaseExecutionEvent {
|
||||
output: any
|
||||
durationMs: number
|
||||
startedAt: string
|
||||
executionOrder: number
|
||||
endedAt: string
|
||||
// Iteration context for loops and parallels
|
||||
iterationCurrent?: number
|
||||
iterationTotal?: number
|
||||
iterationType?: SubflowType
|
||||
@@ -123,8 +119,8 @@ export interface BlockErrorEvent extends BaseExecutionEvent {
|
||||
error: string
|
||||
durationMs: number
|
||||
startedAt: string
|
||||
executionOrder: number
|
||||
endedAt: string
|
||||
// Iteration context for loops and parallels
|
||||
iterationCurrent?: number
|
||||
iterationTotal?: number
|
||||
iterationType?: SubflowType
|
||||
@@ -222,6 +218,7 @@ export function createSSECallbacks(options: SSECallbackOptions) {
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
blockType: string,
|
||||
executionOrder: number,
|
||||
iterationContext?: { iterationCurrent: number; iterationTotal: number; iterationType: string }
|
||||
) => {
|
||||
sendEvent({
|
||||
@@ -233,6 +230,7 @@ export function createSSECallbacks(options: SSECallbackOptions) {
|
||||
blockId,
|
||||
blockName,
|
||||
blockType,
|
||||
executionOrder,
|
||||
...(iterationContext && {
|
||||
iterationCurrent: iterationContext.iterationCurrent,
|
||||
iterationTotal: iterationContext.iterationTotal,
|
||||
@@ -251,6 +249,7 @@ export function createSSECallbacks(options: SSECallbackOptions) {
|
||||
output: any
|
||||
executionTime: number
|
||||
startedAt: string
|
||||
executionOrder: number
|
||||
endedAt: string
|
||||
},
|
||||
iterationContext?: { iterationCurrent: number; iterationTotal: number; iterationType: string }
|
||||
@@ -278,6 +277,7 @@ export function createSSECallbacks(options: SSECallbackOptions) {
|
||||
error: callbackData.output.error,
|
||||
durationMs: callbackData.executionTime || 0,
|
||||
startedAt: callbackData.startedAt,
|
||||
executionOrder: callbackData.executionOrder,
|
||||
endedAt: callbackData.endedAt,
|
||||
...iterationData,
|
||||
},
|
||||
@@ -296,6 +296,7 @@ export function createSSECallbacks(options: SSECallbackOptions) {
|
||||
output: callbackData.output,
|
||||
durationMs: callbackData.executionTime || 0,
|
||||
startedAt: callbackData.startedAt,
|
||||
executionOrder: callbackData.executionOrder,
|
||||
endedAt: callbackData.endedAt,
|
||||
...iterationData,
|
||||
},
|
||||
|
||||
@@ -287,6 +287,14 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
return entry
|
||||
}
|
||||
|
||||
if (
|
||||
typeof update === 'object' &&
|
||||
update.iterationCurrent !== undefined &&
|
||||
entry.iterationCurrent !== update.iterationCurrent
|
||||
) {
|
||||
return entry
|
||||
}
|
||||
|
||||
if (typeof update === 'string') {
|
||||
const newOutput = updateBlockOutput(entry.output, update)
|
||||
return { ...entry, output: newOutput }
|
||||
@@ -324,6 +332,10 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
updatedEntry.success = update.success
|
||||
}
|
||||
|
||||
if (update.startedAt !== undefined) {
|
||||
updatedEntry.startedAt = update.startedAt
|
||||
}
|
||||
|
||||
if (update.endedAt !== undefined) {
|
||||
updatedEntry.endedAt = update.endedAt
|
||||
}
|
||||
@@ -397,9 +409,15 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
},
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState as Partial<ConsoleStore> | undefined
|
||||
const entries = (persisted?.entries ?? currentState.entries).map((entry, index) => {
|
||||
if (entry.executionOrder === undefined) {
|
||||
return { ...entry, executionOrder: index + 1 }
|
||||
}
|
||||
return entry
|
||||
})
|
||||
return {
|
||||
...currentState,
|
||||
entries: persisted?.entries ?? currentState.entries,
|
||||
entries,
|
||||
isOpen: persisted?.isOpen ?? currentState.isOpen,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ConsoleEntry {
|
||||
blockType: string
|
||||
executionId?: string
|
||||
startedAt?: string
|
||||
executionOrder: number
|
||||
endedAt?: string
|
||||
durationMs?: number
|
||||
success?: boolean
|
||||
@@ -20,9 +21,7 @@ export interface ConsoleEntry {
|
||||
iterationCurrent?: number
|
||||
iterationTotal?: number
|
||||
iterationType?: SubflowType
|
||||
/** Whether this block is currently running */
|
||||
isRunning?: boolean
|
||||
/** Whether this block execution was canceled */
|
||||
isCanceled?: boolean
|
||||
}
|
||||
|
||||
@@ -33,14 +32,12 @@ export interface ConsoleUpdate {
|
||||
error?: string | Error | null
|
||||
warning?: string
|
||||
success?: boolean
|
||||
startedAt?: string
|
||||
endedAt?: string
|
||||
durationMs?: number
|
||||
input?: any
|
||||
/** Whether this block is currently running */
|
||||
isRunning?: boolean
|
||||
/** Whether this block execution was canceled */
|
||||
isCanceled?: boolean
|
||||
/** Iteration context for subflow blocks */
|
||||
iterationCurrent?: number
|
||||
iterationTotal?: number
|
||||
iterationType?: SubflowType
|
||||
|
||||
@@ -286,6 +286,30 @@ describe('HTTP Request Tool', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle nested objects and arrays in URL-encoded form data', async () => {
|
||||
tester.setup({ result: 'success' })
|
||||
|
||||
const body = {
|
||||
name: 'test',
|
||||
data: { nested: 'value' },
|
||||
items: [1, 2, 3],
|
||||
}
|
||||
|
||||
await tester.execute({
|
||||
url: 'https://api.example.com/submit',
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: [{ cells: { Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' } }],
|
||||
})
|
||||
|
||||
const fetchCall = (global.fetch as any).mock.calls[0]
|
||||
const bodyStr = fetchCall[1].body
|
||||
|
||||
expect(bodyStr).toContain('name=test')
|
||||
expect(bodyStr).toContain('data=%7B%22nested%22%3A%22value%22%7D')
|
||||
expect(bodyStr).toContain('items=%5B1%2C2%2C3%5D')
|
||||
})
|
||||
|
||||
it('should handle OAuth client credentials requests', async () => {
|
||||
tester.setup({ access_token: 'token123', token_type: 'Bearer' })
|
||||
|
||||
|
||||
@@ -105,7 +105,10 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
|
||||
const urlencoded = new URLSearchParams()
|
||||
Object.entries(params.body as Record<string, unknown>).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
urlencoded.append(key, String(value))
|
||||
urlencoded.append(
|
||||
key,
|
||||
typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||
)
|
||||
}
|
||||
})
|
||||
return urlencoded.toString()
|
||||
|
||||
Reference in New Issue
Block a user