Compare commits

...

11 Commits

Author SHA1 Message Date
waleed
3ac79b0443 fix(logs): format duration in log details panel 2026-02-03 13:08:54 -08:00
waleed
47cb0a2cc9 fix(formatting): return null for missing values, strip trailing zeros 2026-02-02 23:44:28 -08:00
Waleed
a529db112e feat(ee): add enterprise modules (#3121) 2026-02-02 23:44:28 -08:00
waleed
c69d6bf976 fix(formatting): use parseFloat to preserve fractional milliseconds 2026-02-02 23:31:40 -08:00
waleed
567f39267c fix(logs): add precision to logs list duration formatting 2026-02-02 23:10:19 -08:00
waleed
12409f5eb5 fix(formatting): preserve original precision and rounding behavior 2026-02-02 22:58:44 -08:00
waleed
7386d227eb fix(formatting): consolidate duration formatting into shared utility 2026-02-02 14:53:14 -08:00
Waleed
a9b7d75d87 feat(editor): added docs link to editor (#3116) 2026-02-02 12:22:08 -08:00
Vikhyath Mondreti
0449804ffb improvement(billing): duplicate checks for bypasses, logger billing actor consistency, run from block (#3107)
* improvement(billing): improve against direct subscription creation bypasses

* more usage of block/unblock helpers

* address bugbot comments

* fail closed

* only run dup check for orgs
2026-02-02 10:52:08 -08:00
Vikhyath Mondreti
c286f3ed24 fix(mcp): child workflow with response block returns error (#3114) 2026-02-02 09:30:35 -08:00
Vikhyath Mondreti
b738550815 fix(cleanup-cron): stale execution cleanup integer overflow (#3113) 2026-02-02 09:03:56 -08:00
65 changed files with 530 additions and 421 deletions

View File

@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { getEnv, isTruthy } from '@/lib/core/config/env' 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' export const dynamic = 'force-dynamic'

View File

@@ -8,6 +8,7 @@ import { verifyCronAuth } from '@/lib/auth/internal'
const logger = createLogger('CleanupStaleExecutions') const logger = createLogger('CleanupStaleExecutions')
const STALE_THRESHOLD_MINUTES = 30 const STALE_THRESHOLD_MINUTES = 30
const MAX_INT32 = 2_147_483_647
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@@ -45,13 +46,14 @@ export async function GET(request: NextRequest) {
try { try {
const staleDurationMs = Date.now() - new Date(execution.startedAt).getTime() const staleDurationMs = Date.now() - new Date(execution.startedAt).getTime()
const staleDurationMinutes = Math.round(staleDurationMs / 60000) const staleDurationMinutes = Math.round(staleDurationMs / 60000)
const totalDurationMs = Math.min(staleDurationMs, MAX_INT32)
await db await db
.update(workflowExecutionLogs) .update(workflowExecutionLogs)
.set({ .set({
status: 'failed', status: 'failed',
endedAt: new Date(), endedAt: new Date(),
totalDurationMs: staleDurationMs, totalDurationMs,
executionData: sql`jsonb_set( executionData: sql`jsonb_set(
COALESCE(execution_data, '{}'::jsonb), COALESCE(execution_data, '{}'::jsonb),
ARRAY['error'], ARRAY['error'],

View File

@@ -284,7 +284,7 @@ async function handleToolsCall(
content: [ content: [
{ type: 'text', text: JSON.stringify(executeResult.output || executeResult, null, 2) }, { type: 'text', text: JSON.stringify(executeResult.output || executeResult, null, 2) },
], ],
isError: !executeResult.success, isError: executeResult.success === false,
} }
return NextResponse.json(createResponse(id, result)) return NextResponse.json(createResponse(id, result))

View File

@@ -20,6 +20,7 @@ import { z } from 'zod'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails' import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing' import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client' import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer' import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -501,6 +502,18 @@ export async function PUT(
} }
} }
if (status === 'accepted') {
try {
await syncUsageLimitsFromSubscription(session.user.id)
} catch (syncError) {
logger.error('Failed to sync usage limits after joining org', {
userId: session.user.id,
organizationId,
error: syncError,
})
}
}
logger.info(`Organization invitation ${status}`, { logger.info(`Organization invitation ${status}`, {
organizationId, organizationId,
invitationId, invitationId,

View File

@@ -29,7 +29,7 @@ import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
import { import {
InvitationsNotAllowedError, InvitationsNotAllowedError,
validateInvitationsAllowed, validateInvitationsAllowed,
} from '@/executor/utils/permission-check' } from '@/ee/access-control/utils/permission-check'
const logger = createLogger('OrganizationInvitations') const logger = createLogger('OrganizationInvitations')

View File

@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { hasActiveSubscription } from '@/lib/billing'
const logger = createLogger('SubscriptionTransferAPI') const logger = createLogger('SubscriptionTransferAPI')
@@ -88,6 +89,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
) )
} }
// Check if org already has an active subscription (prevent duplicates)
if (await hasActiveSubscription(organizationId)) {
return NextResponse.json(
{ error: 'Organization already has an active subscription' },
{ status: 409 }
)
}
await db await db
.update(subscription) .update(subscription)
.set({ referenceId: organizationId }) .set({ referenceId: organizationId })

View File

@@ -203,6 +203,10 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
} }
updateData.billingBlocked = body.billingBlocked updateData.billingBlocked = body.billingBlocked
// Clear the reason when unblocking
if (body.billingBlocked === false) {
updateData.billingBlockedReason = null
}
updated.push('billingBlocked') updated.push('billingBlocked')
} }

View File

@@ -1,6 +1,4 @@
import { db, workflow as workflowTable } from '@sim/db'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod' import { z } from 'zod'
@@ -8,6 +6,7 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse' import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { markExecutionCancelled } from '@/lib/execution/cancellation' import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session' import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { createSSECallbacks } from '@/lib/workflows/executor/execution-events' import { createSSECallbacks } from '@/lib/workflows/executor/execution-events'
@@ -75,12 +74,31 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const { startBlockId, sourceSnapshot, input } = validation.data const { startBlockId, sourceSnapshot, input } = validation.data
const executionId = uuidv4() const executionId = uuidv4()
const [workflowRecord] = await db // Run preprocessing checks (billing, rate limits, usage limits)
.select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId }) const preprocessResult = await preprocessExecution({
.from(workflowTable) workflowId,
.where(eq(workflowTable.id, workflowId)) userId,
.limit(1) triggerType: 'manual',
executionId,
requestId,
checkRateLimit: false, // Manual executions don't rate limit
checkDeployment: false, // Run-from-block doesn't require deployment
})
if (!preprocessResult.success) {
const { error } = preprocessResult
logger.warn(`[${requestId}] Preprocessing failed for run-from-block`, {
workflowId,
error: error?.message,
statusCode: error?.statusCode,
})
return NextResponse.json(
{ error: error?.message || 'Execution blocked' },
{ status: error?.statusCode || 500 }
)
}
const workflowRecord = preprocessResult.workflowRecord
if (!workflowRecord?.workspaceId) { if (!workflowRecord?.workspaceId) {
return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 }) return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 })
} }
@@ -92,6 +110,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflowId, workflowId,
startBlockId, startBlockId,
executedBlocksCount: sourceSnapshot.executedBlocks.length, executedBlocksCount: sourceSnapshot.executedBlocks.length,
billingActorUserId: preprocessResult.actorUserId,
}) })
const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId) const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId)

View File

@@ -102,7 +102,7 @@ describe('Workspace Invitations API Route', () => {
inArray: vi.fn().mockImplementation((field, values) => ({ type: 'inArray', field, values })), 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), validateInvitationsAllowed: vi.fn().mockResolvedValue(undefined),
InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error { InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error {
constructor() { constructor() {

View File

@@ -21,7 +21,7 @@ import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import { import {
InvitationsNotAllowedError, InvitationsNotAllowedError,
validateInvitationsAllowed, validateInvitationsAllowed,
} from '@/executor/utils/permission-check' } from '@/ee/access-control/utils/permission-check'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -38,7 +38,6 @@ export async function GET(req: NextRequest) {
} }
try { try {
// Get all workspaces where the user has permissions
const userWorkspaces = await db const userWorkspaces = await db
.select({ id: workspace.id }) .select({ id: workspace.id })
.from(workspace) .from(workspace)
@@ -55,10 +54,8 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ invitations: [] }) return NextResponse.json({ invitations: [] })
} }
// Get all workspaceIds where the user is a member
const workspaceIds = userWorkspaces.map((w) => w.id) const workspaceIds = userWorkspaces.map((w) => w.id)
// Find all invitations for those workspaces
const invitations = await db const invitations = await db
.select() .select()
.from(workspaceInvitation) .from(workspaceInvitation)

View File

@@ -14,11 +14,11 @@ import {
ChatMessageContainer, ChatMessageContainer,
EmailAuth, EmailAuth,
PasswordAuth, PasswordAuth,
SSOAuth,
VoiceInterface, VoiceInterface,
} from '@/app/chat/components' } from '@/app/chat/components'
import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants' import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants'
import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks' import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks'
import SSOAuth from '@/ee/sso/components/sso-auth'
const logger = createLogger('ChatClient') const logger = createLogger('ChatClient')

View File

@@ -1,6 +1,5 @@
export { default as EmailAuth } from './auth/email/email-auth' export { default as EmailAuth } from './auth/email/email-auth'
export { default as PasswordAuth } from './auth/password/password-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 { ChatErrorState } from './error-state/error-state'
export { ChatHeader } from './header/header' export { ChatHeader } from './header/header'
export { ChatInput } from './input/input' export { ChatInput } from './input/input'

View File

@@ -1,7 +1,7 @@
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' 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' import { Knowledge } from './knowledge'
interface KnowledgePageProps { interface KnowledgePageProps {
@@ -23,7 +23,6 @@ export default async function KnowledgePage({ params }: KnowledgePageProps) {
redirect('/') redirect('/')
} }
// Check permission group restrictions
const permissionConfig = await getUserPermissionConfig(session.user.id) const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideKnowledgeBaseTab) { if (permissionConfig?.hideKnowledgeBaseTab) {
redirect(`/workspace/${workspaceId}`) redirect(`/workspace/${workspaceId}`)

View File

@@ -18,6 +18,7 @@ import {
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { cn } from '@/lib/core/utils/cn' 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 { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
import { import {
ExecutionSnapshot, ExecutionSnapshot,
@@ -453,7 +454,7 @@ export const LogDetails = memo(function LogDetails({
Duration Duration
</span> </span>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'> <span className='font-medium text-[13px] text-[var(--text-secondary)]'>
{log.duration || '—'} {formatDuration(log.duration, { precision: 2 }) || '—'}
</span> </span>
</div> </div>

View File

@@ -6,11 +6,11 @@ import Link from 'next/link'
import { List, type RowComponentProps, useListRef } from 'react-window' import { List, type RowComponentProps, useListRef } from 'react-window'
import { Badge, buttonVariants } from '@/components/emcn' import { Badge, buttonVariants } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { import {
DELETED_WORKFLOW_COLOR, DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL, DELETED_WORKFLOW_LABEL,
formatDate, formatDate,
formatDuration,
getDisplayStatus, getDisplayStatus,
LOG_COLUMNS, LOG_COLUMNS,
StatusBadge, StatusBadge,
@@ -113,7 +113,7 @@ const LogRow = memo(
<div className={`${LOG_COLUMNS.duration.width} ${LOG_COLUMNS.duration.minWidth}`}> <div className={`${LOG_COLUMNS.duration.width} ${LOG_COLUMNS.duration.minWidth}`}>
<Badge variant='default' className='rounded-[6px] px-[9px] py-[2px] text-[12px]'> <Badge variant='default' className='rounded-[6px] px-[9px] py-[2px] text-[12px]'>
{formatDuration(log.duration) || '—'} {formatDuration(log.duration, { precision: 2 }) || '—'}
</Badge> </Badge>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import { format } from 'date-fns' import { format } from 'date-fns'
import { Badge } from '@/components/emcn' import { Badge } from '@/components/emcn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options' import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' 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 * 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) * @param ms - Latency in milliseconds (number)
* @returns Formatted latency string * @returns Formatted latency string
*/ */
export function formatLatency(ms: number): string { export function formatLatency(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—' if (!Number.isFinite(ms) || ms <= 0) return '—'
return formatDuration(ms, { precision: 2 }) ?? '—'
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`
} }
export const formatDate = (dateString: string) => { export const formatDate = (dateString: string) => {

View File

@@ -6,7 +6,7 @@ import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates' import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
import Templates 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 { interface TemplatesPageProps {
params: Promise<{ params: Promise<{

View File

@@ -3,6 +3,7 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react' import { memo, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { ChevronUp } from 'lucide-react' import { ChevronUp } from 'lucide-react'
import { formatDuration } from '@/lib/core/utils/formatting'
import { CopilotMarkdownRenderer } from '../markdown-renderer' import { CopilotMarkdownRenderer } from '../markdown-renderer'
/** Removes thinking tags (raw or escaped) and special tags from streamed content */ /** Removes thinking tags (raw or escaped) and special tags from streamed content */
@@ -241,15 +242,11 @@ export function ThinkingBlock({
return () => window.clearInterval(intervalId) return () => window.clearInterval(intervalId)
}, [isStreaming, isExpanded, userHasScrolledAway]) }, [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 hasContent = cleanContent.length > 0
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags 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) => { const getStreamingLabel = (lbl: string) => {
if (lbl === 'Thought') return 'Thinking' if (lbl === 'Thought') return 'Thinking'

View File

@@ -15,6 +15,7 @@ import {
hasInterrupt as hasInterruptFromConfig, hasInterrupt as hasInterruptFromConfig,
isSpecialTool as isSpecialToolFromConfig, isSpecialTool as isSpecialToolFromConfig,
} from '@/lib/copilot/tools/client/ui-config' } 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 { 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 { 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' 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) (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 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 = () => ( const renderCollapsibleContent = () => (
<> <>

View File

@@ -50,6 +50,12 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
/** Stable empty object to avoid creating new references */ /** Stable empty object to avoid creating new references */
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any> const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
/** Shared style for dashed divider lines */
const DASHED_DIVIDER_STYLE = {
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
} as const
/** /**
* Icon component for rendering block icons. * Icon component for rendering block icons.
* *
@@ -89,31 +95,23 @@ export function Editor() {
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
const title = currentBlock?.name || 'Editor' const title = currentBlock?.name || 'Editor'
// Check if selected block is a subflow (loop or parallel)
const isSubflow = const isSubflow =
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel') currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
// Get subflow display properties from configs
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
// Check if selected block is a workflow block
const isWorkflowBlock = const isWorkflowBlock =
currentBlock && (currentBlock.type === 'workflow' || currentBlock.type === 'workflow_input') currentBlock && (currentBlock.type === 'workflow' || currentBlock.type === 'workflow_input')
// Get workspace ID from params
const params = useParams() const params = useParams()
const workspaceId = params.workspaceId as string const workspaceId = params.workspaceId as string
// Refs for resize functionality
const subBlocksRef = useRef<HTMLDivElement>(null) const subBlocksRef = useRef<HTMLDivElement>(null)
// Get user permissions
const userPermissions = useUserPermissionsContext() const userPermissions = useUserPermissionsContext()
// Get active workflow ID
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
// Get block properties (advanced/trigger modes)
const { advancedMode, triggerMode } = useEditorBlockProperties( const { advancedMode, triggerMode } = useEditorBlockProperties(
currentBlockId, currentBlockId,
currentWorkflow.isSnapshotView currentWorkflow.isSnapshotView
@@ -145,10 +143,9 @@ export function Editor() {
[subBlocksForCanonical] [subBlocksForCanonical]
) )
const canonicalModeOverrides = currentBlock?.data?.canonicalModes const canonicalModeOverrides = currentBlock?.data?.canonicalModes
const advancedValuesPresent = hasAdvancedValues( const advancedValuesPresent = useMemo(
subBlocksForCanonical, () => hasAdvancedValues(subBlocksForCanonical, blockSubBlockValues, canonicalIndex),
blockSubBlockValues, [subBlocksForCanonical, blockSubBlockValues, canonicalIndex]
canonicalIndex
) )
const displayAdvancedOptions = userPermissions.canEdit const displayAdvancedOptions = userPermissions.canEdit
? advancedMode ? advancedMode
@@ -156,11 +153,9 @@ export function Editor() {
const hasAdvancedOnlyFields = useMemo(() => { const hasAdvancedOnlyFields = useMemo(() => {
for (const subBlock of subBlocksForCanonical) { for (const subBlock of subBlocksForCanonical) {
// Must be standalone advanced (mode: 'advanced' without canonicalParamId)
if (subBlock.mode !== 'advanced') continue if (subBlock.mode !== 'advanced') continue
if (canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) continue if (canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) continue
// Check condition - skip if condition not met for current values
if ( if (
subBlock.condition && subBlock.condition &&
!evaluateSubBlockCondition(subBlock.condition, blockSubBlockValues) !evaluateSubBlockCondition(subBlock.condition, blockSubBlockValues)
@@ -173,7 +168,6 @@ export function Editor() {
return false return false
}, [subBlocksForCanonical, canonicalIndex.canonicalIdBySubBlockId, blockSubBlockValues]) }, [subBlocksForCanonical, canonicalIndex.canonicalIdBySubBlockId, blockSubBlockValues])
// Get subblock layout using custom hook
const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout( const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout(
blockConfig || ({} as any), blockConfig || ({} as any),
currentBlockId || '', currentBlockId || '',
@@ -206,31 +200,34 @@ export function Editor() {
return { regularSubBlocks: regular, advancedOnlySubBlocks: advancedOnly } return { regularSubBlocks: regular, advancedOnlySubBlocks: advancedOnly }
}, [subBlocks, canonicalIndex.canonicalIdBySubBlockId]) }, [subBlocks, canonicalIndex.canonicalIdBySubBlockId])
// Get block connections
const { incomingConnections, hasIncomingConnections } = useBlockConnections(currentBlockId || '') const { incomingConnections, hasIncomingConnections } = useBlockConnections(currentBlockId || '')
// Connections resize hook
const { handleMouseDown: handleConnectionsResizeMouseDown, isResizing } = useConnectionsResize({ const { handleMouseDown: handleConnectionsResizeMouseDown, isResizing } = useConnectionsResize({
subBlocksRef, subBlocksRef,
}) })
// Collaborative actions
const { const {
collaborativeSetBlockCanonicalMode, collaborativeSetBlockCanonicalMode,
collaborativeUpdateBlockName, collaborativeUpdateBlockName,
collaborativeToggleBlockAdvancedMode, collaborativeToggleBlockAdvancedMode,
} = useCollaborativeWorkflow() } = useCollaborativeWorkflow()
// Advanced mode toggle handler
const handleToggleAdvancedMode = useCallback(() => { const handleToggleAdvancedMode = useCallback(() => {
if (!currentBlockId || !userPermissions.canEdit) return if (!currentBlockId || !userPermissions.canEdit) return
collaborativeToggleBlockAdvancedMode(currentBlockId) collaborativeToggleBlockAdvancedMode(currentBlockId)
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode]) }, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
// Rename state
const [isRenaming, setIsRenaming] = useState(false) const [isRenaming, setIsRenaming] = useState(false)
const [editedName, setEditedName] = useState('') const [editedName, setEditedName] = useState('')
const nameInputRef = useRef<HTMLInputElement>(null)
/**
* Ref callback that auto-selects the input text when mounted.
*/
const nameInputRefCallback = useCallback((element: HTMLInputElement | null) => {
if (element) {
element.select()
}
}, [])
/** /**
* Handles starting the rename process. * Handles starting the rename process.
@@ -251,7 +248,6 @@ export function Editor() {
if (trimmedName && trimmedName !== currentBlock?.name) { if (trimmedName && trimmedName !== currentBlock?.name) {
const result = collaborativeUpdateBlockName(currentBlockId, trimmedName) const result = collaborativeUpdateBlockName(currentBlockId, trimmedName)
if (!result.success) { if (!result.success) {
// Keep rename mode open on error so user can correct the name
return return
} }
} }
@@ -266,14 +262,6 @@ export function Editor() {
setEditedName('') setEditedName('')
}, []) }, [])
// Focus input when entering rename mode
useEffect(() => {
if (isRenaming && nameInputRef.current) {
nameInputRef.current.select()
}
}, [isRenaming])
// Trigger rename mode when signaled from context menu
useEffect(() => { useEffect(() => {
if (shouldFocusRename && currentBlock) { if (shouldFocusRename && currentBlock) {
handleStartRename() handleStartRename()
@@ -284,17 +272,13 @@ export function Editor() {
/** /**
* Handles opening documentation link in a new secure tab. * Handles opening documentation link in a new secure tab.
*/ */
const handleOpenDocs = () => { const handleOpenDocs = useCallback(() => {
const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink
if (docsLink) { window.open(docsLink || 'https://docs.sim.ai/quick-reference', '_blank', 'noopener,noreferrer')
window.open(docsLink, '_blank', 'noopener,noreferrer') }, [isSubflow, subflowConfig?.docsLink, blockConfig?.docsLink])
}
}
// Get child workflow ID for workflow blocks
const childWorkflowId = isWorkflowBlock ? blockSubBlockValues?.workflowId : null const childWorkflowId = isWorkflowBlock ? blockSubBlockValues?.workflowId : null
// Fetch child workflow state for preview (only for workflow blocks with a selected workflow)
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } = const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } =
useWorkflowState(childWorkflowId) useWorkflowState(childWorkflowId)
@@ -307,7 +291,6 @@ export function Editor() {
} }
}, [childWorkflowId, workspaceId]) }, [childWorkflowId, workspaceId])
// Determine if connections are at minimum height (collapsed state)
const isConnectionsAtMinHeight = connectionsHeight <= 35 const isConnectionsAtMinHeight = connectionsHeight <= 35
return ( return (
@@ -328,7 +311,7 @@ export function Editor() {
)} )}
{isRenaming ? ( {isRenaming ? (
<input <input
ref={nameInputRef} ref={nameInputRefCallback}
type='text' type='text'
value={editedName} value={editedName}
onChange={(e) => setEditedName(e.target.value)} onChange={(e) => setEditedName(e.target.value)}
@@ -399,23 +382,21 @@ export function Editor() {
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
)} */} )} */}
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && ( <Tooltip.Root>
<Tooltip.Root> <Tooltip.Trigger asChild>
<Tooltip.Trigger asChild> <Button
<Button variant='ghost'
variant='ghost' className='p-0'
className='p-0' onClick={handleOpenDocs}
onClick={handleOpenDocs} aria-label='Open documentation'
aria-label='Open documentation' >
> <BookOpen className='h-[14px] w-[14px]' />
<BookOpen className='h-[14px] w-[14px]' /> </Button>
</Button> </Tooltip.Trigger>
</Tooltip.Trigger> <Tooltip.Content side='top'>
<Tooltip.Content side='top'> <p>Open docs</p>
<p>Open docs</p> </Tooltip.Content>
</Tooltip.Content> </Tooltip.Root>
</Tooltip.Root>
)}
</div> </div>
</div> </div>
@@ -495,13 +476,7 @@ export function Editor() {
</div> </div>
</div> </div>
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'> <div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div <div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div> </div>
</> </>
)} )}
@@ -566,13 +541,7 @@ export function Editor() {
/> />
{showDivider && ( {showDivider && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'> <div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div <div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div> </div>
)} )}
</div> </div>
@@ -581,13 +550,7 @@ export function Editor() {
{hasAdvancedOnlyFields && userPermissions.canEdit && ( {hasAdvancedOnlyFields && userPermissions.canEdit && (
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'> <div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
<div <div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
className='h-[1.25px] flex-1'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
<button <button
type='button' type='button'
onClick={handleToggleAdvancedMode} onClick={handleToggleAdvancedMode}
@@ -600,13 +563,7 @@ export function Editor() {
className={`h-[14px] w-[14px] transition-transform duration-200 ${displayAdvancedOptions ? 'rotate-180' : ''}`} className={`h-[14px] w-[14px] transition-transform duration-200 ${displayAdvancedOptions ? 'rotate-180' : ''}`}
/> />
</button> </button>
<div <div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
className='h-[1.25px] flex-1'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div> </div>
)} )}
@@ -630,13 +587,7 @@ export function Editor() {
/> />
{index < advancedOnlySubBlocks.length - 1 && ( {index < advancedOnlySubBlocks.length - 1 && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'> <div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div <div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div> </div>
)} )}
</div> </div>

View File

@@ -24,6 +24,7 @@ import {
Tooltip, Tooltip,
} from '@/components/emcn' } from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env' 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 { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { import {
@@ -43,7 +44,6 @@ import {
type EntryNode, type EntryNode,
type ExecutionGroup, type ExecutionGroup,
flattenBlockEntriesOnly, flattenBlockEntriesOnly,
formatDuration,
getBlockColor, getBlockColor,
getBlockIcon, getBlockIcon,
groupEntriesByExecution, groupEntriesByExecution,
@@ -128,7 +128,7 @@ const BlockRow = memo(function BlockRow({
<StatusDisplay <StatusDisplay
isRunning={isRunning} isRunning={isRunning}
isCanceled={isCanceled} isCanceled={isCanceled}
formattedDuration={formatDuration(entry.durationMs)} formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
/> />
</span> </span>
</div> </div>
@@ -201,7 +201,7 @@ const IterationNodeRow = memo(function IterationNodeRow({
<StatusDisplay <StatusDisplay
isRunning={hasRunningChild} isRunning={hasRunningChild}
isCanceled={hasCanceledChild} isCanceled={hasCanceledChild}
formattedDuration={formatDuration(entry.durationMs)} formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
/> />
</span> </span>
</div> </div>
@@ -314,7 +314,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
<StatusDisplay <StatusDisplay
isRunning={hasRunningDescendant} isRunning={hasRunningDescendant}
isCanceled={hasCanceledDescendant} isCanceled={hasCanceledDescendant}
formattedDuration={formatDuration(entry.durationMs)} formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
/> />
</span> </span>
</div> </div>

View File

@@ -53,17 +53,6 @@ export function getBlockColor(blockType: string): string {
return '#6b7280' 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 * Determines if a keyboard event originated from a text-editable element
*/ */

View File

@@ -30,6 +30,7 @@ import {
Textarea, Textarea,
} from '@/components/emcn' } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { formatEditSequence } from '@/lib/workflows/training/compute-edit-sequence' import { formatEditSequence } from '@/lib/workflows/training/compute-edit-sequence'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' 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-muted)]'>Duration:</span>{' '}
<span className='text-[var(--text-secondary)]'> <span className='text-[var(--text-secondary)]'>
{dataset.metadata?.duration {dataset.metadata?.duration
? `${(dataset.metadata.duration / 1000).toFixed(1)}s` ? formatDuration(dataset.metadata.duration, {
precision: 1,
})
: 'N/A'} : 'N/A'}
</span> </span>
</div> </div>

View File

@@ -246,7 +246,6 @@ export function CredentialSets() {
setNewSetDescription('') setNewSetDescription('')
setNewSetProvider('google-email') setNewSetProvider('google-email')
// Open detail view for the newly created group
if (result?.credentialSet) { if (result?.credentialSet) {
setViewingSet(result.credentialSet) setViewingSet(result.credentialSet)
} }
@@ -336,7 +335,6 @@ export function CredentialSets() {
email, email,
}) })
// Start 60s cooldown
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 })) setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const interval = setInterval(() => { const interval = setInterval(() => {
setResendCooldowns((prev) => { setResendCooldowns((prev) => {
@@ -393,7 +391,6 @@ export function CredentialSets() {
return <GmailIcon className='h-4 w-4' /> return <GmailIcon className='h-4 w-4' />
} }
// All hooks must be called before any early returns
const activeMemberships = useMemo( const activeMemberships = useMemo(
() => memberships.filter((m) => m.status === 'active'), () => memberships.filter((m) => m.status === 'active'),
[memberships] [memberships]
@@ -447,7 +444,6 @@ export function CredentialSets() {
<div className='flex h-full flex-col gap-[16px]'> <div className='flex h-full flex-col gap-[16px]'>
<div className='min-h-0 flex-1 overflow-y-auto'> <div className='min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-[16px]'> <div className='flex flex-col gap-[16px]'>
{/* Group Info */}
<div className='flex items-center gap-[16px]'> <div className='flex items-center gap-[16px]'>
<div className='flex items-center gap-[8px]'> <div className='flex items-center gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'> <span className='font-medium text-[13px] text-[var(--text-primary)]'>
@@ -471,7 +467,6 @@ export function CredentialSets() {
</div> </div>
</div> </div>
{/* Invite Section - Email Tags Input */}
<div className='flex flex-col gap-[4px]'> <div className='flex flex-col gap-[4px]'>
<div className='flex items-center gap-[8px]'> <div className='flex items-center gap-[8px]'>
<TagInput <TagInput
@@ -495,7 +490,6 @@ export function CredentialSets() {
{emailError && <p className='text-[12px] text-[var(--text-error)]'>{emailError}</p>} {emailError && <p className='text-[12px] text-[var(--text-error)]'>{emailError}</p>}
</div> </div>
{/* Members List - styled like team members */}
<div className='flex flex-col gap-[16px]'> <div className='flex flex-col gap-[16px]'>
<h4 className='font-medium text-[14px] text-[var(--text-primary)]'>Members</h4> <h4 className='font-medium text-[14px] text-[var(--text-primary)]'>Members</h4>
@@ -519,7 +513,6 @@ export function CredentialSets() {
</p> </p>
) : ( ) : (
<div className='flex flex-col gap-[16px]'> <div className='flex flex-col gap-[16px]'>
{/* Active Members */}
{activeMembers.map((member) => { {activeMembers.map((member) => {
const name = member.userName || 'Unknown' const name = member.userName || 'Unknown'
const avatarInitial = name.charAt(0).toUpperCase() const avatarInitial = name.charAt(0).toUpperCase()
@@ -572,7 +565,6 @@ export function CredentialSets() {
) )
})} })}
{/* Pending Invitations */}
{pendingInvitations.map((invitation) => { {pendingInvitations.map((invitation) => {
const email = invitation.email || 'Unknown' const email = invitation.email || 'Unknown'
const emailPrefix = email.split('@')[0] const emailPrefix = email.split('@')[0]
@@ -641,7 +633,6 @@ export function CredentialSets() {
</div> </div>
</div> </div>
{/* Footer Actions */}
<div className='mt-auto flex items-center justify-end'> <div className='mt-auto flex items-center justify-end'>
<Button onClick={handleBackToList} variant='tertiary'> <Button onClick={handleBackToList} variant='tertiary'>
Back Back
@@ -822,7 +813,6 @@ export function CredentialSets() {
</div> </div>
</div> </div>
{/* Create Polling Group Modal */}
<Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}> <Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}>
<ModalContent size='sm'> <ModalContent size='sm'>
<ModalHeader>Create Polling Group</ModalHeader> <ModalHeader>Create Polling Group</ModalHeader>
@@ -895,7 +885,6 @@ export function CredentialSets() {
</ModalContent> </ModalContent>
</Modal> </Modal>
{/* Leave Confirmation Modal */}
<Modal open={!!leavingMembership} onOpenChange={() => setLeavingMembership(null)}> <Modal open={!!leavingMembership} onOpenChange={() => setLeavingMembership(null)}>
<ModalContent size='sm'> <ModalContent size='sm'>
<ModalHeader>Leave Polling Group</ModalHeader> <ModalHeader>Leave Polling Group</ModalHeader>
@@ -923,7 +912,6 @@ export function CredentialSets() {
</ModalContent> </ModalContent>
</Modal> </Modal>
{/* Delete Confirmation Modal */}
<Modal open={!!deletingSet} onOpenChange={() => setDeletingSet(null)}> <Modal open={!!deletingSet} onOpenChange={() => setDeletingSet(null)}>
<ModalContent size='sm'> <ModalContent size='sm'>
<ModalHeader>Delete Polling Group</ModalHeader> <ModalHeader>Delete Polling Group</ModalHeader>

View File

@@ -1,4 +1,3 @@
export { AccessControl } from './access-control/access-control'
export { ApiKeys } from './api-keys/api-keys' export { ApiKeys } from './api-keys/api-keys'
export { BYOK } from './byok/byok' export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot' export { Copilot } from './copilot/copilot'
@@ -10,7 +9,6 @@ export { Files as FileUploads } from './files/files'
export { General } from './general/general' export { General } from './general/general'
export { Integrations } from './integrations/integrations' export { Integrations } from './integrations/integrations'
export { MCP } from './mcp/mcp' export { MCP } from './mcp/mcp'
export { SSO } from './sso/sso'
export { Subscription } from './subscription/subscription' export { Subscription } from './subscription/subscription'
export { TeamManagement } from './team-management/team-management' export { TeamManagement } from './team-management/team-management'
export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers' export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers'

View File

@@ -407,14 +407,12 @@ export function MCP({ initialServerId }: MCPProps) {
const [urlScrollLeft, setUrlScrollLeft] = useState(0) const [urlScrollLeft, setUrlScrollLeft] = useState(0)
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({}) const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
// Auto-select server when initialServerId is provided
useEffect(() => { useEffect(() => {
if (initialServerId && servers.some((s) => s.id === initialServerId)) { if (initialServerId && servers.some((s) => s.id === initialServerId)) {
setSelectedServerId(initialServerId) setSelectedServerId(initialServerId)
} }
}, [initialServerId, servers]) }, [initialServerId, servers])
// Force refresh tools when entering server detail view to detect stale schemas
useEffect(() => { useEffect(() => {
if (selectedServerId) { if (selectedServerId) {
forceRefreshTools(workspaceId) forceRefreshTools(workspaceId)
@@ -717,7 +715,6 @@ export function MCP({ initialServerId }: MCPProps) {
`Refreshed MCP server: ${serverId}, workflows updated: ${result.workflowsUpdated}` `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 const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) { if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) {
logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`) logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`)

View File

@@ -41,7 +41,6 @@ import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags' import { isHosted } from '@/lib/core/config/feature-flags'
import { getUserRole } from '@/lib/workspaces/organization' import { getUserRole } from '@/lib/workspaces/organization'
import { import {
AccessControl,
ApiKeys, ApiKeys,
BYOK, BYOK,
Copilot, Copilot,
@@ -53,15 +52,16 @@ import {
General, General,
Integrations, Integrations,
MCP, MCP,
SSO,
Subscription, Subscription,
TeamManagement, TeamManagement,
WorkflowMcpServers, WorkflowMcpServers,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components' } 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 { 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 { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings'
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization' import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription' import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
import { usePermissionConfig } from '@/hooks/use-permission-config' import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsModalStore } from '@/stores/modals/settings/store' import { useSettingsModalStore } from '@/stores/modals/settings/store'

View File

@@ -19,6 +19,7 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter'
import { decryptSecret } from '@/lib/core/security/encryption' import { decryptSecret } from '@/lib/core/security/encryption'
import { formatDuration } from '@/lib/core/utils/formatting'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
import { sendEmail } from '@/lib/messaging/email/mailer' 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 { function formatCost(cost?: Record<string, unknown>): string {
if (!cost?.total) return 'N/A' if (!cost?.total) return 'N/A'
const total = cost.total as number const total = cost.total as number
@@ -302,7 +297,7 @@ async function deliverEmail(
workflowName: payload.data.workflowName || 'Unknown Workflow', workflowName: payload.data.workflowName || 'Unknown Workflow',
status: payload.data.status, status: payload.data.status,
trigger: payload.data.trigger, trigger: payload.data.trigger,
duration: formatDuration(payload.data.totalDurationMs), duration: formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-',
cost: formatCost(payload.data.cost), cost: formatCost(payload.data.cost),
logUrl, logUrl,
alertReason, alertReason,
@@ -315,7 +310,7 @@ async function deliverEmail(
to: subscription.emailRecipients, to: subscription.emailRecipients,
subject, subject,
html, 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', emailType: 'notifications',
}) })
@@ -373,7 +368,10 @@ async function deliverSlack(
fields: [ fields: [
{ type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` }, { type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` },
{ type: 'mrkdwn', text: `*Trigger:*\n${payload.data.trigger}` }, { 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)}` }, { type: 'mrkdwn', text: `*Cost:*\n${formatCost(payload.data.cost)}` },
], ],
}, },

View File

@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import type { ToolCallGroup, ToolCallState } from '@/lib/copilot/types' import type { ToolCallGroup, ToolCallState } from '@/lib/copilot/types'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
interface ToolCallProps { interface ToolCallProps {
toolCall: ToolCallState toolCall: ToolCallState
@@ -225,11 +226,6 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
const isError = toolCall.state === 'error' const isError = toolCall.state === 'error'
const isAborted = toolCall.state === 'aborted' const isAborted = toolCall.state === 'aborted'
const formatDuration = (duration?: number) => {
if (!duration) return ''
return duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s`
}
return ( return (
<div <div
className={cn( className={cn(
@@ -279,7 +275,7 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
)} )}
style={{ fontSize: '0.625rem' }} style={{ fontSize: '0.625rem' }}
> >
{formatDuration(toolCall.duration)} {toolCall.duration ? formatDuration(toolCall.duration, { precision: 1 }) : ''}
</Badge> </Badge>
)} )}
</div> </div>

43
apps/sim/ee/LICENSE Normal file
View 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
View 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`).

View File

@@ -29,7 +29,6 @@ import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { getUserColor } from '@/lib/workspaces/colors' import { getUserColor } from '@/lib/workspaces/colors'
import { getUserRole } from '@/lib/workspaces/organization' import { getUserRole } from '@/lib/workspaces/organization'
import { getAllBlocks } from '@/blocks' import { getAllBlocks } from '@/blocks'
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
import { import {
type PermissionGroup, type PermissionGroup,
useBulkAddPermissionGroupMembers, useBulkAddPermissionGroupMembers,
@@ -39,7 +38,8 @@ import {
usePermissionGroups, usePermissionGroups,
useRemovePermissionGroupMember, useRemovePermissionGroupMember,
useUpdatePermissionGroup, 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 { useSubscriptionData } from '@/hooks/queries/subscription'
import { PROVIDER_DEFINITIONS } from '@/providers/models' import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { getAllProviderIds } from '@/providers/utils' import { getAllProviderIds } from '@/providers/utils'
@@ -255,7 +255,6 @@ export function AccessControl() {
queryEnabled queryEnabled
) )
// Show loading while dependencies load, or while permission groups query is pending
const isLoading = orgsLoading || subLoading || (queryEnabled && groupsLoading) const isLoading = orgsLoading || subLoading || (queryEnabled && groupsLoading)
const { data: organization } = useOrganization(activeOrganization?.id || '') const { data: organization } = useOrganization(activeOrganization?.id || '')
@@ -410,10 +409,8 @@ export function AccessControl() {
}, [viewingGroup, editingConfig]) }, [viewingGroup, editingConfig])
const allBlocks = useMemo(() => { 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') const blocks = getAllBlocks().filter((b) => !b.hideFromToolbar && b.type !== 'start_trigger')
return blocks.sort((a, b) => { return blocks.sort((a, b) => {
// Group by category: triggers first, then blocks, then tools
const categoryOrder = { triggers: 0, blocks: 1, tools: 2 } const categoryOrder = { triggers: 0, blocks: 1, tools: 2 }
const catA = categoryOrder[a.category] ?? 3 const catA = categoryOrder[a.category] ?? 3
const catB = categoryOrder[b.category] ?? 3 const catB = categoryOrder[b.category] ?? 3
@@ -555,10 +552,9 @@ export function AccessControl() {
}, [viewingGroup, editingConfig, activeOrganization?.id, updatePermissionGroup]) }, [viewingGroup, editingConfig, activeOrganization?.id, updatePermissionGroup])
const handleOpenAddMembersModal = useCallback(() => { const handleOpenAddMembersModal = useCallback(() => {
const existingMemberUserIds = new Set(members.map((m) => m.userId))
setSelectedMemberIds(new Set()) setSelectedMemberIds(new Set())
setShowAddMembersModal(true) setShowAddMembersModal(true)
}, [members]) }, [])
const handleAddSelectedMembers = useCallback(async () => { const handleAddSelectedMembers = useCallback(async () => {
if (!viewingGroup || selectedMemberIds.size === 0) return if (!viewingGroup || selectedMemberIds.size === 0) return
@@ -891,7 +887,6 @@ export function AccessControl() {
prev prev
? { ? {
...prev, ...prev,
// When deselecting all, keep start_trigger allowed (it should never be disabled)
allowedIntegrations: allAllowed ? ['start_trigger'] : null, allowedIntegrations: allAllowed ? ['start_trigger'] : null,
} }
: prev : prev

View File

@@ -1,3 +1,5 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { fetchJson } from '@/hooks/selectors/helpers' import { fetchJson } from '@/hooks/selectors/helpers'

View File

@@ -11,55 +11,13 @@ import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { getUserRole } from '@/lib/workspaces/organization/utils' 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 { useOrganizations } from '@/hooks/queries/organization'
import { useConfigureSSO, useSSOProviders } from '@/hooks/queries/sso'
import { useSubscriptionData } from '@/hooks/queries/subscription' import { useSubscriptionData } from '@/hooks/queries/subscription'
const logger = createLogger('SSO') 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 { interface SSOProvider {
id: string id: string
providerId: string providerId: string
@@ -565,7 +523,7 @@ export function SSO() {
<Combobox <Combobox
value={formData.providerId} value={formData.providerId}
onChange={(value: string) => handleInputChange('providerId', value)} onChange={(value: string) => handleInputChange('providerId', value)}
options={TRUSTED_SSO_PROVIDERS.map((id) => ({ options={SSO_TRUSTED_PROVIDERS.map((id) => ({
label: id, label: id,
value: id, value: id,
}))} }))}

View File

@@ -1,3 +1,7 @@
/**
* List of trusted SSO provider identifiers.
* Used for validation and autocomplete in SSO configuration.
*/
export const SSO_TRUSTED_PROVIDERS = [ export const SSO_TRUSTED_PROVIDERS = [
'okta', 'okta',
'okta-saml', 'okta-saml',

View File

@@ -1,3 +1,5 @@
'use client'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { organizationKeys } from '@/hooks/queries/organization' 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),
})
}
},
})
}

View File

@@ -5,6 +5,7 @@ import {
hydrateUserFilesWithBase64, hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server' } from '@/lib/uploads/utils/user-file-base64.server'
import { sanitizeInputFormat, sanitizeTools } from '@/lib/workflows/comparison/normalize' import { sanitizeInputFormat, sanitizeTools } from '@/lib/workflows/comparison/normalize'
import { validateBlockType } from '@/ee/access-control/utils/permission-check'
import { import {
BlockType, BlockType,
buildResumeApiUrl, buildResumeApiUrl,
@@ -31,7 +32,6 @@ import { streamingResponseFormatProcessor } from '@/executor/utils'
import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors' import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors'
import { isJSONString } from '@/executor/utils/json' import { isJSONString } from '@/executor/utils/json'
import { filterOutputForLog } from '@/executor/utils/output-filter' import { filterOutputForLog } from '@/executor/utils/output-filter'
import { validateBlockType } from '@/executor/utils/permission-check'
import type { VariableResolver } from '@/executor/variables/resolver' import type { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types' import type { SerializedBlock } from '@/serializer/types'
import type { SubflowType } from '@/stores/workflows/workflow/types' import type { SubflowType } from '@/stores/workflows/workflow/types'

View File

@@ -6,6 +6,12 @@ import { createMcpToolId } from '@/lib/mcp/utils'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getAllBlocks } from '@/blocks' import { getAllBlocks } from '@/blocks'
import type { BlockOutput } from '@/blocks/types' 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 { AGENT, BlockType, DEFAULTS, REFERENCE, stripCustomToolPrefix } from '@/executor/constants'
import { memoryService } from '@/executor/handlers/agent/memory' import { memoryService } from '@/executor/handlers/agent/memory'
import type { import type {
@@ -18,12 +24,6 @@ import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/execu
import { collectBlockData } from '@/executor/utils/block-data' import { collectBlockData } from '@/executor/utils/block-data'
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http' import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
import { stringifyJSON } from '@/executor/utils/json' import { stringifyJSON } from '@/executor/utils/json'
import {
validateBlockType,
validateCustomToolsAllowed,
validateMcpToolsAllowed,
validateModelProvider,
} from '@/executor/utils/permission-check'
import { executeProviderRequest } from '@/providers' import { executeProviderRequest } from '@/providers'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types' import type { SerializedBlock } from '@/serializer/types'

View File

@@ -4,11 +4,11 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { BlockOutput } from '@/blocks/types' import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants' import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types' import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http' import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json' import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
import { validateModelProvider } from '@/executor/utils/permission-check'
import { calculateCost, getProviderFromModel } from '@/providers/utils' import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types' import type { SerializedBlock } from '@/serializer/types'

View File

@@ -6,6 +6,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router' import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import type { BlockOutput } from '@/blocks/types' import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import { import {
BlockType, BlockType,
DEFAULTS, DEFAULTS,
@@ -15,7 +16,6 @@ import {
} from '@/executor/constants' } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types' import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAuthHeaders } from '@/executor/utils/http' import { buildAuthHeaders } from '@/executor/utils/http'
import { validateModelProvider } from '@/executor/utils/permission-check'
import { calculateCost, getProviderFromModel } from '@/providers/utils' import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types' import type { SerializedBlock } from '@/serializer/types'

View File

@@ -1,3 +1,5 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchJson } from '@/hooks/selectors/helpers' import { fetchJson } from '@/hooks/selectors/helpers'

View File

@@ -1,3 +1,5 @@
'use client'
import { useMemo } from 'react' import { useMemo } from 'react'
import { getEnv, isTruthy } from '@/lib/core/config/env' import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags' import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags'
@@ -5,8 +7,8 @@ import {
DEFAULT_PERMISSION_GROUP_CONFIG, DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig, type PermissionGroupConfig,
} from '@/lib/permission-groups/types' } from '@/lib/permission-groups/types'
import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
import { useOrganizations } from '@/hooks/queries/organization' import { useOrganizations } from '@/hooks/queries/organization'
import { useUserPermissionConfig } from '@/hooks/queries/permission-groups'
export interface PermissionConfigResult { export interface PermissionConfigResult {
config: PermissionGroupConfig config: PermissionGroupConfig

View File

@@ -59,8 +59,8 @@ import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation' import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous' import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
const logger = createLogger('Auth') const logger = createLogger('Auth')

View File

@@ -1,20 +1,37 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import * as schema from '@sim/db/schema' import * as schema from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { hasActiveSubscription } from '@/lib/billing'
const logger = createLogger('BillingAuthorization')
/** /**
* Check if a user is authorized to manage billing for a given reference ID * Check if a user is authorized to manage billing for a given reference ID
* Reference ID can be either a user ID (individual subscription) or organization ID (team subscription) * Reference ID can be either a user ID (individual subscription) or organization ID (team subscription)
*
* This function also performs duplicate subscription validation for organizations:
* - Rejects if an organization already has an active subscription (prevents duplicates)
* - Personal subscriptions (referenceId === userId) skip this check to allow upgrades
*/ */
export async function authorizeSubscriptionReference( export async function authorizeSubscriptionReference(
userId: string, userId: string,
referenceId: string referenceId: string
): Promise<boolean> { ): Promise<boolean> {
// User can always manage their own subscriptions // User can always manage their own subscriptions (Pro upgrades, etc.)
if (referenceId === userId) { if (referenceId === userId) {
return true return true
} }
// For organizations: check for existing active subscriptions to prevent duplicates
if (await hasActiveSubscription(referenceId)) {
logger.warn('Blocking checkout - active subscription already exists for organization', {
userId,
referenceId,
})
return false
}
// Check if referenceId is an organizationId the user has admin rights to // Check if referenceId is an organizationId the user has admin rights to
const members = await db const members = await db
.select() .select()

View File

@@ -25,9 +25,11 @@ export function useSubscriptionUpgrade() {
} }
let currentSubscriptionId: string | undefined let currentSubscriptionId: string | undefined
let allSubscriptions: any[] = []
try { try {
const listResult = await client.subscription.list() const listResult = await client.subscription.list()
const activePersonalSub = listResult.data?.find( allSubscriptions = listResult.data || []
const activePersonalSub = allSubscriptions.find(
(sub: any) => sub.status === 'active' && sub.referenceId === userId (sub: any) => sub.status === 'active' && sub.referenceId === userId
) )
currentSubscriptionId = activePersonalSub?.id currentSubscriptionId = activePersonalSub?.id
@@ -50,6 +52,25 @@ export function useSubscriptionUpgrade() {
) )
if (existingOrg) { if (existingOrg) {
// Check if this org already has an active team subscription
const existingTeamSub = allSubscriptions.find(
(sub: any) =>
sub.status === 'active' &&
sub.referenceId === existingOrg.id &&
(sub.plan === 'team' || sub.plan === 'enterprise')
)
if (existingTeamSub) {
logger.warn('Organization already has an active team subscription', {
userId,
organizationId: existingOrg.id,
existingSubscriptionId: existingTeamSub.id,
})
throw new Error(
'This organization already has an active team subscription. Please manage it from the billing settings.'
)
}
logger.info('Using existing organization for team plan upgrade', { logger.info('Using existing organization for team plan upgrade', {
userId, userId,
organizationId: existingOrg.id, organizationId: existingOrg.id,

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema' import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm' import { and, eq, inArray } from 'drizzle-orm'
import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils' import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
@@ -26,10 +26,22 @@ export async function getHighestPrioritySubscription(userId: string) {
let orgSubs: typeof personalSubs = [] let orgSubs: typeof personalSubs = []
if (orgIds.length > 0) { if (orgIds.length > 0) {
orgSubs = await db // Verify orgs exist to filter out orphaned subscriptions
.select() const existingOrgs = await db
.from(subscription) .select({ id: organization.id })
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active'))) .from(organization)
.where(inArray(organization.id, orgIds))
const validOrgIds = existingOrgs.map((o) => o.id)
if (validOrgIds.length > 0) {
orgSubs = await db
.select()
.from(subscription)
.where(
and(inArray(subscription.referenceId, validOrgIds), eq(subscription.status, 'active'))
)
}
} }
const allSubs = [...personalSubs, ...orgSubs] const allSubs = [...personalSubs, ...orgSubs]

View File

@@ -25,6 +25,28 @@ const logger = createLogger('SubscriptionCore')
export { getHighestPrioritySubscription } export { getHighestPrioritySubscription }
/**
* Check if a referenceId (user ID or org ID) has an active subscription
* Used for duplicate subscription prevention
*
* Fails closed: returns true on error to prevent duplicate creation
*/
export async function hasActiveSubscription(referenceId: string): Promise<boolean> {
try {
const [activeSub] = await db
.select({ id: subscription.id })
.from(subscription)
.where(and(eq(subscription.referenceId, referenceId), eq(subscription.status, 'active')))
.limit(1)
return !!activeSub
} catch (error) {
logger.error('Error checking active subscription', { error, referenceId })
// Fail closed: assume subscription exists to prevent duplicate creation
return true
}
}
/** /**
* Check if user is on Pro plan (direct or via organization) * Check if user is on Pro plan (direct or via organization)
*/ */

View File

@@ -11,6 +11,7 @@ export {
getHighestPrioritySubscription as getActiveSubscription, getHighestPrioritySubscription as getActiveSubscription,
getUserSubscriptionState as getSubscriptionState, getUserSubscriptionState as getSubscriptionState,
hasAccessControlAccess, hasAccessControlAccess,
hasActiveSubscription,
hasCredentialSetsAccess, hasCredentialSetsAccess,
hasSSOAccess, hasSSOAccess,
isEnterpriseOrgAdminOrOwner, isEnterpriseOrgAdminOrOwner,
@@ -32,6 +33,11 @@ export {
} from '@/lib/billing/core/usage' } from '@/lib/billing/core/usage'
export * from '@/lib/billing/credits/balance' export * from '@/lib/billing/credits/balance'
export * from '@/lib/billing/credits/purchase' export * from '@/lib/billing/credits/purchase'
export {
blockOrgMembers,
getOrgMemberIds,
unblockOrgMembers,
} from '@/lib/billing/organizations/membership'
export * from '@/lib/billing/subscriptions/utils' export * from '@/lib/billing/subscriptions/utils'
export { canEditUsageLimit as canEditLimit } from '@/lib/billing/subscriptions/utils' export { canEditUsageLimit as canEditLimit } from '@/lib/billing/subscriptions/utils'
export * from '@/lib/billing/types' export * from '@/lib/billing/types'

View File

@@ -8,6 +8,7 @@ import {
} from '@sim/db/schema' } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { hasActiveSubscription } from '@/lib/billing'
import { getPlanPricing } from '@/lib/billing/core/billing' import { getPlanPricing } from '@/lib/billing/core/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
@@ -159,6 +160,16 @@ export async function ensureOrganizationForTeamSubscription(
if (existingMembership.length > 0) { if (existingMembership.length > 0) {
const membership = existingMembership[0] const membership = existingMembership[0]
if (membership.role === 'owner' || membership.role === 'admin') { if (membership.role === 'owner' || membership.role === 'admin') {
// Check if org already has an active subscription (prevent duplicates)
if (await hasActiveSubscription(membership.organizationId)) {
logger.error('Organization already has an active subscription', {
userId,
organizationId: membership.organizationId,
newSubscriptionId: subscription.id,
})
throw new Error('Organization already has an active subscription')
}
logger.info('User already owns/admins an org, using it', { logger.info('User already owns/admins an org, using it', {
userId, userId,
organizationId: membership.organizationId, organizationId: membership.organizationId,

View File

@@ -15,13 +15,86 @@ import {
userStats, userStats,
} from '@sim/db/schema' } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm' import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client' import { requireStripeClient } from '@/lib/billing/stripe-client'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
const logger = createLogger('OrganizationMembership') const logger = createLogger('OrganizationMembership')
export type BillingBlockReason = 'payment_failed' | 'dispute'
/**
* Get all member user IDs for an organization
*/
export async function getOrgMemberIds(organizationId: string): Promise<string[]> {
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
return members.map((m) => m.userId)
}
/**
* Block all members of an organization for billing reasons
* Returns the number of members actually blocked
*
* Reason priority: dispute > payment_failed
* A payment_failed block won't overwrite an existing dispute block
*/
export async function blockOrgMembers(
organizationId: string,
reason: BillingBlockReason
): Promise<number> {
const memberIds = await getOrgMemberIds(organizationId)
if (memberIds.length === 0) {
return 0
}
// Don't overwrite dispute blocks with payment_failed (dispute is higher priority)
const whereClause =
reason === 'payment_failed'
? and(
inArray(userStats.userId, memberIds),
or(ne(userStats.billingBlockedReason, 'dispute'), isNull(userStats.billingBlockedReason))
)
: inArray(userStats.userId, memberIds)
const result = await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: reason })
.where(whereClause)
.returning({ userId: userStats.userId })
return result.length
}
/**
* Unblock all members of an organization blocked for a specific reason
* Only unblocks members blocked for the specified reason (not other reasons)
* Returns the number of members actually unblocked
*/
export async function unblockOrgMembers(
organizationId: string,
reason: BillingBlockReason
): Promise<number> {
const memberIds = await getOrgMemberIds(organizationId)
if (memberIds.length === 0) {
return 0
}
const result = await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(and(inArray(userStats.userId, memberIds), eq(userStats.billingBlockedReason, reason)))
.returning({ userId: userStats.userId })
return result.length
}
export interface RestoreProResult { export interface RestoreProResult {
restored: boolean restored: boolean
usageRestored: boolean usageRestored: boolean

View File

@@ -1,8 +1,9 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { member, subscription, user, userStats } from '@sim/db/schema' import { subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import type Stripe from 'stripe' import type Stripe from 'stripe'
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing'
import { requireStripeClient } from '@/lib/billing/stripe-client' import { requireStripeClient } from '@/lib/billing/stripe-client'
const logger = createLogger('DisputeWebhooks') const logger = createLogger('DisputeWebhooks')
@@ -57,36 +58,34 @@ export async function handleChargeDispute(event: Stripe.Event): Promise<void> {
if (subs.length > 0) { if (subs.length > 0) {
const orgId = subs[0].referenceId const orgId = subs[0].referenceId
const memberCount = await blockOrgMembers(orgId, 'dispute')
const owners = await db if (memberCount > 0) {
.select({ userId: member.userId }) logger.warn('Blocked all org members due to dispute', {
.from(member)
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
.limit(1)
if (owners.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'dispute' })
.where(eq(userStats.userId, owners[0].userId))
logger.warn('Blocked org owner due to dispute', {
disputeId: dispute.id, disputeId: dispute.id,
ownerId: owners[0].userId,
organizationId: orgId, organizationId: orgId,
memberCount,
}) })
} }
} }
} }
/** /**
* Handles charge.dispute.closed - unblocks user if dispute was won * Handles charge.dispute.closed - unblocks user if dispute was won or warning closed
*
* Status meanings:
* - 'won': Merchant won, customer's chargeback denied → unblock
* - 'lost': Customer won, money refunded → stay blocked (they owe us)
* - 'warning_closed': Pre-dispute inquiry closed without chargeback → unblock (false alarm)
*/ */
export async function handleDisputeClosed(event: Stripe.Event): Promise<void> { export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
const dispute = event.data.object as Stripe.Dispute const dispute = event.data.object as Stripe.Dispute
if (dispute.status !== 'won') { // Only unblock if we won or the warning was closed without a full dispute
logger.info('Dispute not won, user remains blocked', { const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed'
if (!shouldUnblock) {
logger.info('Dispute resolved against us, user remains blocked', {
disputeId: dispute.id, disputeId: dispute.id,
status: dispute.status, status: dispute.status,
}) })
@@ -98,7 +97,7 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
return return
} }
// Find and unblock user (Pro plans) // Find and unblock user (Pro plans) - only if blocked for dispute, not other reasons
const users = await db const users = await db
.select({ id: user.id }) .select({ id: user.id })
.from(user) .from(user)
@@ -109,16 +108,17 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
await db await db
.update(userStats) .update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null }) .set({ billingBlocked: false, billingBlockedReason: null })
.where(eq(userStats.userId, users[0].id)) .where(and(eq(userStats.userId, users[0].id), eq(userStats.billingBlockedReason, 'dispute')))
logger.info('Unblocked user after winning dispute', { logger.info('Unblocked user after dispute resolved in our favor', {
disputeId: dispute.id, disputeId: dispute.id,
userId: users[0].id, userId: users[0].id,
status: dispute.status,
}) })
return return
} }
// Find and unblock org owner (Team/Enterprise) // Find and unblock all org members (Team/Enterprise) - consistent with payment success
const subs = await db const subs = await db
.select({ referenceId: subscription.referenceId }) .select({ referenceId: subscription.referenceId })
.from(subscription) .from(subscription)
@@ -127,24 +127,13 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
if (subs.length > 0) { if (subs.length > 0) {
const orgId = subs[0].referenceId const orgId = subs[0].referenceId
const memberCount = await unblockOrgMembers(orgId, 'dispute')
const owners = await db logger.info('Unblocked all org members after dispute resolved in our favor', {
.select({ userId: member.userId }) disputeId: dispute.id,
.from(member) organizationId: orgId,
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner'))) memberCount,
.limit(1) status: dispute.status,
})
if (owners.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(eq(userStats.userId, owners[0].userId))
logger.info('Unblocked org owner after winning dispute', {
disputeId: dispute.id,
ownerId: owners[0].userId,
organizationId: orgId,
})
}
} }
} }

View File

@@ -8,12 +8,13 @@ import {
userStats, userStats,
} from '@sim/db/schema' } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm' import { and, eq, inArray, isNull, ne, or } from 'drizzle-orm'
import type Stripe from 'stripe' import type Stripe from 'stripe'
import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails' import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing' import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance' import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase' import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client' import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer' import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -502,24 +503,7 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
} }
if (sub.plan === 'team' || sub.plan === 'enterprise') { if (sub.plan === 'team' || sub.plan === 'enterprise') {
const members = await db await unblockOrgMembers(sub.referenceId, 'payment_failed')
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, sub.referenceId))
const memberIds = members.map((m) => m.userId)
if (memberIds.length > 0) {
// Only unblock users blocked for payment_failed, not disputes
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(
and(
inArray(userStats.userId, memberIds),
eq(userStats.billingBlockedReason, 'payment_failed')
)
)
}
} else { } else {
// Only unblock users blocked for payment_failed, not disputes // Only unblock users blocked for payment_failed, not disputes
await db await db
@@ -616,28 +600,26 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
if (records.length > 0) { if (records.length > 0) {
const sub = records[0] const sub = records[0]
if (sub.plan === 'team' || sub.plan === 'enterprise') { if (sub.plan === 'team' || sub.plan === 'enterprise') {
const members = await db const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed')
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, sub.referenceId))
const memberIds = members.map((m) => m.userId)
if (memberIds.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
.where(inArray(userStats.userId, memberIds))
}
logger.info('Blocked team/enterprise members due to payment failure', { logger.info('Blocked team/enterprise members due to payment failure', {
organizationId: sub.referenceId, organizationId: sub.referenceId,
memberCount: members.length, memberCount,
isOverageInvoice, isOverageInvoice,
}) })
} else { } else {
// Don't overwrite dispute blocks (dispute > payment_failed priority)
await db await db
.update(userStats) .update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' }) .set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
.where(eq(userStats.userId, sub.referenceId)) .where(
and(
eq(userStats.userId, sub.referenceId),
or(
ne(userStats.billingBlockedReason, 'dispute'),
isNull(userStats.billingBlockedReason)
)
)
)
logger.info('Blocked user due to payment failure', { logger.info('Blocked user due to payment failure', {
userId: sub.referenceId, userId: sub.referenceId,
isOverageInvoice, isOverageInvoice,

View File

@@ -3,6 +3,7 @@ import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq, ne } from 'drizzle-orm' import { and, eq, ne } from 'drizzle-orm'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing' import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { hasActiveSubscription } from '@/lib/billing/core/subscription'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership' import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client' import { requireStripeClient } from '@/lib/billing/stripe-client'
@@ -52,14 +53,37 @@ async function restoreMemberProSubscriptions(organizationId: string): Promise<nu
/** /**
* Cleanup organization when team/enterprise subscription is deleted. * Cleanup organization when team/enterprise subscription is deleted.
* - Checks if other active subscriptions point to this org (skip deletion if so)
* - Restores member Pro subscriptions * - Restores member Pro subscriptions
* - Deletes the organization * - Deletes the organization (only if no other active subs)
* - Syncs usage limits for former members (resets to free or Pro tier) * - Syncs usage limits for former members (resets to free or Pro tier)
*/ */
async function cleanupOrganizationSubscription(organizationId: string): Promise<{ async function cleanupOrganizationSubscription(organizationId: string): Promise<{
restoredProCount: number restoredProCount: number
membersSynced: number membersSynced: number
organizationDeleted: boolean
}> { }> {
// Check if other active subscriptions still point to this org
// Note: The subscription being deleted is already marked as 'canceled' by better-auth
// before this handler runs, so we only find truly active ones
if (await hasActiveSubscription(organizationId)) {
logger.info('Skipping organization deletion - other active subscriptions exist', {
organizationId,
})
// Still sync limits for members since this subscription was deleted
const memberUserIds = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
for (const m of memberUserIds) {
await syncUsageLimitsFromSubscription(m.userId)
}
return { restoredProCount: 0, membersSynced: memberUserIds.length, organizationDeleted: false }
}
// Get member userIds before deletion (needed for limit syncing after org deletion) // Get member userIds before deletion (needed for limit syncing after org deletion)
const memberUserIds = await db const memberUserIds = await db
.select({ userId: member.userId }) .select({ userId: member.userId })
@@ -75,7 +99,7 @@ async function cleanupOrganizationSubscription(organizationId: string): Promise<
await syncUsageLimitsFromSubscription(m.userId) await syncUsageLimitsFromSubscription(m.userId)
} }
return { restoredProCount, membersSynced: memberUserIds.length } return { restoredProCount, membersSynced: memberUserIds.length, organizationDeleted: true }
} }
/** /**
@@ -172,15 +196,14 @@ export async function handleSubscriptionDeleted(subscription: {
referenceId: subscription.referenceId, referenceId: subscription.referenceId,
}) })
const { restoredProCount, membersSynced } = await cleanupOrganizationSubscription( const { restoredProCount, membersSynced, organizationDeleted } =
subscription.referenceId await cleanupOrganizationSubscription(subscription.referenceId)
)
logger.info('Successfully processed enterprise subscription cancellation', { logger.info('Successfully processed enterprise subscription cancellation', {
subscriptionId: subscription.id, subscriptionId: subscription.id,
stripeSubscriptionId, stripeSubscriptionId,
restoredProCount, restoredProCount,
organizationDeleted: true, organizationDeleted,
membersSynced, membersSynced,
}) })
return return
@@ -297,7 +320,7 @@ export async function handleSubscriptionDeleted(subscription: {
const cleanup = await cleanupOrganizationSubscription(subscription.referenceId) const cleanup = await cleanupOrganizationSubscription(subscription.referenceId)
restoredProCount = cleanup.restoredProCount restoredProCount = cleanup.restoredProCount
membersSynced = cleanup.membersSynced membersSynced = cleanup.membersSynced
organizationDeleted = true organizationDeleted = cleanup.organizationDeleted
} else if (subscription.plan === 'pro') { } else if (subscription.plan === 'pro') {
await syncUsageLimitsFromSubscription(subscription.referenceId) await syncUsageLimitsFromSubscription(subscription.referenceId)
membersSynced = 1 membersSynced = 1

View File

@@ -5,8 +5,8 @@ import { and, eq, isNull } from 'drizzle-orm'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { isHiddenFromDisplay } from '@/blocks/types' import { isHiddenFromDisplay } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { escapeRegExp } from '@/executor/constants' import { escapeRegExp } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import type { ChatContext } from '@/stores/panel/copilot/types' import type { ChatContext } from '@/stores/panel/copilot/types'
export type AgentContextType = export type AgentContextType =

View File

@@ -7,7 +7,7 @@ import {
} from '@/lib/copilot/tools/shared/schemas' } from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry' import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
import { isHiddenFromDisplay, type SubBlockConfig } from '@/blocks/types' 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 { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry' import { tools as toolsRegistry } from '@/tools/registry'
import { getTrigger, isTriggerValid } from '@/triggers' import { getTrigger, isTriggerValid } from '@/triggers'

View File

@@ -6,7 +6,7 @@ import {
type GetBlockOptionsResultType, type GetBlockOptionsResultType,
} from '@/lib/copilot/tools/shared/schemas' } from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry' 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' import { tools as toolsRegistry } from '@/tools/registry'
export const getBlockOptionsServerTool: BaseServerTool< export const getBlockOptionsServerTool: BaseServerTool<

View File

@@ -6,7 +6,7 @@ import {
} from '@/lib/copilot/tools/shared/schemas' } from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry' import { registry as blockRegistry } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types' 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< export const getBlocksAndToolsServerTool: BaseServerTool<
ReturnType<typeof GetBlocksAndToolsInput.parse>, ReturnType<typeof GetBlocksAndToolsInput.parse>,

View File

@@ -8,7 +8,7 @@ import {
} from '@/lib/copilot/tools/shared/schemas' } from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry' import { registry as blockRegistry } from '@/blocks/registry'
import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types' 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 { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry' import { tools as toolsRegistry } from '@/tools/registry'
import { getTrigger, isTriggerValid } from '@/triggers' import { getTrigger, isTriggerValid } from '@/triggers'

View File

@@ -3,7 +3,7 @@ import { z } from 'zod'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { registry as blockRegistry } from '@/blocks/registry' import { registry as blockRegistry } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types' 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 GetTriggerBlocksInput = z.object({})
export const GetTriggerBlocksResult = z.object({ export const GetTriggerBlocksResult = z.object({

View File

@@ -15,8 +15,8 @@ import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/
import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getAllBlocks, getBlock } from '@/blocks/registry' import { getAllBlocks, getBlock } from '@/blocks/registry'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types' 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 { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'

View File

@@ -153,22 +153,50 @@ export function formatCompactTimestamp(iso: string): string {
} }
/** /**
* Format a duration in milliseconds to a human-readable format * Format a duration to a human-readable format
* @param durationMs - The duration in milliseconds * @param duration - Duration in milliseconds (number) or as string (e.g., "500ms")
* @param options - Optional formatting options * @param options - Optional formatting options
* @param options.precision - Number of decimal places for seconds (default: 0) * @param options.precision - Number of decimal places for seconds (default: 0), trailing zeros are stripped
* @returns A formatted duration string * @returns A formatted duration string, or null if input is null/undefined
*/ */
export function formatDuration(durationMs: number, options?: { precision?: number }): string { export function formatDuration(
const precision = options?.precision ?? 0 duration: number | string | undefined | null,
options?: { precision?: number }
if (durationMs < 1000) { ): string | null {
return `${durationMs}ms` 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) { 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) const minutes = Math.floor(seconds / 60)

View File

@@ -33,6 +33,7 @@ import type {
WorkflowExecutionSnapshot, WorkflowExecutionSnapshot,
WorkflowState, WorkflowState,
} from '@/lib/logs/types' } from '@/lib/logs/types'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
export interface ToolCall { export interface ToolCall {
name: string name: string
@@ -503,7 +504,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
} }
try { try {
// Get the workflow record to get the userId // Get the workflow record to get workspace and fallback userId
const [workflowRecord] = await db const [workflowRecord] = await db
.select() .select()
.from(workflow) .from(workflow)
@@ -515,7 +516,12 @@ export class ExecutionLogger implements IExecutionLoggerService {
return return
} }
const userId = workflowRecord.userId let billingUserId: string | null = null
if (workflowRecord.workspaceId) {
billingUserId = await getWorkspaceBilledAccountUserId(workflowRecord.workspaceId)
}
const userId = billingUserId || workflowRecord.userId
const costToStore = costSummary.totalCost const costToStore = costSummary.totalCost
const existing = await db.select().from(userStats).where(eq(userStats.userId, userId)) const existing = await db.select().from(userStats).where(eq(userStats.userId, userId))