Skip to content
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import type { BrowserImportProfile } from '@sim/desktop-bridge'
import {
ChipModal,
Expand Down Expand Up @@ -39,29 +39,29 @@ function browserOptions(profiles: BrowserImportProfile[]) {
*/
export function ImportModal({ open, onOpenChange, profiles, pending, onImport }: ImportModalProps) {
const browsers = useMemo(() => browserOptions(profiles), [profiles])
const [browserId, setBrowserId] = useState(browsers[0]?.value ?? '')
const [pickedBrowserId, setPickedBrowserId] = useState(browsers[0]?.value ?? '')

/**
* A reload can drop the browser or profile that was picked. Falling back here
* rather than correcting in an effect matters: the effect form commits and
* paints one frame in which the profile still belongs to the previously
* selected browser, and Import is enabled during it.
*/
const browserId = browsers.some((browser) => browser.value === pickedBrowserId)
? pickedBrowserId
: (browsers[0]?.value ?? '')

const profilesForBrowser = useMemo(
() => profiles.filter((profile) => profile.browserId === browserId),
[browserId, profiles]
)
const [profileId, setProfileId] = useState(profilesForBrowser[0]?.id ?? '')

// Keep the selection valid as the browser changes or the list reloads,
// rather than leaving a profile selected that belongs to another browser.
useEffect(() => {
if (!profilesForBrowser.some((profile) => profile.id === profileId)) {
setProfileId(profilesForBrowser[0]?.id ?? '')
}
}, [profileId, profilesForBrowser])
const [pickedProfileId, setPickedProfileId] = useState(profilesForBrowser[0]?.id ?? '')

useEffect(() => {
if (!browsers.some((browser) => browser.value === browserId)) {
setBrowserId(browsers[0]?.value ?? '')
}
}, [browserId, browsers])
const profileId = profilesForBrowser.some((profile) => profile.id === pickedProfileId)
? pickedProfileId
: (profilesForBrowser[0]?.id ?? '')

const selected = profiles.find((profile) => profile.id === profileId) ?? null
const selected = profilesForBrowser.find((profile) => profile.id === profileId) ?? null

return (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Import from your browser'>
Expand All @@ -79,7 +79,7 @@ export function ImportModal({ open, onOpenChange, profiles, pending, onImport }:
title='Browser'
options={browsers}
value={browserId}
onChange={setBrowserId}
onChange={setPickedBrowserId}
placeholder='Select a browser'
align='start'
disabled={pending || browsers.length === 0}
Expand All @@ -92,7 +92,7 @@ export function ImportModal({ open, onOpenChange, profiles, pending, onImport }:
label: profile.profileLabel,
}))}
value={profileId}
onChange={setProfileId}
onChange={setPickedProfileId}
placeholder='Select a profile'
align='start'
disabled={pending || profilesForBrowser.length === 0}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export const ActionBar = memo(
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleLocked,
} = useCollaborativeWorkflow()
const { setPendingSelection } = useWorkflowRegistry()
const setPendingSelection = useWorkflowRegistry((state) => state.setPendingSelection)
const { handleCancelExecution, handleRunFromBlock } = useWorkflowExecution()
const handleDuplicateBlock = useCallback(() => {
const { copyBlocks, preparePasteData } = useWorkflowRegistry.getState()
Expand Down Expand Up @@ -249,7 +249,7 @@ export const ActionBar = memo(
})
)

const { activeWorkflowId } = useWorkflowRegistry()
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const snapshot = useLastExecutionSnapshot(activeWorkflowId)
const userPermissions = useUserPermissionsContext()
const edges = useWorkflowStore((state) => state.edges)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,7 @@ const WorkflowContent = React.memo(
embedded,
})

const isWorkflowEmpty = useMemo(() => Object.keys(blocks).length === 0, [blocks])
const isWorkflowEmpty = !hasBlocks

/** Handles OAuth connect events dispatched by Copilot tools. */
useEffect(() => {
Expand Down
6 changes: 0 additions & 6 deletions apps/sim/components/ui/thinking-loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,6 @@ export function ThinkingLoader({
const cycling = variant === undefined
const [retainMorphStages, setRetainMorphStages] = useState(cycling)

useEffect(() => {
if (variant !== undefined) {
setCycleVariant(variant)
}
}, [variant])

useEffect(() => {
if (!cycling) return
// Settle: stop the cycle and melt to the terminal orb (goo handles the morph).
Expand Down
16 changes: 16 additions & 0 deletions apps/sim/ee/access-control/utils/permission-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,22 @@ describe('validateBlockType', () => {
it('always allows start_trigger', async () => {
await validateBlockType(undefined, undefined, 'start_trigger')
})

it('case-folds a stored allowlist so a mixed-case entry still matches', async () => {
Comment thread
waleedlatif1 marked this conversation as resolved.
setEnterpriseOrgWorkspace()
queueGroupResolution([{ config: { allowedIntegrations: ['Slack'] } }])

await validateBlockType('user-123', 'workspace-1', 'slack')
})

it('still rejects a block absent from a mixed-case stored allowlist', async () => {
setEnterpriseOrgWorkspace()
queueGroupResolution([{ config: { allowedIntegrations: ['Slack'] } }])

await expect(validateBlockType('user-123', 'workspace-1', 'discord')).rejects.toThrow(
IntegrationNotAllowedError
)
})
})

describe('when env allowlist is configured', () => {
Expand Down
30 changes: 12 additions & 18 deletions apps/sim/ee/access-control/utils/permission-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
isPublicApiDisabled,
} from '@/lib/core/config/env-flags'
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
import {
DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig,
Expand Down Expand Up @@ -108,29 +109,22 @@ export class ChatDeployAuthNotAllowedError extends Error {

/**
* Merges the env allowlist into a permission config.
* If `config` is null and no env allowlist is set, returns null.
* If `config` is null but env allowlist is set, returns a default config with only allowedIntegrations set.
* If both are set, intersects the two allowlists.
*
* Returns null only when neither layer restricts anything. Otherwise the group's
* own allowlist is intersected with the env one by
* {@link intersectIntegrationAllowlists}, which case-folds both sides — callers
* compare against a lowercased block type, and a stored config reaches here
* straight off the wire, where the contract permits any casing.
*/
function mergeEnvAllowlist(config: PermissionGroupConfig | null): PermissionGroupConfig | null {
const envAllowlist = getAllowedIntegrationsFromEnv()
if (config === null && envAllowlist === null) return null

if (envAllowlist === null) {
return config
}

if (config === null) {
return { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: envAllowlist }
const base = config ?? DEFAULT_PERMISSION_GROUP_CONFIG
return {
...base,
allowedIntegrations: intersectIntegrationAllowlists(base.allowedIntegrations, envAllowlist),
}

const merged =
config.allowedIntegrations === null
? envAllowlist
: config.allowedIntegrations
.map((i) => i.toLowerCase())
.filter((i) => envAllowlist.includes(i))

return { ...config, allowedIntegrations: merged }
}

/**
Expand Down
17 changes: 14 additions & 3 deletions apps/sim/hooks/queries/organization.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,19 @@ describe('organization identity transitions', () => {
expect(container).not.toHaveTextContent('Member A')
expect(container).not.toHaveTextContent('org-a')
expect(container.querySelector('button')).toBeNull()
expect(mockGetFullOrganization).toHaveBeenCalledWith({
query: { organizationId: 'org-b' },
})
expect(mockGetFullOrganization).toHaveBeenCalledWith(
expect.objectContaining({ query: { organizationId: 'org-b' } })
)
})

it('forwards the query signal so an in-flight org fetch can be cancelled', async () => {
mockGetFullOrganization.mockResolvedValue({ data: ORGANIZATION_A })

renderOrganization('org-a')

await flushQueries()

const [args] = mockGetFullOrganization.mock.calls[0]
expect(args.fetchOptions?.signal).toBeInstanceOf(AbortSignal)
})
})
3 changes: 2 additions & 1 deletion apps/sim/hooks/queries/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,10 @@ export function useMemberRemovalImpact(
* (no cross-org cache collision). The active-org caller passes the active org's
* id, so its behavior is unchanged.
*/
async function fetchOrganization(orgId: string, _signal?: AbortSignal) {
async function fetchOrganization(orgId: string, signal?: AbortSignal) {
const response = await client.organization.getFullOrganization({
query: { organizationId: orgId },
fetchOptions: { signal },
})
return response.data
}
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/core/rate-limiter/hosted-key/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export interface EnqueueResult {
/**
* Per-workspace+provider FIFO queue for hosted-key acquisitions.
*
* Callers `enqueue` to claim a position, then `waitForHead` until they're at
* Callers `enqueue` to claim a position, then poll `checkHead` until they're at
* the head, then attempt to consume from the token bucket. On success or cap
* exceeded, they `dequeue` to make room for the next caller.
*
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/knowledge/search/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ const FTS_CONFIG = 'english'

/**
* Reciprocal-rank-fusion damping constant. 60 is the value from the original RRF
* paper and matches the docs Ask-AI retriever (`apps/docs/app/api/chat/route.ts`).
* paper and matches the docs search retriever (`apps/docs/app/api/search/route.ts`).
*/
export const RRF_K = 60

Expand Down
12 changes: 3 additions & 9 deletions apps/sim/lib/logs/log-views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,7 @@ const DEFAULT_MATCH_TIME_BUDGET_MS = 5_000
*/
const DEFAULT_MAX_SCANNED_CHARS = 64 * 1024 * 1024

// Overview (Level 2): block tree with timing + cost, NO input/output.

/** Block tree with timing and cost, without input/output. */
export interface OverviewSpan {
id: string
blockId?: string
Expand Down Expand Up @@ -75,10 +74,7 @@ export function toOverview(spans: TraceSpan[]): OverviewSpan[] {
})
}

// ---------------------------------------------------------------------------
// Trace (Level 1.5): condensed per-block digest — names, statuses, counts.
// ---------------------------------------------------------------------------

/** Condensed per-block digest: names, statuses, counts. */
export interface TraceDigestEntry {
/** Block id when the spans carry one; the drill-in key for `full` blockIds. */
blockId?: string
Expand Down Expand Up @@ -125,9 +121,7 @@ export function toTrace(spans: TraceSpan[]): TraceDigestEntry[] {
return Array.from(byKey.values())
}

// ---------------------------------------------------------------------------
// Full (Level 3): block tree WITH materialized input/output.

/** Block tree with materialized input/output. */
export interface FullSpan extends OverviewSpan {
startTime?: string
endTime?: string
Expand Down
7 changes: 4 additions & 3 deletions apps/sim/lib/resources/orchestration/restore-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ type RestorableFolderType = 'folder' | 'knowledge_folder' | 'table_folder'

/**
* Deliberately a total `Record` over the folder types, not a `Partial` one: adding a tree to
* `RestorableFolderType` without a mapping here has to fail the build. With a partial map the
* lookup would yield `undefined`, `performRestoreFolder` would fall back to its `'workflow'`
* default, and the restore would silently target the wrong tree.
* `RestorableFolderType` without a mapping has to fail the build *here*, at the mapping. A
* `Partial` still compiles with the tree missing — the lookup widens to
* `FolderResourceType | undefined`, so the error moves to the `restoreFolder` call site, and
* suppressing it there leaves the cascade resolving an undefined folder config.
*/
const FOLDER_RESOURCE_TYPE_BY_RESTORABLE: Record<RestorableFolderType, FolderResourceType> = {
folder: 'workflow',
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/table/import-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export interface BulkImportBatch {
* Inserts one batch of rows for an async import in a single committed statement.
*
* Differs from {@link batchInsertRowsWithTx} for the bulk-load case: caller-supplied
* contiguous positions (no `acquireTablePositionLock` / `nextAutoPosition` scan — an
* contiguous order keys (no `acquireRowOrderLock` scan — an
* import owns its hidden table as the sole writer), no `RETURNING`, and **no
* `fireTableTrigger` / `runWorkflowColumn`** (a 1M-row import must not dispatch a
* workflow run per row). `row_count` is maintained set-based by the statement-level
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/table/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ function readLocks(row: {
* Uses an advisory lock (not `SELECT ... FOR UPDATE` on the definition row) so
* it adds no edges to the row-lock graph — the row-count trigger (migration
* 0198) locks the definition row from `insertRow`/`deleteRow`, and a FOR UPDATE
* here would invert that order. Mirrors `acquireTablePositionLock`. The lock and
* here would invert that order. Mirrors `acquireRowOrderLock`. The lock and
* the read both release at COMMIT/ROLLBACK; the wait is bounded by the
* `statement_timeout` set in `setTableTxTimeouts`.
*/
Expand Down
48 changes: 2 additions & 46 deletions apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import { createLogger } from '@sim/logger'
import { isFolderInWorkspace } from '@sim/platform-authz/workflow'
import { getPostgresConstraintName, getPostgresErrorCode, toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull, min, ne } from 'drizzle-orm'
import { and, eq, isNull, ne } from 'drizzle-orm'
import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import type { DbOrTx } from '@/lib/db/types'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { archiveWorkflow, restoreWorkflow } from '@/lib/workflows/lifecycle'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { nextWorkflowSortOrder } from '@/lib/workflows/sort-order'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'

const logger = createLogger('WorkflowLifecycle')
Expand Down Expand Up @@ -126,51 +127,6 @@ export interface PerformRestoreWorkflowResult {
workflow?: Awaited<ReturnType<typeof restoreWorkflow>>['workflow']
}

async function nextWorkflowSortOrder(
workspaceId: string,
folderId: string | null | undefined
): Promise<number> {
const workflowParentCondition = folderId
? eq(workflow.folderId, folderId)
: isNull(workflow.folderId)
const folderParentCondition = folderId
? eq(folderTable.parentId, folderId)
: isNull(folderTable.parentId)

const [[workflowMinResult], [folderMinResult]] = await Promise.all([
db
.select({ minOrder: min(workflow.sortOrder) })
.from(workflow)
.where(
and(
eq(workflow.workspaceId, workspaceId),
workflowParentCondition,
isNull(workflow.archivedAt)
)
),
db
.select({ minOrder: min(folderTable.sortOrder) })
.from(folderTable)
.where(
and(
eq(folderTable.workspaceId, workspaceId),
eq(folderTable.resourceType, 'workflow'),
folderParentCondition
)
),
])

const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce<
number | null
>((currentMin, candidate) => {
if (candidate == null) return currentMin
if (currentMin == null) return candidate
return Math.min(currentMin, candidate)
}, null)

return minSortOrder != null ? minSortOrder - 1 : 0
}

async function workflowNameExistsInFolder(params: {
workspaceId: string
name: string
Expand Down
Loading
Loading