mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-04 11:45:07 -05:00
Compare commits
46 Commits
fix/traces
...
v0.5.79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bb68335ee | ||
|
|
8528fbe2d2 | ||
|
|
31fdd2be13 | ||
|
|
028bc652c2 | ||
|
|
c6bf5cd58c | ||
|
|
11dc18a80d | ||
|
|
ab4e9dc72f | ||
|
|
1c58c35bd8 | ||
|
|
d63a5cb504 | ||
|
|
8bd5d41723 | ||
|
|
c12931bc50 | ||
|
|
e9c4251c1c | ||
|
|
cc2be33d6b | ||
|
|
45371e521e | ||
|
|
0ce0f98aa5 | ||
|
|
dff1c9d083 | ||
|
|
b09f683072 | ||
|
|
a8bb0db660 | ||
|
|
af82820a28 | ||
|
|
4372841797 | ||
|
|
5e8c843241 | ||
|
|
7bf3d73ee6 | ||
|
|
7ffc11a738 | ||
|
|
be578e2ed7 | ||
|
|
f415e5edc4 | ||
|
|
13a6e6c3fa | ||
|
|
f5ab7f21ae | ||
|
|
bfb6fffe38 | ||
|
|
4fbec0a43f | ||
|
|
585f5e365b | ||
|
|
3792bdd252 | ||
|
|
eb5d1f3e5b | ||
|
|
54ab82c8dd | ||
|
|
f895bf469b | ||
|
|
dd3209af06 | ||
|
|
b6ba3b50a7 | ||
|
|
b304233062 | ||
|
|
57e4b49bd6 | ||
|
|
e12dd204ed | ||
|
|
3d9d9cbc54 | ||
|
|
0f4ec962ad | ||
|
|
4827866f9a | ||
|
|
3e697d9ed9 | ||
|
|
4431a1a484 | ||
|
|
4d1a9a3f22 | ||
|
|
eb07a080fb |
@@ -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 '@/ee/sso/components/sso-form'
|
import SSOForm from '@/app/(auth)/sso/sso-form'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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 {
|
||||||
@@ -46,14 +45,13 @@ 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,
|
totalDurationMs: staleDurationMs,
|
||||||
executionData: sql`jsonb_set(
|
executionData: sql`jsonb_set(
|
||||||
COALESCE(execution_data, '{}'::jsonb),
|
COALESCE(execution_data, '{}'::jsonb),
|
||||||
ARRAY['error'],
|
ARRAY['error'],
|
||||||
|
|||||||
@@ -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 === false,
|
isError: !executeResult.success,
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(createResponse(id, result))
|
return NextResponse.json(createResponse(id, result))
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ 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'
|
||||||
@@ -502,18 +501,6 @@ 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,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
|||||||
import {
|
import {
|
||||||
InvitationsNotAllowedError,
|
InvitationsNotAllowedError,
|
||||||
validateInvitationsAllowed,
|
validateInvitationsAllowed,
|
||||||
} from '@/ee/access-control/utils/permission-check'
|
} from '@/executor/utils/permission-check'
|
||||||
|
|
||||||
const logger = createLogger('OrganizationInvitations')
|
const logger = createLogger('OrganizationInvitations')
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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')
|
||||||
|
|
||||||
@@ -89,14 +88,6 @@ 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 })
|
||||||
|
|||||||
@@ -203,10 +203,6 @@ 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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
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'
|
||||||
@@ -6,7 +8,6 @@ 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'
|
||||||
@@ -74,31 +75,12 @@ 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()
|
||||||
|
|
||||||
// Run preprocessing checks (billing, rate limits, usage limits)
|
const [workflowRecord] = await db
|
||||||
const preprocessResult = await preprocessExecution({
|
.select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId })
|
||||||
workflowId,
|
.from(workflowTable)
|
||||||
userId,
|
.where(eq(workflowTable.id, workflowId))
|
||||||
triggerType: 'manual',
|
.limit(1)
|
||||||
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 })
|
||||||
}
|
}
|
||||||
@@ -110,7 +92,6 @@ 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)
|
||||||
|
|||||||
@@ -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('@/ee/access-control/utils/permission-check', () => ({
|
vi.doMock('@/executor/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() {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
|||||||
import {
|
import {
|
||||||
InvitationsNotAllowedError,
|
InvitationsNotAllowedError,
|
||||||
validateInvitationsAllowed,
|
validateInvitationsAllowed,
|
||||||
} from '@/ee/access-control/utils/permission-check'
|
} from '@/executor/utils/permission-check'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ 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)
|
||||||
@@ -54,8 +55,10 @@ 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)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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'
|
||||||
|
|||||||
@@ -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 '@/ee/access-control/utils/permission-check'
|
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||||
import { Knowledge } from './knowledge'
|
import { Knowledge } from './knowledge'
|
||||||
|
|
||||||
interface KnowledgePageProps {
|
interface KnowledgePageProps {
|
||||||
@@ -23,6 +23,7 @@ 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}`)
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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,
|
||||||
@@ -454,7 +453,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)]'>
|
||||||
{formatDuration(log.duration, { precision: 2 }) || '—'}
|
{log.duration || '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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, { precision: 2 }) || '—'}
|
{formatDuration(log.duration) || '—'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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'
|
||||||
@@ -363,14 +362,47 @@ 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) => {
|
||||||
|
|||||||
@@ -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 '@/ee/access-control/utils/permission-check'
|
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||||
|
|
||||||
interface TemplatesPageProps {
|
interface TemplatesPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
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 */
|
||||||
@@ -242,11 +241,15 @@ 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
|
||||||
// Round to nearest second (minimum 1s) to match original behavior
|
const durationText = `${label} for ${formatDuration(duration)}`
|
||||||
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'
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ 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'
|
||||||
@@ -849,10 +848,13 @@ 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)
|
||||||
// Round to nearest second (minimum 1s) to match original behavior
|
const durationText = `${outerLabel} for ${formatDuration(duration)}`
|
||||||
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
|
|
||||||
const durationText = `${outerLabel} for ${formatDuration(roundedMs)}`
|
|
||||||
|
|
||||||
const renderCollapsibleContent = () => (
|
const renderCollapsibleContent = () => (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -50,12 +50,6 @@ 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.
|
||||||
*
|
*
|
||||||
@@ -95,23 +89,31 @@ 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
|
||||||
@@ -143,9 +145,10 @@ export function Editor() {
|
|||||||
[subBlocksForCanonical]
|
[subBlocksForCanonical]
|
||||||
)
|
)
|
||||||
const canonicalModeOverrides = currentBlock?.data?.canonicalModes
|
const canonicalModeOverrides = currentBlock?.data?.canonicalModes
|
||||||
const advancedValuesPresent = useMemo(
|
const advancedValuesPresent = hasAdvancedValues(
|
||||||
() => hasAdvancedValues(subBlocksForCanonical, blockSubBlockValues, canonicalIndex),
|
subBlocksForCanonical,
|
||||||
[subBlocksForCanonical, blockSubBlockValues, canonicalIndex]
|
blockSubBlockValues,
|
||||||
|
canonicalIndex
|
||||||
)
|
)
|
||||||
const displayAdvancedOptions = userPermissions.canEdit
|
const displayAdvancedOptions = userPermissions.canEdit
|
||||||
? advancedMode
|
? advancedMode
|
||||||
@@ -153,9 +156,11 @@ 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)
|
||||||
@@ -168,6 +173,7 @@ 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 || '',
|
||||||
@@ -200,34 +206,31 @@ 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.
|
||||||
@@ -248,6 +251,7 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,6 +266,14 @@ 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()
|
||||||
@@ -272,13 +284,17 @@ export function Editor() {
|
|||||||
/**
|
/**
|
||||||
* Handles opening documentation link in a new secure tab.
|
* Handles opening documentation link in a new secure tab.
|
||||||
*/
|
*/
|
||||||
const handleOpenDocs = useCallback(() => {
|
const handleOpenDocs = () => {
|
||||||
const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink
|
const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink
|
||||||
window.open(docsLink || 'https://docs.sim.ai/quick-reference', '_blank', 'noopener,noreferrer')
|
if (docsLink) {
|
||||||
}, [isSubflow, subflowConfig?.docsLink, blockConfig?.docsLink])
|
window.open(docsLink, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
@@ -291,6 +307,7 @@ 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 (
|
||||||
@@ -311,7 +328,7 @@ export function Editor() {
|
|||||||
)}
|
)}
|
||||||
{isRenaming ? (
|
{isRenaming ? (
|
||||||
<input
|
<input
|
||||||
ref={nameInputRefCallback}
|
ref={nameInputRef}
|
||||||
type='text'
|
type='text'
|
||||||
value={editedName}
|
value={editedName}
|
||||||
onChange={(e) => setEditedName(e.target.value)}
|
onChange={(e) => setEditedName(e.target.value)}
|
||||||
@@ -382,21 +399,23 @@ export function Editor() {
|
|||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
)} */}
|
)} */}
|
||||||
<Tooltip.Root>
|
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && (
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Root>
|
||||||
<Button
|
<Tooltip.Trigger asChild>
|
||||||
variant='ghost'
|
<Button
|
||||||
className='p-0'
|
variant='ghost'
|
||||||
onClick={handleOpenDocs}
|
className='p-0'
|
||||||
aria-label='Open documentation'
|
onClick={handleOpenDocs}
|
||||||
>
|
aria-label='Open documentation'
|
||||||
<BookOpen className='h-[14px] w-[14px]' />
|
>
|
||||||
</Button>
|
<BookOpen className='h-[14px] w-[14px]' />
|
||||||
</Tooltip.Trigger>
|
</Button>
|
||||||
<Tooltip.Content side='top'>
|
</Tooltip.Trigger>
|
||||||
<p>Open docs</p>
|
<Tooltip.Content side='top'>
|
||||||
</Tooltip.Content>
|
<p>Open docs</p>
|
||||||
</Tooltip.Root>
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -476,7 +495,13 @@ 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 className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
|
<div
|
||||||
|
className='h-[1.25px]'
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -541,7 +566,13 @@ 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 className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
|
<div
|
||||||
|
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>
|
||||||
@@ -550,7 +581,13 @@ 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 className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
|
<div
|
||||||
|
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}
|
||||||
@@ -563,7 +600,13 @@ 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 className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
|
<div
|
||||||
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -587,7 +630,13 @@ 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 className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
|
<div
|
||||||
|
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>
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ 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 {
|
||||||
@@ -44,6 +43,7 @@ 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, { precision: 2 }) ?? '-'}
|
formattedDuration={formatDuration(entry.durationMs)}
|
||||||
/>
|
/>
|
||||||
</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, { precision: 2 }) ?? '-'}
|
formattedDuration={formatDuration(entry.durationMs)}
|
||||||
/>
|
/>
|
||||||
</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, { precision: 2 }) ?? '-'}
|
formattedDuration={formatDuration(entry.durationMs)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ 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'
|
||||||
@@ -576,9 +575,7 @@ 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
|
||||||
? formatDuration(dataset.metadata.duration, {
|
? `${(dataset.metadata.duration / 1000).toFixed(1)}s`
|
||||||
precision: 1,
|
|
||||||
})
|
|
||||||
: 'N/A'}
|
: 'N/A'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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,
|
||||||
@@ -38,8 +39,7 @@ import {
|
|||||||
usePermissionGroups,
|
usePermissionGroups,
|
||||||
useRemovePermissionGroupMember,
|
useRemovePermissionGroupMember,
|
||||||
useUpdatePermissionGroup,
|
useUpdatePermissionGroup,
|
||||||
} from '@/ee/access-control/hooks/permission-groups'
|
} from '@/hooks/queries/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,6 +255,7 @@ 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 || '')
|
||||||
|
|
||||||
@@ -409,8 +410,10 @@ 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
|
||||||
@@ -552,9 +555,10 @@ 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
|
||||||
@@ -887,6 +891,7 @@ 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
|
||||||
@@ -246,6 +246,7 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -335,6 +336,7 @@ 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) => {
|
||||||
@@ -391,6 +393,7 @@ 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]
|
||||||
@@ -444,6 +447,7 @@ 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)]'>
|
||||||
@@ -467,6 +471,7 @@ 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
|
||||||
@@ -490,6 +495,7 @@ 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>
|
||||||
|
|
||||||
@@ -513,6 +519,7 @@ 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()
|
||||||
@@ -565,6 +572,7 @@ 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]
|
||||||
@@ -633,6 +641,7 @@ 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
|
||||||
@@ -813,6 +822,7 @@ 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>
|
||||||
@@ -885,6 +895,7 @@ 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>
|
||||||
@@ -912,6 +923,7 @@ 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>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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'
|
||||||
@@ -9,6 +10,7 @@ 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'
|
||||||
|
|||||||
@@ -407,12 +407,14 @@ 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)
|
||||||
@@ -715,6 +717,7 @@ 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`)
|
||||||
|
|||||||
@@ -11,13 +11,55 @@ 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
|
||||||
@@ -523,7 +565,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={SSO_TRUSTED_PROVIDERS.map((id) => ({
|
options={TRUSTED_SSO_PROVIDERS.map((id) => ({
|
||||||
label: id,
|
label: id,
|
||||||
value: id,
|
value: id,
|
||||||
}))}
|
}))}
|
||||||
@@ -41,6 +41,7 @@ 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,
|
||||||
@@ -52,16 +53,15 @@ 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'
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ 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'
|
||||||
@@ -228,6 +227,12 @@ 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
|
||||||
@@ -297,7 +302,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, { precision: 1 }) ?? '-',
|
duration: formatDuration(payload.data.totalDurationMs),
|
||||||
cost: formatCost(payload.data.cost),
|
cost: formatCost(payload.data.cost),
|
||||||
logUrl,
|
logUrl,
|
||||||
alertReason,
|
alertReason,
|
||||||
@@ -310,7 +315,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, { precision: 1 }) ?? '-'}\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)}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
|
||||||
emailType: 'notifications',
|
emailType: 'notifications',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -368,10 +373,7 @@ 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)}` },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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
|
||||||
@@ -226,6 +225,11 @@ 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(
|
||||||
@@ -275,7 +279,7 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
|
|||||||
)}
|
)}
|
||||||
style={{ fontSize: '0.625rem' }}
|
style={{ fontSize: '0.625rem' }}
|
||||||
>
|
>
|
||||||
{toolCall.duration ? formatDuration(toolCall.duration, { precision: 1 }) : ''}
|
{formatDuration(toolCall.duration)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# 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`).
|
|
||||||
@@ -5,7 +5,6 @@ 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,
|
||||||
@@ -32,6 +31,7 @@ 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'
|
||||||
|
|||||||
@@ -6,12 +6,6 @@ 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 {
|
||||||
@@ -24,6 +18,12 @@ 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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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,
|
||||||
@@ -16,6 +15,7 @@ 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'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'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'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'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'
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
'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'
|
||||||
|
|
||||||
@@ -77,3 +75,39 @@ 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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
'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'
|
||||||
@@ -7,8 +5,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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
/**
|
|
||||||
* 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',
|
||||||
@@ -1,37 +1,20 @@
|
|||||||
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 (Pro upgrades, etc.)
|
// User can always manage their own subscriptions
|
||||||
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()
|
||||||
|
|||||||
@@ -25,11 +25,9 @@ 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()
|
||||||
allSubscriptions = listResult.data || []
|
const activePersonalSub = listResult.data?.find(
|
||||||
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
|
||||||
@@ -52,25 +50,6 @@ 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,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { member, organization, subscription } from '@sim/db/schema'
|
import { member, 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,22 +26,10 @@ export async function getHighestPrioritySubscription(userId: string) {
|
|||||||
|
|
||||||
let orgSubs: typeof personalSubs = []
|
let orgSubs: typeof personalSubs = []
|
||||||
if (orgIds.length > 0) {
|
if (orgIds.length > 0) {
|
||||||
// Verify orgs exist to filter out orphaned subscriptions
|
orgSubs = await db
|
||||||
const existingOrgs = await db
|
.select()
|
||||||
.select({ id: organization.id })
|
.from(subscription)
|
||||||
.from(organization)
|
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
|
||||||
.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]
|
||||||
|
|||||||
@@ -25,28 +25,6 @@ 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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export {
|
|||||||
getHighestPrioritySubscription as getActiveSubscription,
|
getHighestPrioritySubscription as getActiveSubscription,
|
||||||
getUserSubscriptionState as getSubscriptionState,
|
getUserSubscriptionState as getSubscriptionState,
|
||||||
hasAccessControlAccess,
|
hasAccessControlAccess,
|
||||||
hasActiveSubscription,
|
|
||||||
hasCredentialSetsAccess,
|
hasCredentialSetsAccess,
|
||||||
hasSSOAccess,
|
hasSSOAccess,
|
||||||
isEnterpriseOrgAdminOrOwner,
|
isEnterpriseOrgAdminOrOwner,
|
||||||
@@ -33,11 +32,6 @@ 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'
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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'
|
||||||
|
|
||||||
@@ -160,16 +159,6 @@ 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,
|
||||||
|
|||||||
@@ -15,86 +15,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, isNull, ne, or, sql } from 'drizzle-orm'
|
import { and, eq, 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
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { subscription, user, userStats } from '@sim/db/schema'
|
import { member, 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')
|
||||||
@@ -58,34 +57,36 @@ 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')
|
|
||||||
|
|
||||||
if (memberCount > 0) {
|
const owners = await db
|
||||||
logger.warn('Blocked all org members due to dispute', {
|
.select({ userId: member.userId })
|
||||||
|
.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 or warning closed
|
* Handles charge.dispute.closed - unblocks user if dispute was won
|
||||||
*
|
|
||||||
* 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
|
||||||
|
|
||||||
// Only unblock if we won or the warning was closed without a full dispute
|
if (dispute.status !== 'won') {
|
||||||
const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed'
|
logger.info('Dispute not won, user remains blocked', {
|
||||||
|
|
||||||
if (!shouldUnblock) {
|
|
||||||
logger.info('Dispute resolved against us, user remains blocked', {
|
|
||||||
disputeId: dispute.id,
|
disputeId: dispute.id,
|
||||||
status: dispute.status,
|
status: dispute.status,
|
||||||
})
|
})
|
||||||
@@ -97,7 +98,7 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and unblock user (Pro plans) - only if blocked for dispute, not other reasons
|
// Find and unblock user (Pro plans)
|
||||||
const users = await db
|
const users = await db
|
||||||
.select({ id: user.id })
|
.select({ id: user.id })
|
||||||
.from(user)
|
.from(user)
|
||||||
@@ -108,17 +109,16 @@ 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(and(eq(userStats.userId, users[0].id), eq(userStats.billingBlockedReason, 'dispute')))
|
.where(eq(userStats.userId, users[0].id))
|
||||||
|
|
||||||
logger.info('Unblocked user after dispute resolved in our favor', {
|
logger.info('Unblocked user after winning dispute', {
|
||||||
disputeId: dispute.id,
|
disputeId: dispute.id,
|
||||||
userId: users[0].id,
|
userId: users[0].id,
|
||||||
status: dispute.status,
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and unblock all org members (Team/Enterprise) - consistent with payment success
|
// Find and unblock org owner (Team/Enterprise)
|
||||||
const subs = await db
|
const subs = await db
|
||||||
.select({ referenceId: subscription.referenceId })
|
.select({ referenceId: subscription.referenceId })
|
||||||
.from(subscription)
|
.from(subscription)
|
||||||
@@ -127,13 +127,24 @@ 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')
|
|
||||||
|
|
||||||
logger.info('Unblocked all org members after dispute resolved in our favor', {
|
const owners = await db
|
||||||
disputeId: dispute.id,
|
.select({ userId: member.userId })
|
||||||
organizationId: orgId,
|
.from(member)
|
||||||
memberCount,
|
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
|
||||||
status: dispute.status,
|
.limit(1)
|
||||||
})
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ 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, isNull, ne, or } from 'drizzle-orm'
|
import { and, eq, inArray } 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'
|
||||||
@@ -503,7 +502,24 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
||||||
await unblockOrgMembers(sub.referenceId, 'payment_failed')
|
const members = await db
|
||||||
|
.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
|
||||||
@@ -600,26 +616,28 @@ 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 memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed')
|
const members = await db
|
||||||
|
.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,
|
memberCount: members.length,
|
||||||
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(
|
.where(eq(userStats.userId, sub.referenceId))
|
||||||
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,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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'
|
||||||
@@ -53,37 +52,14 @@ 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 (only if no other active subs)
|
* - Deletes the organization
|
||||||
* - 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 })
|
||||||
@@ -99,7 +75,7 @@ async function cleanupOrganizationSubscription(organizationId: string): Promise<
|
|||||||
await syncUsageLimitsFromSubscription(m.userId)
|
await syncUsageLimitsFromSubscription(m.userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { restoredProCount, membersSynced: memberUserIds.length, organizationDeleted: true }
|
return { restoredProCount, membersSynced: memberUserIds.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,14 +172,15 @@ export async function handleSubscriptionDeleted(subscription: {
|
|||||||
referenceId: subscription.referenceId,
|
referenceId: subscription.referenceId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { restoredProCount, membersSynced, organizationDeleted } =
|
const { restoredProCount, membersSynced } = await cleanupOrganizationSubscription(
|
||||||
await cleanupOrganizationSubscription(subscription.referenceId)
|
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,
|
organizationDeleted: true,
|
||||||
membersSynced,
|
membersSynced,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -320,7 +297,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 = cleanup.organizationDeleted
|
organizationDeleted = true
|
||||||
} else if (subscription.plan === 'pro') {
|
} else if (subscription.plan === 'pro') {
|
||||||
await syncUsageLimitsFromSubscription(subscription.referenceId)
|
await syncUsageLimitsFromSubscription(subscription.referenceId)
|
||||||
membersSynced = 1
|
membersSynced = 1
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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 '@/ee/access-control/utils/permission-check'
|
import { getUserPermissionConfig } from '@/executor/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'
|
||||||
|
|||||||
@@ -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 '@/ee/access-control/utils/permission-check'
|
import { getUserPermissionConfig } from '@/executor/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<
|
||||||
|
|||||||
@@ -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 '@/ee/access-control/utils/permission-check'
|
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||||
|
|
||||||
export const getBlocksAndToolsServerTool: BaseServerTool<
|
export const getBlocksAndToolsServerTool: BaseServerTool<
|
||||||
ReturnType<typeof GetBlocksAndToolsInput.parse>,
|
ReturnType<typeof GetBlocksAndToolsInput.parse>,
|
||||||
|
|||||||
@@ -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 '@/ee/access-control/utils/permission-check'
|
import { getUserPermissionConfig } from '@/executor/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'
|
||||||
|
|||||||
@@ -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 '@/ee/access-control/utils/permission-check'
|
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||||
|
|
||||||
export const GetTriggerBlocksInput = z.object({})
|
export const GetTriggerBlocksInput = z.object({})
|
||||||
export const GetTriggerBlocksResult = z.object({
|
export const GetTriggerBlocksResult = z.object({
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/
|
|||||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
import { 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'
|
||||||
|
|
||||||
|
|||||||
@@ -153,50 +153,22 @@ export function formatCompactTimestamp(iso: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a duration to a human-readable format
|
* Format a duration in milliseconds to a human-readable format
|
||||||
* @param duration - Duration in milliseconds (number) or as string (e.g., "500ms")
|
* @param durationMs - The duration in milliseconds
|
||||||
* @param options - Optional formatting options
|
* @param options - Optional formatting options
|
||||||
* @param options.precision - Number of decimal places for seconds (default: 0), trailing zeros are stripped
|
* @param options.precision - Number of decimal places for seconds (default: 0)
|
||||||
* @returns A formatted duration string, or null if input is null/undefined
|
* @returns A formatted duration string
|
||||||
*/
|
*/
|
||||||
export function formatDuration(
|
export function formatDuration(durationMs: number, options?: { precision?: number }): string {
|
||||||
duration: number | string | undefined | null,
|
|
||||||
options?: { precision?: number }
|
|
||||||
): string | null {
|
|
||||||
if (duration === undefined || duration === null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
const precision = options?.precision ?? 0
|
||||||
|
|
||||||
if (ms < 1) {
|
if (durationMs < 1000) {
|
||||||
// Sub-millisecond: show with 2 decimal places
|
return `${durationMs}ms`
|
||||||
return `${ms.toFixed(2)}ms`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ms < 1000) {
|
const seconds = durationMs / 1000
|
||||||
// Milliseconds: round to integer
|
|
||||||
return `${Math.round(ms)}ms`
|
|
||||||
}
|
|
||||||
|
|
||||||
const seconds = ms / 1000
|
|
||||||
if (seconds < 60) {
|
if (seconds < 60) {
|
||||||
if (precision > 0) {
|
return precision > 0 ? `${seconds.toFixed(precision)}s` : `${Math.floor(seconds)}s`
|
||||||
// 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)
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ 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
|
||||||
@@ -504,7 +503,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the workflow record to get workspace and fallback userId
|
// Get the workflow record to get the userId
|
||||||
const [workflowRecord] = await db
|
const [workflowRecord] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(workflow)
|
.from(workflow)
|
||||||
@@ -516,12 +515,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let billingUserId: string | null = null
|
const userId = workflowRecord.userId
|
||||||
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))
|
||||||
|
|||||||
Reference in New Issue
Block a user