mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-04 11:45:07 -05:00
Compare commits
14 Commits
v0.5.79
...
cursor/run
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3d7673331 | ||
|
|
40983b4b99 | ||
|
|
68bdf50684 | ||
|
|
4db6e556b7 | ||
|
|
4ba22527b6 | ||
|
|
c51f266ad7 | ||
|
|
4ca00810b2 | ||
|
|
710bf75bca | ||
|
|
f21fe2309c | ||
|
|
9c3fd1f7af | ||
|
|
a9b7d75d87 | ||
|
|
0449804ffb | ||
|
|
c286f3ed24 | ||
|
|
b738550815 |
@@ -180,6 +180,11 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
|
||||
<td>Right-click → **Enable/Disable**</td>
|
||||
<td><ActionImage src="/static/quick-reference/disable-block.png" alt="Disable block" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Lock/Unlock a block</td>
|
||||
<td>Hover block → Click lock icon (Admin only)</td>
|
||||
<td><ActionImage src="/static/quick-reference/lock-block.png" alt="Lock block" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Toggle handle orientation</td>
|
||||
<td>Right-click → **Toggle Handles**</td>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
The [Pulse](https://www.pulseapi.com/) tool enables seamless extraction of text and structured content from a wide variety of documents—including PDFs, images, and Office files—using state-of-the-art OCR (Optical Character Recognition) powered by Pulse. Designed for automated agentic workflows, Pulse Parser makes it easy to unlock valuable information trapped in unstructured documents and integrate the extracted content directly into your workflow.
|
||||
The [Pulse](https://www.runpulse.com) tool enables seamless extraction of text and structured content from a wide variety of documents—including PDFs, images, and Office files—using state-of-the-art OCR (Optical Character Recognition) powered by Pulse. Designed for automated agentic workflows, Pulse Parser makes it easy to unlock valuable information trapped in unstructured documents and integrate the extracted content directly into your workflow.
|
||||
|
||||
With Pulse, you can:
|
||||
|
||||
|
||||
BIN
apps/docs/public/static/quick-reference/lock-block.png
Normal file
BIN
apps/docs/public/static/quick-reference/lock-block.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -1,6 +1,6 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import SSOForm from '@/app/(auth)/sso/sso-form'
|
||||
import SSOForm from '@/ee/sso/components/sso-form'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
const logger = createLogger('CleanupStaleExecutions')
|
||||
|
||||
const STALE_THRESHOLD_MINUTES = 30
|
||||
const MAX_INT32 = 2_147_483_647
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -45,13 +46,14 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const staleDurationMs = Date.now() - new Date(execution.startedAt).getTime()
|
||||
const staleDurationMinutes = Math.round(staleDurationMs / 60000)
|
||||
const totalDurationMs = Math.min(staleDurationMs, MAX_INT32)
|
||||
|
||||
await db
|
||||
.update(workflowExecutionLogs)
|
||||
.set({
|
||||
status: 'failed',
|
||||
endedAt: new Date(),
|
||||
totalDurationMs: staleDurationMs,
|
||||
totalDurationMs,
|
||||
executionData: sql`jsonb_set(
|
||||
COALESCE(execution_data, '{}'::jsonb),
|
||||
ARRAY['error'],
|
||||
|
||||
@@ -284,7 +284,7 @@ async function handleToolsCall(
|
||||
content: [
|
||||
{ type: 'text', text: JSON.stringify(executeResult.output || executeResult, null, 2) },
|
||||
],
|
||||
isError: !executeResult.success,
|
||||
isError: executeResult.success === false,
|
||||
}
|
||||
|
||||
return NextResponse.json(createResponse(id, result))
|
||||
|
||||
@@ -20,6 +20,7 @@ import { z } from 'zod'
|
||||
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasAccessControlAccess } from '@/lib/billing'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
@@ -501,6 +502,18 @@ export async function PUT(
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'accepted') {
|
||||
try {
|
||||
await syncUsageLimitsFromSubscription(session.user.id)
|
||||
} catch (syncError) {
|
||||
logger.error('Failed to sync usage limits after joining org', {
|
||||
userId: session.user.id,
|
||||
organizationId,
|
||||
error: syncError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Organization invitation ${status}`, {
|
||||
organizationId,
|
||||
invitationId,
|
||||
|
||||
@@ -29,7 +29,7 @@ import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
InvitationsNotAllowedError,
|
||||
validateInvitationsAllowed,
|
||||
} from '@/executor/utils/permission-check'
|
||||
} from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
const logger = createLogger('OrganizationInvitations')
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasActiveSubscription } from '@/lib/billing'
|
||||
|
||||
const logger = createLogger('SubscriptionTransferAPI')
|
||||
|
||||
@@ -88,6 +89,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
)
|
||||
}
|
||||
|
||||
// Check if org already has an active subscription (prevent duplicates)
|
||||
if (await hasActiveSubscription(organizationId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organization already has an active subscription' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({ referenceId: organizationId })
|
||||
|
||||
@@ -203,6 +203,10 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
|
||||
}
|
||||
|
||||
updateData.billingBlocked = body.billingBlocked
|
||||
// Clear the reason when unblocking
|
||||
if (body.billingBlocked === false) {
|
||||
updateData.billingBlockedReason = null
|
||||
}
|
||||
updated.push('billingBlocked')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { db, workflow as workflowTable } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
@@ -8,6 +6,7 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
||||
import { createSSECallbacks } from '@/lib/workflows/executor/execution-events'
|
||||
@@ -75,12 +74,31 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const { startBlockId, sourceSnapshot, input } = validation.data
|
||||
const executionId = uuidv4()
|
||||
|
||||
const [workflowRecord] = await db
|
||||
.select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId })
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.limit(1)
|
||||
// Run preprocessing checks (billing, rate limits, usage limits)
|
||||
const preprocessResult = await preprocessExecution({
|
||||
workflowId,
|
||||
userId,
|
||||
triggerType: 'manual',
|
||||
executionId,
|
||||
requestId,
|
||||
checkRateLimit: false, // Manual executions don't rate limit
|
||||
checkDeployment: false, // Run-from-block doesn't require deployment
|
||||
})
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
const { error } = preprocessResult
|
||||
logger.warn(`[${requestId}] Preprocessing failed for run-from-block`, {
|
||||
workflowId,
|
||||
error: error?.message,
|
||||
statusCode: error?.statusCode,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: error?.message || 'Execution blocked' },
|
||||
{ status: error?.statusCode || 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const workflowRecord = preprocessResult.workflowRecord
|
||||
if (!workflowRecord?.workspaceId) {
|
||||
return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 })
|
||||
}
|
||||
@@ -92,6 +110,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
workflowId,
|
||||
startBlockId,
|
||||
executedBlocksCount: sourceSnapshot.executedBlocks.length,
|
||||
billingActorUserId: preprocessResult.actorUserId,
|
||||
})
|
||||
|
||||
const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId)
|
||||
|
||||
@@ -567,6 +567,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
blockType: string,
|
||||
executionOrder: number,
|
||||
iterationContext?: IterationContext
|
||||
) => {
|
||||
logger.info(`[${requestId}] 🔷 onBlockStart called:`, { blockId, blockName, blockType })
|
||||
@@ -579,6 +580,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
blockId,
|
||||
blockName,
|
||||
blockType,
|
||||
executionOrder,
|
||||
...(iterationContext && {
|
||||
iterationCurrent: iterationContext.iterationCurrent,
|
||||
iterationTotal: iterationContext.iterationTotal,
|
||||
@@ -617,6 +619,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
error: callbackData.output.error,
|
||||
durationMs: callbackData.executionTime || 0,
|
||||
startedAt: callbackData.startedAt,
|
||||
executionOrder: callbackData.executionOrder,
|
||||
endedAt: callbackData.endedAt,
|
||||
...(iterationContext && {
|
||||
iterationCurrent: iterationContext.iterationCurrent,
|
||||
@@ -644,6 +647,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
output: callbackData.output,
|
||||
durationMs: callbackData.executionTime || 0,
|
||||
startedAt: callbackData.startedAt,
|
||||
executionOrder: callbackData.executionOrder,
|
||||
endedAt: callbackData.endedAt,
|
||||
...(iterationContext && {
|
||||
iterationCurrent: iterationContext.iterationCurrent,
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('Workspace Invitations API Route', () => {
|
||||
inArray: vi.fn().mockImplementation((field, values) => ({ type: 'inArray', field, values })),
|
||||
}))
|
||||
|
||||
vi.doMock('@/executor/utils/permission-check', () => ({
|
||||
vi.doMock('@/ee/access-control/utils/permission-check', () => ({
|
||||
validateInvitationsAllowed: vi.fn().mockResolvedValue(undefined),
|
||||
InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error {
|
||||
constructor() {
|
||||
|
||||
@@ -21,7 +21,7 @@ import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
||||
import {
|
||||
InvitationsNotAllowedError,
|
||||
validateInvitationsAllowed,
|
||||
} from '@/executor/utils/permission-check'
|
||||
} from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -38,7 +38,6 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all workspaces where the user has permissions
|
||||
const userWorkspaces = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
@@ -55,10 +54,8 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ invitations: [] })
|
||||
}
|
||||
|
||||
// Get all workspaceIds where the user is a member
|
||||
const workspaceIds = userWorkspaces.map((w) => w.id)
|
||||
|
||||
// Find all invitations for those workspaces
|
||||
const invitations = await db
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
ChatMessageContainer,
|
||||
EmailAuth,
|
||||
PasswordAuth,
|
||||
SSOAuth,
|
||||
VoiceInterface,
|
||||
} from '@/app/chat/components'
|
||||
import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants'
|
||||
import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks'
|
||||
import SSOAuth from '@/ee/sso/components/sso-auth'
|
||||
|
||||
const logger = createLogger('ChatClient')
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export { default as EmailAuth } from './auth/email/email-auth'
|
||||
export { default as PasswordAuth } from './auth/password/password-auth'
|
||||
export { default as SSOAuth } from './auth/sso/sso-auth'
|
||||
export { ChatErrorState } from './error-state/error-state'
|
||||
export { ChatHeader } from './header/header'
|
||||
export { ChatInput } from './input/input'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { Knowledge } from './knowledge'
|
||||
|
||||
interface KnowledgePageProps {
|
||||
@@ -23,7 +23,6 @@ export default async function KnowledgePage({ params }: KnowledgePageProps) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
// Check permission group restrictions
|
||||
const permissionConfig = await getUserPermissionConfig(session.user.id)
|
||||
if (permissionConfig?.hideKnowledgeBaseTab) {
|
||||
redirect(`/workspace/${workspaceId}`)
|
||||
|
||||
@@ -104,14 +104,12 @@ function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[8px] rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span className='truncate font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
<div className='flex flex-col gap-[4px] rounded-[6px] bg-[var(--surface-1)] px-[8px] py-[6px]'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-[8px]'>
|
||||
<span className='min-w-0 flex-1 truncate font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -142,20 +140,18 @@ export function FileCards({ files, isExecutionFile = false, workspaceId }: FileC
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||
<div className='mt-[4px] flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px] dark:bg-transparent'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Files ({files.length})
|
||||
</span>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{files.map((file, index) => (
|
||||
<FileCard
|
||||
key={file.id || `file-${index}`}
|
||||
file={file}
|
||||
isExecutionFile={isExecutionFile}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{files.map((file, index) => (
|
||||
<FileCard
|
||||
key={file.id || `file-${index}`}
|
||||
file={file}
|
||||
isExecutionFile={isExecutionFile}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import {
|
||||
ExecutionSnapshot,
|
||||
@@ -453,7 +454,7 @@ export const LogDetails = memo(function LogDetails({
|
||||
Duration
|
||||
</span>
|
||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
{log.duration || '—'}
|
||||
{formatDuration(log.duration, { precision: 2 }) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ import Link from 'next/link'
|
||||
import { List, type RowComponentProps, useListRef } from 'react-window'
|
||||
import { Badge, buttonVariants } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import {
|
||||
DELETED_WORKFLOW_COLOR,
|
||||
DELETED_WORKFLOW_LABEL,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
getDisplayStatus,
|
||||
LOG_COLUMNS,
|
||||
StatusBadge,
|
||||
@@ -113,7 +113,7 @@ const LogRow = memo(
|
||||
|
||||
<div className={`${LOG_COLUMNS.duration.width} ${LOG_COLUMNS.duration.minWidth}`}>
|
||||
<Badge variant='default' className='rounded-[6px] px-[9px] py-[2px] text-[12px]'>
|
||||
{formatDuration(log.duration) || '—'}
|
||||
{formatDuration(log.duration, { precision: 2 }) || '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
@@ -362,47 +363,14 @@ export function mapToExecutionLogAlt(log: RawLogResponse): ExecutionLog {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration for display in logs UI
|
||||
* If duration is under 1 second, displays as milliseconds (e.g., "500ms")
|
||||
* If duration is 1 second or more, displays as seconds (e.g., "1.23s")
|
||||
* @param duration - Duration string (e.g., "500ms") or null
|
||||
* @returns Formatted duration string or null
|
||||
*/
|
||||
export function formatDuration(duration: string | null): string | null {
|
||||
if (!duration) return null
|
||||
|
||||
// Extract numeric value from duration string (e.g., "500ms" -> 500)
|
||||
const ms = Number.parseInt(duration.replace(/[^0-9]/g, ''), 10)
|
||||
|
||||
if (!Number.isFinite(ms)) return duration
|
||||
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`
|
||||
}
|
||||
|
||||
// Convert to seconds with up to 2 decimal places
|
||||
const seconds = ms / 1000
|
||||
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format latency value for display in dashboard UI
|
||||
* If latency is under 1 second, displays as milliseconds (e.g., "500ms")
|
||||
* If latency is 1 second or more, displays as seconds (e.g., "1.23s")
|
||||
* @param ms - Latency in milliseconds (number)
|
||||
* @returns Formatted latency string
|
||||
*/
|
||||
export function formatLatency(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
||||
|
||||
if (ms < 1000) {
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
|
||||
// Convert to seconds with up to 2 decimal places
|
||||
const seconds = ms / 1000
|
||||
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
|
||||
return formatDuration(ms, { precision: 2 }) ?? '—'
|
||||
}
|
||||
|
||||
export const formatDate = (dateString: string) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
interface TemplatesPageProps {
|
||||
params: Promise<{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback } from 'react'
|
||||
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
|
||||
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react'
|
||||
import { Button, Copy, PlayOutline, Tooltip, Trash2 } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||
@@ -49,6 +49,7 @@ export const ActionBar = memo(
|
||||
collaborativeBatchRemoveBlocks,
|
||||
collaborativeBatchToggleBlockEnabled,
|
||||
collaborativeBatchToggleBlockHandles,
|
||||
collaborativeBatchToggleLocked,
|
||||
} = useCollaborativeWorkflow()
|
||||
const { setPendingSelection } = useWorkflowRegistry()
|
||||
const { handleRunFromBlock } = useWorkflowExecution()
|
||||
@@ -84,16 +85,28 @@ export const ActionBar = memo(
|
||||
)
|
||||
}, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection])
|
||||
|
||||
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
|
||||
const {
|
||||
isEnabled,
|
||||
horizontalHandles,
|
||||
parentId,
|
||||
parentType,
|
||||
isLocked,
|
||||
isParentLocked,
|
||||
isParentDisabled,
|
||||
} = useWorkflowStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const block = state.blocks[blockId]
|
||||
const parentId = block?.data?.parentId
|
||||
const parentBlock = parentId ? state.blocks[parentId] : undefined
|
||||
return {
|
||||
isEnabled: block?.enabled ?? true,
|
||||
horizontalHandles: block?.horizontalHandles ?? false,
|
||||
parentId,
|
||||
parentType: parentId ? state.blocks[parentId]?.type : undefined,
|
||||
parentType: parentBlock?.type,
|
||||
isLocked: block?.locked ?? false,
|
||||
isParentLocked: parentBlock?.locked ?? false,
|
||||
isParentDisabled: parentBlock ? !parentBlock.enabled : false,
|
||||
}
|
||||
},
|
||||
[blockId]
|
||||
@@ -161,26 +174,28 @@ export const ActionBar = memo(
|
||||
{!isNoteBlock && !isInsideSubflow && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (canRunFromBlock && !disabled) {
|
||||
handleRunFromBlockClick()
|
||||
}
|
||||
}}
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled || !canRunFromBlock}
|
||||
>
|
||||
<PlayOutline className={ICON_SIZE} />
|
||||
</Button>
|
||||
<span className='inline-flex'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (canRunFromBlock && !disabled) {
|
||||
handleRunFromBlockClick()
|
||||
}
|
||||
}}
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled || !canRunFromBlock}
|
||||
>
|
||||
<PlayOutline className={ICON_SIZE} />
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{(() => {
|
||||
if (disabled) return getTooltipMessage('Run from block')
|
||||
if (disabled) return getTooltipMessage('Run')
|
||||
if (isExecuting) return 'Execution in progress'
|
||||
if (!dependenciesSatisfied) return 'Run upstream blocks first'
|
||||
return 'Run from block'
|
||||
if (!dependenciesSatisfied) return 'Disabled: Run Blocks Before'
|
||||
return 'Run'
|
||||
})()}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
@@ -193,18 +208,54 @@ export const ActionBar = memo(
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
// Can't enable if parent is disabled (must enable parent first)
|
||||
const cantEnable = !isEnabled && isParentDisabled
|
||||
if (!disabled && !isLocked && !isParentLocked && !cantEnable) {
|
||||
collaborativeBatchToggleBlockEnabled([blockId])
|
||||
}
|
||||
}}
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled}
|
||||
disabled={
|
||||
disabled || isLocked || isParentLocked || (!isEnabled && isParentDisabled)
|
||||
}
|
||||
>
|
||||
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
|
||||
{isLocked || isParentLocked
|
||||
? 'Block is locked'
|
||||
: !isEnabled && isParentDisabled
|
||||
? 'Parent container is disabled'
|
||||
: getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{userPermissions.canAdmin && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// Can't unlock a block if its parent container is locked
|
||||
if (!disabled && !(isLocked && isParentLocked)) {
|
||||
collaborativeBatchToggleLocked([blockId])
|
||||
}
|
||||
}}
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled || (isLocked && isParentLocked)}
|
||||
>
|
||||
{isLocked ? <Unlock className={ICON_SIZE} /> : <Lock className={ICON_SIZE} />}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{isLocked && isParentLocked
|
||||
? 'Parent container is locked'
|
||||
: isLocked
|
||||
? 'Unlock Block'
|
||||
: 'Lock Block'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
@@ -216,17 +267,21 @@ export const ActionBar = memo(
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
if (!disabled && !isLocked && !isParentLocked) {
|
||||
handleDuplicateBlock()
|
||||
}
|
||||
}}
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled}
|
||||
disabled={disabled || isLocked || isParentLocked}
|
||||
>
|
||||
<Copy className={ICON_SIZE} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{getTooltipMessage('Duplicate Block')}</Tooltip.Content>
|
||||
<Tooltip.Content side='top'>
|
||||
{isLocked || isParentLocked
|
||||
? 'Block is locked'
|
||||
: getTooltipMessage('Duplicate Block')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
@@ -237,12 +292,12 @@ export const ActionBar = memo(
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
if (!disabled && !isLocked && !isParentLocked) {
|
||||
collaborativeBatchToggleBlockHandles([blockId])
|
||||
}
|
||||
}}
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled}
|
||||
disabled={disabled || isLocked || isParentLocked}
|
||||
>
|
||||
{horizontalHandles ? (
|
||||
<ArrowLeftRight className={ICON_SIZE} />
|
||||
@@ -252,7 +307,9 @@ export const ActionBar = memo(
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
|
||||
{isLocked || isParentLocked
|
||||
? 'Block is locked'
|
||||
: getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
@@ -264,19 +321,23 @@ export const ActionBar = memo(
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled && userPermissions.canEdit) {
|
||||
if (!disabled && userPermissions.canEdit && !isLocked && !isParentLocked) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('remove-from-subflow', { detail: { blockIds: [blockId] } })
|
||||
)
|
||||
}
|
||||
}}
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled || !userPermissions.canEdit}
|
||||
disabled={disabled || !userPermissions.canEdit || isLocked || isParentLocked}
|
||||
>
|
||||
<LogOut className={ICON_SIZE} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content>
|
||||
<Tooltip.Content side='top'>
|
||||
{isLocked || isParentLocked
|
||||
? 'Block is locked'
|
||||
: getTooltipMessage('Remove from Subflow')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
@@ -286,17 +347,19 @@ export const ActionBar = memo(
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
if (!disabled && !isLocked && !isParentLocked) {
|
||||
collaborativeBatchRemoveBlocks([blockId])
|
||||
}
|
||||
}}
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled}
|
||||
disabled={disabled || isLocked || isParentLocked}
|
||||
>
|
||||
<Trash2 className={ICON_SIZE} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{getTooltipMessage('Delete Block')}</Tooltip.Content>
|
||||
<Tooltip.Content side='top'>
|
||||
{isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete Block')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface BlockInfo {
|
||||
horizontalHandles: boolean
|
||||
parentId?: string
|
||||
parentType?: string
|
||||
locked?: boolean
|
||||
isParentLocked?: boolean
|
||||
isParentDisabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,10 +49,17 @@ export interface BlockMenuProps {
|
||||
showRemoveFromSubflow?: boolean
|
||||
/** Whether run from block is available (has snapshot, was executed, not inside subflow) */
|
||||
canRunFromBlock?: boolean
|
||||
/** Whether to disable edit actions (user can't edit OR blocks are locked) */
|
||||
disableEdit?: boolean
|
||||
/** Whether the user has edit permission (ignoring locked state) */
|
||||
userCanEdit?: boolean
|
||||
isExecuting?: boolean
|
||||
/** Whether the selected block is a trigger (has no incoming edges) */
|
||||
isPositionalTrigger?: boolean
|
||||
/** Callback to toggle locked state of selected blocks */
|
||||
onToggleLocked?: () => void
|
||||
/** Whether the user has admin permissions */
|
||||
canAdmin?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,13 +88,22 @@ export function BlockMenu({
|
||||
showRemoveFromSubflow = false,
|
||||
canRunFromBlock = false,
|
||||
disableEdit = false,
|
||||
userCanEdit = true,
|
||||
isExecuting = false,
|
||||
isPositionalTrigger = false,
|
||||
onToggleLocked,
|
||||
canAdmin = false,
|
||||
}: BlockMenuProps) {
|
||||
const isSingleBlock = selectedBlocks.length === 1
|
||||
|
||||
const allEnabled = selectedBlocks.every((b) => b.enabled)
|
||||
const allDisabled = selectedBlocks.every((b) => !b.enabled)
|
||||
const allLocked = selectedBlocks.every((b) => b.locked)
|
||||
const allUnlocked = selectedBlocks.every((b) => !b.locked)
|
||||
// Can't unlock blocks that have locked parents
|
||||
const hasBlockWithLockedParent = selectedBlocks.some((b) => b.locked && b.isParentLocked)
|
||||
// Can't enable blocks that have disabled parents
|
||||
const hasBlockWithDisabledParent = selectedBlocks.some((b) => !b.enabled && b.isParentDisabled)
|
||||
|
||||
const hasSingletonBlock = selectedBlocks.some(
|
||||
(b) =>
|
||||
@@ -108,6 +127,12 @@ export function BlockMenu({
|
||||
return 'Toggle Enabled'
|
||||
}
|
||||
|
||||
const getToggleLockedLabel = () => {
|
||||
if (allLocked) return 'Unlock'
|
||||
if (allUnlocked) return 'Lock'
|
||||
return 'Toggle Lock'
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
@@ -139,7 +164,7 @@ export function BlockMenu({
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit || !hasClipboard}
|
||||
disabled={!userCanEdit || !hasClipboard}
|
||||
onClick={() => {
|
||||
onPaste()
|
||||
onClose()
|
||||
@@ -164,13 +189,15 @@ export function BlockMenu({
|
||||
{!allNoteBlocks && <PopoverDivider />}
|
||||
{!allNoteBlocks && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
disabled={disableEdit || hasBlockWithDisabledParent}
|
||||
onClick={() => {
|
||||
onToggleEnabled()
|
||||
onClose()
|
||||
if (!disableEdit && !hasBlockWithDisabledParent) {
|
||||
onToggleEnabled()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{getToggleEnabledLabel()}
|
||||
{hasBlockWithDisabledParent ? 'Parent is disabled' : getToggleEnabledLabel()}
|
||||
</PopoverItem>
|
||||
)}
|
||||
{!allNoteBlocks && !isSubflow && (
|
||||
@@ -195,6 +222,19 @@ export function BlockMenu({
|
||||
Remove from Subflow
|
||||
</PopoverItem>
|
||||
)}
|
||||
{canAdmin && onToggleLocked && (
|
||||
<PopoverItem
|
||||
disabled={hasBlockWithLockedParent}
|
||||
onClick={() => {
|
||||
if (!hasBlockWithLockedParent) {
|
||||
onToggleLocked()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasBlockWithLockedParent ? 'Parent is locked' : getToggleLockedLabel()}
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Single block actions */}
|
||||
{isSingleBlock && <PopoverDivider />}
|
||||
@@ -233,7 +273,7 @@ export function BlockMenu({
|
||||
}
|
||||
}}
|
||||
>
|
||||
Run from block
|
||||
Run
|
||||
</PopoverItem>
|
||||
{/* Hide "Run until" for triggers - they're always at the start */}
|
||||
{!hasTriggerBlock && (
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface CanvasMenuProps {
|
||||
canUndo?: boolean
|
||||
canRedo?: boolean
|
||||
isInvitationsDisabled?: boolean
|
||||
/** Whether the workflow has locked blocks (disables auto-layout) */
|
||||
hasLockedBlocks?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +62,7 @@ export function CanvasMenu({
|
||||
disableEdit = false,
|
||||
canUndo = false,
|
||||
canRedo = false,
|
||||
hasLockedBlocks = false,
|
||||
}: CanvasMenuProps) {
|
||||
return (
|
||||
<Popover
|
||||
@@ -129,11 +132,12 @@ export function CanvasMenu({
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit}
|
||||
disabled={disableEdit || hasLockedBlocks}
|
||||
onClick={() => {
|
||||
onAutoLayout()
|
||||
onClose()
|
||||
}}
|
||||
title={hasLockedBlocks ? 'Unlock blocks to use auto-layout' : undefined}
|
||||
>
|
||||
<span>Auto-layout</span>
|
||||
<span className='ml-auto opacity-70 group-hover:opacity-100'>⇧L</span>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { CopilotMarkdownRenderer } from '../markdown-renderer'
|
||||
|
||||
/** Removes thinking tags (raw or escaped) and special tags from streamed content */
|
||||
@@ -241,15 +242,11 @@ export function ThinkingBlock({
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [isStreaming, isExpanded, userHasScrolledAway])
|
||||
|
||||
/** Formats duration in milliseconds to seconds (minimum 1s) */
|
||||
const formatDuration = (ms: number) => {
|
||||
const seconds = Math.max(1, Math.round(ms / 1000))
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
const hasContent = cleanContent.length > 0
|
||||
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
|
||||
const durationText = `${label} for ${formatDuration(duration)}`
|
||||
// Round to nearest second (minimum 1s) to match original behavior
|
||||
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
|
||||
const durationText = `${label} for ${formatDuration(roundedMs)}`
|
||||
|
||||
const getStreamingLabel = (lbl: string) => {
|
||||
if (lbl === 'Thought') return 'Thinking'
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
hasInterrupt as hasInterruptFromConfig,
|
||||
isSpecialTool as isSpecialToolFromConfig,
|
||||
} from '@/lib/copilot/tools/client/ui-config'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
|
||||
import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block'
|
||||
@@ -848,13 +849,10 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
||||
(allParsed.options && Object.keys(allParsed.options).length > 0)
|
||||
)
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
const seconds = Math.max(1, Math.round(ms / 1000))
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
const outerLabel = getSubagentCompletionLabel(toolCall.name)
|
||||
const durationText = `${outerLabel} for ${formatDuration(duration)}`
|
||||
// Round to nearest second (minimum 1s) to match original behavior
|
||||
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
|
||||
const durationText = `${outerLabel} for ${formatDuration(roundedMs)}`
|
||||
|
||||
const renderCollapsibleContent = () => (
|
||||
<>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function CredentialSelector({
|
||||
previewValue,
|
||||
}: CredentialSelectorProps) {
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [editingValue, setEditingValue] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
|
||||
@@ -128,11 +128,7 @@ export function CredentialSelector({
|
||||
return ''
|
||||
}, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setInputValue(resolvedLabel)
|
||||
}
|
||||
}, [resolvedLabel, isEditing])
|
||||
const displayValue = isEditing ? editingValue : resolvedLabel
|
||||
|
||||
const invalidSelection =
|
||||
!isPreview &&
|
||||
@@ -295,7 +291,7 @@ export function CredentialSelector({
|
||||
const selectedCredentialProvider = selectedCredential?.provider ?? provider
|
||||
|
||||
const overlayContent = useMemo(() => {
|
||||
if (!inputValue) return null
|
||||
if (!displayValue) return null
|
||||
|
||||
if (isCredentialSetSelected && selectedCredentialSet) {
|
||||
return (
|
||||
@@ -303,7 +299,7 @@ export function CredentialSelector({
|
||||
<div className='mr-2 flex-shrink-0 opacity-90'>
|
||||
<Users className='h-3 w-3' />
|
||||
</div>
|
||||
<span className='truncate'>{inputValue}</span>
|
||||
<span className='truncate'>{displayValue}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -313,12 +309,12 @@ export function CredentialSelector({
|
||||
<div className='mr-2 flex-shrink-0 opacity-90'>
|
||||
{getProviderIcon(selectedCredentialProvider)}
|
||||
</div>
|
||||
<span className='truncate'>{inputValue}</span>
|
||||
<span className='truncate'>{displayValue}</span>
|
||||
</div>
|
||||
)
|
||||
}, [
|
||||
getProviderIcon,
|
||||
inputValue,
|
||||
displayValue,
|
||||
selectedCredentialProvider,
|
||||
isCredentialSetSelected,
|
||||
selectedCredentialSet,
|
||||
@@ -335,7 +331,6 @@ export function CredentialSelector({
|
||||
const credentialSetId = value.slice(CREDENTIAL_SET.PREFIX.length)
|
||||
const matchedSet = credentialSets.find((cs) => cs.id === credentialSetId)
|
||||
if (matchedSet) {
|
||||
setInputValue(matchedSet.name)
|
||||
handleCredentialSetSelect(credentialSetId)
|
||||
return
|
||||
}
|
||||
@@ -343,13 +338,12 @@ export function CredentialSelector({
|
||||
|
||||
const matchedCred = credentials.find((c) => c.id === value)
|
||||
if (matchedCred) {
|
||||
setInputValue(matchedCred.name)
|
||||
handleSelect(value)
|
||||
return
|
||||
}
|
||||
|
||||
setIsEditing(true)
|
||||
setInputValue(value)
|
||||
setEditingValue(value)
|
||||
},
|
||||
[credentials, credentialSets, handleAddCredential, handleSelect, handleCredentialSetSelect]
|
||||
)
|
||||
@@ -359,7 +353,7 @@ export function CredentialSelector({
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
groups={comboboxGroups}
|
||||
value={inputValue}
|
||||
value={displayValue}
|
||||
selectedValue={rawSelectedId}
|
||||
onChange={handleComboboxChange}
|
||||
onOpenChange={handleOpenChange}
|
||||
|
||||
@@ -908,8 +908,10 @@ const PopoverContextCapture: React.FC<{
|
||||
* When in nested folders, goes back one level at a time.
|
||||
* At the root folder level, closes the folder.
|
||||
*/
|
||||
const TagDropdownBackButton: React.FC = () => {
|
||||
const { isInFolder, closeFolder, colorScheme, size } = usePopoverContext()
|
||||
const TagDropdownBackButton: React.FC<{ setSelectedIndex: (index: number) => void }> = ({
|
||||
setSelectedIndex,
|
||||
}) => {
|
||||
const { isInFolder, closeFolder, size, isKeyboardNav, setKeyboardNav } = usePopoverContext()
|
||||
const nestedNav = useNestedNavigation()
|
||||
|
||||
if (!isInFolder) return null
|
||||
@@ -922,28 +924,31 @@ const TagDropdownBackButton: React.FC = () => {
|
||||
closeFolder()
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (isKeyboardNav) return
|
||||
setKeyboardNav(false)
|
||||
setSelectedIndex(-1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base',
|
||||
size === 'sm' ? 'h-[22px] text-[11px]' : 'h-[26px] text-[13px]',
|
||||
colorScheme === 'inverted'
|
||||
? 'text-white hover:bg-[#363636] hover:text-white dark:text-[var(--text-primary)] dark:hover:bg-[var(--surface-5)]'
|
||||
: 'text-[var(--text-primary)] hover:bg-[var(--border-1)]'
|
||||
)}
|
||||
role='button'
|
||||
onClick={handleBackClick}
|
||||
<PopoverItem
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleBackClick(e)
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
>
|
||||
<svg
|
||||
className={size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5'}
|
||||
className={cn('shrink-0', size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5')}
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M15 19l-7-7 7-7' />
|
||||
</svg>
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<span className='shrink-0'>Back</span>
|
||||
</PopoverItem>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1961,8 +1966,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<TagDropdownBackButton />
|
||||
<PopoverScrollArea ref={scrollAreaRef}>
|
||||
<TagDropdownBackButton setSelectedIndex={setSelectedIndex} />
|
||||
{flatTagList.length === 0 ? (
|
||||
<div className='px-[6px] py-[8px] text-[12px] text-[var(--white)]/60'>
|
||||
No matching tags found
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Lock,
|
||||
Pencil,
|
||||
Unlock,
|
||||
} from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
@@ -46,10 +48,17 @@ import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { usePanelEditorStore } from '@/stores/panel'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
/** Stable empty object to avoid creating new references */
|
||||
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.
|
||||
*
|
||||
@@ -89,31 +98,31 @@ export function Editor() {
|
||||
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
|
||||
const title = currentBlock?.name || 'Editor'
|
||||
|
||||
// Check if selected block is a subflow (loop or parallel)
|
||||
const isSubflow =
|
||||
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
|
||||
|
||||
// Get subflow display properties from configs
|
||||
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
|
||||
|
||||
// Check if selected block is a workflow block
|
||||
const isWorkflowBlock =
|
||||
currentBlock && (currentBlock.type === 'workflow' || currentBlock.type === 'workflow_input')
|
||||
|
||||
// Get workspace ID from params
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
// Refs for resize functionality
|
||||
const subBlocksRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Get user permissions
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
// Get active workflow ID
|
||||
// Check if block is locked (or inside a locked container) and compute edit permission
|
||||
// Locked blocks cannot be edited by anyone (admins can only lock/unlock)
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const parentId = currentBlock?.data?.parentId as string | undefined
|
||||
const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false
|
||||
const isLocked = (currentBlock?.locked ?? false) || isParentLocked
|
||||
const canEditBlock = userPermissions.canEdit && !isLocked
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
|
||||
// Get block properties (advanced/trigger modes)
|
||||
const { advancedMode, triggerMode } = useEditorBlockProperties(
|
||||
currentBlockId,
|
||||
currentWorkflow.isSnapshotView
|
||||
@@ -145,22 +154,17 @@ export function Editor() {
|
||||
[subBlocksForCanonical]
|
||||
)
|
||||
const canonicalModeOverrides = currentBlock?.data?.canonicalModes
|
||||
const advancedValuesPresent = hasAdvancedValues(
|
||||
subBlocksForCanonical,
|
||||
blockSubBlockValues,
|
||||
canonicalIndex
|
||||
const advancedValuesPresent = useMemo(
|
||||
() => hasAdvancedValues(subBlocksForCanonical, blockSubBlockValues, canonicalIndex),
|
||||
[subBlocksForCanonical, blockSubBlockValues, canonicalIndex]
|
||||
)
|
||||
const displayAdvancedOptions = userPermissions.canEdit
|
||||
? advancedMode
|
||||
: advancedMode || advancedValuesPresent
|
||||
const displayAdvancedOptions = canEditBlock ? advancedMode : advancedMode || advancedValuesPresent
|
||||
|
||||
const hasAdvancedOnlyFields = useMemo(() => {
|
||||
for (const subBlock of subBlocksForCanonical) {
|
||||
// Must be standalone advanced (mode: 'advanced' without canonicalParamId)
|
||||
if (subBlock.mode !== 'advanced') continue
|
||||
if (canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) continue
|
||||
|
||||
// Check condition - skip if condition not met for current values
|
||||
if (
|
||||
subBlock.condition &&
|
||||
!evaluateSubBlockCondition(subBlock.condition, blockSubBlockValues)
|
||||
@@ -173,7 +177,6 @@ export function Editor() {
|
||||
return false
|
||||
}, [subBlocksForCanonical, canonicalIndex.canonicalIdBySubBlockId, blockSubBlockValues])
|
||||
|
||||
// Get subblock layout using custom hook
|
||||
const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout(
|
||||
blockConfig || ({} as any),
|
||||
currentBlockId || '',
|
||||
@@ -206,40 +209,44 @@ export function Editor() {
|
||||
return { regularSubBlocks: regular, advancedOnlySubBlocks: advancedOnly }
|
||||
}, [subBlocks, canonicalIndex.canonicalIdBySubBlockId])
|
||||
|
||||
// Get block connections
|
||||
const { incomingConnections, hasIncomingConnections } = useBlockConnections(currentBlockId || '')
|
||||
|
||||
// Connections resize hook
|
||||
const { handleMouseDown: handleConnectionsResizeMouseDown, isResizing } = useConnectionsResize({
|
||||
subBlocksRef,
|
||||
})
|
||||
|
||||
// Collaborative actions
|
||||
const {
|
||||
collaborativeSetBlockCanonicalMode,
|
||||
collaborativeUpdateBlockName,
|
||||
collaborativeToggleBlockAdvancedMode,
|
||||
collaborativeBatchToggleLocked,
|
||||
} = useCollaborativeWorkflow()
|
||||
|
||||
// Advanced mode toggle handler
|
||||
const handleToggleAdvancedMode = useCallback(() => {
|
||||
if (!currentBlockId || !userPermissions.canEdit) return
|
||||
if (!currentBlockId || !canEditBlock) return
|
||||
collaborativeToggleBlockAdvancedMode(currentBlockId)
|
||||
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
|
||||
}, [currentBlockId, canEditBlock, collaborativeToggleBlockAdvancedMode])
|
||||
|
||||
// Rename state
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
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.
|
||||
*/
|
||||
const handleStartRename = useCallback(() => {
|
||||
if (!userPermissions.canEdit || !currentBlock) return
|
||||
if (!canEditBlock || !currentBlock) return
|
||||
setEditedName(currentBlock.name || '')
|
||||
setIsRenaming(true)
|
||||
}, [userPermissions.canEdit, currentBlock])
|
||||
}, [canEditBlock, currentBlock])
|
||||
|
||||
/**
|
||||
* Handles saving the renamed block.
|
||||
@@ -251,7 +258,6 @@ export function Editor() {
|
||||
if (trimmedName && trimmedName !== currentBlock?.name) {
|
||||
const result = collaborativeUpdateBlockName(currentBlockId, trimmedName)
|
||||
if (!result.success) {
|
||||
// Keep rename mode open on error so user can correct the name
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -266,14 +272,6 @@ export function Editor() {
|
||||
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(() => {
|
||||
if (shouldFocusRename && currentBlock) {
|
||||
handleStartRename()
|
||||
@@ -284,17 +282,13 @@ export function Editor() {
|
||||
/**
|
||||
* Handles opening documentation link in a new secure tab.
|
||||
*/
|
||||
const handleOpenDocs = () => {
|
||||
const handleOpenDocs = useCallback(() => {
|
||||
const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink
|
||||
if (docsLink) {
|
||||
window.open(docsLink, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
window.open(docsLink || 'https://docs.sim.ai/quick-reference', '_blank', 'noopener,noreferrer')
|
||||
}, [isSubflow, subflowConfig?.docsLink, blockConfig?.docsLink])
|
||||
|
||||
// Get child workflow ID for workflow blocks
|
||||
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 } =
|
||||
useWorkflowState(childWorkflowId)
|
||||
|
||||
@@ -307,7 +301,6 @@ export function Editor() {
|
||||
}
|
||||
}, [childWorkflowId, workspaceId])
|
||||
|
||||
// Determine if connections are at minimum height (collapsed state)
|
||||
const isConnectionsAtMinHeight = connectionsHeight <= 35
|
||||
|
||||
return (
|
||||
@@ -328,7 +321,7 @@ export function Editor() {
|
||||
)}
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
ref={nameInputRefCallback}
|
||||
type='text'
|
||||
value={editedName}
|
||||
onChange={(e) => setEditedName(e.target.value)}
|
||||
@@ -358,6 +351,36 @@ export function Editor() {
|
||||
)}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-[8px]'>
|
||||
{/* Locked indicator - clickable to unlock if user has admin permissions, block is locked, and parent is not locked */}
|
||||
{isLocked && currentBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
{userPermissions.canAdmin && currentBlock.locked && !isParentLocked ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='p-0'
|
||||
onClick={() => collaborativeBatchToggleLocked([currentBlockId!])}
|
||||
aria-label='Unlock block'
|
||||
>
|
||||
<Unlock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
|
||||
</Button>
|
||||
) : (
|
||||
<div className='flex items-center justify-center'>
|
||||
<Lock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
|
||||
</div>
|
||||
)}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>
|
||||
{isParentLocked
|
||||
? 'Parent container is locked'
|
||||
: userPermissions.canAdmin && currentBlock.locked
|
||||
? 'Unlock block'
|
||||
: 'Block is locked'}
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{/* Rename button */}
|
||||
{currentBlock && (
|
||||
<Tooltip.Root>
|
||||
@@ -366,7 +389,7 @@ export function Editor() {
|
||||
variant='ghost'
|
||||
className='p-0'
|
||||
onClick={isRenaming ? handleSaveRename : handleStartRename}
|
||||
disabled={!userPermissions.canEdit}
|
||||
disabled={!canEditBlock}
|
||||
aria-label={isRenaming ? 'Save name' : 'Rename block'}
|
||||
>
|
||||
{isRenaming ? (
|
||||
@@ -399,23 +422,21 @@ export function Editor() {
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)} */}
|
||||
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='p-0'
|
||||
onClick={handleOpenDocs}
|
||||
aria-label='Open documentation'
|
||||
>
|
||||
<BookOpen className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Open docs</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='p-0'
|
||||
onClick={handleOpenDocs}
|
||||
aria-label='Open documentation'
|
||||
>
|
||||
<BookOpen className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Open docs</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -434,7 +455,7 @@ export function Editor() {
|
||||
incomingConnections={incomingConnections}
|
||||
handleConnectionsResizeMouseDown={handleConnectionsResizeMouseDown}
|
||||
toggleConnectionsCollapsed={toggleConnectionsCollapsed}
|
||||
userCanEdit={userPermissions.canEdit}
|
||||
userCanEdit={canEditBlock}
|
||||
isConnectionsAtMinHeight={isConnectionsAtMinHeight}
|
||||
/>
|
||||
) : (
|
||||
@@ -495,13 +516,7 @@ export function Editor() {
|
||||
</div>
|
||||
</div>
|
||||
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||
<div
|
||||
className='h-[1.25px]'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -542,14 +557,14 @@ export function Editor() {
|
||||
config={subBlock}
|
||||
isPreview={false}
|
||||
subBlockValues={subBlockState}
|
||||
disabled={!userPermissions.canEdit}
|
||||
disabled={!canEditBlock}
|
||||
fieldDiffStatus={undefined}
|
||||
allowExpandInPreview={false}
|
||||
canonicalToggle={
|
||||
isCanonicalSwap && canonicalMode && canonicalId
|
||||
? {
|
||||
mode: canonicalMode,
|
||||
disabled: !userPermissions.canEdit,
|
||||
disabled: !canEditBlock,
|
||||
onToggle: () => {
|
||||
if (!currentBlockId) return
|
||||
const nextMode =
|
||||
@@ -566,28 +581,16 @@ export function Editor() {
|
||||
/>
|
||||
{showDivider && (
|
||||
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||
<div
|
||||
className='h-[1.25px]'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{hasAdvancedOnlyFields && userPermissions.canEdit && (
|
||||
{hasAdvancedOnlyFields && canEditBlock && (
|
||||
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
|
||||
<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 className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleToggleAdvancedMode}
|
||||
@@ -600,13 +603,7 @@ export function Editor() {
|
||||
className={`h-[14px] w-[14px] transition-transform duration-200 ${displayAdvancedOptions ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<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 className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -624,19 +621,13 @@ export function Editor() {
|
||||
config={subBlock}
|
||||
isPreview={false}
|
||||
subBlockValues={subBlockState}
|
||||
disabled={!userPermissions.canEdit}
|
||||
disabled={!canEditBlock}
|
||||
fieldDiffStatus={undefined}
|
||||
allowExpandInPreview={false}
|
||||
/>
|
||||
{index < advancedOnlySubBlocks.length - 1 && (
|
||||
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||
<div
|
||||
className='h-[1.25px]'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -45,11 +45,13 @@ import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowI
|
||||
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useChatStore } from '@/stores/chat/store'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import type { PanelTab } from '@/stores/panel'
|
||||
import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import { getWorkflowWithValues } from '@/stores/workflows'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('Panel')
|
||||
/**
|
||||
@@ -119,6 +121,11 @@ export const Panel = memo(function Panel() {
|
||||
hydration.phase === 'state-loading'
|
||||
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
|
||||
|
||||
// Check for locked blocks (disables auto-layout)
|
||||
const hasLockedBlocks = useWorkflowStore((state) =>
|
||||
Object.values(state.blocks).some((block) => block.locked)
|
||||
)
|
||||
|
||||
// Delete workflow hook
|
||||
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
|
||||
workspaceId,
|
||||
@@ -230,11 +237,24 @@ export const Panel = memo(function Panel() {
|
||||
|
||||
setIsAutoLayouting(true)
|
||||
try {
|
||||
await autoLayoutWithFitView()
|
||||
const result = await autoLayoutWithFitView()
|
||||
if (!result.success && result.error) {
|
||||
useNotificationStore.getState().addNotification({
|
||||
level: 'info',
|
||||
message: result.error,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsAutoLayouting(false)
|
||||
}
|
||||
}, [isExecuting, userPermissions.canEdit, isAutoLayouting, autoLayoutWithFitView])
|
||||
}, [
|
||||
isExecuting,
|
||||
userPermissions.canEdit,
|
||||
isAutoLayouting,
|
||||
autoLayoutWithFitView,
|
||||
activeWorkflowId,
|
||||
])
|
||||
|
||||
/**
|
||||
* Handles exporting workflow as JSON
|
||||
@@ -404,7 +424,10 @@ export const Panel = memo(function Panel() {
|
||||
<PopoverContent align='start' side='bottom' sideOffset={8}>
|
||||
<PopoverItem
|
||||
onClick={handleAutoLayout}
|
||||
disabled={isExecuting || !userPermissions.canEdit || isAutoLayouting}
|
||||
disabled={
|
||||
isExecuting || !userPermissions.canEdit || isAutoLayouting || hasLockedBlocks
|
||||
}
|
||||
title={hasLockedBlocks ? 'Unlock blocks to use auto-layout' : undefined}
|
||||
>
|
||||
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
|
||||
<span>Auto layout</span>
|
||||
|
||||
@@ -80,6 +80,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
: undefined
|
||||
|
||||
const isEnabled = currentBlock?.enabled ?? true
|
||||
const isLocked = currentBlock?.locked ?? false
|
||||
const isPreview = data?.isPreview || false
|
||||
|
||||
// Focus state
|
||||
@@ -200,7 +201,10 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
{blockName}
|
||||
</span>
|
||||
</div>
|
||||
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
|
||||
<div className='flex items-center gap-1'>
|
||||
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
|
||||
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isPreview && (
|
||||
|
||||
@@ -105,11 +105,9 @@ export function useTerminalFilters() {
|
||||
})
|
||||
}
|
||||
|
||||
// Apply sorting by timestamp
|
||||
// Sort by executionOrder (monotonically increasing integer from server)
|
||||
result = [...result].sort((a, b) => {
|
||||
const timeA = new Date(a.timestamp).getTime()
|
||||
const timeB = new Date(b.timestamp).getTime()
|
||||
const comparison = timeA - timeB
|
||||
const comparison = a.executionOrder - b.executionOrder
|
||||
return sortConfig.direction === 'asc' ? comparison : -comparison
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
import {
|
||||
@@ -43,7 +44,6 @@ import {
|
||||
type EntryNode,
|
||||
type ExecutionGroup,
|
||||
flattenBlockEntriesOnly,
|
||||
formatDuration,
|
||||
getBlockColor,
|
||||
getBlockIcon,
|
||||
groupEntriesByExecution,
|
||||
@@ -128,7 +128,7 @@ const BlockRow = memo(function BlockRow({
|
||||
<StatusDisplay
|
||||
isRunning={isRunning}
|
||||
isCanceled={isCanceled}
|
||||
formattedDuration={formatDuration(entry.durationMs)}
|
||||
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -201,7 +201,7 @@ const IterationNodeRow = memo(function IterationNodeRow({
|
||||
<StatusDisplay
|
||||
isRunning={hasRunningChild}
|
||||
isCanceled={hasCanceledChild}
|
||||
formattedDuration={formatDuration(entry.durationMs)}
|
||||
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -314,7 +314,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
|
||||
<StatusDisplay
|
||||
isRunning={hasRunningDescendant}
|
||||
isCanceled={hasCanceledDescendant}
|
||||
formattedDuration={formatDuration(entry.durationMs)}
|
||||
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -53,17 +53,6 @@ export function getBlockColor(blockType: string): string {
|
||||
return '#6b7280'
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats duration from milliseconds to readable format
|
||||
*/
|
||||
export function formatDuration(ms?: number): string {
|
||||
if (ms === undefined || ms === null) return '-'
|
||||
if (ms < 1000) {
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a keyboard event originated from a text-editable element
|
||||
*/
|
||||
@@ -195,13 +184,9 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
group.blocks.push(entry)
|
||||
}
|
||||
|
||||
// Sort blocks within each iteration by start time ascending (oldest first, top-down)
|
||||
// Sort blocks within each iteration by executionOrder ascending (oldest first, top-down)
|
||||
for (const group of iterationGroupsMap.values()) {
|
||||
group.blocks.sort((a, b) => {
|
||||
const aStart = new Date(a.startedAt || a.timestamp).getTime()
|
||||
const bStart = new Date(b.startedAt || b.timestamp).getTime()
|
||||
return aStart - bStart
|
||||
})
|
||||
group.blocks.sort((a, b) => a.executionOrder - b.executionOrder)
|
||||
}
|
||||
|
||||
// Group iterations by iterationType to create subflow parents
|
||||
@@ -236,6 +221,8 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
const totalDuration = allBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
|
||||
|
||||
// Create synthetic subflow parent entry
|
||||
// Use the minimum executionOrder from all child blocks for proper ordering
|
||||
const subflowExecutionOrder = Math.min(...allBlocks.map((b) => b.executionOrder))
|
||||
const syntheticSubflow: ConsoleEntry = {
|
||||
id: `subflow-${iterationType}-${firstIteration.blocks[0]?.executionId || 'unknown'}`,
|
||||
timestamp: new Date(subflowStartMs).toISOString(),
|
||||
@@ -245,6 +232,7 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
blockType: iterationType,
|
||||
executionId: firstIteration.blocks[0]?.executionId,
|
||||
startedAt: new Date(subflowStartMs).toISOString(),
|
||||
executionOrder: subflowExecutionOrder,
|
||||
endedAt: new Date(subflowEndMs).toISOString(),
|
||||
durationMs: totalDuration,
|
||||
success: !allBlocks.some((b) => b.error),
|
||||
@@ -262,6 +250,8 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
)
|
||||
const iterDuration = iterBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
|
||||
|
||||
// Use the minimum executionOrder from blocks in this iteration
|
||||
const iterExecutionOrder = Math.min(...iterBlocks.map((b) => b.executionOrder))
|
||||
const syntheticIteration: ConsoleEntry = {
|
||||
id: `iteration-${iterationType}-${iterGroup.iterationCurrent}-${iterBlocks[0]?.executionId || 'unknown'}`,
|
||||
timestamp: new Date(iterStartMs).toISOString(),
|
||||
@@ -271,6 +261,7 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
blockType: iterationType,
|
||||
executionId: iterBlocks[0]?.executionId,
|
||||
startedAt: new Date(iterStartMs).toISOString(),
|
||||
executionOrder: iterExecutionOrder,
|
||||
endedAt: new Date(iterEndMs).toISOString(),
|
||||
durationMs: iterDuration,
|
||||
success: !iterBlocks.some((b) => b.error),
|
||||
@@ -311,14 +302,9 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
nodeType: 'block' as const,
|
||||
}))
|
||||
|
||||
// Combine all nodes and sort by start time ascending (oldest first, top-down)
|
||||
// Combine all nodes and sort by executionOrder ascending (oldest first, top-down)
|
||||
const allNodes = [...subflowNodes, ...regularNodes]
|
||||
allNodes.sort((a, b) => {
|
||||
const aStart = new Date(a.entry.startedAt || a.entry.timestamp).getTime()
|
||||
const bStart = new Date(b.entry.startedAt || b.entry.timestamp).getTime()
|
||||
return aStart - bStart
|
||||
})
|
||||
|
||||
allNodes.sort((a, b) => a.entry.executionOrder - b.entry.executionOrder)
|
||||
return allNodes
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
|
||||
import { formatEditSequence } from '@/lib/workflows/training/compute-edit-sequence'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||
@@ -575,7 +576,9 @@ export function TrainingModal() {
|
||||
<span className='text-[var(--text-muted)]'>Duration:</span>{' '}
|
||||
<span className='text-[var(--text-secondary)]'>
|
||||
{dataset.metadata?.duration
|
||||
? `${(dataset.metadata.duration / 1000).toFixed(1)}s`
|
||||
? formatDuration(dataset.metadata.duration, {
|
||||
precision: 1,
|
||||
})
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface UseBlockStateReturn {
|
||||
diffStatus: DiffStatus
|
||||
/** Whether this is a deleted block in diff mode */
|
||||
isDeletedBlock: boolean
|
||||
/** Whether the block is locked */
|
||||
isLocked: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +42,11 @@ export function useBlockState(
|
||||
? (data.blockState?.enabled ?? true)
|
||||
: (currentBlock?.enabled ?? true)
|
||||
|
||||
// Determine if block is locked
|
||||
const isLocked = data.isPreview
|
||||
? (data.blockState?.locked ?? false)
|
||||
: (currentBlock?.locked ?? false)
|
||||
|
||||
// Get diff status
|
||||
const diffStatus: DiffStatus =
|
||||
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
|
||||
@@ -68,5 +75,6 @@ export function useBlockState(
|
||||
isActive,
|
||||
diffStatus,
|
||||
isDeletedBlock: isDeletedBlock ?? false,
|
||||
isLocked,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,6 +672,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
currentWorkflow,
|
||||
activeWorkflowId,
|
||||
isEnabled,
|
||||
isLocked,
|
||||
handleClick,
|
||||
hasRing,
|
||||
ringStyles,
|
||||
@@ -1100,7 +1101,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<div className='relative z-10 flex flex-shrink-0 items-center gap-2'>
|
||||
<div className='relative z-10 flex flex-shrink-0 items-center gap-1'>
|
||||
{isWorkflowSelector &&
|
||||
childWorkflowId &&
|
||||
typeof childIsDeployed === 'boolean' &&
|
||||
@@ -1133,6 +1134,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
|
||||
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
|
||||
|
||||
{type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (
|
||||
<Tooltip.Root>
|
||||
|
||||
@@ -47,6 +47,7 @@ export function useBlockVisual({
|
||||
isActive: isExecuting,
|
||||
diffStatus,
|
||||
isDeletedBlock,
|
||||
isLocked,
|
||||
} = useBlockState(blockId, currentWorkflow, data)
|
||||
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
@@ -103,6 +104,7 @@ export function useBlockVisual({
|
||||
currentWorkflow,
|
||||
activeWorkflowId,
|
||||
isEnabled,
|
||||
isLocked,
|
||||
handleClick,
|
||||
hasRing,
|
||||
ringStyles,
|
||||
|
||||
@@ -31,7 +31,8 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
|
||||
nodes.map((n) => {
|
||||
const block = blocks[n.id]
|
||||
const parentId = block?.data?.parentId
|
||||
const parentType = parentId ? blocks[parentId]?.type : undefined
|
||||
const parentBlock = parentId ? blocks[parentId] : undefined
|
||||
const parentType = parentBlock?.type
|
||||
return {
|
||||
id: n.id,
|
||||
type: block?.type || '',
|
||||
@@ -39,6 +40,9 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
|
||||
horizontalHandles: block?.horizontalHandles ?? false,
|
||||
parentId,
|
||||
parentType,
|
||||
locked: block?.locked ?? false,
|
||||
isParentLocked: parentBlock?.locked ?? false,
|
||||
isParentDisabled: parentBlock ? !parentBlock.enabled : false,
|
||||
}
|
||||
}),
|
||||
[blocks]
|
||||
|
||||
@@ -926,6 +926,7 @@ export function useWorkflowExecution() {
|
||||
})
|
||||
|
||||
// Add entry to terminal immediately with isRunning=true
|
||||
// Use server-provided executionOrder to ensure correct sort order
|
||||
const startedAt = new Date().toISOString()
|
||||
addConsole({
|
||||
input: {},
|
||||
@@ -933,6 +934,7 @@ export function useWorkflowExecution() {
|
||||
success: undefined,
|
||||
durationMs: undefined,
|
||||
startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt: undefined,
|
||||
workflowId: activeWorkflowId,
|
||||
blockId: data.blockId,
|
||||
@@ -948,8 +950,6 @@ export function useWorkflowExecution() {
|
||||
},
|
||||
|
||||
onBlockCompleted: (data) => {
|
||||
logger.info('onBlockCompleted received:', { data })
|
||||
|
||||
activeBlocksSet.delete(data.blockId)
|
||||
setActiveBlocks(new Set(activeBlocksSet))
|
||||
setBlockRunStatus(data.blockId, 'success')
|
||||
@@ -976,6 +976,7 @@ export function useWorkflowExecution() {
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt,
|
||||
})
|
||||
|
||||
@@ -987,6 +988,7 @@ export function useWorkflowExecution() {
|
||||
replaceOutput: data.output,
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
isRunning: false,
|
||||
// Pass through iteration context for subflow grouping
|
||||
@@ -1027,6 +1029,7 @@ export function useWorkflowExecution() {
|
||||
error: data.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt,
|
||||
})
|
||||
|
||||
@@ -1039,6 +1042,7 @@ export function useWorkflowExecution() {
|
||||
success: false,
|
||||
error: data.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
isRunning: false,
|
||||
// Pass through iteration context for subflow grouping
|
||||
@@ -1163,6 +1167,7 @@ export function useWorkflowExecution() {
|
||||
|
||||
if (existingLogs.length === 0) {
|
||||
// No blocks executed yet - this is a pre-execution error
|
||||
// Use 0 for executionOrder so validation errors appear first
|
||||
addConsole({
|
||||
input: {},
|
||||
output: {},
|
||||
@@ -1170,6 +1175,7 @@ export function useWorkflowExecution() {
|
||||
error: data.error,
|
||||
durationMs: data.duration || 0,
|
||||
startedAt: new Date(Date.now() - (data.duration || 0)).toISOString(),
|
||||
executionOrder: 0,
|
||||
endedAt: new Date().toISOString(),
|
||||
workflowId: activeWorkflowId,
|
||||
blockId: 'validation',
|
||||
@@ -1237,6 +1243,7 @@ export function useWorkflowExecution() {
|
||||
blockType = error.blockType || blockType
|
||||
}
|
||||
|
||||
// Use MAX_SAFE_INTEGER so execution errors appear at the end of the log
|
||||
useTerminalConsoleStore.getState().addConsole({
|
||||
input: {},
|
||||
output: {},
|
||||
@@ -1244,6 +1251,7 @@ export function useWorkflowExecution() {
|
||||
error: normalizedMessage,
|
||||
durationMs: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
executionOrder: Number.MAX_SAFE_INTEGER,
|
||||
endedAt: new Date().toISOString(),
|
||||
workflowId: activeWorkflowId || '',
|
||||
blockId,
|
||||
@@ -1615,6 +1623,7 @@ export function useWorkflowExecution() {
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt,
|
||||
})
|
||||
|
||||
@@ -1624,6 +1633,7 @@ export function useWorkflowExecution() {
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt,
|
||||
workflowId,
|
||||
blockId: data.blockId,
|
||||
@@ -1653,6 +1663,7 @@ export function useWorkflowExecution() {
|
||||
output: {},
|
||||
success: false,
|
||||
error: data.error,
|
||||
executionOrder: data.executionOrder,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
@@ -1665,6 +1676,7 @@ export function useWorkflowExecution() {
|
||||
error: data.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt,
|
||||
workflowId,
|
||||
blockId: data.blockId,
|
||||
|
||||
@@ -52,6 +52,16 @@ export async function applyAutoLayoutAndUpdateStore(
|
||||
return { success: false, error: 'No blocks to layout' }
|
||||
}
|
||||
|
||||
// Check for locked blocks - auto-layout is disabled when blocks are locked
|
||||
const hasLockedBlocks = Object.values(blocks).some((block) => block.locked)
|
||||
if (hasLockedBlocks) {
|
||||
logger.info('Auto layout skipped: workflow contains locked blocks', { workflowId })
|
||||
return {
|
||||
success: false,
|
||||
error: 'Auto-layout is disabled when blocks are locked. Unlock blocks to use auto-layout.',
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with default options
|
||||
const layoutOptions = {
|
||||
spacing: {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Result of filtering protected blocks from a deletion operation
|
||||
*/
|
||||
export interface FilterProtectedBlocksResult {
|
||||
/** Block IDs that can be deleted (not protected) */
|
||||
deletableIds: string[]
|
||||
/** Block IDs that are protected and cannot be deleted */
|
||||
protectedIds: string[]
|
||||
/** Whether all blocks are protected (deletion should be cancelled entirely) */
|
||||
allProtected: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block is protected from editing/deletion.
|
||||
* A block is protected if it is locked or if its parent container is locked.
|
||||
*
|
||||
* @param blockId - The ID of the block to check
|
||||
* @param blocks - Record of all blocks in the workflow
|
||||
* @returns True if the block is protected
|
||||
*/
|
||||
export function isBlockProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
|
||||
const block = blocks[blockId]
|
||||
if (!block) return false
|
||||
|
||||
// Block is locked directly
|
||||
if (block.locked) return true
|
||||
|
||||
// Block is inside a locked container
|
||||
const parentId = block.data?.parentId
|
||||
if (parentId && blocks[parentId]?.locked) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an edge is protected from modification.
|
||||
* An edge is protected if either its source or target block is protected.
|
||||
*
|
||||
* @param edge - The edge to check (must have source and target)
|
||||
* @param blocks - Record of all blocks in the workflow
|
||||
* @returns True if the edge is protected
|
||||
*/
|
||||
export function isEdgeProtected(
|
||||
edge: { source: string; target: string },
|
||||
blocks: Record<string, BlockState>
|
||||
): boolean {
|
||||
return isBlockProtected(edge.source, blocks) || isBlockProtected(edge.target, blocks)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out protected blocks from a list of block IDs for deletion.
|
||||
* Protected blocks are those that are locked or inside a locked container.
|
||||
*
|
||||
* @param blockIds - Array of block IDs to filter
|
||||
* @param blocks - Record of all blocks in the workflow
|
||||
* @returns Result containing deletable IDs, protected IDs, and whether all are protected
|
||||
*/
|
||||
export function filterProtectedBlocks(
|
||||
blockIds: string[],
|
||||
blocks: Record<string, BlockState>
|
||||
): FilterProtectedBlocksResult {
|
||||
const protectedIds = blockIds.filter((id) => isBlockProtected(id, blocks))
|
||||
const deletableIds = blockIds.filter((id) => !protectedIds.includes(id))
|
||||
|
||||
return {
|
||||
deletableIds,
|
||||
protectedIds,
|
||||
allProtected: protectedIds.length === blockIds.length && blockIds.length > 0,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './auto-layout-utils'
|
||||
export * from './block-protection-utils'
|
||||
export * from './block-ring-utils'
|
||||
export * from './node-position-utils'
|
||||
export * from './workflow-canvas-helpers'
|
||||
|
||||
@@ -111,6 +111,7 @@ export async function executeWorkflowWithFullLogging(
|
||||
success: true,
|
||||
durationMs: event.data.durationMs,
|
||||
startedAt: new Date(Date.now() - event.data.durationMs).toISOString(),
|
||||
executionOrder: event.data.executionOrder,
|
||||
endedAt: new Date().toISOString(),
|
||||
workflowId: activeWorkflowId,
|
||||
blockId: event.data.blockId,
|
||||
@@ -140,6 +141,7 @@ export async function executeWorkflowWithFullLogging(
|
||||
error: event.data.error,
|
||||
durationMs: event.data.durationMs,
|
||||
startedAt: new Date(Date.now() - event.data.durationMs).toISOString(),
|
||||
executionOrder: event.data.executionOrder,
|
||||
endedAt: new Date().toISOString(),
|
||||
workflowId: activeWorkflowId,
|
||||
blockId: event.data.blockId,
|
||||
|
||||
@@ -55,7 +55,10 @@ import {
|
||||
clearDragHighlights,
|
||||
computeClampedPositionUpdates,
|
||||
estimateBlockDimensions,
|
||||
filterProtectedBlocks,
|
||||
getClampedPositionForNode,
|
||||
isBlockProtected,
|
||||
isEdgeProtected,
|
||||
isInEditableElement,
|
||||
resolveParentChildSelectionConflicts,
|
||||
validateTriggerPaste,
|
||||
@@ -543,6 +546,7 @@ const WorkflowContent = React.memo(() => {
|
||||
collaborativeBatchRemoveBlocks,
|
||||
collaborativeBatchToggleBlockEnabled,
|
||||
collaborativeBatchToggleBlockHandles,
|
||||
collaborativeBatchToggleLocked,
|
||||
undo,
|
||||
redo,
|
||||
} = useCollaborativeWorkflow()
|
||||
@@ -1069,8 +1073,27 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
const handleContextDelete = useCallback(() => {
|
||||
const blockIds = contextMenuBlocks.map((b) => b.id)
|
||||
collaborativeBatchRemoveBlocks(blockIds)
|
||||
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks])
|
||||
const { deletableIds, protectedIds, allProtected } = filterProtectedBlocks(blockIds, blocks)
|
||||
|
||||
if (protectedIds.length > 0) {
|
||||
if (allProtected) {
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: 'Cannot delete locked blocks or blocks inside locked containers',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: `Skipped ${protectedIds.length} protected block(s)`,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
}
|
||||
if (deletableIds.length > 0) {
|
||||
collaborativeBatchRemoveBlocks(deletableIds)
|
||||
}
|
||||
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks, addNotification, activeWorkflowId, blocks])
|
||||
|
||||
const handleContextToggleEnabled = useCallback(() => {
|
||||
const blockIds = contextMenuBlocks.map((block) => block.id)
|
||||
@@ -1082,6 +1105,11 @@ const WorkflowContent = React.memo(() => {
|
||||
collaborativeBatchToggleBlockHandles(blockIds)
|
||||
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
|
||||
|
||||
const handleContextToggleLocked = useCallback(() => {
|
||||
const blockIds = contextMenuBlocks.map((block) => block.id)
|
||||
collaborativeBatchToggleLocked(blockIds)
|
||||
}, [contextMenuBlocks, collaborativeBatchToggleLocked])
|
||||
|
||||
const handleContextRemoveFromSubflow = useCallback(() => {
|
||||
const blocksToRemove = contextMenuBlocks.filter(
|
||||
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
|
||||
@@ -1145,7 +1173,7 @@ const WorkflowContent = React.memo(() => {
|
||||
block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
|
||||
|
||||
if (isInsideSubflow) return { canRun: false, reason: 'Cannot run from inside subflow' }
|
||||
if (!dependenciesSatisfied) return { canRun: false, reason: 'Run upstream blocks first' }
|
||||
if (!dependenciesSatisfied) return { canRun: false, reason: 'Disabled: Run Blocks Before' }
|
||||
if (isNoteBlock) return { canRun: false, reason: undefined }
|
||||
if (isExecuting) return { canRun: false, reason: undefined }
|
||||
|
||||
@@ -1951,7 +1979,6 @@ const WorkflowContent = React.memo(() => {
|
||||
const loadingWorkflowRef = useRef<string | null>(null)
|
||||
const currentWorkflowExists = Boolean(workflows[workflowIdParam])
|
||||
|
||||
/** Initializes workflow when it exists in registry and needs hydration. */
|
||||
useEffect(() => {
|
||||
const currentId = workflowIdParam
|
||||
const currentWorkspaceHydration = hydration.workspaceId
|
||||
@@ -2128,6 +2155,7 @@ const WorkflowContent = React.memo(() => {
|
||||
parentId: block.data?.parentId,
|
||||
extent: block.data?.extent || undefined,
|
||||
dragHandle: '.workflow-drag-handle',
|
||||
draggable: !isBlockProtected(block.id, blocks),
|
||||
data: {
|
||||
...block.data,
|
||||
name: block.name,
|
||||
@@ -2163,6 +2191,7 @@ const WorkflowContent = React.memo(() => {
|
||||
position,
|
||||
parentId: block.data?.parentId,
|
||||
dragHandle,
|
||||
draggable: !isBlockProtected(block.id, blocks),
|
||||
extent: (() => {
|
||||
// Clamp children to subflow body (exclude header)
|
||||
const parentId = block.data?.parentId as string | undefined
|
||||
@@ -2491,12 +2520,18 @@ const WorkflowContent = React.memo(() => {
|
||||
const edgeIdsToRemove = changes
|
||||
.filter((change: any) => change.type === 'remove')
|
||||
.map((change: any) => change.id)
|
||||
.filter((edgeId: string) => {
|
||||
// Prevent removing edges connected to protected blocks
|
||||
const edge = edges.find((e) => e.id === edgeId)
|
||||
if (!edge) return true
|
||||
return !isEdgeProtected(edge, blocks)
|
||||
})
|
||||
|
||||
if (edgeIdsToRemove.length > 0) {
|
||||
collaborativeBatchRemoveEdges(edgeIdsToRemove)
|
||||
}
|
||||
},
|
||||
[collaborativeBatchRemoveEdges]
|
||||
[collaborativeBatchRemoveEdges, edges, blocks]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -2558,6 +2593,16 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
if (!sourceNode || !targetNode) return
|
||||
|
||||
// Prevent connections to/from protected blocks
|
||||
if (isEdgeProtected(connection, blocks)) {
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: 'Cannot connect to locked blocks or blocks inside locked containers',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get parent information (handle container start node case)
|
||||
const sourceParentId =
|
||||
blocks[sourceNode.id]?.data?.parentId ||
|
||||
@@ -2620,7 +2665,7 @@ const WorkflowContent = React.memo(() => {
|
||||
connectionCompletedRef.current = true
|
||||
}
|
||||
},
|
||||
[addEdge, getNodes, blocks]
|
||||
[addEdge, getNodes, blocks, addNotification, activeWorkflowId]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -2715,6 +2760,9 @@ const WorkflowContent = React.memo(() => {
|
||||
// Only consider container nodes that aren't the dragged node
|
||||
if (n.type !== 'subflowNode' || n.id === node.id) return false
|
||||
|
||||
// Don't allow dropping into locked containers
|
||||
if (blocks[n.id]?.locked) return false
|
||||
|
||||
// Get the container's absolute position
|
||||
const containerAbsolutePos = getNodeAbsolutePosition(n.id)
|
||||
|
||||
@@ -2807,6 +2855,8 @@ const WorkflowContent = React.memo(() => {
|
||||
/** Captures initial parent ID and position when drag starts. */
|
||||
const onNodeDragStart = useCallback(
|
||||
(_event: React.MouseEvent, node: any) => {
|
||||
// Note: Protected blocks are already non-draggable via the `draggable` node property
|
||||
|
||||
// Store the original parent ID when starting to drag
|
||||
const currentParentId = blocks[node.id]?.data?.parentId || null
|
||||
setDragStartParentId(currentParentId)
|
||||
@@ -2835,7 +2885,7 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
})
|
||||
},
|
||||
[blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId]
|
||||
[blocks, setDragStartPosition, getNodes, setPotentialParentId]
|
||||
)
|
||||
|
||||
/** Handles node drag stop to establish parent-child relationships. */
|
||||
@@ -2897,6 +2947,18 @@ const WorkflowContent = React.memo(() => {
|
||||
// Don't process parent changes if the node hasn't actually changed parent or is being moved within same parent
|
||||
if (potentialParentId === dragStartParentId) return
|
||||
|
||||
// Prevent moving locked blocks out of locked containers
|
||||
// Unlocked blocks (e.g., duplicates) can be moved out freely
|
||||
if (dragStartParentId && blocks[dragStartParentId]?.locked && blocks[node.id]?.locked) {
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: 'Cannot move locked blocks out of locked containers',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
setPotentialParentId(dragStartParentId) // Reset to original parent
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a starter block - starter blocks should never be in containers
|
||||
const isStarterBlock = node.data?.type === 'starter'
|
||||
if (isStarterBlock) {
|
||||
@@ -3293,6 +3355,16 @@ const WorkflowContent = React.memo(() => {
|
||||
/** Stable delete handler to avoid creating new function references per edge. */
|
||||
const handleEdgeDelete = useCallback(
|
||||
(edgeId: string) => {
|
||||
// Prevent removing edges connected to protected blocks
|
||||
const edge = edges.find((e) => e.id === edgeId)
|
||||
if (edge && isEdgeProtected(edge, blocks)) {
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: 'Cannot remove connections from locked blocks',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
removeEdge(edgeId)
|
||||
// Remove this edge from selection (find by edge ID value)
|
||||
setSelectedEdges((prev) => {
|
||||
@@ -3305,7 +3377,7 @@ const WorkflowContent = React.memo(() => {
|
||||
return next
|
||||
})
|
||||
},
|
||||
[removeEdge]
|
||||
[removeEdge, edges, blocks, addNotification, activeWorkflowId]
|
||||
)
|
||||
|
||||
/** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */
|
||||
@@ -3346,9 +3418,15 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
// Handle edge deletion first (edges take priority if selected)
|
||||
if (selectedEdges.size > 0) {
|
||||
// Get all selected edge IDs and batch delete them
|
||||
const edgeIds = Array.from(selectedEdges.values())
|
||||
collaborativeBatchRemoveEdges(edgeIds)
|
||||
// Get all selected edge IDs and filter out edges connected to protected blocks
|
||||
const edgeIds = Array.from(selectedEdges.values()).filter((edgeId) => {
|
||||
const edge = edges.find((e) => e.id === edgeId)
|
||||
if (!edge) return true
|
||||
return !isEdgeProtected(edge, blocks)
|
||||
})
|
||||
if (edgeIds.length > 0) {
|
||||
collaborativeBatchRemoveEdges(edgeIds)
|
||||
}
|
||||
setSelectedEdges(new Map())
|
||||
return
|
||||
}
|
||||
@@ -3365,7 +3443,29 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
event.preventDefault()
|
||||
const selectedIds = selectedNodes.map((node) => node.id)
|
||||
collaborativeBatchRemoveBlocks(selectedIds)
|
||||
const { deletableIds, protectedIds, allProtected } = filterProtectedBlocks(
|
||||
selectedIds,
|
||||
blocks
|
||||
)
|
||||
|
||||
if (protectedIds.length > 0) {
|
||||
if (allProtected) {
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: 'Cannot delete locked blocks or blocks inside locked containers',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: `Skipped ${protectedIds.length} protected block(s)`,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
}
|
||||
if (deletableIds.length > 0) {
|
||||
collaborativeBatchRemoveBlocks(deletableIds)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
@@ -3376,6 +3476,10 @@ const WorkflowContent = React.memo(() => {
|
||||
getNodes,
|
||||
collaborativeBatchRemoveBlocks,
|
||||
effectivePermissions.canEdit,
|
||||
blocks,
|
||||
edges,
|
||||
addNotification,
|
||||
activeWorkflowId,
|
||||
])
|
||||
|
||||
return (
|
||||
@@ -3496,12 +3600,18 @@ const WorkflowContent = React.memo(() => {
|
||||
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
|
||||
)}
|
||||
canRunFromBlock={runFromBlockState.canRun}
|
||||
disableEdit={!effectivePermissions.canEdit}
|
||||
disableEdit={
|
||||
!effectivePermissions.canEdit ||
|
||||
contextMenuBlocks.some((b) => b.locked || b.isParentLocked)
|
||||
}
|
||||
userCanEdit={effectivePermissions.canEdit}
|
||||
isExecuting={isExecuting}
|
||||
isPositionalTrigger={
|
||||
contextMenuBlocks.length === 1 &&
|
||||
edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0
|
||||
}
|
||||
onToggleLocked={handleContextToggleLocked}
|
||||
canAdmin={effectivePermissions.canAdmin}
|
||||
/>
|
||||
|
||||
<CanvasMenu
|
||||
@@ -3524,6 +3634,7 @@ const WorkflowContent = React.memo(() => {
|
||||
disableEdit={!effectivePermissions.canEdit}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
hasLockedBlocks={Object.values(blocks).some((b) => b.locked)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -246,7 +246,6 @@ export function CredentialSets() {
|
||||
setNewSetDescription('')
|
||||
setNewSetProvider('google-email')
|
||||
|
||||
// Open detail view for the newly created group
|
||||
if (result?.credentialSet) {
|
||||
setViewingSet(result.credentialSet)
|
||||
}
|
||||
@@ -336,7 +335,6 @@ export function CredentialSets() {
|
||||
email,
|
||||
})
|
||||
|
||||
// Start 60s cooldown
|
||||
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
|
||||
const interval = setInterval(() => {
|
||||
setResendCooldowns((prev) => {
|
||||
@@ -393,7 +391,6 @@ export function CredentialSets() {
|
||||
return <GmailIcon className='h-4 w-4' />
|
||||
}
|
||||
|
||||
// All hooks must be called before any early returns
|
||||
const activeMemberships = useMemo(
|
||||
() => memberships.filter((m) => m.status === 'active'),
|
||||
[memberships]
|
||||
@@ -447,7 +444,6 @@ export function CredentialSets() {
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
{/* Group Info */}
|
||||
<div className='flex items-center gap-[16px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
@@ -471,7 +467,6 @@ export function CredentialSets() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invite Section - Email Tags Input */}
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<TagInput
|
||||
@@ -495,7 +490,6 @@ export function CredentialSets() {
|
||||
{emailError && <p className='text-[12px] text-[var(--text-error)]'>{emailError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Members List - styled like team members */}
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
<h4 className='font-medium text-[14px] text-[var(--text-primary)]'>Members</h4>
|
||||
|
||||
@@ -519,7 +513,6 @@ export function CredentialSets() {
|
||||
</p>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
{/* Active Members */}
|
||||
{activeMembers.map((member) => {
|
||||
const name = member.userName || 'Unknown'
|
||||
const avatarInitial = name.charAt(0).toUpperCase()
|
||||
@@ -572,7 +565,6 @@ export function CredentialSets() {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Pending Invitations */}
|
||||
{pendingInvitations.map((invitation) => {
|
||||
const email = invitation.email || 'Unknown'
|
||||
const emailPrefix = email.split('@')[0]
|
||||
@@ -641,7 +633,6 @@ export function CredentialSets() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className='mt-auto flex items-center justify-end'>
|
||||
<Button onClick={handleBackToList} variant='tertiary'>
|
||||
Back
|
||||
@@ -822,7 +813,6 @@ export function CredentialSets() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Polling Group Modal */}
|
||||
<Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Create Polling Group</ModalHeader>
|
||||
@@ -895,7 +885,6 @@ export function CredentialSets() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Leave Confirmation Modal */}
|
||||
<Modal open={!!leavingMembership} onOpenChange={() => setLeavingMembership(null)}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Leave Polling Group</ModalHeader>
|
||||
@@ -923,7 +912,6 @@ export function CredentialSets() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal open={!!deletingSet} onOpenChange={() => setDeletingSet(null)}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Polling Group</ModalHeader>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { AccessControl } from './access-control/access-control'
|
||||
export { ApiKeys } from './api-keys/api-keys'
|
||||
export { BYOK } from './byok/byok'
|
||||
export { Copilot } from './copilot/copilot'
|
||||
@@ -10,7 +9,6 @@ export { Files as FileUploads } from './files/files'
|
||||
export { General } from './general/general'
|
||||
export { Integrations } from './integrations/integrations'
|
||||
export { MCP } from './mcp/mcp'
|
||||
export { SSO } from './sso/sso'
|
||||
export { Subscription } from './subscription/subscription'
|
||||
export { TeamManagement } from './team-management/team-management'
|
||||
export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers'
|
||||
|
||||
@@ -407,14 +407,12 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
|
||||
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
|
||||
|
||||
// Auto-select server when initialServerId is provided
|
||||
useEffect(() => {
|
||||
if (initialServerId && servers.some((s) => s.id === initialServerId)) {
|
||||
setSelectedServerId(initialServerId)
|
||||
}
|
||||
}, [initialServerId, servers])
|
||||
|
||||
// Force refresh tools when entering server detail view to detect stale schemas
|
||||
useEffect(() => {
|
||||
if (selectedServerId) {
|
||||
forceRefreshTools(workspaceId)
|
||||
@@ -675,6 +673,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
|
||||
/**
|
||||
* Opens the detail view for a specific server.
|
||||
* Note: Tool refresh is handled by the useEffect that watches selectedServerId
|
||||
*/
|
||||
const handleViewDetails = useCallback((serverId: string) => {
|
||||
setSelectedServerId(serverId)
|
||||
@@ -717,7 +716,6 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
`Refreshed MCP server: ${serverId}, workflows updated: ${result.workflowsUpdated}`
|
||||
)
|
||||
|
||||
// If the active workflow was updated, reload its subblock values from DB
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) {
|
||||
logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`)
|
||||
|
||||
@@ -41,7 +41,6 @@ import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { getUserRole } from '@/lib/workspaces/organization'
|
||||
import {
|
||||
AccessControl,
|
||||
ApiKeys,
|
||||
BYOK,
|
||||
Copilot,
|
||||
@@ -53,16 +52,18 @@ import {
|
||||
General,
|
||||
Integrations,
|
||||
MCP,
|
||||
SSO,
|
||||
Subscription,
|
||||
TeamManagement,
|
||||
WorkflowMcpServers,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components'
|
||||
import { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile'
|
||||
import { AccessControl } from '@/ee/access-control/components/access-control'
|
||||
import { SSO } from '@/ee/sso/components/sso-settings'
|
||||
import { ssoKeys, useSSOProviders } from '@/ee/sso/hooks/sso'
|
||||
import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings'
|
||||
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
|
||||
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
|
||||
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { useSuperUserStatus } from '@/hooks/queries/user-profile'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSettingsModalStore } from '@/stores/modals/settings/store'
|
||||
|
||||
@@ -204,13 +205,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
||||
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
|
||||
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
|
||||
const [isSuperUser, setIsSuperUser] = useState(false)
|
||||
const { data: session } = useSession()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: organizationsData } = useOrganizations()
|
||||
const { data: generalSettings } = useGeneralSettings()
|
||||
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
|
||||
const { data: superUserData } = useSuperUserStatus(session?.user?.id)
|
||||
|
||||
const activeOrganization = organizationsData?.activeOrganization
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
@@ -229,22 +230,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const hasEnterprisePlan = subscriptionStatus.isEnterprise
|
||||
const hasOrganization = !!activeOrganization?.id
|
||||
|
||||
// Fetch superuser status
|
||||
useEffect(() => {
|
||||
const fetchSuperUserStatus = async () => {
|
||||
if (!userId) return
|
||||
try {
|
||||
const response = await fetch('/api/user/super-user')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setIsSuperUser(data.isSuperUser)
|
||||
}
|
||||
} catch {
|
||||
setIsSuperUser(false)
|
||||
}
|
||||
}
|
||||
fetchSuperUserStatus()
|
||||
}, [userId])
|
||||
const isSuperUser = superUserData?.isSuperUser ?? false
|
||||
|
||||
// Memoize SSO provider ownership check
|
||||
const isSSOProviderOwner = useMemo(() => {
|
||||
@@ -328,7 +314,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
generalSettings?.superUserModeEnabled,
|
||||
])
|
||||
|
||||
// Memoized callbacks to prevent infinite loops in child components
|
||||
const effectiveActiveSection = useMemo(() => {
|
||||
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
|
||||
return 'general'
|
||||
}
|
||||
return activeSection
|
||||
}, [activeSection])
|
||||
|
||||
const registerEnvironmentBeforeLeaveHandler = useCallback(
|
||||
(handler: (onProceed: () => void) => void) => {
|
||||
environmentBeforeLeaveHandler.current = handler
|
||||
@@ -342,19 +334,18 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
|
||||
const handleSectionChange = useCallback(
|
||||
(sectionId: SettingsSection) => {
|
||||
if (sectionId === activeSection) return
|
||||
if (sectionId === effectiveActiveSection) return
|
||||
|
||||
if (activeSection === 'environment' && environmentBeforeLeaveHandler.current) {
|
||||
if (effectiveActiveSection === 'environment' && environmentBeforeLeaveHandler.current) {
|
||||
environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId))
|
||||
return
|
||||
}
|
||||
|
||||
setActiveSection(sectionId)
|
||||
},
|
||||
[activeSection]
|
||||
[effectiveActiveSection]
|
||||
)
|
||||
|
||||
// Apply initial section from store when modal opens
|
||||
useEffect(() => {
|
||||
if (open && initialSection) {
|
||||
setActiveSection(initialSection)
|
||||
@@ -365,7 +356,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
}
|
||||
}, [open, initialSection, mcpServerId, clearInitialState])
|
||||
|
||||
// Clear pending server ID when section changes away from MCP
|
||||
useEffect(() => {
|
||||
if (activeSection !== 'mcp') {
|
||||
setPendingMcpServerId(null)
|
||||
@@ -391,14 +381,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
}
|
||||
}, [onOpenChange])
|
||||
|
||||
// Redirect away from billing tabs if billing is disabled
|
||||
useEffect(() => {
|
||||
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
|
||||
setActiveSection('general')
|
||||
}
|
||||
}, [activeSection])
|
||||
|
||||
// Prefetch functions for React Query
|
||||
const prefetchGeneral = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: generalSettingsKeys.settings(),
|
||||
@@ -489,9 +471,17 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
|
||||
// Handle dialog close - delegate to environment component if it's active
|
||||
const handleDialogOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen && activeSection === 'environment' && environmentBeforeLeaveHandler.current) {
|
||||
if (
|
||||
!newOpen &&
|
||||
effectiveActiveSection === 'environment' &&
|
||||
environmentBeforeLeaveHandler.current
|
||||
) {
|
||||
environmentBeforeLeaveHandler.current(() => onOpenChange(false))
|
||||
} else if (!newOpen && activeSection === 'integrations' && integrationsCloseHandler.current) {
|
||||
} else if (
|
||||
!newOpen &&
|
||||
effectiveActiveSection === 'integrations' &&
|
||||
integrationsCloseHandler.current
|
||||
) {
|
||||
integrationsCloseHandler.current(newOpen)
|
||||
} else {
|
||||
onOpenChange(newOpen)
|
||||
@@ -522,7 +512,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
{sectionItems.map((item) => (
|
||||
<SModalSidebarItem
|
||||
key={item.id}
|
||||
active={activeSection === item.id}
|
||||
active={effectiveActiveSection === item.id}
|
||||
icon={<item.icon />}
|
||||
onMouseEnter={() => handlePrefetch(item.id)}
|
||||
onClick={() => handleSectionChange(item.id)}
|
||||
@@ -538,35 +528,36 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
|
||||
<SModalMain>
|
||||
<SModalMainHeader>
|
||||
{navigationItems.find((item) => item.id === activeSection)?.label || activeSection}
|
||||
{navigationItems.find((item) => item.id === effectiveActiveSection)?.label ||
|
||||
effectiveActiveSection}
|
||||
</SModalMainHeader>
|
||||
<SModalMainBody>
|
||||
{activeSection === 'general' && <General onOpenChange={onOpenChange} />}
|
||||
{activeSection === 'environment' && (
|
||||
{effectiveActiveSection === 'general' && <General onOpenChange={onOpenChange} />}
|
||||
{effectiveActiveSection === 'environment' && (
|
||||
<EnvironmentVariables
|
||||
registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'template-profile' && <TemplateProfile />}
|
||||
{activeSection === 'integrations' && (
|
||||
{effectiveActiveSection === 'template-profile' && <TemplateProfile />}
|
||||
{effectiveActiveSection === 'integrations' && (
|
||||
<Integrations
|
||||
onOpenChange={onOpenChange}
|
||||
registerCloseHandler={registerIntegrationsCloseHandler}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'credential-sets' && <CredentialSets />}
|
||||
{activeSection === 'access-control' && <AccessControl />}
|
||||
{activeSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
|
||||
{activeSection === 'files' && <FileUploads />}
|
||||
{isBillingEnabled && activeSection === 'subscription' && <Subscription />}
|
||||
{isBillingEnabled && activeSection === 'team' && <TeamManagement />}
|
||||
{activeSection === 'sso' && <SSO />}
|
||||
{activeSection === 'byok' && <BYOK />}
|
||||
{activeSection === 'copilot' && <Copilot />}
|
||||
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
||||
{activeSection === 'custom-tools' && <CustomTools />}
|
||||
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
||||
{activeSection === 'debug' && <Debug />}
|
||||
{effectiveActiveSection === 'credential-sets' && <CredentialSets />}
|
||||
{effectiveActiveSection === 'access-control' && <AccessControl />}
|
||||
{effectiveActiveSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
|
||||
{effectiveActiveSection === 'files' && <FileUploads />}
|
||||
{isBillingEnabled && effectiveActiveSection === 'subscription' && <Subscription />}
|
||||
{isBillingEnabled && effectiveActiveSection === 'team' && <TeamManagement />}
|
||||
{effectiveActiveSection === 'sso' && <SSO />}
|
||||
{effectiveActiveSection === 'byok' && <BYOK />}
|
||||
{effectiveActiveSection === 'copilot' && <Copilot />}
|
||||
{effectiveActiveSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
||||
{effectiveActiveSection === 'custom-tools' && <CustomTools />}
|
||||
{effectiveActiveSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
||||
{effectiveActiveSection === 'debug' && <Debug />}
|
||||
</SModalMainBody>
|
||||
</SModalMain>
|
||||
</SModalContent>
|
||||
|
||||
@@ -231,6 +231,8 @@ export function FolderItem({
|
||||
const isFolderSelected = store.selectedFolders.has(folder.id)
|
||||
|
||||
if (!isFolderSelected) {
|
||||
// Replace selection with just this folder (Finder/Explorer pattern)
|
||||
store.clearAllSelection()
|
||||
store.selectFolder(folder.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -189,6 +189,9 @@ export function WorkflowItem({
|
||||
const isCurrentlySelected = store.selectedWorkflows.has(workflow.id)
|
||||
|
||||
if (!isCurrentlySelected) {
|
||||
// Replace selection with just this item (Finder/Explorer pattern)
|
||||
// This clears both workflow and folder selections
|
||||
store.clearAllSelection()
|
||||
store.selectWorkflow(workflow.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
@@ -227,12 +228,6 @@ async function deliverWebhook(
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
return `${(ms / 60000).toFixed(1)}m`
|
||||
}
|
||||
|
||||
function formatCost(cost?: Record<string, unknown>): string {
|
||||
if (!cost?.total) return 'N/A'
|
||||
const total = cost.total as number
|
||||
@@ -302,7 +297,7 @@ async function deliverEmail(
|
||||
workflowName: payload.data.workflowName || 'Unknown Workflow',
|
||||
status: payload.data.status,
|
||||
trigger: payload.data.trigger,
|
||||
duration: formatDuration(payload.data.totalDurationMs),
|
||||
duration: formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-',
|
||||
cost: formatCost(payload.data.cost),
|
||||
logUrl,
|
||||
alertReason,
|
||||
@@ -315,7 +310,7 @@ async function deliverEmail(
|
||||
to: subscription.emailRecipients,
|
||||
subject,
|
||||
html,
|
||||
text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs)}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
|
||||
text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
|
||||
emailType: 'notifications',
|
||||
})
|
||||
|
||||
@@ -373,7 +368,10 @@ async function deliverSlack(
|
||||
fields: [
|
||||
{ type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` },
|
||||
{ type: 'mrkdwn', text: `*Trigger:*\n${payload.data.trigger}` },
|
||||
{ type: 'mrkdwn', text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs)}` },
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}`,
|
||||
},
|
||||
{ type: 'mrkdwn', text: `*Cost:*\n${formatCost(payload.data.cost)}` },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -458,8 +458,8 @@ export function getCodeEditorProps(options?: {
|
||||
'caret-[var(--text-primary)] dark:caret-white',
|
||||
// Font smoothing
|
||||
'[-webkit-font-smoothing:antialiased] [-moz-osx-font-smoothing:grayscale]',
|
||||
// Disable interaction for streaming/preview
|
||||
(isStreaming || isPreview) && 'pointer-events-none'
|
||||
// Disable interaction for streaming/preview/disabled
|
||||
(isStreaming || isPreview || disabled) && 'pointer-events-none'
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +260,9 @@ const Popover: React.FC<PopoverProps> = ({
|
||||
setIsKeyboardNav(false)
|
||||
setSelectedIndex(-1)
|
||||
registeredItemsRef.current = []
|
||||
} else {
|
||||
// Reset hover state when opening to prevent stale submenu from previous menu
|
||||
setLastHoveredItem(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import type { ToolCallGroup, ToolCallState } from '@/lib/copilot/types'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
|
||||
interface ToolCallProps {
|
||||
toolCall: ToolCallState
|
||||
@@ -225,11 +226,6 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
|
||||
const isError = toolCall.state === 'error'
|
||||
const isAborted = toolCall.state === 'aborted'
|
||||
|
||||
const formatDuration = (duration?: number) => {
|
||||
if (!duration) return ''
|
||||
return duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -279,7 +275,7 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
|
||||
)}
|
||||
style={{ fontSize: '0.625rem' }}
|
||||
>
|
||||
{formatDuration(toolCall.duration)}
|
||||
{toolCall.duration ? formatDuration(toolCall.duration, { precision: 1 }) : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
43
apps/sim/ee/LICENSE
Normal file
43
apps/sim/ee/LICENSE
Normal file
@@ -0,0 +1,43 @@
|
||||
Sim Enterprise License
|
||||
|
||||
Copyright (c) 2025-present Sim Studio, Inc.
|
||||
|
||||
This software and associated documentation files (the "Software") are licensed
|
||||
under the following terms:
|
||||
|
||||
1. LICENSE GRANT
|
||||
|
||||
Subject to the terms of this license, Sim Studio, Inc. grants you a limited,
|
||||
non-exclusive, non-transferable license to use the Software for:
|
||||
|
||||
- Development, testing, and evaluation purposes
|
||||
- Internal non-production use
|
||||
|
||||
Production use of the Software requires a valid Sim Enterprise subscription.
|
||||
|
||||
2. RESTRICTIONS
|
||||
|
||||
You may not:
|
||||
|
||||
- Use the Software in production without a valid Enterprise subscription
|
||||
- Modify, adapt, or create derivative works of the Software
|
||||
- Redistribute, sublicense, or transfer the Software
|
||||
- Remove or alter any proprietary notices in the Software
|
||||
|
||||
3. ENTERPRISE SUBSCRIPTION
|
||||
|
||||
Production deployment of enterprise features requires an active Sim Enterprise
|
||||
subscription. Contact sales@simstudio.ai for licensing information.
|
||||
|
||||
4. DISCLAIMER
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
|
||||
5. LIMITATION OF LIABILITY
|
||||
|
||||
IN NO EVENT SHALL SIM STUDIO, INC. BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
|
||||
|
||||
For questions about enterprise licensing, contact: sales@simstudio.ai
|
||||
21
apps/sim/ee/README.md
Normal file
21
apps/sim/ee/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Sim Enterprise Edition
|
||||
|
||||
This directory contains enterprise features that require a Sim Enterprise subscription
|
||||
for production use.
|
||||
|
||||
## Features
|
||||
|
||||
- **SSO (Single Sign-On)**: OIDC and SAML authentication integration
|
||||
- **Access Control**: Permission groups for fine-grained user access management
|
||||
- **Credential Sets**: Shared credential pools for email polling workflows
|
||||
|
||||
## Licensing
|
||||
|
||||
See [LICENSE](./LICENSE) for terms. Development and testing use is permitted.
|
||||
Production deployment requires an active Enterprise subscription.
|
||||
|
||||
## Architecture
|
||||
|
||||
Enterprise features are imported directly throughout the codebase. The `ee/` directory
|
||||
is required at build time. Feature visibility is controlled at runtime via environment
|
||||
variables (e.g., `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED`).
|
||||
@@ -29,7 +29,6 @@ import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
import { getUserRole } from '@/lib/workspaces/organization'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
|
||||
import {
|
||||
type PermissionGroup,
|
||||
useBulkAddPermissionGroupMembers,
|
||||
@@ -39,7 +38,8 @@ import {
|
||||
usePermissionGroups,
|
||||
useRemovePermissionGroupMember,
|
||||
useUpdatePermissionGroup,
|
||||
} from '@/hooks/queries/permission-groups'
|
||||
} from '@/ee/access-control/hooks/permission-groups'
|
||||
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
import { getAllProviderIds } from '@/providers/utils'
|
||||
@@ -255,7 +255,6 @@ export function AccessControl() {
|
||||
queryEnabled
|
||||
)
|
||||
|
||||
// Show loading while dependencies load, or while permission groups query is pending
|
||||
const isLoading = orgsLoading || subLoading || (queryEnabled && groupsLoading)
|
||||
const { data: organization } = useOrganization(activeOrganization?.id || '')
|
||||
|
||||
@@ -410,10 +409,8 @@ export function AccessControl() {
|
||||
}, [viewingGroup, editingConfig])
|
||||
|
||||
const allBlocks = useMemo(() => {
|
||||
// Filter out hidden blocks and start_trigger (which should never be disabled)
|
||||
const blocks = getAllBlocks().filter((b) => !b.hideFromToolbar && b.type !== 'start_trigger')
|
||||
return blocks.sort((a, b) => {
|
||||
// Group by category: triggers first, then blocks, then tools
|
||||
const categoryOrder = { triggers: 0, blocks: 1, tools: 2 }
|
||||
const catA = categoryOrder[a.category] ?? 3
|
||||
const catB = categoryOrder[b.category] ?? 3
|
||||
@@ -555,10 +552,9 @@ export function AccessControl() {
|
||||
}, [viewingGroup, editingConfig, activeOrganization?.id, updatePermissionGroup])
|
||||
|
||||
const handleOpenAddMembersModal = useCallback(() => {
|
||||
const existingMemberUserIds = new Set(members.map((m) => m.userId))
|
||||
setSelectedMemberIds(new Set())
|
||||
setShowAddMembersModal(true)
|
||||
}, [members])
|
||||
}, [])
|
||||
|
||||
const handleAddSelectedMembers = useCallback(async () => {
|
||||
if (!viewingGroup || selectedMemberIds.size === 0) return
|
||||
@@ -891,7 +887,6 @@ export function AccessControl() {
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
// When deselecting all, keep start_trigger allowed (it should never be disabled)
|
||||
allowedIntegrations: allAllowed ? ['start_trigger'] : null,
|
||||
}
|
||||
: prev
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
import { fetchJson } from '@/hooks/selectors/helpers'
|
||||
@@ -11,55 +11,13 @@ import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getUserRole } from '@/lib/workspaces/organization/utils'
|
||||
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
|
||||
import { useConfigureSSO, useSSOProviders } from '@/ee/sso/hooks/sso'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useConfigureSSO, useSSOProviders } from '@/hooks/queries/sso'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
const logger = createLogger('SSO')
|
||||
|
||||
const TRUSTED_SSO_PROVIDERS = [
|
||||
'okta',
|
||||
'okta-saml',
|
||||
'okta-prod',
|
||||
'okta-dev',
|
||||
'okta-staging',
|
||||
'okta-test',
|
||||
'azure-ad',
|
||||
'azure-active-directory',
|
||||
'azure-corp',
|
||||
'azure-enterprise',
|
||||
'adfs',
|
||||
'adfs-company',
|
||||
'adfs-corp',
|
||||
'adfs-enterprise',
|
||||
'auth0',
|
||||
'auth0-prod',
|
||||
'auth0-dev',
|
||||
'auth0-staging',
|
||||
'onelogin',
|
||||
'onelogin-prod',
|
||||
'onelogin-corp',
|
||||
'jumpcloud',
|
||||
'jumpcloud-prod',
|
||||
'jumpcloud-corp',
|
||||
'ping-identity',
|
||||
'ping-federate',
|
||||
'pingone',
|
||||
'shibboleth',
|
||||
'shibboleth-idp',
|
||||
'google-workspace',
|
||||
'google-sso',
|
||||
'saml',
|
||||
'saml2',
|
||||
'saml-sso',
|
||||
'oidc',
|
||||
'oidc-sso',
|
||||
'openid-connect',
|
||||
'custom-sso',
|
||||
'enterprise-sso',
|
||||
'company-sso',
|
||||
]
|
||||
|
||||
interface SSOProvider {
|
||||
id: string
|
||||
providerId: string
|
||||
@@ -565,7 +523,7 @@ export function SSO() {
|
||||
<Combobox
|
||||
value={formData.providerId}
|
||||
onChange={(value: string) => handleInputChange('providerId', value)}
|
||||
options={TRUSTED_SSO_PROVIDERS.map((id) => ({
|
||||
options={SSO_TRUSTED_PROVIDERS.map((id) => ({
|
||||
label: id,
|
||||
value: id,
|
||||
}))}
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* List of trusted SSO provider identifiers.
|
||||
* Used for validation and autocomplete in SSO configuration.
|
||||
*/
|
||||
export const SSO_TRUSTED_PROVIDERS = [
|
||||
'okta',
|
||||
'okta-saml',
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { organizationKeys } from '@/hooks/queries/organization'
|
||||
|
||||
@@ -75,39 +77,3 @@ export function useConfigureSSO() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete SSO provider mutation
|
||||
*/
|
||||
interface DeleteSSOParams {
|
||||
providerId: string
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export function useDeleteSSO() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ providerId }: DeleteSSOParams) => {
|
||||
const response = await fetch(`/api/auth/sso/providers/${providerId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to delete SSO provider')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ssoKeys.providers() })
|
||||
|
||||
if (variables.orgId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.detail(variables.orgId),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
hydrateUserFilesWithBase64,
|
||||
} from '@/lib/uploads/utils/user-file-base64.server'
|
||||
import { sanitizeInputFormat, sanitizeTools } from '@/lib/workflows/comparison/normalize'
|
||||
import { validateBlockType } from '@/ee/access-control/utils/permission-check'
|
||||
import {
|
||||
BlockType,
|
||||
buildResumeApiUrl,
|
||||
@@ -20,18 +21,18 @@ import {
|
||||
generatePauseContextId,
|
||||
mapNodeMetadataToPauseScopes,
|
||||
} from '@/executor/human-in-the-loop/utils.ts'
|
||||
import type {
|
||||
BlockHandler,
|
||||
BlockLog,
|
||||
BlockState,
|
||||
ExecutionContext,
|
||||
NormalizedBlockOutput,
|
||||
import {
|
||||
type BlockHandler,
|
||||
type BlockLog,
|
||||
type BlockState,
|
||||
type ExecutionContext,
|
||||
getNextExecutionOrder,
|
||||
type NormalizedBlockOutput,
|
||||
} from '@/executor/types'
|
||||
import { streamingResponseFormatProcessor } from '@/executor/utils'
|
||||
import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors'
|
||||
import { isJSONString } from '@/executor/utils/json'
|
||||
import { filterOutputForLog } from '@/executor/utils/output-filter'
|
||||
import { validateBlockType } from '@/executor/utils/permission-check'
|
||||
import type { VariableResolver } from '@/executor/variables/resolver'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import type { SubflowType } from '@/stores/workflows/workflow/types'
|
||||
@@ -68,7 +69,7 @@ export class BlockExecutor {
|
||||
if (!isSentinel) {
|
||||
blockLog = this.createBlockLog(ctx, node.id, block, node)
|
||||
ctx.blockLogs.push(blockLog)
|
||||
this.callOnBlockStart(ctx, node, block)
|
||||
this.callOnBlockStart(ctx, node, block, blockLog.executionOrder)
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
@@ -159,7 +160,7 @@ export class BlockExecutor {
|
||||
|
||||
this.state.setBlockOutput(node.id, normalizedOutput, duration)
|
||||
|
||||
if (!isSentinel) {
|
||||
if (!isSentinel && blockLog) {
|
||||
const displayOutput = filterOutputForLog(block.metadata?.id || '', normalizedOutput, {
|
||||
block,
|
||||
})
|
||||
@@ -170,8 +171,9 @@ export class BlockExecutor {
|
||||
this.sanitizeInputsForLog(resolvedInputs),
|
||||
displayOutput,
|
||||
duration,
|
||||
blockLog!.startedAt,
|
||||
blockLog!.endedAt
|
||||
blockLog.startedAt,
|
||||
blockLog.executionOrder,
|
||||
blockLog.endedAt
|
||||
)
|
||||
}
|
||||
|
||||
@@ -268,7 +270,7 @@ export class BlockExecutor {
|
||||
}
|
||||
)
|
||||
|
||||
if (!isSentinel) {
|
||||
if (!isSentinel && blockLog) {
|
||||
const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
|
||||
this.callOnBlockComplete(
|
||||
ctx,
|
||||
@@ -277,8 +279,9 @@ export class BlockExecutor {
|
||||
this.sanitizeInputsForLog(input),
|
||||
displayOutput,
|
||||
duration,
|
||||
blockLog!.startedAt,
|
||||
blockLog!.endedAt
|
||||
blockLog.startedAt,
|
||||
blockLog.executionOrder,
|
||||
blockLog.endedAt
|
||||
)
|
||||
}
|
||||
|
||||
@@ -346,6 +349,7 @@ export class BlockExecutor {
|
||||
blockName,
|
||||
blockType: block.metadata?.id ?? DEFAULTS.BLOCK_TYPE,
|
||||
startedAt: new Date().toISOString(),
|
||||
executionOrder: getNextExecutionOrder(ctx),
|
||||
endedAt: '',
|
||||
durationMs: 0,
|
||||
success: false,
|
||||
@@ -409,7 +413,12 @@ export class BlockExecutor {
|
||||
return result
|
||||
}
|
||||
|
||||
private callOnBlockStart(ctx: ExecutionContext, node: DAGNode, block: SerializedBlock): void {
|
||||
private callOnBlockStart(
|
||||
ctx: ExecutionContext,
|
||||
node: DAGNode,
|
||||
block: SerializedBlock,
|
||||
executionOrder: number
|
||||
): void {
|
||||
const blockId = node.id
|
||||
const blockName = block.metadata?.name ?? blockId
|
||||
const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE
|
||||
@@ -417,7 +426,13 @@ export class BlockExecutor {
|
||||
const iterationContext = this.getIterationContext(ctx, node)
|
||||
|
||||
if (this.contextExtensions.onBlockStart) {
|
||||
this.contextExtensions.onBlockStart(blockId, blockName, blockType, iterationContext)
|
||||
this.contextExtensions.onBlockStart(
|
||||
blockId,
|
||||
blockName,
|
||||
blockType,
|
||||
executionOrder,
|
||||
iterationContext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +444,7 @@ export class BlockExecutor {
|
||||
output: NormalizedBlockOutput,
|
||||
duration: number,
|
||||
startedAt: string,
|
||||
executionOrder: number,
|
||||
endedAt: string
|
||||
): void {
|
||||
const blockId = node.id
|
||||
@@ -447,6 +463,7 @@ export class BlockExecutor {
|
||||
output,
|
||||
executionTime: duration,
|
||||
startedAt,
|
||||
executionOrder,
|
||||
endedAt,
|
||||
},
|
||||
iterationContext
|
||||
|
||||
@@ -55,7 +55,13 @@ export interface IterationContext {
|
||||
|
||||
export interface ExecutionCallbacks {
|
||||
onStream?: (streamingExec: any) => Promise<void>
|
||||
onBlockStart?: (blockId: string, blockName: string, blockType: string) => Promise<void>
|
||||
onBlockStart?: (
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
blockType: string,
|
||||
executionOrder: number,
|
||||
iterationContext?: IterationContext
|
||||
) => Promise<void>
|
||||
onBlockComplete?: (
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
@@ -97,6 +103,7 @@ export interface ContextExtensions {
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
blockType: string,
|
||||
executionOrder: number,
|
||||
iterationContext?: IterationContext
|
||||
) => Promise<void>
|
||||
onBlockComplete?: (
|
||||
@@ -108,6 +115,7 @@ export interface ContextExtensions {
|
||||
output: NormalizedBlockOutput
|
||||
executionTime: number
|
||||
startedAt: string
|
||||
executionOrder: number
|
||||
endedAt: string
|
||||
},
|
||||
iterationContext?: IterationContext
|
||||
|
||||
@@ -6,6 +6,12 @@ import { createMcpToolId } from '@/lib/mcp/utils'
|
||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import {
|
||||
validateBlockType,
|
||||
validateCustomToolsAllowed,
|
||||
validateMcpToolsAllowed,
|
||||
validateModelProvider,
|
||||
} from '@/ee/access-control/utils/permission-check'
|
||||
import { AGENT, BlockType, DEFAULTS, REFERENCE, stripCustomToolPrefix } from '@/executor/constants'
|
||||
import { memoryService } from '@/executor/handlers/agent/memory'
|
||||
import type {
|
||||
@@ -18,12 +24,6 @@ import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/execu
|
||||
import { collectBlockData } from '@/executor/utils/block-data'
|
||||
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
|
||||
import { stringifyJSON } from '@/executor/utils/json'
|
||||
import {
|
||||
validateBlockType,
|
||||
validateCustomToolsAllowed,
|
||||
validateMcpToolsAllowed,
|
||||
validateModelProvider,
|
||||
} from '@/executor/utils/permission-check'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
@@ -4,11 +4,11 @@ import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
|
||||
import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
|
||||
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
|
||||
import { validateModelProvider } from '@/executor/utils/permission-check'
|
||||
import { calculateCost, getProviderFromModel } from '@/providers/utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
|
||||
import {
|
||||
BlockType,
|
||||
DEFAULTS,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
} from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { buildAuthHeaders } from '@/executor/utils/http'
|
||||
import { validateModelProvider } from '@/executor/utils/permission-check'
|
||||
import { calculateCost, getProviderFromModel } from '@/providers/utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
|
||||
@@ -212,11 +212,11 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
/**
|
||||
* Parses a potentially nested workflow error message to extract:
|
||||
* - The chain of workflow names
|
||||
* - The actual root error message (preserving the block prefix for the failing block)
|
||||
* - The actual root error message (preserving the block name prefix for the failing block)
|
||||
*
|
||||
* Handles formats like:
|
||||
* - "workflow-name" failed: error
|
||||
* - [block_type] Block Name: "workflow-name" failed: error
|
||||
* - Block Name: "workflow-name" failed: error
|
||||
* - Workflow chain: A → B | error
|
||||
*/
|
||||
private parseNestedWorkflowError(message: string): { chain: string[]; rootError: string } {
|
||||
@@ -234,8 +234,8 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
|
||||
// Extract workflow names from patterns like:
|
||||
// - "workflow-name" failed:
|
||||
// - [block_type] Block Name: "workflow-name" failed:
|
||||
const workflowPattern = /(?:\[[^\]]+\]\s*[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
|
||||
// - Block Name: "workflow-name" failed:
|
||||
const workflowPattern = /(?:\[[^\]]+\]\s*)?(?:[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
|
||||
let match: RegExpExecArray | null
|
||||
let lastIndex = 0
|
||||
|
||||
@@ -247,7 +247,7 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
}
|
||||
|
||||
// The root error is everything after the last match
|
||||
// Keep the block prefix (e.g., [function] Function 1:) so we know which block failed
|
||||
// Keep the block name prefix (e.g., Function 1:) so we know which block failed
|
||||
const rootError = lastIndex > 0 ? remaining.slice(lastIndex) : remaining
|
||||
|
||||
return { chain, rootError: rootError.trim() || 'Unknown error' }
|
||||
|
||||
@@ -7,7 +7,11 @@ import type { DAG } from '@/executor/dag/builder'
|
||||
import type { EdgeManager } from '@/executor/execution/edge-manager'
|
||||
import type { LoopScope } from '@/executor/execution/state'
|
||||
import type { BlockStateController, ContextExtensions } from '@/executor/execution/types'
|
||||
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
|
||||
import {
|
||||
type ExecutionContext,
|
||||
getNextExecutionOrder,
|
||||
type NormalizedBlockOutput,
|
||||
} from '@/executor/types'
|
||||
import type { LoopConfigWithNodes } from '@/executor/types/loop'
|
||||
import { replaceValidReferences } from '@/executor/utils/reference-validation'
|
||||
import {
|
||||
@@ -286,6 +290,7 @@ export class LoopOrchestrator {
|
||||
output,
|
||||
executionTime: DEFAULTS.EXECUTION_TIME,
|
||||
startedAt: now,
|
||||
executionOrder: getNextExecutionOrder(ctx),
|
||||
endedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { DEFAULTS } from '@/executor/constants'
|
||||
import type { DAG } from '@/executor/dag/builder'
|
||||
import type { ParallelScope } from '@/executor/execution/state'
|
||||
import type { BlockStateWriter, ContextExtensions } from '@/executor/execution/types'
|
||||
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
|
||||
import {
|
||||
type ExecutionContext,
|
||||
getNextExecutionOrder,
|
||||
type NormalizedBlockOutput,
|
||||
} from '@/executor/types'
|
||||
import type { ParallelConfigWithNodes } from '@/executor/types/parallel'
|
||||
import { ParallelExpander } from '@/executor/utils/parallel-expansion'
|
||||
import {
|
||||
@@ -270,6 +274,7 @@ export class ParallelOrchestrator {
|
||||
output,
|
||||
executionTime: 0,
|
||||
startedAt: now,
|
||||
executionOrder: getNextExecutionOrder(ctx),
|
||||
endedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -114,6 +114,11 @@ export interface BlockLog {
|
||||
loopId?: string
|
||||
parallelId?: string
|
||||
iterationIndex?: number
|
||||
/**
|
||||
* Monotonically increasing integer (1, 2, 3, ...) for accurate block ordering.
|
||||
* Generated via getNextExecutionOrder() to ensure deterministic sorting.
|
||||
*/
|
||||
executionOrder: number
|
||||
/**
|
||||
* Child workflow trace spans for nested workflow execution.
|
||||
* Stored separately from output to keep output clean for display
|
||||
@@ -227,7 +232,12 @@ export interface ExecutionContext {
|
||||
edges?: Array<{ source: string; target: string }>
|
||||
|
||||
onStream?: (streamingExecution: StreamingExecution) => Promise<void>
|
||||
onBlockStart?: (blockId: string, blockName: string, blockType: string) => Promise<void>
|
||||
onBlockStart?: (
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
blockType: string,
|
||||
executionOrder: number
|
||||
) => Promise<void>
|
||||
onBlockComplete?: (
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
@@ -268,6 +278,23 @@ export interface ExecutionContext {
|
||||
* Stop execution after this block completes. Used for "run until block" feature.
|
||||
*/
|
||||
stopAfterBlockId?: string
|
||||
|
||||
/**
|
||||
* Counter for generating monotonically increasing execution order values.
|
||||
* Starts at 0 and increments for each block. Use getNextExecutionOrder() to access.
|
||||
*/
|
||||
executionOrderCounter?: { value: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the next execution order value for a block.
|
||||
* Returns a simple incrementing integer (1, 2, 3, ...) for clear ordering.
|
||||
*/
|
||||
export function getNextExecutionOrder(ctx: ExecutionContext): number {
|
||||
if (!ctx.executionOrderCounter) {
|
||||
ctx.executionOrderCounter = { value: 0 }
|
||||
}
|
||||
return ++ctx.executionOrderCounter.value
|
||||
}
|
||||
|
||||
export interface ExecutionResult {
|
||||
|
||||
@@ -47,7 +47,7 @@ export function buildBlockExecutionError(details: BlockExecutionErrorDetails): E
|
||||
const blockName = details.block.metadata?.name || details.block.id
|
||||
const blockType = details.block.metadata?.id || 'unknown'
|
||||
|
||||
const error = new Error(`[${blockType}] ${blockName}: ${errorMessage}`)
|
||||
const error = new Error(`${blockName}: ${errorMessage}`)
|
||||
|
||||
Object.assign(error, {
|
||||
blockId: details.block.id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { LOOP, PARALLEL, PARSING, REFERENCE } from '@/executor/constants'
|
||||
import type { ContextExtensions } from '@/executor/execution/types'
|
||||
import type { BlockLog, ExecutionContext } from '@/executor/types'
|
||||
import { type BlockLog, type ExecutionContext, getNextExecutionOrder } from '@/executor/types'
|
||||
import type { VariableResolver } from '@/executor/variables/resolver'
|
||||
|
||||
const logger = createLogger('SubflowUtils')
|
||||
@@ -208,6 +208,7 @@ export function addSubflowErrorLog(
|
||||
contextExtensions: ContextExtensions | null
|
||||
): void {
|
||||
const now = new Date().toISOString()
|
||||
const execOrder = getNextExecutionOrder(ctx)
|
||||
|
||||
const block = ctx.workflow?.blocks?.find((b) => b.id === blockId)
|
||||
const blockName = block?.metadata?.name || (blockType === 'loop' ? 'Loop' : 'Parallel')
|
||||
@@ -217,6 +218,7 @@ export function addSubflowErrorLog(
|
||||
blockName,
|
||||
blockType,
|
||||
startedAt: now,
|
||||
executionOrder: execOrder,
|
||||
endedAt: now,
|
||||
durationMs: 0,
|
||||
success: false,
|
||||
@@ -233,6 +235,7 @@ export function addSubflowErrorLog(
|
||||
output: { error: errorMessage },
|
||||
executionTime: 0,
|
||||
startedAt: now,
|
||||
executionOrder: execOrder,
|
||||
endedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { fetchJson } from '@/hooks/selectors/helpers'
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const logger = createLogger('UserProfileQuery')
|
||||
export const userProfileKeys = {
|
||||
all: ['userProfile'] as const,
|
||||
profile: () => [...userProfileKeys.all, 'profile'] as const,
|
||||
superUser: (userId?: string) => [...userProfileKeys.all, 'superUser', userId ?? ''] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,3 +110,37 @@ export function useUpdateUserProfile() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Superuser status response type
|
||||
*/
|
||||
interface SuperUserStatus {
|
||||
isSuperUser: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch superuser status from API
|
||||
*/
|
||||
async function fetchSuperUserStatus(): Promise<SuperUserStatus> {
|
||||
const response = await fetch('/api/user/super-user')
|
||||
|
||||
if (!response.ok) {
|
||||
return { isSuperUser: false }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return { isSuperUser: data.isSuperUser ?? false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch superuser status
|
||||
* @param userId - User ID for cache isolation (required for proper per-user caching)
|
||||
*/
|
||||
export function useSuperUserStatus(userId?: string) {
|
||||
return useQuery({
|
||||
queryKey: userProfileKeys.superUser(userId),
|
||||
queryFn: fetchSuperUserStatus,
|
||||
enabled: Boolean(userId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - superuser status rarely changes
|
||||
})
|
||||
}
|
||||
|
||||
@@ -409,6 +409,20 @@ export function useCollaborativeWorkflow() {
|
||||
logger.info('Successfully applied batch-toggle-handles from remote user')
|
||||
break
|
||||
}
|
||||
case BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED: {
|
||||
const { blockIds } = payload
|
||||
logger.info('Received batch-toggle-locked from remote user', {
|
||||
userId,
|
||||
count: (blockIds || []).length,
|
||||
})
|
||||
|
||||
if (blockIds && blockIds.length > 0) {
|
||||
useWorkflowStore.getState().batchToggleLocked(blockIds)
|
||||
}
|
||||
|
||||
logger.info('Successfully applied batch-toggle-locked from remote user')
|
||||
break
|
||||
}
|
||||
case BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT: {
|
||||
const { updates } = payload
|
||||
logger.info('Received batch-update-parent from remote user', {
|
||||
@@ -730,6 +744,23 @@ export function useCollaborativeWorkflow() {
|
||||
|
||||
const collaborativeUpdateBlockName = useCallback(
|
||||
(id: string, name: string): { success: boolean; error?: string } => {
|
||||
const blocks = useWorkflowStore.getState().blocks
|
||||
const block = blocks[id]
|
||||
|
||||
if (block) {
|
||||
const parentId = block.data?.parentId
|
||||
const isParentLocked = parentId ? blocks[parentId]?.locked : false
|
||||
if (block.locked || isParentLocked) {
|
||||
logger.error('Cannot rename locked block')
|
||||
useNotificationStore.getState().addNotification({
|
||||
level: 'info',
|
||||
message: 'Cannot rename locked blocks',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return { success: false, error: 'Block is locked' }
|
||||
}
|
||||
}
|
||||
|
||||
const trimmedName = name.trim()
|
||||
const normalizedNewName = normalizeName(trimmedName)
|
||||
|
||||
@@ -823,14 +854,27 @@ export function useCollaborativeWorkflow() {
|
||||
|
||||
if (ids.length === 0) return
|
||||
|
||||
const currentBlocks = useWorkflowStore.getState().blocks
|
||||
const previousStates: Record<string, boolean> = {}
|
||||
const validIds: string[] = []
|
||||
|
||||
// For each ID, collect non-locked blocks and their children for undo/redo
|
||||
for (const id of ids) {
|
||||
const block = useWorkflowStore.getState().blocks[id]
|
||||
if (block) {
|
||||
previousStates[id] = block.enabled
|
||||
validIds.push(id)
|
||||
const block = currentBlocks[id]
|
||||
if (!block) continue
|
||||
|
||||
// Skip locked blocks
|
||||
if (block.locked) continue
|
||||
validIds.push(id)
|
||||
previousStates[id] = block.enabled
|
||||
|
||||
// If it's a loop or parallel, also capture children's previous states for undo/redo
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
Object.entries(currentBlocks).forEach(([blockId, b]) => {
|
||||
if (b.data?.parentId === id && !b.locked) {
|
||||
previousStates[blockId] = b.enabled
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -992,12 +1036,23 @@ export function useCollaborativeWorkflow() {
|
||||
|
||||
if (ids.length === 0) return
|
||||
|
||||
const blocks = useWorkflowStore.getState().blocks
|
||||
|
||||
const isProtected = (blockId: string): boolean => {
|
||||
const block = blocks[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = block.data?.parentId
|
||||
if (parentId && blocks[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const previousStates: Record<string, boolean> = {}
|
||||
const validIds: string[] = []
|
||||
|
||||
for (const id of ids) {
|
||||
const block = useWorkflowStore.getState().blocks[id]
|
||||
if (block) {
|
||||
const block = blocks[id]
|
||||
if (block && !isProtected(id)) {
|
||||
previousStates[id] = block.horizontalHandles ?? false
|
||||
validIds.push(id)
|
||||
}
|
||||
@@ -1025,6 +1080,56 @@ export function useCollaborativeWorkflow() {
|
||||
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
|
||||
)
|
||||
|
||||
const collaborativeBatchToggleLocked = useCallback(
|
||||
(ids: string[]) => {
|
||||
if (isBaselineDiffView) {
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length === 0) return
|
||||
|
||||
const currentBlocks = useWorkflowStore.getState().blocks
|
||||
const previousStates: Record<string, boolean> = {}
|
||||
const validIds: string[] = []
|
||||
|
||||
for (const id of ids) {
|
||||
const block = currentBlocks[id]
|
||||
if (!block) continue
|
||||
|
||||
validIds.push(id)
|
||||
previousStates[id] = block.locked ?? false
|
||||
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
Object.entries(currentBlocks).forEach(([blockId, b]) => {
|
||||
if (b.data?.parentId === id) {
|
||||
previousStates[blockId] = b.locked ?? false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (validIds.length === 0) return
|
||||
|
||||
const operationId = crypto.randomUUID()
|
||||
|
||||
addToQueue({
|
||||
id: operationId,
|
||||
operation: {
|
||||
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
|
||||
target: OPERATION_TARGETS.BLOCKS,
|
||||
payload: { blockIds: validIds, previousStates },
|
||||
},
|
||||
workflowId: activeWorkflowId || '',
|
||||
userId: session?.user?.id || 'unknown',
|
||||
})
|
||||
|
||||
useWorkflowStore.getState().batchToggleLocked(validIds)
|
||||
|
||||
undoRedo.recordBatchToggleLocked(validIds, previousStates)
|
||||
},
|
||||
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
|
||||
)
|
||||
|
||||
const collaborativeBatchAddEdges = useCallback(
|
||||
(edges: Edge[], options?: { skipUndoRedo?: boolean }) => {
|
||||
if (isBaselineDiffView) {
|
||||
@@ -1038,7 +1143,6 @@ export function useCollaborativeWorkflow() {
|
||||
|
||||
if (edges.length === 0) return false
|
||||
|
||||
// Filter out invalid edges (e.g., edges targeting trigger blocks) and duplicates
|
||||
const blocks = useWorkflowStore.getState().blocks
|
||||
const currentEdges = useWorkflowStore.getState().edges
|
||||
const validEdges = filterValidEdges(edges, blocks)
|
||||
@@ -1669,6 +1773,7 @@ export function useCollaborativeWorkflow() {
|
||||
collaborativeToggleBlockAdvancedMode,
|
||||
collaborativeSetBlockCanonicalMode,
|
||||
collaborativeBatchToggleBlockHandles,
|
||||
collaborativeBatchToggleLocked,
|
||||
collaborativeBatchAddBlocks,
|
||||
collaborativeBatchRemoveBlocks,
|
||||
collaborativeBatchAddEdges,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags'
|
||||
@@ -5,8 +7,8 @@ import {
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
type PermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useUserPermissionConfig } from '@/hooks/queries/permission-groups'
|
||||
|
||||
export interface PermissionConfigResult {
|
||||
config: PermissionGroupConfig
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type BatchRemoveEdgesOperation,
|
||||
type BatchToggleEnabledOperation,
|
||||
type BatchToggleHandlesOperation,
|
||||
type BatchToggleLockedOperation,
|
||||
type BatchUpdateParentOperation,
|
||||
captureLatestEdges,
|
||||
captureLatestSubBlockValues,
|
||||
@@ -415,6 +416,36 @@ export function useUndoRedo() {
|
||||
[activeWorkflowId, userId]
|
||||
)
|
||||
|
||||
const recordBatchToggleLocked = useCallback(
|
||||
(blockIds: string[], previousStates: Record<string, boolean>) => {
|
||||
if (!activeWorkflowId || blockIds.length === 0) return
|
||||
|
||||
const operation: BatchToggleLockedOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED,
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: { blockIds, previousStates },
|
||||
}
|
||||
|
||||
const inverse: BatchToggleLockedOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED,
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: { blockIds, previousStates },
|
||||
}
|
||||
|
||||
const entry = createOperationEntry(operation, inverse)
|
||||
useUndoRedoStore.getState().push(activeWorkflowId, userId, entry)
|
||||
|
||||
logger.debug('Recorded batch toggle locked', { blockIds, previousStates })
|
||||
},
|
||||
[activeWorkflowId, userId]
|
||||
)
|
||||
|
||||
const undo = useCallback(async () => {
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
@@ -777,7 +808,9 @@ export function useUndoRedo() {
|
||||
const toggleOp = entry.inverse as BatchToggleEnabledOperation
|
||||
const { blockIds, previousStates } = toggleOp.data
|
||||
|
||||
const validBlockIds = blockIds.filter((id) => useWorkflowStore.getState().blocks[id])
|
||||
// Restore all blocks in previousStates (includes children of containers)
|
||||
const allBlockIds = Object.keys(previousStates)
|
||||
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
|
||||
if (validBlockIds.length === 0) {
|
||||
logger.debug('Undo batch-toggle-enabled skipped; no blocks exist')
|
||||
break
|
||||
@@ -788,14 +821,14 @@ export function useUndoRedo() {
|
||||
operation: {
|
||||
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
|
||||
target: OPERATION_TARGETS.BLOCKS,
|
||||
payload: { blockIds: validBlockIds, previousStates },
|
||||
payload: { blockIds, previousStates },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
// Use setBlockEnabled to directly restore to previous state
|
||||
// This is more robust than conditional toggle in collaborative scenarios
|
||||
// This restores all affected blocks including children of containers
|
||||
validBlockIds.forEach((blockId) => {
|
||||
useWorkflowStore.getState().setBlockEnabled(blockId, previousStates[blockId])
|
||||
})
|
||||
@@ -829,6 +862,36 @@ export function useUndoRedo() {
|
||||
})
|
||||
break
|
||||
}
|
||||
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED: {
|
||||
const toggleOp = entry.inverse as BatchToggleLockedOperation
|
||||
const { blockIds, previousStates } = toggleOp.data
|
||||
|
||||
// Restore all blocks in previousStates (includes children of containers)
|
||||
const allBlockIds = Object.keys(previousStates)
|
||||
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
|
||||
if (validBlockIds.length === 0) {
|
||||
logger.debug('Undo batch-toggle-locked skipped; no blocks exist')
|
||||
break
|
||||
}
|
||||
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
|
||||
target: OPERATION_TARGETS.BLOCKS,
|
||||
payload: { blockIds, previousStates },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
// Use setBlockLocked to directly restore to previous state
|
||||
// This restores all affected blocks including children of containers
|
||||
validBlockIds.forEach((blockId) => {
|
||||
useWorkflowStore.getState().setBlockLocked(blockId, previousStates[blockId])
|
||||
})
|
||||
break
|
||||
}
|
||||
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
|
||||
const applyDiffInverse = entry.inverse as any
|
||||
const { baselineSnapshot } = applyDiffInverse.data
|
||||
@@ -1365,7 +1428,9 @@ export function useUndoRedo() {
|
||||
const toggleOp = entry.operation as BatchToggleEnabledOperation
|
||||
const { blockIds, previousStates } = toggleOp.data
|
||||
|
||||
const validBlockIds = blockIds.filter((id) => useWorkflowStore.getState().blocks[id])
|
||||
// Process all blocks in previousStates (includes children of containers)
|
||||
const allBlockIds = Object.keys(previousStates)
|
||||
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
|
||||
if (validBlockIds.length === 0) {
|
||||
logger.debug('Redo batch-toggle-enabled skipped; no blocks exist')
|
||||
break
|
||||
@@ -1376,16 +1441,18 @@ export function useUndoRedo() {
|
||||
operation: {
|
||||
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
|
||||
target: OPERATION_TARGETS.BLOCKS,
|
||||
payload: { blockIds: validBlockIds, previousStates },
|
||||
payload: { blockIds, previousStates },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
// Use setBlockEnabled to directly set to toggled state
|
||||
// Redo sets to !previousStates (the state after the original toggle)
|
||||
// Compute target state the same way batchToggleEnabled does:
|
||||
// use !firstBlock.enabled, where firstBlock is blockIds[0]
|
||||
const firstBlockId = blockIds[0]
|
||||
const targetEnabled = !previousStates[firstBlockId]
|
||||
validBlockIds.forEach((blockId) => {
|
||||
useWorkflowStore.getState().setBlockEnabled(blockId, !previousStates[blockId])
|
||||
useWorkflowStore.getState().setBlockEnabled(blockId, targetEnabled)
|
||||
})
|
||||
break
|
||||
}
|
||||
@@ -1417,6 +1484,38 @@ export function useUndoRedo() {
|
||||
})
|
||||
break
|
||||
}
|
||||
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED: {
|
||||
const toggleOp = entry.operation as BatchToggleLockedOperation
|
||||
const { blockIds, previousStates } = toggleOp.data
|
||||
|
||||
// Process all blocks in previousStates (includes children of containers)
|
||||
const allBlockIds = Object.keys(previousStates)
|
||||
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
|
||||
if (validBlockIds.length === 0) {
|
||||
logger.debug('Redo batch-toggle-locked skipped; no blocks exist')
|
||||
break
|
||||
}
|
||||
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
|
||||
target: OPERATION_TARGETS.BLOCKS,
|
||||
payload: { blockIds, previousStates },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
// Compute target state the same way batchToggleLocked does:
|
||||
// use !firstBlock.locked, where firstBlock is blockIds[0]
|
||||
const firstBlockId = blockIds[0]
|
||||
const targetLocked = !previousStates[firstBlockId]
|
||||
validBlockIds.forEach((blockId) => {
|
||||
useWorkflowStore.getState().setBlockLocked(blockId, targetLocked)
|
||||
})
|
||||
break
|
||||
}
|
||||
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
|
||||
// Redo apply-diff means re-applying the proposed state with diff markers
|
||||
const applyDiffOp = entry.operation as any
|
||||
@@ -1738,6 +1837,7 @@ export function useUndoRedo() {
|
||||
recordBatchUpdateParent,
|
||||
recordBatchToggleEnabled,
|
||||
recordBatchToggleHandles,
|
||||
recordBatchToggleLocked,
|
||||
recordApplyDiff,
|
||||
recordAcceptDiff,
|
||||
recordRejectDiff,
|
||||
|
||||
@@ -59,8 +59,8 @@ import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
|
||||
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
|
||||
import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
|
||||
|
||||
const logger = createLogger('Auth')
|
||||
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
import { db } from '@sim/db'
|
||||
import * as schema from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
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
|
||||
* 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(
|
||||
userId: string,
|
||||
referenceId: string
|
||||
): Promise<boolean> {
|
||||
// User can always manage their own subscriptions
|
||||
// User can always manage their own subscriptions (Pro upgrades, etc.)
|
||||
if (referenceId === userId) {
|
||||
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
|
||||
const members = await db
|
||||
.select()
|
||||
|
||||
@@ -25,9 +25,11 @@ export function useSubscriptionUpgrade() {
|
||||
}
|
||||
|
||||
let currentSubscriptionId: string | undefined
|
||||
let allSubscriptions: any[] = []
|
||||
try {
|
||||
const listResult = await client.subscription.list()
|
||||
const activePersonalSub = listResult.data?.find(
|
||||
allSubscriptions = listResult.data || []
|
||||
const activePersonalSub = allSubscriptions.find(
|
||||
(sub: any) => sub.status === 'active' && sub.referenceId === userId
|
||||
)
|
||||
currentSubscriptionId = activePersonalSub?.id
|
||||
@@ -50,6 +52,25 @@ export function useSubscriptionUpgrade() {
|
||||
)
|
||||
|
||||
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', {
|
||||
userId,
|
||||
organizationId: existingOrg.id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, subscription } from '@sim/db/schema'
|
||||
import { member, organization, subscription } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
|
||||
@@ -26,10 +26,22 @@ export async function getHighestPrioritySubscription(userId: string) {
|
||||
|
||||
let orgSubs: typeof personalSubs = []
|
||||
if (orgIds.length > 0) {
|
||||
orgSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
|
||||
// Verify orgs exist to filter out orphaned subscriptions
|
||||
const existingOrgs = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(inArray(organization.id, orgIds))
|
||||
|
||||
const validOrgIds = existingOrgs.map((o) => o.id)
|
||||
|
||||
if (validOrgIds.length > 0) {
|
||||
orgSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(
|
||||
and(inArray(subscription.referenceId, validOrgIds), eq(subscription.status, 'active'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const allSubs = [...personalSubs, ...orgSubs]
|
||||
|
||||
@@ -25,6 +25,28 @@ const logger = createLogger('SubscriptionCore')
|
||||
|
||||
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)
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
getHighestPrioritySubscription as getActiveSubscription,
|
||||
getUserSubscriptionState as getSubscriptionState,
|
||||
hasAccessControlAccess,
|
||||
hasActiveSubscription,
|
||||
hasCredentialSetsAccess,
|
||||
hasSSOAccess,
|
||||
isEnterpriseOrgAdminOrOwner,
|
||||
@@ -32,6 +33,11 @@ export {
|
||||
} from '@/lib/billing/core/usage'
|
||||
export * from '@/lib/billing/credits/balance'
|
||||
export * from '@/lib/billing/credits/purchase'
|
||||
export {
|
||||
blockOrgMembers,
|
||||
getOrgMemberIds,
|
||||
unblockOrgMembers,
|
||||
} from '@/lib/billing/organizations/membership'
|
||||
export * from '@/lib/billing/subscriptions/utils'
|
||||
export { canEditUsageLimit as canEditLimit } from '@/lib/billing/subscriptions/utils'
|
||||
export * from '@/lib/billing/types'
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { hasActiveSubscription } from '@/lib/billing'
|
||||
import { getPlanPricing } from '@/lib/billing/core/billing'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
|
||||
@@ -159,6 +160,16 @@ export async function ensureOrganizationForTeamSubscription(
|
||||
if (existingMembership.length > 0) {
|
||||
const membership = existingMembership[0]
|
||||
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', {
|
||||
userId,
|
||||
organizationId: membership.organizationId,
|
||||
|
||||
@@ -15,13 +15,86 @@ import {
|
||||
userStats,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
|
||||
|
||||
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 {
|
||||
restored: boolean
|
||||
usageRestored: boolean
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, subscription, user, userStats } from '@sim/db/schema'
|
||||
import { subscription, user, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
|
||||
const logger = createLogger('DisputeWebhooks')
|
||||
@@ -57,36 +58,34 @@ export async function handleChargeDispute(event: Stripe.Event): Promise<void> {
|
||||
|
||||
if (subs.length > 0) {
|
||||
const orgId = subs[0].referenceId
|
||||
const memberCount = await blockOrgMembers(orgId, 'dispute')
|
||||
|
||||
const owners = await db
|
||||
.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', {
|
||||
if (memberCount > 0) {
|
||||
logger.warn('Blocked all org members due to dispute', {
|
||||
disputeId: dispute.id,
|
||||
ownerId: owners[0].userId,
|
||||
organizationId: orgId,
|
||||
memberCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles charge.dispute.closed - unblocks user if dispute was won
|
||||
* Handles charge.dispute.closed - unblocks user if dispute was won or warning closed
|
||||
*
|
||||
* Status meanings:
|
||||
* - 'won': Merchant won, customer's chargeback denied → unblock
|
||||
* - 'lost': Customer won, money refunded → stay blocked (they owe us)
|
||||
* - 'warning_closed': Pre-dispute inquiry closed without chargeback → unblock (false alarm)
|
||||
*/
|
||||
export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
|
||||
const dispute = event.data.object as Stripe.Dispute
|
||||
|
||||
if (dispute.status !== 'won') {
|
||||
logger.info('Dispute not won, user remains blocked', {
|
||||
// Only unblock if we won or the warning was closed without a full dispute
|
||||
const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed'
|
||||
|
||||
if (!shouldUnblock) {
|
||||
logger.info('Dispute resolved against us, user remains blocked', {
|
||||
disputeId: dispute.id,
|
||||
status: dispute.status,
|
||||
})
|
||||
@@ -98,7 +97,7 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// Find and unblock user (Pro plans)
|
||||
// Find and unblock user (Pro plans) - only if blocked for dispute, not other reasons
|
||||
const users = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
@@ -109,16 +108,17 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: false, billingBlockedReason: null })
|
||||
.where(eq(userStats.userId, users[0].id))
|
||||
.where(and(eq(userStats.userId, users[0].id), eq(userStats.billingBlockedReason, 'dispute')))
|
||||
|
||||
logger.info('Unblocked user after winning dispute', {
|
||||
logger.info('Unblocked user after dispute resolved in our favor', {
|
||||
disputeId: dispute.id,
|
||||
userId: users[0].id,
|
||||
status: dispute.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Find and unblock org owner (Team/Enterprise)
|
||||
// Find and unblock all org members (Team/Enterprise) - consistent with payment success
|
||||
const subs = await db
|
||||
.select({ referenceId: subscription.referenceId })
|
||||
.from(subscription)
|
||||
@@ -127,24 +127,13 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
|
||||
|
||||
if (subs.length > 0) {
|
||||
const orgId = subs[0].referenceId
|
||||
const memberCount = await unblockOrgMembers(orgId, 'dispute')
|
||||
|
||||
const owners = await db
|
||||
.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: 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,
|
||||
})
|
||||
}
|
||||
logger.info('Unblocked all org members after dispute resolved in our favor', {
|
||||
disputeId: dispute.id,
|
||||
organizationId: orgId,
|
||||
memberCount,
|
||||
status: dispute.status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
userStats,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { and, eq, inArray, isNull, ne, or } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails'
|
||||
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
|
||||
import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance'
|
||||
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
|
||||
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing/organizations/membership'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
@@ -502,24 +503,7 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
|
||||
}
|
||||
|
||||
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
||||
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')
|
||||
)
|
||||
)
|
||||
}
|
||||
await unblockOrgMembers(sub.referenceId, 'payment_failed')
|
||||
} else {
|
||||
// Only unblock users blocked for payment_failed, not disputes
|
||||
await db
|
||||
@@ -616,28 +600,26 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
|
||||
if (records.length > 0) {
|
||||
const sub = records[0]
|
||||
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
||||
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))
|
||||
}
|
||||
const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed')
|
||||
logger.info('Blocked team/enterprise members due to payment failure', {
|
||||
organizationId: sub.referenceId,
|
||||
memberCount: members.length,
|
||||
memberCount,
|
||||
isOverageInvoice,
|
||||
})
|
||||
} else {
|
||||
// Don't overwrite dispute blocks (dispute > payment_failed priority)
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
.where(
|
||||
and(
|
||||
eq(userStats.userId, sub.referenceId),
|
||||
or(
|
||||
ne(userStats.billingBlockedReason, 'dispute'),
|
||||
isNull(userStats.billingBlockedReason)
|
||||
)
|
||||
)
|
||||
)
|
||||
logger.info('Blocked user due to payment failure', {
|
||||
userId: sub.referenceId,
|
||||
isOverageInvoice,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { member, organization, subscription } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, ne } from 'drizzle-orm'
|
||||
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
|
||||
import { hasActiveSubscription } from '@/lib/billing/core/subscription'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
@@ -52,14 +53,37 @@ async function restoreMemberProSubscriptions(organizationId: string): Promise<nu
|
||||
|
||||
/**
|
||||
* Cleanup organization when team/enterprise subscription is deleted.
|
||||
* - Checks if other active subscriptions point to this org (skip deletion if so)
|
||||
* - Restores member Pro subscriptions
|
||||
* - Deletes the organization
|
||||
* - Deletes the organization (only if no other active subs)
|
||||
* - Syncs usage limits for former members (resets to free or Pro tier)
|
||||
*/
|
||||
async function cleanupOrganizationSubscription(organizationId: string): Promise<{
|
||||
restoredProCount: 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)
|
||||
const memberUserIds = await db
|
||||
.select({ userId: member.userId })
|
||||
@@ -75,7 +99,7 @@ async function cleanupOrganizationSubscription(organizationId: string): Promise<
|
||||
await syncUsageLimitsFromSubscription(m.userId)
|
||||
}
|
||||
|
||||
return { restoredProCount, membersSynced: memberUserIds.length }
|
||||
return { restoredProCount, membersSynced: memberUserIds.length, organizationDeleted: true }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,15 +196,14 @@ export async function handleSubscriptionDeleted(subscription: {
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
|
||||
const { restoredProCount, membersSynced } = await cleanupOrganizationSubscription(
|
||||
subscription.referenceId
|
||||
)
|
||||
const { restoredProCount, membersSynced, organizationDeleted } =
|
||||
await cleanupOrganizationSubscription(subscription.referenceId)
|
||||
|
||||
logger.info('Successfully processed enterprise subscription cancellation', {
|
||||
subscriptionId: subscription.id,
|
||||
stripeSubscriptionId,
|
||||
restoredProCount,
|
||||
organizationDeleted: true,
|
||||
organizationDeleted,
|
||||
membersSynced,
|
||||
})
|
||||
return
|
||||
@@ -297,7 +320,7 @@ export async function handleSubscriptionDeleted(subscription: {
|
||||
const cleanup = await cleanupOrganizationSubscription(subscription.referenceId)
|
||||
restoredProCount = cleanup.restoredProCount
|
||||
membersSynced = cleanup.membersSynced
|
||||
organizationDeleted = true
|
||||
organizationDeleted = cleanup.organizationDeleted
|
||||
} else if (subscription.plan === 'pro') {
|
||||
await syncUsageLimitsFromSubscription(subscription.referenceId)
|
||||
membersSynced = 1
|
||||
|
||||
@@ -5,8 +5,8 @@ import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
|
||||
import { isHiddenFromDisplay } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { escapeRegExp } from '@/executor/constants'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import type { ChatContext } from '@/stores/panel/copilot/types'
|
||||
|
||||
export type AgentContextType =
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@/lib/copilot/tools/shared/schemas'
|
||||
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
|
||||
import { isHiddenFromDisplay, type SubBlockConfig } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
import { tools as toolsRegistry } from '@/tools/registry'
|
||||
import { getTrigger, isTriggerValid } from '@/triggers'
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type GetBlockOptionsResultType,
|
||||
} from '@/lib/copilot/tools/shared/schemas'
|
||||
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { tools as toolsRegistry } from '@/tools/registry'
|
||||
|
||||
export const getBlockOptionsServerTool: BaseServerTool<
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@/lib/copilot/tools/shared/schemas'
|
||||
import { registry as blockRegistry } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
export const getBlocksAndToolsServerTool: BaseServerTool<
|
||||
ReturnType<typeof GetBlocksAndToolsInput.parse>,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@/lib/copilot/tools/shared/schemas'
|
||||
import { registry as blockRegistry } from '@/blocks/registry'
|
||||
import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
import { tools as toolsRegistry } from '@/tools/registry'
|
||||
import { getTrigger, isTriggerValid } from '@/triggers'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod'
|
||||
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
|
||||
import { registry as blockRegistry } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
export const GetTriggerBlocksInput = z.object({})
|
||||
export const GetTriggerBlocksResult = z.object({
|
||||
|
||||
@@ -15,8 +15,8 @@ import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { getAllBlocks, getBlock } from '@/blocks/registry'
|
||||
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
|
||||
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
|
||||
|
||||
@@ -54,6 +54,7 @@ type SkippedItemType =
|
||||
| 'block_not_found'
|
||||
| 'invalid_block_type'
|
||||
| 'block_not_allowed'
|
||||
| 'block_locked'
|
||||
| 'tool_not_allowed'
|
||||
| 'invalid_edge_target'
|
||||
| 'invalid_edge_source'
|
||||
@@ -618,6 +619,7 @@ function createBlockFromParams(
|
||||
subBlocks: {},
|
||||
outputs: outputs,
|
||||
data: parentId ? { parentId, extent: 'parent' as const } : {},
|
||||
locked: false,
|
||||
}
|
||||
|
||||
// Add validated inputs as subBlocks
|
||||
@@ -1520,6 +1522,24 @@ function applyOperationsToWorkflowState(
|
||||
break
|
||||
}
|
||||
|
||||
// Check if block is locked or inside a locked container
|
||||
const deleteBlock = modifiedState.blocks[block_id]
|
||||
const deleteParentId = deleteBlock.data?.parentId as string | undefined
|
||||
const deleteParentLocked = deleteParentId
|
||||
? modifiedState.blocks[deleteParentId]?.locked
|
||||
: false
|
||||
if (deleteBlock.locked || deleteParentLocked) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'block_locked',
|
||||
operationType: 'delete',
|
||||
blockId: block_id,
|
||||
reason: deleteParentLocked
|
||||
? `Block "${block_id}" is inside locked container "${deleteParentId}" and cannot be deleted`
|
||||
: `Block "${block_id}" is locked and cannot be deleted`,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Find all child blocks to remove
|
||||
const blocksToRemove = new Set<string>([block_id])
|
||||
const findChildren = (parentId: string) => {
|
||||
@@ -1555,6 +1575,21 @@ function applyOperationsToWorkflowState(
|
||||
|
||||
const block = modifiedState.blocks[block_id]
|
||||
|
||||
// Check if block is locked or inside a locked container
|
||||
const editParentId = block.data?.parentId as string | undefined
|
||||
const editParentLocked = editParentId ? modifiedState.blocks[editParentId]?.locked : false
|
||||
if (block.locked || editParentLocked) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'block_locked',
|
||||
operationType: 'edit',
|
||||
blockId: block_id,
|
||||
reason: editParentLocked
|
||||
? `Block "${block_id}" is inside locked container "${editParentId}" and cannot be edited`
|
||||
: `Block "${block_id}" is locked and cannot be edited`,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Ensure block has essential properties
|
||||
if (!block.type) {
|
||||
logger.warn(`Block ${block_id} missing type property, skipping edit`, {
|
||||
@@ -2122,6 +2157,19 @@ function applyOperationsToWorkflowState(
|
||||
|
||||
// Handle nested nodes (for loops/parallels created from scratch)
|
||||
if (params.nestedNodes) {
|
||||
// Defensive check: verify parent is not locked before adding children
|
||||
// (Parent was just created with locked: false, but check for consistency)
|
||||
const parentBlock = modifiedState.blocks[block_id]
|
||||
if (parentBlock?.locked) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'block_locked',
|
||||
operationType: 'add_nested_nodes',
|
||||
blockId: block_id,
|
||||
reason: `Container "${block_id}" is locked - cannot add nested nodes`,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => {
|
||||
// Validate childId is a valid string
|
||||
if (!isValidKey(childId)) {
|
||||
@@ -2209,6 +2257,18 @@ function applyOperationsToWorkflowState(
|
||||
break
|
||||
}
|
||||
|
||||
// Check if subflow is locked
|
||||
if (subflowBlock.locked) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'block_locked',
|
||||
operationType: 'insert_into_subflow',
|
||||
blockId: block_id,
|
||||
reason: `Subflow "${subflowId}" is locked - cannot insert block "${block_id}"`,
|
||||
details: { subflowId },
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
if (subflowBlock.type !== 'loop' && subflowBlock.type !== 'parallel') {
|
||||
logger.error('Subflow block has invalid type', {
|
||||
subflowId,
|
||||
@@ -2247,6 +2307,17 @@ function applyOperationsToWorkflowState(
|
||||
break
|
||||
}
|
||||
|
||||
// Check if existing block is locked
|
||||
if (existingBlock.locked) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'block_locked',
|
||||
operationType: 'insert_into_subflow',
|
||||
blockId: block_id,
|
||||
reason: `Block "${block_id}" is locked and cannot be moved into a subflow`,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Moving existing block into subflow - just update parent
|
||||
existingBlock.data = {
|
||||
...existingBlock.data,
|
||||
@@ -2392,6 +2463,30 @@ function applyOperationsToWorkflowState(
|
||||
break
|
||||
}
|
||||
|
||||
// Check if block is locked
|
||||
if (block.locked) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'block_locked',
|
||||
operationType: 'extract_from_subflow',
|
||||
blockId: block_id,
|
||||
reason: `Block "${block_id}" is locked and cannot be extracted from subflow`,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Check if parent subflow is locked
|
||||
const parentSubflow = modifiedState.blocks[subflowId]
|
||||
if (parentSubflow?.locked) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'block_locked',
|
||||
operationType: 'extract_from_subflow',
|
||||
blockId: block_id,
|
||||
reason: `Subflow "${subflowId}" is locked - cannot extract block "${block_id}"`,
|
||||
details: { subflowId },
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Verify it's actually a child of this subflow
|
||||
if (block.data?.parentId !== subflowId) {
|
||||
logger.warn('Block is not a child of specified subflow', {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user