From b5d17aad17aa4741a89c55a0e0943f61648515d9 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:11:51 -0700 Subject: [PATCH 01/20] fix(a11y): name icon-only buttons across product UI (#7975) * fix(resource): name bulk action buttons for accessibility * fix(a11y): name remaining icon-only product buttons --------- Co-authored-by: Bill Leoutsakos --- .../chat/components/input/input.tsx | 1 + .../message/components/file-download.tsx | 1 + .../chat/components/message/message.tsx | 1 + apps/sim/app/playground/page.tsx | 14 +++++++++-- .../components/action-bar/action-bar.tsx | 2 ++ .../components/resource/resource.tsx | 2 ++ .../queued-messages/queued-messages.tsx | 4 ++++ .../document-tags-modal.tsx | 1 + .../add-documents-modal.tsx | 2 ++ .../base-tags-modal/base-tags-modal.tsx | 1 + .../create-base-modal/create-base-modal.tsx | 1 + .../components/trace-view/trace-view.tsx | 2 ++ .../components/log-details/log-details.tsx | 2 ++ .../workflow-sidebar/workflow-sidebar.tsx | 1 + .../components/action-bar/action-bar.tsx | 5 ++++ .../w/[workflowId]/components/chat/chat.tsx | 11 ++++++++- .../general/components/versions.tsx | 2 ++ .../components/general/general.tsx | 1 + .../components/file-upload/file-upload.tsx | 2 ++ .../selector-combobox/selector-combobox.tsx | 1 + .../sub-block/components/table/table.tsx | 1 + .../editor/components/sub-block/sub-block.tsx | 1 + .../panel/components/editor/editor.tsx | 1 + .../w/[workflowId]/components/panel/panel.tsx | 10 ++++++-- .../workflow-search-replace.tsx | 9 +++++++- .../workflow-controls/workflow-controls.tsx | 10 +++++++- .../preview-editor/preview-editor.tsx | 23 +++++++++++++++++-- .../components/help-modal/help-modal.tsx | 1 + .../w/components/sidebar/sidebar.tsx | 4 ++++ .../components/custom-block-detail.tsx | 1 + .../src/edge/workflow-edge-view.tsx | 1 + 31 files changed, 110 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/(interfaces)/chat/components/input/input.tsx b/apps/sim/app/(interfaces)/chat/components/input/input.tsx index e488f8a8ea9..4cb83962390 100644 --- a/apps/sim/app/(interfaces)/chat/components/input/input.tsx +++ b/apps/sim/app/(interfaces)/chat/components/input/input.tsx @@ -222,6 +222,7 @@ export const ChatInput: React.FC<{ @@ -178,7 +183,12 @@ export default function PlaygroundPage() {
- diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx index f928eec1c6b..7678123e352 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx @@ -34,6 +34,7 @@ function ActionButton({ icon: Icon, label, onClick, disabled }: ActionButtonProp
@@ -1096,6 +1103,7 @@ export function Chat() { {isStreaming ? ( @@ -801,6 +801,7 @@ export const Panel = memo(function Panel() { event.stopPropagation()} > {matchCountLabel} - @@ -609,6 +614,7 @@ function WorkflowSearchReplacePanel({ focusRef }: WorkflowSearchReplacePanelProp onChange={(event) => setQuery(event.target.value)} /> )} @@ -1155,7 +1160,12 @@ function PreviewEditorContent({ className='flex-1 text-[var(--text-primary)] text-sm' /> {onClose && ( - )} @@ -1224,6 +1234,7 @@ function PreviewEditorContent({ ) : ( ) : ( ) } diff --git a/packages/emcn/src/icons/index.ts b/packages/emcn/src/icons/index.ts index c1b843309ca..2afc237ccba 100644 --- a/packages/emcn/src/icons/index.ts +++ b/packages/emcn/src/icons/index.ts @@ -144,6 +144,7 @@ export { Split } from './split' export { Sprout } from './sprout' export { Square } from './square' export { SquareArrowUpRight } from './square-arrow-up-right' +export { StopFilled } from './stop-filled' export { Strikethrough } from './strikethrough' export { Sun } from './sun' export { Table } from './table' diff --git a/packages/emcn/src/icons/stop-filled.tsx b/packages/emcn/src/icons/stop-filled.tsx new file mode 100644 index 00000000000..c946f3122c2 --- /dev/null +++ b/packages/emcn/src/icons/stop-filled.tsx @@ -0,0 +1,12 @@ +import type { SVGProps } from 'react' + +/** + * Filled stop icon. Callers provide the size and fill through SVG props. + */ +export function StopFilled(props: SVGProps) { + return ( + + + + ) +} From ba952351e96aca6ac5d95a57907b7ab22af54bcb Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 18 Sep 2026 13:00:41 -0700 Subject: [PATCH 03/20] fix(connectors): save service account changes with source settings (#7978) --- .../connectors/[connectorId]/access/route.ts | 2 + .../integrations/search-source-setup.test.tsx | 13 +- .../[connectorId]/source-detail.test.tsx | 16 ++- .../sources/[connectorId]/source-detail.tsx | 2 + .../connector-access-field.test.tsx | 14 +- .../connector-access-field.tsx | 19 +-- .../connector-settings-fields.test.tsx | 31 +++- .../connector-settings-fields.tsx | 133 +++++++++--------- .../use-connector-settings-form.test.tsx | 67 ++++++++- .../use-connector-settings-form.ts | 51 ++++++- .../lib/api/contracts/knowledge/connectors.ts | 2 + .../application/connector-access.test.ts | 76 ++++++++++ .../knowledge/application/connector-access.ts | 25 +++- .../orchestration/connector-access.test.ts | 87 ++++++++++-- .../orchestration/connector-access.ts | 71 ++++------ .../orchestration/connectors.test.ts | 7 +- .../lib/knowledge/orchestration/connectors.ts | 45 ++++-- 17 files changed, 488 insertions(+), 173 deletions(-) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts index af895bf5a0d..447c7219922 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts @@ -25,6 +25,8 @@ export const PATCH = defineInternalJsonRoute({ knowledgeBaseId: params.id, accessMode: body.accessMode, credentialId: body.credentialId, + sourceConfig: body.sourceConfig, + syncIntervalMinutes: body.syncIntervalMinutes, resolveBillingAttribution: (workspaceId: string) => resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), source: 'ui' as const, diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx index 267977e0ead..8b191c6ae24 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx @@ -130,6 +130,13 @@ vi.mock('@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope' }), })) vi.mock('@/hooks/queries/kb/connectors', () => ({ + isConnectorSyncingOrPending: (row: { + status: string + accessMode?: string + memberSyncStatus?: string + }) => + ['pending', 'syncing'].includes(row.status) || + ['pending', 'running'].includes(row.memberSyncStatus ?? ''), useSearchIndex: ( scope: { workspaceId?: string; organizationId?: string }, options: { enabled: boolean } @@ -1367,10 +1374,10 @@ describe('administrator source prerequisites in real connector dialogs', () => { (node) => node.textContent?.trim() === replacement.name )! await act(async () => option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))) - expect(button('Save')).toBeDisabled() - expect(button('Change service account')).toBeEnabled() + expect(button('Save')).toBeEnabled() + expect(document.body.textContent).not.toContain('Change service account') - await click(button('Change service account')) + await click(button('Save')) expect(mocks.applyAccess).toHaveBeenCalledExactlyOnceWith( { diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx index c97e4c8a3c4..31435adee4a 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx @@ -38,7 +38,8 @@ vi.mock('@/hooks/use-oauth-return', () => ({ useOAuthReturnForKBConnectors: vi.f vi.mock('@/hooks/queries/kb/connectors', () => ({ useSearchIndex: mocks.index, useConnectorDetail: mocks.detail, - isConnectorSyncingOrPending: () => false, + isConnectorSyncingOrPending: (row: ConnectorData) => + row.status === 'syncing' || row.status === 'pending', })) vi.mock('@/hooks/queries/search-integrations', () => ({ useSearchIntegrations: mocks.integrations, @@ -187,6 +188,19 @@ describe('organization source detail navigation', () => { expect(button, `Missing ${text}`).toBeTruthy() await act(async () => button!.click()) } + + it('passes live sync status to the form without replacing its settings baseline', async () => { + await render('?view=settings') + const baseline = mocks.form.mock.lastCall![0].connector + mocks.dirty = true + mocks.detail.mockReturnValue({ data: { ...connector, status: 'syncing' } }) + await render('?view=settings') + expect(mocks.form.mock.lastCall![0]).toMatchObject({ connector: baseline, syncing: true }) + + mocks.detail.mockReturnValue({ data: { ...connector, status: 'active' } }) + await render('?view=settings') + expect(mocks.form.mock.lastCall![0]).toMatchObject({ connector: baseline, syncing: false }) + }) it.each(['documents', 'settings', 'history'])( 'replaces the removed connection with Sources from the %s view', async (view) => { diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx index ae654870d01..8c75f40fefc 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx @@ -425,6 +425,7 @@ function SourceSettingsForm({ }: SourceSettingsFormProps) { const form = useConnectorSettingsForm({ connector: baseline, + syncing: isConnectorSyncingOrPending(connector), scope, knowledgeBaseId: connector.knowledgeBaseId, isSearchIndex: true, @@ -444,6 +445,7 @@ function SourceSettingsForm({ dirty: form.dirty, saving: form.saving, saveDisabled: !form.canSave, + saveTooltip: form.saveBlockedReason, onSave: form.save, onDiscard, })} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx index 4ecbc389925..61664330801 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx @@ -98,7 +98,8 @@ describe('connection method selection', () => { isAvailabilityReady: false, }) expect(container.textContent).not.toContain('This connection method is not available') - expect(container.querySelector('[aria-label="Sync using: Service account"]')).toBeDisabled() + expect(container.textContent).toContain('Service account') + expect(container.querySelector('[role="combobox"]')).toBeNull() }) it('shows a real unavailable method after availability finishes loading', async () => { @@ -119,15 +120,10 @@ describe('connection method selection', () => { { mode: 'admin', label: 'Service account' }, ] as const)('shows a locked $mode method without allowing changes', async ({ mode, label }) => { await render({ value: { accessMode: mode }, lockAccessMode: true }) - const dropdown = container.querySelector( - `[aria-label="Sync using: ${label}"]` - ) - expect(dropdown).toBeDisabled() - expect(dropdown).toHaveTextContent(label) - expect(container.textContent).toContain('Add a new connection to change the sync method.') + expect(container.textContent).toContain(label) + expect(container.textContent).not.toContain('Add a new connection') expect(container.querySelector('[role="radiogroup"]')).toBeNull() - await act(async () => dropdown!.click()) - expect(document.querySelector('[role="menu"]')).toBeNull() + expect(container.querySelector('button')).toBeNull() expect(onChange).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx index 7e85e99cc36..12f32dab613 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx @@ -5,7 +5,6 @@ import { ChipButtonGroup, ChipButtonGroupItem, ChipCombobox, - ChipDropdown, ChipLink, ChipModalField, type ComboboxOption, @@ -159,23 +158,13 @@ export function ConnectorAccessField({ hint={ canAdmin && isAvailabilityReady && !currentMode?.allowed ? `This connection method is not available in this ${scope.kind}.` - : lockAccessMode - ? 'Add a new connection to change the sync method.' - : value.accessMode === 'workspace' - ? 'Everyone in this workspace can search these documents.' - : undefined + : value.accessMode === 'workspace' + ? 'Everyone in this workspace can search these documents.' + : undefined } >
- {slackSetupOnly ? null : lockAccessMode ? ( - ({ value: mode, label }))} - disabled - className='w-fit' - /> - ) : showModeSelector ? ( + {slackSetupOnly ? null : !lockAccessMode && showModeSelector ? ( { it.each([true, false])( 'locks the sync method only for Search settings (%s)', async (isSearchIndex) => { - await render(confluenceConnectorMeta, { isSearchIndex }) + await render(confluenceConnectorMeta, { isSearchIndex, needsWorkspaceCredential: false }) expect(mocks.accessField).toHaveBeenLastCalledWith( expect.objectContaining({ lockAccessMode: isSearchIndex }) ) @@ -501,6 +501,35 @@ describe('connector settings service-account choices', () => { ) }) + it('browses spaces with the draft replacement account without a separate save action', async () => { + mocks.renderConfigFields = true + mocks.credentials = [ + { + id: 'replacement', + name: 'Updated account', + provider: 'confluence', + type: 'service_account', + }, + ] + await render(confluenceConnectorMeta, { + credentialId: 'previous', + workspaceCredentialId: 'replacement', + accessModeChanged: false, + sourceConfig: { domain: 'https://example.atlassian.net', spaceKey: ['ENG'] }, + isFieldVisible: (field) => field.id === 'spaceSelector', + }) + + expect(mocks.selectorOptions).toHaveBeenLastCalledWith( + 'confluence.spaces', + expect.objectContaining({ + context: expect.objectContaining({ oauthCredential: 'replacement' }), + }) + ) + expect(mocks.accessField).not.toHaveBeenCalled() + expect(container.textContent).not.toContain('Change service account') + expect(container.textContent).not.toContain('Cancel') + }) + it.each([ { meta: confluenceConnectorMeta, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx index d880808a513..4cb850c6ce9 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx @@ -91,6 +91,7 @@ export interface ConnectorSettingsFieldsProps { hasMaxAccess: boolean isSaving: boolean error: string | null + saveBlockedReason?: string access: ConnectorAccessSelection onAccessChange: (access: ConnectorAccessSelection) => void canAdmin: boolean @@ -133,6 +134,7 @@ export function ConnectorSettingsFields({ hasMaxAccess, isSaving, error, + saveBlockedReason, access, onAccessChange, canAdmin, @@ -204,7 +206,9 @@ export function ConnectorSettingsFields({ }) useCredentialRefreshTriggers(refetchCredentials, providerId ?? '', scope) const [browseCredentialId, setBrowseCredentialId] = useState(null) - const selectorCredentialId = syncsPerMember ? browseCredentialId : credentialId + const selectorCredentialId = syncsPerMember + ? browseCredentialId + : (workspaceCredentialId ?? credentialId) const selectorCredential = rawCredentials.find((item) => item.id === selectorCredentialId) const installations = rawCredentials.filter( (credential) => credential.provider === GITHUB_INSTALLATION_PROVIDER_ID @@ -228,6 +232,7 @@ export function ConnectorSettingsFields({ [rawCredentials, connectorConfig, access.accessMode] ) + const hideFixedAccessMode = isSearchIndex && needsWorkspaceCredential && canAdmin && allowAdmin const hiddenCapFieldIds = derivedAclCapFieldIds(connectorConfig, access.accessMode) const isOptionalSetupField = (field: ConnectorConfigField) => Boolean(gitlabPermissions && connectorConfig) && @@ -330,70 +335,67 @@ export function ConnectorSettingsFields({ disabled={isSaving || !canAdmin} /> )} - {connectorConfig && showAccessField && !isGitHubInstallationSource && ( - -
- - {isSwitchingAccess ? 'Re-enabling…' : 'Re-enable per-member sync'} - -
-

- Members and their documents are kept; the next sync restores their access. -

-
- ) : accessDirty ? ( -
-
- - {isSwitchingAccess - ? 'Switching…' - : isContentCredentialChange - ? isSearchIndex - ? requiresServiceAccount - ? 'Change service account' - : 'Change account' - : 'Change indexing account' - : 'Apply connection method'} - - - {accessSetupHint ? 'Edit settings' : 'Cancel'} - + {connectorConfig && + showAccessField && + !isGitHubInstallationSource && + !hideFixedAccessMode && ( + +
+ + {isSwitchingAccess ? 'Re-enabling…' : 'Re-enable per-member sync'} + +
+

+ Members and their documents are kept; the next sync restores their access. +

-

- {accessSetupHint ?? - (isContentCredentialChange - ? syncsPerMember + ) : accessDirty && (!isContentCredentialChange || syncsPerMember) ? ( +

+
+ + {isSwitchingAccess + ? 'Switching…' + : isContentCredentialChange + ? 'Change indexing account' + : 'Apply connection method'} + + + {accessSetupHint ? 'Edit settings' : 'Cancel'} + +
+

+ {accessSetupHint ?? + (isContentCredentialChange ? 'The next sync uses this account. Members keep their connected accounts and source permissions.' - : 'The next sync uses this account and refreshes source permissions.' - : SWITCH_NOTICE[access.accessMode])} -

-
- ) : undefined - } - /> - )} + : SWITCH_NOTICE[access.accessMode])} +

+
+ ) : undefined + } + /> + )} {connectorConfig && needsWorkspaceCredential && canAdmin && ( )} + {saveBlockedReason && ( +

+ {saveBlockedReason} +

+ )} {error} ) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.test.tsx index dea50c56b85..ca249a9a810 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.test.tsx @@ -14,6 +14,13 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/hooks/queries/kb/connectors', () => ({ + isConnectorSyncingOrPending: (row: { + status: string + accessMode?: string + memberSyncStatus?: string + }) => + ['pending', 'syncing'].includes(row.status) || + ['pending', 'running'].includes(row.memberSyncStatus ?? ''), useUpdateConnector: () => ({ mutate: mocks.update, isPending: mocks.settingsPending }), useUpdateConnectorAccess: () => ({ mutate: mocks.applyAccess, isPending: mocks.accessPending }), })) @@ -32,7 +39,10 @@ vi.mock('@/hooks/use-permission-config', () => ({ ['slack', { oauthAvailable: true, state: 'ready' }], ['slack_v2', { oauthAvailable: true, state: 'ready' }], ]), - oauthServiceAvailability: new Map([['github-repositories', true]]), + oauthServiceAvailability: new Map([ + ['github-repositories', true], + ['confluence', true], + ]), isIntegrationAvailabilityReady: true, isIntegrationAvailabilityFetching: false, integrationAvailabilityError: null, @@ -222,6 +232,61 @@ describe('shared connector settings form', () => { expect(mocks.applyAccess).not.toHaveBeenCalled() }) + it('saves an account replacement and source edits together and retains the draft on rejection', () => { + const row = connector({ + connectorType: 'confluence', + accessMode: 'admin', + credentialId: 'old-account', + sourceConfig: { domain: 'example.atlassian.net', spaceKey: ['ENG'] }, + }) + render(row, 'replacement') + act(() => form.fieldsProps.onWorkspaceCredentialChange('new-account')) + act(() => form.fieldsProps.onFieldChange('labelFilter', 'published')) + expect(form.canSave).toBe(true) + act(() => form.save()) + expect(mocks.update).not.toHaveBeenCalled() + expect(mocks.applyAccess).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + access: expect.objectContaining({ + accessMode: 'admin', + credentialId: 'new-account', + sourceConfig: expect.objectContaining({ labelFilter: 'published' }), + }), + }), + expect.any(Object) + ) + act(() => + mocks.applyAccess.mock.calls[0][1].onError(new Error('Account cannot access this space')) + ) + expect(form.fieldsProps.workspaceCredentialId).toBe('new-account') + expect(form.fieldsProps.sourceConfig.labelFilter).toBe('published') + expect(form.canSave).toBe(true) + expect(onSaved).not.toHaveBeenCalled() + }) + + it.each(['pending', 'syncing'] as const)( + 'keeps the account draft while %s and enables Save when idle', + (status) => { + const row = connector({ + connectorType: 'confluence', + accessMode: 'admin', + credentialId: 'old-account', + status, + sourceConfig: { domain: 'example.atlassian.net', spaceKey: ['ENG'] }, + }) + render(row, 'syncing') + act(() => form.fieldsProps.onWorkspaceCredentialChange('new-account')) + expect(form.canSave).toBe(false) + expect(form.saveBlockedReason).toBe('Wait for the current sync to finish before saving.') + act(() => form.save()) + expect(mocks.applyAccess).not.toHaveBeenCalled() + render({ ...row, status: 'active' }, 'syncing') + expect(form.fieldsProps.workspaceCredentialId).toBe('new-account') + expect(form.canSave).toBe(true) + expect(form.saveBlockedReason).toBeUndefined() + } + ) + it('keeps general knowledge-base listing caps editable and includes their changes on save', () => { const sourceConfig = { label: ['INBOX'], diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts index c764d2b0500..53dec40f0a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts @@ -24,6 +24,7 @@ import { useGitLabPermissionForm } from '@/connectors/gitlab/permission-config/u import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { type ConnectorData, + isConnectorSyncingOrPending, useUpdateConnector, useUpdateConnectorAccess, } from '@/hooks/queries/kb/connectors' @@ -122,6 +123,7 @@ interface UseConnectorSettingsFormOptions { isSearchIndex?: boolean connector: ConnectorData onSaved: (connector: ConnectorData) => void + syncing?: boolean } /** Editable connector settings shared by the source page and knowledge-base modal. */ @@ -131,6 +133,7 @@ export function useConnectorSettingsForm({ isSearchIndex = false, connector, onSaved, + syncing = isConnectorSyncingOrPending(connector), }: UseConnectorSettingsFormOptions) { const connectorConfig = CONNECTOR_META_REGISTRY[connector.connectorType] ?? null @@ -263,6 +266,8 @@ export function useConnectorSettingsForm({ workspaceCredentialId !== connector.credentialId) || (access.accessMode === 'members' && contentCredentialId !== (connector.accessMode === 'members' ? connector.credentialId : null)) + const accountChanged = + accessDirty && !accessModeChanged && isContentEngineAccessMode(access.accessMode) /** Exposes credential selection for mode changes and administrator credential recovery. */ const needsWorkspaceCredential = connectorConfig?.auth.mode === 'oauth' && @@ -272,7 +277,8 @@ export function useConnectorSettingsForm({ const missingAdminField = accessDirty && access.accessMode === 'admin' ? connectorConfig?.configFields.find((field) => { - const value = connector.sourceConfig[field.id] + const config = accessModeChanged ? connector.sourceConfig : resolveSourceConfig() + const value = config[field.canonicalParamId ?? field.id] return ( !field.required && isConnectorFieldRequired(field, connectorConfig, 'admin') && @@ -328,7 +334,10 @@ export function useConnectorSettingsForm({ if ( !searchSettingsAllowed || !settingsComplete || - accessDirty || + (accessDirty && !accountChanged) || + (accountChanged && !accessComplete) || + syncing || + isSaving || (showGitLabPermissions && !permissionsComplete) ) return @@ -365,6 +374,26 @@ export function useConnectorSettingsForm({ updates.sourceConfig = next } + if (accountChanged) { + updateAccess( + { + knowledgeBaseId, + connectorId: connector.id, + access: { + accessMode: access.accessMode, + credentialId: workspaceCredentialId ?? connector.credentialId, + sourceConfig: updates.sourceConfig, + syncIntervalMinutes: updates.syncIntervalMinutes, + }, + }, + { + onSuccess: onSaved, + onError: (err) => setError(err.message), + } + ) + return + } + if (Object.keys(updates).length === 0) { onSaved(connector) return @@ -386,6 +415,12 @@ export function useConnectorSettingsForm({ }, [ access.accessMode, accessDirty, + accountChanged, + accessComplete, + syncing, + isSaving, + updateAccess, + workspaceCredentialId, canonicalModes, connector, connectorConfig, @@ -453,6 +488,11 @@ export function useConnectorSettingsForm({ setContentCredentialId(connector.accessMode === 'members' ? connector.credentialId : null) }, [connector]) + const saveBlockedReason = + syncing && (hasChanges || accountChanged) + ? 'Wait for the current sync to finish before saving.' + : undefined + const fieldsProps: ConnectorSettingsFieldsProps = { gitlabPermissions: showGitLabPermissions ? gitlabPermissions : undefined, availability: { @@ -479,6 +519,7 @@ export function useConnectorSettingsForm({ hasMaxAccess, isSaving, error: error ?? searchSetupError, + saveBlockedReason, access, onAccessChange: setAccess, canAdmin, @@ -508,9 +549,11 @@ export function useConnectorSettingsForm({ docsUrl, dirty: hasChanges || accessDirty, saving: isSaving, + saveBlockedReason, canSave: - hasChanges && - !accessDirty && + (hasChanges || accountChanged) && + (!accessDirty || (accountChanged && accessComplete)) && + !syncing && !isSaving && searchSettingsAllowed && Boolean(settingsComplete) && diff --git a/apps/sim/lib/api/contracts/knowledge/connectors.ts b/apps/sim/lib/api/contracts/knowledge/connectors.ts index 1580228c949..89ed3dee598 100644 --- a/apps/sim/lib/api/contracts/knowledge/connectors.ts +++ b/apps/sim/lib/api/contracts/knowledge/connectors.ts @@ -58,6 +58,8 @@ export const updateConnectorAccessBodySchema = z.object({ accessMode: connectorRequestedAccessModeSchema, /** Null removes dedicated content ingestion; omission preserves it in members mode. */ credentialId: z.string().min(1).nullable().optional(), + sourceConfig: z.record(z.string(), z.unknown()).optional(), + syncIntervalMinutes: z.number().int().min(0).optional(), }) export type UpdateConnectorAccessBody = z.input diff --git a/apps/sim/lib/knowledge/application/connector-access.test.ts b/apps/sim/lib/knowledge/application/connector-access.test.ts index d80c90158a6..d593662c52e 100644 --- a/apps/sim/lib/knowledge/application/connector-access.test.ts +++ b/apps/sim/lib/knowledge/application/connector-access.test.ts @@ -647,3 +647,79 @@ describe('connector access application boundary', () => { ) }) }) + +describe('account and settings save', () => { + it('validates the replacement with the edited configuration before passing a single mutation', async () => { + const sourceConfig = { domain: 'example.atlassian.net', spaceKey: ['ENG'] } + mocks.connector.mockResolvedValue({ + ...row, + accessMode: 'admin', + connectorType: 'confluence', + credentialId: 'old', + updatedAt: new Date('2026-09-01'), + }) + mocks.meta.mockReturnValue({ + name: 'Confluence', + auth: { mode: 'oauth', provider: 'confluence' }, + mirrorsSourceAcls: true, + }) + await updateKnowledgeConnectorAccess.execute({ + principal, + input: { + ...input, + accessMode: 'admin', + credentialId: 'new', + sourceConfig, + syncIntervalMinutes: 1440, + }, + }) + expect(mocks.validate).toHaveBeenCalledWith( + expect.objectContaining({ + connector: expect.objectContaining({ credentialId: 'new' }), + sourceConfig, + }) + ) + expect(mocks.update).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + target: { accessMode: 'admin', credentialId: 'new' }, + sourceConfig, + syncIntervalMinutes: 1440, + expectedUpdatedAt: new Date('2026-09-01'), + }) + ) + }) + + it('leaves both account and settings unchanged when provider validation rejects the replacement', async () => { + mocks.connector.mockResolvedValue({ ...row, accessMode: 'admin' }) + mocks.validate.mockResolvedValue({ + errorCode: 'validation', + message: 'Cannot access this space', + }) + await expect( + updateKnowledgeConnectorAccess.execute({ + principal, + input: { + ...input, + accessMode: 'admin', + sourceConfig: { host: 'new.example.test' }, + }, + }) + ).rejects.toThrow('Cannot access this space') + expect(mocks.update).not.toHaveBeenCalled() + }) + + it('rejects combined settings when switching access modes before resolving credentials', async () => { + await expect( + updateKnowledgeConnectorAccess.execute({ + principal, + input: { + ...input, + accessMode: 'admin', + sourceConfig: { host: 'new.example.test' }, + }, + }) + ).rejects.toThrow('Save source settings separately') + expect(mocks.update).not.toHaveBeenCalled() + expect(mocks.token).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/application/connector-access.ts b/apps/sim/lib/knowledge/application/connector-access.ts index 518d5123eeb..06e3e1dd90e 100644 --- a/apps/sim/lib/knowledge/application/connector-access.ts +++ b/apps/sim/lib/knowledge/application/connector-access.ts @@ -194,6 +194,8 @@ export interface UpdateKnowledgeConnectorAccessInput { accessMode: ConnectorAccessMode /** Workspace mode: the credential the connector syncs as from now on. */ credentialId?: string | null + sourceConfig?: Record + syncIntervalMinutes?: number source?: KnowledgeOperationSource resolveBillingAttribution?(workspaceId: string): Promise } @@ -243,6 +245,15 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ ) } const previousConfig = connector.sourceConfig as Record + if ( + (input.sourceConfig !== undefined || input.syncIntervalMinutes !== undefined) && + (input.accessMode === 'members' || input.accessMode !== connector.accessMode) + ) { + throw new OrchestrationError( + 'validation', + 'Save source settings separately when changing the connection method.' + ) + } const sourceConfig = await prepareGitHubInstallationSource({ principal, requestId, @@ -256,7 +267,7 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ isSearchIndex: context.knowledgeBase.isSearchIndex === true, accessMode: input.accessMode, actingUserId, - sourceConfig: previousConfig, + sourceConfig: input.sourceConfig ?? previousConfig, previousConfig, }) @@ -351,6 +362,9 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ knowledgeBase: { id: context.knowledgeBaseId, name: context.knowledgeBase.name, ...owner }, connectorId: context.connectorId, target, + sourceConfig: input.sourceConfig === undefined ? undefined : sourceConfig, + syncIntervalMinutes: input.syncIntervalMinutes, + expectedUpdatedAt: connector.updatedAt, resolveBillingAttribution: () => (owner.workspaceId ? input.resolveBillingAttribution?.(owner.workspaceId) : undefined) ?? resolveKnowledgeBillingAttribution(principal, context), @@ -369,13 +383,18 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ resourceType: AuditResourceType.CONNECTOR, resourceId: result.connector.id, resourceName: result.connector.connectorType, - description: `Switched connector access to ${input.accessMode} mode for knowledge base "${context.knowledgeBase.name}"`, + description: `Updated connector connection for knowledge base "${context.knowledgeBase.name}"`, metadata: { source: input.source, knowledgeBaseId: context.knowledgeBaseId, knowledgeBaseName: context.knowledgeBase.name, connectorType: result.connector.connectorType, - updatedFields: ['accessMode'], + updatedFields: [ + 'accessMode', + ...(input.credentialId !== undefined ? ['credentialId'] : []), + ...(input.sourceConfig !== undefined ? ['sourceConfig'] : []), + ...(input.syncIntervalMinutes !== undefined ? ['syncIntervalMinutes'] : []), + ], accessMode: input.accessMode, }, } diff --git a/apps/sim/lib/knowledge/orchestration/connector-access.test.ts b/apps/sim/lib/knowledge/orchestration/connector-access.test.ts index 8e8f3e82c40..e00b29d643d 100644 --- a/apps/sim/lib/knowledge/orchestration/connector-access.test.ts +++ b/apps/sim/lib/knowledge/orchestration/connector-access.test.ts @@ -23,6 +23,12 @@ const mocks = vi.hoisted(() => ({ rewriteAcls: vi.fn(), })) +vi.mock('@/connectors/registry.server', () => ({ + CONNECTOR_REGISTRY: { + google_drive: { configFields: [], permissionScopedListing: { capFieldIds: [] } }, + }, +})) + vi.mock('@/lib/knowledge/connectors/member-observations', () => ({ rewriteConnectorAcls: mocks.rewriteAcls, })) @@ -99,6 +105,7 @@ const WORKSPACE_CONNECTOR = { status: 'active', syncLockToken: null, memberSyncLockToken: null, + updatedAt: new Date('2026-09-01T00:00:00Z'), } const MEMBERS_CONNECTOR = { @@ -486,7 +493,7 @@ describe('performUpdateKnowledgeConnectorAccess', () => { }) it('changes a workspace credential without the lease, drops the watermark, and queues a full sync', async () => { - queueTableRows(schemaMock.knowledgeConnector, [ + dbChainMockFns.limit.mockResolvedValue([ { ...WORKSPACE_CONNECTOR, lastSyncAt: new Date('2026-08-01T00:00:00Z') }, ]) dbChainMockFns.returning.mockResolvedValueOnce([ @@ -525,6 +532,68 @@ describe('performUpdateKnowledgeConnectorAccess', () => { expect(mocks.revoke).not.toHaveBeenCalled() }) + it('commits the replacement account and source settings in one write', async () => { + const sourceConfig = { folderId: ['f-2'] } + dbChainMockFns.limit.mockResolvedValue([WORKSPACE_CONNECTOR]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { ...WORKSPACE_CONNECTOR, credentialId: 'cred-2', sourceConfig, syncIntervalMinutes: 1440 }, + ]) + const outcome = await performUpdateKnowledgeConnectorAccess({ + knowledgeBase: KB, + connectorId: 'c-1', + target: { accessMode: 'workspace', credentialId: 'cred-2' }, + sourceConfig, + syncIntervalMinutes: 1440, + resolveBillingAttribution, + ...ACTOR, + }) + expect(outcome).toMatchObject({ success: true, changed: true }) + expect(dbChainMockFns.update).toHaveBeenCalledOnce() + expect(setCallWith('credentialId')).toMatchObject({ + credentialId: 'cred-2', + sourceConfig, + syncIntervalMinutes: 1440, + lastSyncAt: null, + listingCheckpoint: null, + directoryCheckpoint: null, + }) + expect(mocks.dispatchSync).toHaveBeenCalledOnce() + }) + + it('rejects the complete save if a sync starts during validation', async () => { + queueTableRows(schemaMock.knowledgeConnector, [WORKSPACE_CONNECTOR]) + queueTableRows(schemaMock.knowledgeConnector, [{ ...WORKSPACE_CONNECTOR, status: 'syncing' }]) + const outcome = await performUpdateKnowledgeConnectorAccess({ + knowledgeBase: KB, + connectorId: 'c-1', + target: { accessMode: 'workspace', credentialId: 'cred-2' }, + sourceConfig: { folderId: ['f-2'] }, + resolveBillingAttribution, + ...ACTOR, + }) + expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.dispatchSync).not.toHaveBeenCalled() + }) + + it('keeps a committed account replacement successful when sync dispatch fails', async () => { + dbChainMockFns.limit.mockResolvedValue([WORKSPACE_CONNECTOR]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { ...WORKSPACE_CONNECTOR, credentialId: 'cred-2' }, + ]) + mocks.dispatchSync.mockRejectedValueOnce(new Error('queue unavailable')) + + const outcome = await switchTo({ accessMode: 'workspace', credentialId: 'cred-2' }) + + expect(outcome).toMatchObject({ + success: true, + changed: true, + connector: { credentialId: 'cred-2' }, + }) + expect(setCallWith('credentialId')).toMatchObject({ nextSyncAt: expect.any(Date) }) + expect(mocks.dispatchSync).toHaveBeenCalledOnce() + }) + it('refuses a credential change while a sync owns the connector', async () => { queueTableRows(schemaMock.knowledgeConnector, [WORKSPACE_CONNECTOR]) queueTableRows(schemaMock.knowledgeConnector, [ @@ -543,7 +612,7 @@ describe('performUpdateKnowledgeConnectorAccess', () => { }) it('changes the credential of a paused connector without queuing a sync', async () => { - queueTableRows(schemaMock.knowledgeConnector, [{ ...WORKSPACE_CONNECTOR, status: 'paused' }]) + dbChainMockFns.limit.mockResolvedValue([{ ...WORKSPACE_CONNECTOR, status: 'paused' }]) dbChainMockFns.returning.mockResolvedValueOnce([ { ...WORKSPACE_CONNECTOR, status: 'paused', credentialId: 'cred-2' }, ]) @@ -576,7 +645,7 @@ describe('performUpdateKnowledgeConnectorAccess', () => { listingCheckpoint: { pageToken: 'old-listing' }, directoryCheckpoint: { pageToken: 'old-directory' }, } - queueTableRows(schemaMock.knowledgeConnector, [disabled]) + dbChainMockFns.limit.mockResolvedValue([disabled]) dbChainMockFns.returning.mockResolvedValueOnce([ { ...disabled, credentialId: 'cred-2', lastSyncAt: null }, ]) @@ -603,16 +672,6 @@ describe('performUpdateKnowledgeConnectorAccess', () => { updatedAt: expect.any(Date), }) const condition = dbChainMockFns.where.mock.calls.at(-1)?.[0] - expect( - hasMockCondition( - condition, - (node: MockCondition) => - node.type === 'inArray' && - node.column === schemaMock.knowledgeConnector.status && - Array.isArray(node.values) && - node.values.includes('disabled') - ) - ).toBe(true) for (const [column, value] of [ [schemaMock.knowledgeConnector.id, 'c-1'], [schemaMock.knowledgeConnector.knowledgeBaseId, 'kb-1'], @@ -662,7 +721,7 @@ describe('performUpdateKnowledgeConnectorAccess', () => { error: 'Sync already in progress', errorCode: 'conflict', }) - expect(dbChainMockFns.update).toHaveBeenCalledOnce() + expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(mocks.dispatchSync).not.toHaveBeenCalled() expect(mocks.dispatchMemberSync).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/knowledge/orchestration/connector-access.ts b/apps/sim/lib/knowledge/orchestration/connector-access.ts index bf4aab69bfc..94ff695cfc7 100644 --- a/apps/sim/lib/knowledge/orchestration/connector-access.ts +++ b/apps/sim/lib/knowledge/orchestration/connector-access.ts @@ -30,6 +30,7 @@ import { getKnowledgeConnector, type KnowledgeConnectorRow, lockCredentialGroupOption, + performUpdateKnowledgeConnector, } from '@/lib/knowledge/orchestration/connectors' import { classifyKnowledgeFailure, @@ -193,6 +194,9 @@ export interface PerformUpdateKnowledgeConnectorAccessParams extends KnowledgeOp knowledgeBase: { id: string; name: string; workspaceId?: string; organizationId?: string } connectorId: string target: ConnectorAccessTarget + sourceConfig?: Record + syncIntervalMinutes?: number + expectedUpdatedAt?: Date resolveBillingAttribution: () => Promise } @@ -238,7 +242,18 @@ export async function performUpdateKnowledgeConnectorAccess( ? target.binding.credentialGroupOptionId === existing.credentialGroupOptionId && (target.credentialId ?? null) === existing.credentialId : target.credentialId === existing.credentialId) - if (unchanged) { + const settingsChanged = + params.sourceConfig !== undefined || params.syncIntervalMinutes !== undefined + if ( + settingsChanged && + (target.accessMode === 'members' || target.accessMode !== existing.accessMode) + ) { + return fail( + 'Save source settings separately when changing the connection method.', + 'validation' + ) + } + if (unchanged && !settingsChanged) { /** * Re-applying the current binding on a connector whose member sync was * disabled is how it is re-enabled: the next run reconciles members from @@ -275,51 +290,19 @@ export async function performUpdateKnowledgeConnectorAccess( return { success: true, connector, changed: false } } - /** - * Staying in the same credential-backed mode with a different credential - * moves no document's visibility, so the lease is not taken. It does change - * what the source shows: the new credential may see a different corpus, and - * only a full listing reconciles that, so the incremental watermark is - * dropped and a sync queued unless the source is paused or disabled. The - * write refuses while a sync owns the row, whose terminal write would - * otherwise put the watermark straight back. - */ if (target.accessMode !== 'members' && target.accessMode === existing.accessMode) { - const now = new Date() - const [updated] = await db - .update(knowledgeConnector) - .set({ + const outcome = await performUpdateKnowledgeConnector({ + ...params, + knowledgeBase: { ...kb, workspaceId: kb.workspaceId ?? null }, + expectedUpdatedAt: params.expectedUpdatedAt ?? existing.updatedAt, + updates: { credentialId: target.credentialId, - lastSyncAt: null, - listingCheckpoint: null, - directoryCheckpoint: null, - nextSyncAt: now, - updatedAt: now, - }) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, kb.id), - inArray(knowledgeConnector.status, [...SWITCHABLE_CONNECTOR_STATUSES, 'disabled']), - eq(knowledgeConnector.status, existing.status), - isNull(knowledgeConnector.syncLockToken), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .returning() - if (!updated) { - const current = await getKnowledgeConnector(kb.id, connectorId) - return current - ? fail('Sync already in progress', 'conflict') - : fail('Connector not found', 'not_found') - } - logger.info(`[${requestId}] Changed the credential of connector ${connectorId}`) - const { encryptedApiKey: _secret, ...connector } = updated - if (existing.status !== 'paused' && existing.status !== 'disabled') { - await dispatchContentSyncBestEffort(connectorId, params, requestId, now) - } - return { success: true, connector, changed: true } + sourceConfig: params.sourceConfig, + syncIntervalMinutes: params.syncIntervalMinutes, + }, + recordSemanticAudit: false, + }) + return outcome.success ? { ...outcome, changed: true } : outcome } const switchId = generateId() diff --git a/apps/sim/lib/knowledge/orchestration/connectors.test.ts b/apps/sim/lib/knowledge/orchestration/connectors.test.ts index e9606037f74..80c4cb3168c 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.test.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.test.ts @@ -804,7 +804,7 @@ describe('performUpdateKnowledgeConnector', () => { }) }) - it('reports a queue failure and leaves the source sync due for retry', async () => { + it('returns the committed settings and leaves the source sync due when dispatch fails', async () => { dbChainMockFns.limit.mockResolvedValueOnce([ { id: 'conn-1', @@ -833,9 +833,8 @@ describe('performUpdateKnowledgeConnector', () => { }) expect(outcome).toMatchObject({ - success: false, - errorCode: 'internal', - error: 'queue unavailable', + success: true, + connector: { id: 'conn-1' }, }) expect(dbChainMockFns.update).toHaveBeenCalledOnce() expect(dbChainMockFns.set).toHaveBeenCalledWith( diff --git a/apps/sim/lib/knowledge/orchestration/connectors.ts b/apps/sim/lib/knowledge/orchestration/connectors.ts index 209d500ea68..f4e887ee237 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.ts @@ -659,6 +659,8 @@ export interface PerformUpdateKnowledgeConnectorParams extends KnowledgeOperatio knowledgeBase: ConnectorKnowledgeBase connectorId: string updates: { + /** An authorized access operation has already validated this replacement. */ + credentialId?: string | null sourceConfig?: Record syncIntervalMinutes?: number status?: 'active' | 'paused' @@ -759,9 +761,15 @@ export async function performUpdateKnowledgeConnector( * so allowing it would silently discard the change. Refusing is the only * answer that is honest about either. */ + const credentialChanged = + updates.credentialId !== undefined && updates.credentialId !== existing.credentialId + if (updates.credentialId !== undefined && existing.accessMode === 'members') { + return fail('Member account changes require the access operation', 'validation') + } const membershipOnly = Boolean( params.permissionChange && !params.permissionChange.requiresContentSync && + !credentialChanged && updates.sourceConfig === undefined && updates.syncIntervalMinutes === undefined && updates.status === undefined @@ -780,7 +788,8 @@ export async function performUpdateKnowledgeConnector( */ if ( existing.status === 'pending' && - (updates.sourceConfig !== undefined || + (credentialChanged || + updates.sourceConfig !== undefined || updates.syncIntervalMinutes !== undefined || params.permissionChange?.requiresContentSync) ) { @@ -869,7 +878,9 @@ export async function performUpdateKnowledgeConnector( const resultingStatus = updates.status ?? existing.status const shouldDispatchSourceSync = - (updates.sourceConfig !== undefined || params.permissionChange?.requiresContentSync === true) && + (credentialChanged || + updates.sourceConfig !== undefined || + params.permissionChange?.requiresContentSync === true) && resultingStatus !== 'paused' && resultingStatus !== 'disabled' /** @@ -897,6 +908,13 @@ export async function performUpdateKnowledgeConnector( const values: Partial = { updatedAt: updateTimestamp, } + if (credentialChanged) { + values.credentialId = updates.credentialId + values.lastSyncAt = null + values.listingCheckpoint = null + values.directoryCheckpoint = null + values.nextSyncAt = updateTimestamp + } if (params.permissionChange?.encryptedApiKey) values.encryptedApiKey = params.permissionChange.encryptedApiKey if (params.permissionChange?.requiresAclReset) values.accessRewritePending = true @@ -966,7 +984,11 @@ export async function performUpdateKnowledgeConnector( isNull(knowledgeConnector.deletedAt), ] updateConditions.push(eq(knowledgeConnector.status, existing.status)) - if (sourceConfigToStore !== undefined || params.permissionChange) + if (credentialChanged) { + updateConditions.push(isNull(knowledgeConnector.syncLockToken)) + updateConditions.push(eq(knowledgeConnector.accessMode, existing.accessMode)) + } + if (credentialChanged || sourceConfigToStore !== undefined || params.permissionChange) updateConditions.push(eq(knowledgeConnector.updatedAt, existing.updatedAt)) if (syncsPerMember) { updateConditions.push(eq(knowledgeConnector.memberSyncStatus, existing.memberSyncStatus)) @@ -1051,10 +1073,12 @@ export async function performUpdateKnowledgeConnector( requireRunnable: true, }) } catch (error) { - return classifyKnowledgeFailure( - error, - requestId, - `Dispatch source-change member sync for connector ${connectorId}` + logger.error( + `[${requestId}] Saved connector; member sync remains due after dispatch failed`, + { + connectorId, + error, + } ) } } @@ -1068,11 +1092,10 @@ export async function performUpdateKnowledgeConnector( requireRunnable: true, }) } catch (error) { - return classifyKnowledgeFailure( + logger.error(`[${requestId}] Saved connector; sync remains due after dispatch failed`, { + connectorId, error, - requestId, - `Dispatch source-change sync for connector ${connectorId}` - ) + }) } } From 81cd64390accc1f17bb28f6ca8fd9084e49d0135 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:00:53 -0700 Subject: [PATCH 04/20] refactor(ui): remove styles already supplied by EMCN controls (#7979) Co-authored-by: Bill Leoutsakos --- apps/sim/app/(auth)/components/constants.ts | 2 +- .../message-container/message-container.tsx | 2 +- .../message/components/file-download.tsx | 2 +- .../home/components/composer/composer.tsx | 2 +- .../sim/app/o/[organizationId]/search/search.tsx | 2 +- .../components/action-bar/action-bar.tsx | 2 +- .../components/resource/resource.tsx | 4 ++-- .../user-input/components/constants.ts | 2 +- .../[id]/components/action-bar/action-bar.tsx | 2 +- .../components/trace-view/trace-view.tsx | 6 +++--- .../logs/components/log-details/log-details.tsx | 4 ++-- .../settings/components/admin/admin.tsx | 4 ++-- .../mcp-server-form-modal.tsx | 2 +- .../run-status-control/run-status-control.tsx | 2 +- .../table-action-bar/table-action-bar.tsx | 2 +- .../components/table-filter/table-filter.tsx | 14 ++------------ .../import-progress-menu.tsx | 2 +- .../w/[workflowId]/components/chat/chat.tsx | 4 ++-- .../deploy-modal/components/general/general.tsx | 2 +- .../grouped-checkbox-list.tsx | 2 +- .../editor/components/sub-block/sub-block.tsx | 2 +- .../w/[workflowId]/components/panel/panel.tsx | 16 ++++++++-------- .../replacement-controls.tsx | 4 ++-- .../components/output-panel/output-panel.tsx | 4 ++-- .../w/components/preview/preview.tsx | 2 +- 25 files changed, 41 insertions(+), 51 deletions(-) diff --git a/apps/sim/app/(auth)/components/constants.ts b/apps/sim/app/(auth)/components/constants.ts index 130b84711b6..beb515c525d 100644 --- a/apps/sim/app/(auth)/components/constants.ts +++ b/apps/sim/app/(auth)/components/constants.ts @@ -14,4 +14,4 @@ export const AUTH_CONTROL_HEIGHT = 'h-9' * under `justify-center` (the landing `HeroCta` idiom). Height-only inputs use * {@link AUTH_CONTROL_HEIGHT}; buttons compose this on top of it. */ -export const AUTH_BUTTON_CLASS = `${AUTH_CONTROL_HEIGHT} w-full justify-center [&>span]:flex-none` +export const AUTH_BUTTON_CLASS = `${AUTH_CONTROL_HEIGHT} justify-center [&>span]:flex-none` diff --git a/apps/sim/app/(interfaces)/chat/components/message-container/message-container.tsx b/apps/sim/app/(interfaces)/chat/components/message-container/message-container.tsx index b508e99ec58..9a355614557 100644 --- a/apps/sim/app/(interfaces)/chat/components/message-container/message-container.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message-container/message-container.tsx @@ -72,7 +72,7 @@ export function ChatMessageContainer({ diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 782b40e7fa1..9339e42c12d 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -176,7 +176,7 @@ export const WorkflowOutputSection = memo( e.stopPropagation() handleCopy() }} - className='size-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-xs hover-hover:bg-[var(--surface-3)]' + className='size-[20px] cursor-pointer border-[var(--border-1)] bg-transparent p-0 backdrop-blur-xs hover-hover:bg-[var(--surface-3)]' > {copied ? ( @@ -197,7 +197,7 @@ export const WorkflowOutputSection = memo( e.stopPropagation() activateSearch() }} - className='size-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-xs hover-hover:bg-[var(--surface-3)]' + className='size-[20px] cursor-pointer border-[var(--border-1)] bg-transparent p-0 backdrop-blur-xs hover-hover:bg-[var(--surface-3)]' > diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index 85fa1441c6e..77303d43160 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -445,7 +445,7 @@ export function Admin() {
{!autoApply && (
{filter !== null && ( - )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx index a5619ed9ed0..33efeb839b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx @@ -64,7 +64,7 @@ export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuP return ( - @@ -1122,7 +1122,7 @@ export function Chat() { isStreaming } className={cn( - 'size-[22px] rounded-full p-0 transition-colors', + 'size-[22px] rounded-full p-0', chatMessage.trim() || chatFiles.length > 0 ? 'bg-[#383838] hover-hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover-hover:bg-[#CFCFCF]' : 'bg-[#808080] dark:bg-[#808080]' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx index edbdcb7b3ee..231ea553c16 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx @@ -246,7 +246,7 @@ export function GeneralDeploy({ type='button' variant='default' onClick={() => setShowExpandedPreview(true)} - className='absolute right-[8px] bottom-2 z-10 size-[28px] cursor-pointer border border-[var(--border)] bg-transparent p-0 backdrop-blur-xs hover-hover:bg-[var(--surface-3)]' + className='absolute right-[8px] bottom-2 z-10 size-[28px] cursor-pointer bg-transparent p-0 backdrop-blur-xs hover-hover:bg-[var(--surface-3)]' > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/grouped-checkbox-list/grouped-checkbox-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/grouped-checkbox-list/grouped-checkbox-list.tsx index ac575431325..d385a77ab71 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/grouped-checkbox-list/grouped-checkbox-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/grouped-checkbox-list/grouped-checkbox-list.tsx @@ -118,7 +118,7 @@ export function GroupedCheckboxList({ disabled={disabled} onClick={() => setOpen(true)} className={cn( - 'flex w-full cursor-pointer items-center justify-between rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 font-sans text-[var(--text-primary)] text-sm outline-hidden focus:outline-hidden focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-[var(--surface-5)]', + 'flex w-full cursor-pointer justify-between rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] font-sans text-[var(--text-primary)] text-sm focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-[var(--surface-5)]', 'hover-hover:bg-[var(--surface-active)]' )} > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index a0c7fc1f22c..594b374808c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -300,7 +300,7 @@ const renderLabel = ( {!wandState.isSearchActive ? ( @@ -802,7 +802,7 @@ export const Panel = memo(function Panel() { )} + {fieldState && ( + + {fieldState} + + )} event.preventDefault() : undefined} className={cn( matchTriggerWidth && 'w-[var(--radix-dropdown-menu-trigger-width)] max-w-none', + insideModal && 'max-h-[min(240px,var(--radix-popper-available-height,240px))]', contentClassName )} > From 7a8f39a6dca7d7c8136431a7298c1f1221d56ecd Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 18 Sep 2026 13:24:57 -0700 Subject: [PATCH 07/20] fix(connectors): explain disabled settings with tooltips (#7981) * fix(connectors): explain disabled settings with tooltips * fix(settings): preserve accessible disabled-action explanations --- .../integrations/search-source-setup.test.tsx | 3 +- .../connector-access-field.test.tsx | 21 +++++++++++- .../connector-access-field.tsx | 19 ++++++++++- .../knowledge/[id]/components/consts.ts | 2 +- .../connector-settings-fields.test.tsx | 8 +++++ .../connector-settings-fields.tsx | 10 +++--- .../edit-connector-modal.tsx | 1 + .../settings/settings-header-shell.test.tsx | 26 ++++++++++++++ .../components/settings/settings-header.tsx | 2 +- .../components/chip-modal/chip-modal.test.tsx | 34 +++++++++++++++++++ .../src/components/chip-modal/chip-modal.tsx | 4 +-- 11 files changed, 117 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx index 8b191c6ae24..07ec5c2c42d 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx @@ -988,7 +988,8 @@ describe('member content credentials in real add and edit dialogs', () => { await chooseSyncFrequency('Manual only') expect(document.body.textContent).toContain('Documents become unavailable after 24 hours') await chooseSyncFrequency('Every hour') - expect(document.body.textContent).toContain('Permissions are checked on every sync.') + expect(document.body.textContent).not.toContain('Documents become unavailable after 24 hours') + expect(document.body.textContent).not.toContain('Permissions are checked on every sync.') }) it('saves source settings without changing a dedicated indexing account', async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx index 61664330801..f642de988cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx @@ -123,7 +123,26 @@ describe('connection method selection', () => { expect(container.textContent).toContain(label) expect(container.textContent).not.toContain('Add a new connection') expect(container.querySelector('[role="radiogroup"]')).toBeNull() - expect(container.querySelector('button')).toBeNull() + const trigger = container.querySelector('button')! + expect(trigger).toBeDisabled() + expect(document.querySelector('[role="tooltip"]')).toBeNull() + await act(async () => { + trigger.parentElement!.dispatchEvent( + new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 }) + ) + }) + expect(document.querySelector('[role="tooltip"]')).toHaveTextContent( + 'Add a new connection to change the sync method.' + ) + await act(async () => { + trigger.parentElement!.dispatchEvent(new MouseEvent('pointerout', { bubbles: true })) + }) + expect(document.querySelector('[role="tooltip"]')).toBeNull() + expect(trigger.parentElement!.tabIndex).toBe(0) + await act(async () => trigger.parentElement!.focus()) + expect(document.querySelector('[role="tooltip"]')).toHaveTextContent( + 'Add a new connection to change the sync method.' + ) expect(onChange).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx index 12f32dab613..bd2cc556aa0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx @@ -5,9 +5,11 @@ import { ChipButtonGroup, ChipButtonGroupItem, ChipCombobox, + ChipDropdown, ChipLink, ChipModalField, type ComboboxOption, + Tooltip, } from '@sim/emcn' import type { ConnectorAccessMode } from '@/lib/api/contracts/knowledge/connectors' import { type ResourceScope, resourceScopeFromOwner } from '@/lib/core/resource-scope' @@ -164,7 +166,22 @@ export function ConnectorAccessField({ } >
- {slackSetupOnly ? null : !lockAccessMode && showModeSelector ? ( + {slackSetupOnly ? null : lockAccessMode ? ( + + + + ({ value: mode, label }))} + disabled + className='pointer-events-none w-fit' + /> + + + Add a new connection to change the sync method. + + ) : showModeSelector ? ( { }) } + it('announces blocked saves without showing persistent helper text', async () => { + const reason = 'Wait for the current sync to finish before saving.' + await render(confluenceConnectorMeta, { saveBlockedReason: reason }) + const status = container.querySelector('[role="status"]') + expect(status).toHaveClass('sr-only') + expect(status).toHaveTextContent(reason) + }) + async function openAccountChoices() { const dropdown = container.querySelector('[role="combobox"]') if (!dropdown) throw new Error('Missing indexing-account selector') diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx index 4cb850c6ce9..0b696fb0044 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx @@ -465,7 +465,7 @@ export function ConnectorSettingsFields({ title='Account for browsing' hint={ isSearchIndex - ? 'Used to browse available content. Each person connects separately from Integrations to sync their Search content.' + ? 'Members sync with their own accounts connected in Integrations.' : undefined } > @@ -553,11 +553,9 @@ export function ConnectorSettingsFields({ )} - {saveBlockedReason && ( -

- {saveBlockedReason} -

- )} +

+ {saveBlockedReason} +

{error} ) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index 298ba33260d..4c6b9471546 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -108,6 +108,7 @@ export function EditConnectorModal({ label: form.saving ? 'Saving…' : 'Save', onClick: form.save, disabled: !form.canSave, + disabledTooltip: form.saveBlockedReason, }} /> )} diff --git a/apps/sim/components/settings/settings-header-shell.test.tsx b/apps/sim/components/settings/settings-header-shell.test.tsx index ecf53c2706a..cabc42b06f1 100644 --- a/apps/sim/components/settings/settings-header-shell.test.tsx +++ b/apps/sim/components/settings/settings-header-shell.test.tsx @@ -68,6 +68,32 @@ function clickChip(label: string) { } describe('SettingsHeaderShell action routing', () => { + it('makes disabled Save explanations keyboard reachable without enabling Save', () => { + const onSave = vi.fn() + const reason = 'Wait for the current sync to finish before saving.' + renderHeader( + saveDiscardActions({ + dirty: true, + saving: false, + saveDisabled: true, + saveTooltip: reason, + onSave, + onDiscard: vi.fn(), + }) + ) + const save = [...container.querySelectorAll('button')].find( + (button) => button.textContent === 'Save' + )! + const trigger = save.parentElement! + expect(save.disabled).toBe(true) + expect(trigger.tabIndex).toBe(0) + act(() => trigger.focus()) + expect(document.activeElement).toBe(trigger) + expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(reason) + act(() => save.click()) + expect(onSave).not.toHaveBeenCalled() + }) + it('renders Delete before Discard and Save even though the array lists it last', () => { const actions: SettingsAction[] = [ ...saveDiscardActions({ dirty: true, saving: false, onSave: vi.fn(), onDiscard: vi.fn() }), diff --git a/apps/sim/components/settings/settings-header.tsx b/apps/sim/components/settings/settings-header.tsx index 65c51512658..91894c37775 100644 --- a/apps/sim/components/settings/settings-header.tsx +++ b/apps/sim/components/settings/settings-header.tsx @@ -203,7 +203,7 @@ export function SettingsActionChip({ if (!action.tooltip) return chip return ( - + {chip} {action.tooltip} diff --git a/packages/emcn/src/components/chip-modal/chip-modal.test.tsx b/packages/emcn/src/components/chip-modal/chip-modal.test.tsx index c6305e37e32..53a6a1dd16b 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.test.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.test.tsx @@ -341,6 +341,40 @@ describe('ChipModalField file actions', () => { describe('ChipModal default actions', () => { beforeEach(makeElementsVisible) + it.each(['save', 'confirm'] as const)( + 'makes a disabled %s explanation reachable without enabling the action', + (variant) => { + const onClick = vi.fn() + const action = { + label: 'Save', + disabled: true, + disabledTooltip: 'Wait for the current sync to finish before saving.', + onClick, + } + mount( + variant === 'confirm' ? ( + {}} title='Save settings' confirm={action} /> + ) : ( + {}} srTitle='Save settings'> + {}}>Save settings + {}} primaryAction={action} /> + + ) + ) + + const save = buttonByText('Save') + const trigger = save.parentElement! + expect(save.disabled).toBe(true) + expect(trigger.tabIndex).toBe(0) + act(() => trigger.focus()) + expect(document.activeElement).toBe(trigger) + expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(action.disabledTooltip) + pressEnter(trigger) + act(() => save.click()) + expect(onClick).not.toHaveBeenCalled() + } + ) + it('fails safe to the dismiss decision in a confirmation', () => { mount( - + {primaryChip} {primaryAction.disabledTooltip} @@ -1649,7 +1649,7 @@ function renderChipConfirmButton( if (!confirm.disabledTooltip || !disabled) return chip return ( - + {chip} {confirm.disabledTooltip} From 44869dfcf3d50d3cac6417f21b6d9251861577b3 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:16:44 -0700 Subject: [PATCH 08/20] refactor(ui): centralize bulk-action buttons in EMCN (#7982) * refactor(ui): share resource bulk-action button styles * refactor(emcn): own bulk-action button appearance centrally * test(emcn): consolidate bulk-action button coverage --------- Co-authored-by: Bill Leoutsakos --- .../components/action-bar/action-bar.tsx | 28 +--- .../[id]/components/action-bar/action-bar.tsx | 38 +---- .../table-action-bar/table-action-bar.tsx | 12 +- .../bulk-action-button.test.tsx | 156 ++++++++++++++++++ .../bulk-action-button/bulk-action-button.tsx | 52 ++++++ packages/emcn/src/components/index.ts | 5 + 6 files changed, 230 insertions(+), 61 deletions(-) create mode 100644 packages/emcn/src/components/bulk-action-button/bulk-action-button.test.tsx create mode 100644 packages/emcn/src/components/bulk-action-button/bulk-action-button.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx index 4fd9f163f69..7ded34f10d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx @@ -2,8 +2,7 @@ import type { ComponentType } from 'react' import { - Button, - chipFilledFillTokens, + BulkActionButton, cn, DropdownMenu, DropdownMenuContent, @@ -16,12 +15,6 @@ import { Download } from '@sim/emcn/icons' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders' -/** Shared chrome for every action button, so the bar reads as one control strip. */ -const ACTION_BUTTON_CLASS = cn( - chipFilledFillTokens, - 'hover-hover:text-[var(--text-inverse)]! size-[28px] rounded-lg p-0 hover-hover:bg-[var(--brand-secondary)]' -) - interface ActionButtonProps { icon: ComponentType<{ className?: string }> label: string @@ -33,15 +26,9 @@ function ActionButton({ icon: Icon, label, onClick, disabled }: ActionButtonProp return ( - + {label} @@ -129,14 +116,9 @@ export function ResourceActionBar({ - + Move diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx index 6d118e9a5e8..01dbfaead48 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx @@ -1,14 +1,8 @@ -import { Button, chipFilledFillTokens, cn, Tooltip } from '@sim/emcn' +import { BulkActionButton, cn, Tooltip } from '@sim/emcn' import { Ban, Circle, Trash } from '@sim/emcn/icons' import { domAnimation, LazyMotion, m } from 'framer-motion' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -/** One source of truth for the button chrome, so the three actions read as one control strip. */ -const ACTION_BUTTON_CLASS = cn( - chipFilledFillTokens, - 'hover-hover:text-[var(--text-inverse)]! size-[28px] rounded-lg p-0 hover-hover:bg-[var(--brand-secondary)]' -) - interface ActionBarProps { selectedCount: number onEnable?: () => void @@ -92,15 +86,9 @@ export function ActionBar({ {showEnableButton && ( - + Enable @@ -109,15 +97,9 @@ export function ActionBar({ {showDisableButton && ( - + Disable @@ -126,15 +108,9 @@ export function ActionBar({ {onDelete && canEdit && ( - + Delete diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx index 71eac786c43..b04257ae755 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx @@ -1,7 +1,7 @@ 'use client' import type React from 'react' -import { Button, cn, Tooltip } from '@sim/emcn' +import { BulkActionButton, cn, Tooltip } from '@sim/emcn' import { Eye, PlayOutline, RefreshCw, Square } from '@sim/emcn/icons' import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion' @@ -146,22 +146,20 @@ interface ActionIconButtonProps { } /** - * Tooltip-wrapped icon button sharing the action bar's brand-hover chrome, - * so the chrome string lives in one place. + * Supplies the table action's tooltip around the shared EMCN bulk-action button. */ function ActionIconButton({ label, onClick, disabled, children }: ActionIconButtonProps) { return ( - + {label} diff --git a/packages/emcn/src/components/bulk-action-button/bulk-action-button.test.tsx b/packages/emcn/src/components/bulk-action-button/bulk-action-button.test.tsx new file mode 100644 index 00000000000..763106567be --- /dev/null +++ b/packages/emcn/src/components/bulk-action-button/bulk-action-button.test.tsx @@ -0,0 +1,156 @@ +/** @vitest-environment jsdom */ +import { act, createRef, type ReactNode } from 'react' +import { BulkActionButton, Button, DropdownMenu, DropdownMenuTrigger, Tooltip } from '@sim/emcn' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function mount(children: ReactNode) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root?.render(children)) + return container +} + +function button() { + const element = container?.querySelector('button') + if (!element) throw new Error('Button did not render') + return element +} + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + root = null + container = null + vi.useRealTimers() +}) + +/** Exact class inputs used by the action bars before migrating into EMCN. */ +const PREVIOUS_GEOMETRY = + 'hover-hover:text-[var(--text-inverse)]! size-[28px] rounded-lg p-0 hover-hover:bg-[var(--brand-secondary)]' + +describe('BulkActionButton', () => { + for (const surface of [undefined, 'adaptive', 'uniform'] as const) { + it(`preserves the previous button markup for ${surface ?? 'default'}`, () => { + const previousFill = + surface === 'uniform' + ? 'bg-[var(--surface-5)]' + : 'bg-[var(--surface-5)] dark:bg-[var(--surface-4)]' + const view = mount( + <> + + + + + + ) + const [previous, current] = view.querySelectorAll('button') + /** Class order changes when composing recipes; the resolved utility set must not. */ + previous.className = previous.className.split(/\s+/).sort().join(' ') + current.className = current.className.split(/\s+/).sort().join(' ') + expect(current.outerHTML).toBe(previous.outerHTML) + }) + } + + it('forwards the native ref, attributes and original events', () => { + const ref = createRef() + const onClick = vi.fn() + const onKeyDown = vi.fn() + mount( + + ) + expect(ref.current).toBe(button()) + expect(button().dataset.action).toBe('download') + act(() => button().focus()) + expect(document.activeElement).toBe(button()) + const keyEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + act(() => button().dispatchEvent(keyEvent)) + expect(onKeyDown).toHaveBeenCalledTimes(1) + expect(onKeyDown.mock.calls[0][0].nativeEvent).toBe(keyEvent) + act(() => button().click()) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('does not invoke disabled actions', () => { + const onClick = vi.fn() + mount() + act(() => button().click()) + expect(button().disabled).toBe(true) + expect(onClick).not.toHaveBeenCalled() + }) + + for (const type of [undefined, 'button'] as const) { + it(`preserves native form behavior for type=${type ?? 'omitted'}`, () => { + const onSubmit = vi.fn((event) => event.preventDefault()) + mount( +
+ + + ) + act(() => button().click()) + expect(onSubmit).toHaveBeenCalledTimes(type === 'button' ? 0 : 1) + }) + } + + it('composes with the tooltip and menu triggers used by Move', () => { + const onOpenChange = vi.fn() + const onKeyDown = vi.fn() + const ref = createRef() + mount( + + + + + + + + Move + + + ) + expect(container?.querySelectorAll('button')).toHaveLength(1) + expect(ref.current).toBe(button()) + expect(button().getAttribute('aria-haspopup')).toBe('menu') + act(() => + button().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + ) + expect(onKeyDown).toHaveBeenCalledTimes(1) + expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true) + }) + + it('retains the tooltip and accessible name on a direct action', () => { + vi.useFakeTimers() + mount( + + + + + Download selected files + + ) + act(() => + button().dispatchEvent( + new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 }) + ) + ) + expect(document.querySelector('[role="tooltip"]')?.textContent).toBe('Download selected files') + expect(button().getAttribute('aria-label')).toBe('Download') + }) +}) diff --git a/packages/emcn/src/components/bulk-action-button/bulk-action-button.tsx b/packages/emcn/src/components/bulk-action-button/bulk-action-button.tsx new file mode 100644 index 00000000000..b24e402e274 --- /dev/null +++ b/packages/emcn/src/components/bulk-action-button/bulk-action-button.tsx @@ -0,0 +1,52 @@ +import { forwardRef } from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '../../lib/cn' +import { Button, type ButtonProps } from '../button/button' +import { chipFilledFillTokens, chipRadiusClass } from '../chip/chip-chrome' + +/** The shared 28px geometry and brand-hover treatment of selection action bars. */ +export const bulkActionButtonVariants = cva( + `${chipRadiusClass} size-[28px] p-0 hover-hover:bg-[var(--brand-secondary)] hover-hover:text-[var(--text-inverse)]!`, + { + variants: { + surface: { + adaptive: chipFilledFillTokens, + uniform: 'bg-[var(--surface-5)]', + }, + }, + defaultVariants: { surface: 'adaptive' }, + } +) + +export interface BulkActionButtonProps extends Omit { + /** Accessible name for the icon action; tooltip content is supplied separately. */ + 'aria-label': string + /** + * `adaptive` follows the filled chip surface: surface-5 in light mode and surface-4 in dark. + * `uniform` retains surface-5 in both themes, as used by table-cell action bars. + * @default 'adaptive' + */ + surface?: NonNullable['surface']> +} + +/** + * Icon action for a selection's bulk-action bar. Owns its geometry and visual states; + * callers provide icon content, labels, disabled state and command behavior. + * Forwards the native button ref and props for tooltip/menu `asChild` composition. + * Native form behavior is inherited from Button; pass `type` when it must be explicit. + * + * @example + */ +export const BulkActionButton = forwardRef( + ({ surface, className, ...props }, ref) => ( + + ) : ( - + )}
diff --git a/apps/sim/app/o/[organizationId]/search/search.tsx b/apps/sim/app/o/[organizationId]/search/search.tsx index 25633c361e8..f81ea1523a4 100644 --- a/apps/sim/app/o/[organizationId]/search/search.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.tsx @@ -1,7 +1,13 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import { Button, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn' +import { + ComposerActionButton, + cn, + scrollFadeAttributes, + scrollFadeClass, + useScrollEdges, +} from '@sim/emcn' import { ArrowUp, Search } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' @@ -25,11 +31,6 @@ import { } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import { useVoiceInput } from '@/hooks/use-voice-input' -const SUBMIT_BUTTON_BASE = 'size-[28px] shrink-0 rounded-full border-0 p-0' -const SUBMIT_BUTTON_ACTIVE = - 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]' -const SUBMIT_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]' - interface SearchFieldProps { initialValue: string onSubmit: (value: string) => void @@ -98,19 +99,16 @@ function SearchField({ onToggle={voice.toggleListening} /> )} - + - + ) } return ( - + ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx index 9469d62fd28..a28d06bc942 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx @@ -80,6 +80,14 @@ vi.mock('@sim/emcn', () => ({ ), cn: (...values: unknown[]) => values.filter(Boolean).join(' '), + ComposerActionButton: ({ + children, + size: _size, + active: _active, + ...props + }: ButtonHTMLAttributes & { size?: string; active?: boolean }) => ( + + ), Input: ({ ref, className: _className, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 42ab98a01a6..804e7b570f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -4,6 +4,7 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } import { Badge, Button, + ComposerActionButton, cn, Input, Popover, @@ -1102,34 +1103,28 @@ export function Chat() {
{isStreaming ? ( - + ) : ( - + )} diff --git a/packages/emcn/src/components/composer-action-button/composer-action-button.test.tsx b/packages/emcn/src/components/composer-action-button/composer-action-button.test.tsx new file mode 100644 index 00000000000..27bf8856ef0 --- /dev/null +++ b/packages/emcn/src/components/composer-action-button/composer-action-button.test.tsx @@ -0,0 +1,122 @@ +/** @vitest-environment jsdom */ +import { act, createRef, type ReactNode } from 'react' +import { Button, ComposerActionButton } from '@sim/emcn' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function mount(children: ReactNode) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root?.render(children)) + return container +} + +function button() { + const element = container?.querySelector('button') + if (!element) throw new Error('Button did not render') + return element +} + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + root = null + container = null +}) + +/** Exact pre-migration class inputs from the organization composer and workflow chat. */ +const PREVIOUS = { + md: { + base: 'size-[28px] rounded-full border-0 p-0', + active: 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]', + }, + sm: { + base: 'size-[22px] rounded-full p-0', + active: 'bg-[#383838] hover-hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover-hover:bg-[#CFCFCF]', + }, +} as const + +describe('ComposerActionButton', () => { + for (const size of ['md', 'sm'] as const) { + for (const active of [true, false]) { + it(`preserves the previous ${size} markup with active=${active}`, () => { + const previous = PREVIOUS[size] + const view = mount( + <> + + + + + + ) + const [before, after] = view.querySelectorAll('button') + before.className = before.className.split(/\s+/).sort().join(' ') + after.className = after.className.split(/\s+/).sort().join(' ') + expect(after.outerHTML).toBe(before.outerHTML) + }) + } + } + + it('forwards refs and events while keeping active appearance independent of disabled', () => { + const ref = createRef() + const onClick = vi.fn() + const onKeyDown = vi.fn() + const action = (disabled: boolean) => ( + + ) + mount(action(false)) + expect(ref.current).toBe(button()) + expect(button().dataset.action).toBe('send') + act(() => button().focus()) + expect(document.activeElement).toBe(button()) + const keyEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + act(() => button().dispatchEvent(keyEvent)) + expect(onKeyDown).toHaveBeenCalledTimes(1) + expect(onKeyDown.mock.calls[0][0].nativeEvent).toBe(keyEvent) + act(() => button().click()) + expect(onClick).toHaveBeenCalledTimes(1) + const activeClasses = button().className + act(() => root?.render(action(true))) + expect(button().disabled).toBe(true) + expect(button().className).toBe(activeClasses) + act(() => button().click()) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + for (const type of [undefined, 'button'] as const) { + it(`preserves native form behavior for type=${type ?? 'omitted'}`, () => { + const onSubmit = vi.fn((event) => event.preventDefault()) + mount( +
+ + + ) + act(() => button().click()) + expect(onSubmit).toHaveBeenCalledTimes(type === 'button' ? 0 : 1) + }) + } +}) diff --git a/packages/emcn/src/components/composer-action-button/composer-action-button.tsx b/packages/emcn/src/components/composer-action-button/composer-action-button.tsx new file mode 100644 index 00000000000..806f128a179 --- /dev/null +++ b/packages/emcn/src/components/composer-action-button/composer-action-button.tsx @@ -0,0 +1,61 @@ +import { forwardRef } from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '../../lib/cn' +import { Button, type ButtonProps } from '../button/button' + +/** Shared circular send, stop and search appearance, including the compact chat treatment. */ +export const composerActionButtonVariants = cva('rounded-full p-0', { + variants: { + size: { + sm: 'size-[22px]', + md: 'size-[28px] border-0', + }, + active: { + true: 'bg-[#383838] dark:bg-[#E0E0E0]', + false: 'bg-[#808080] dark:bg-[#808080]', + }, + }, + compoundVariants: [ + { size: 'md', active: true, className: 'hover:bg-[#575757] dark:hover:bg-[#CFCFCF]' }, + { + size: 'sm', + active: true, + className: 'hover-hover:bg-[#575757] dark:hover-hover:bg-[#CFCFCF]', + }, + ], + defaultVariants: { size: 'md', active: true }, +}) + +export interface ComposerActionButtonProps extends Omit { + /** Accessible name for the caller's icon action. */ + 'aria-label': string + /** 28px by default; `sm` retains compact chat's 22px geometry and hover treatment. */ + size?: NonNullable['size']> + /** + * Whether to show the active fill. Independent of `disabled`: a populated composer + * can retain its active appearance while execution temporarily prevents submission. + * @default true + */ + active?: boolean +} + +/** + * Circular action at the end of a composer or search field. Owns the button appearance; + * callers retain icons, labels, handlers and submission/streaming conditions. + * Forwards the native button ref and props, including Button's native form behavior. + * + * @example + */ +export const ComposerActionButton = forwardRef( + ({ size, active, className, ...props }, ref) => ( + , })) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index ff7515d1711..56846b001d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -19,7 +19,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { usePostHog } from 'posthog-js/react' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { requestJson } from '@/lib/api/client/request' import { createWorkflowContract } from '@/lib/api/contracts' import { @@ -45,6 +44,7 @@ import { resolveResourceSelectionUpdate, } from '@/app/workspace/[workspaceId]/home/resource-view-policy' import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' import { useFolders } from '@/hooks/queries/folders' import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats' import { useWorkflows } from '@/hooks/queries/workflows' diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx index 51890cea304..b363f80287d 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx @@ -1,10 +1,10 @@ import { Suspense } from 'react' import type { Metadata } from 'next' import { notFound } from 'next/navigation' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { INTEGRATIONS } from '@/lib/integrations' import { IntegrationBlockDetail } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail' import { IntegrationBlockDetailFallback } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail-fallback' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' export async function generateMetadata({ params, diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx index 4267bc40e60..96e4ce8721e 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { ConnectedCredentialDetail } from '@/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' export const metadata: Metadata = { title: 'Connected Integration', diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx index a1b1ea26755..4f814f592f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -13,7 +13,6 @@ import { } from '@sim/emcn' import { useParams } from 'next/navigation' import { useQueryStates } from 'nuqs' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { blockTypeToIconMap, formatIntegrationType, @@ -35,6 +34,7 @@ import { } from '@/app/workspace/[workspaceId]/integrations/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { usePermissionConfig } from '@/hooks/use-permission-config' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx index a49bbd1ee6d..ceb959622ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx @@ -1,8 +1,8 @@ import { Suspense } from 'react' import type { Metadata } from 'next' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { Document } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document' import DocumentLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' interface DocumentPageProps { params: Promise<{ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx index a441f86dfd8..64fd9cc7de7 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx @@ -1,8 +1,8 @@ import { Suspense } from 'react' import type { Metadata } from 'next' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base' import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/loading' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' interface PageProps { params: Promise<{ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.test.tsx index a03d3348d72..36a3e9e6b98 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.test.tsx @@ -37,7 +37,7 @@ vi.mock('@/hooks/use-permission-config', () => ({ usePermissionConfig: () => ({ vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ useUserPermissionConfig: () => ({ data: { config: {} }, isPending: false }), })) -vi.mock('@/hooks/queries/access-requests', () => ({ +vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ useDiscoverAccessRequests: () => ({ data: { enabled: false, entries: [] }, isPending: false }), })) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 2bcfc892f11..ccda6b899a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -8,7 +8,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/constants' import type { KnowledgeBaseData } from '@/lib/knowledge/types' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' @@ -85,6 +84,7 @@ import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/provide import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { BrandIcon } from '@/blocks/brand-icon' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hooks/queries/folders' import { diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx b/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx index f74b6c91084..482b16115b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx @@ -14,7 +14,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/permission-groups/application/read-user-config', () => ({ readUserPermissionConfig: { execute: mocks.policy }, })) -vi.mock('@/lib/permission-access-requests/application/requests', () => ({ +vi.mock('@/ee/access-requests/lib/application/requests', () => ({ discoverAccessRequests: { execute: mocks.discovery }, })) vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.requestJson })) @@ -39,21 +39,21 @@ vi.mock('@sim/emcn/icons', () => ({ Upload: () => null, BookOpen: () => null, })) -vi.mock('@/components/access-requests/request-access-action', () => ({ +vi.mock('@/ee/access-requests/components/request-access-action', () => ({ RequestAccessAction: ({ pendingRequestId }: { pendingRequestId: string | null }) => ( ), })) -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { ApiClientError } from '@/lib/api/client/errors' import { getUserPermissionConfigContract } from '@/lib/api/contracts/permission-groups' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { prefetchWorkspaceAccess } from '@/app/workspace/[workspaceId]/prefetch-access' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' import { accessRequestKeys, workspaceFeatureDiscoveryQuery, -} from '@/hooks/queries/utils/access-request-keys' +} from '@/ee/access-requests/hooks/access-request-keys' import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' const principal = { kind: 'session', userId: 'viewer', sessionId: 'session' } as const diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts b/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts index 182426e174a..1b05ba5913a 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts @@ -11,7 +11,7 @@ import { ACCESS_REQUESTS_STALE_TIME, accessRequestKeys, workspaceFeatureDiscoveryQuery, -} from '@/hooks/queries/utils/access-request-keys' +} from '@/ee/access-requests/hooks/access-request-keys' import { PERMISSION_GROUPS_STALE_TIME, permissionGroupKeys, @@ -46,7 +46,7 @@ export async function prefetchWorkspaceAccess( queryKey: accessRequestKeys.discovery(query), queryFn: async () => { const { discoverAccessRequests } = await import( - '@/lib/permission-access-requests/application/requests' + '@/ee/access-requests/lib/application/requests' ) return discoverAccessRequestsContract.response.schema.parse( await discoverAccessRequests.execute({ principal, input: query }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx index 04e42904940..aeffeec2629 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx @@ -28,7 +28,7 @@ const { vi.mock('next/navigation', () => ({ notFound: mockNotFound, redirect: mockRedirect })) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) -vi.mock('@/components/access-requests/permission-access-boundary', () => ({ +vi.mock('@/ee/access-requests/components/permission-access-boundary', () => ({ PermissionAccessBoundary: vi.fn(() => null), })) vi.mock('@/lib/settings/application/workspace-section-access', () => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 97cf21c2783..df6f6c2d7b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -2,7 +2,6 @@ import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { EmptyState } from '@/components/empty-state/empty-state' import { getOrganizationSettingsHref, @@ -13,6 +12,7 @@ import { authorizeWorkspaceSettingsSection } from '@/lib/settings/application/wo import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { resolveSettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' import { SECTION_PREFETCHERS } from './prefetch' import { SettingsPage } from './settings' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 016de4cb3e4..997bf5af779 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -3,7 +3,6 @@ import { useEffect } from 'react' import dynamic from 'next/dynamic' import { usePostHog } from 'posthog-js/react' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { getSettingsPermissionConfigKey } from '@/components/settings/navigation' import { useSession } from '@/lib/auth/auth-client' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' @@ -15,6 +14,7 @@ import { getSettingsSectionMeta, type SettingsSection, } from '@/app/workspace/[workspaceId]/settings/navigation' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' const Admin = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx index 152bd121970..702187a5ab6 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx @@ -1,7 +1,7 @@ import { Suspense } from 'react' import type { Metadata } from 'next' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import TableLoading from '@/app/workspace/[workspaceId]/tables/[tableId]/loading' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' import { Table } from './table' export const metadata: Metadata = { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 4c68bbdb44c..56257d6dbcc 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -8,7 +8,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import type { TableDefinition } from '@/lib/table' import { generateUniqueTableName, MAX_TABLE_BATCH_ITEMS } from '@/lib/table/constants' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' @@ -80,6 +79,7 @@ import { tablesSortParams, tablesUrlKeys, } from '@/app/workspace/[workspaceId]/tables/search-params' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hooks/queries/folders' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.test.tsx index 9d5ae83298f..71d3b176bd0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.test.tsx @@ -82,10 +82,10 @@ vi.mock('@/hooks/use-permission-config', () => ({ isBlockRequestable: (type: string) => type.startsWith('locked-'), }), })) -vi.mock('@/components/access-requests/permission-access-boundary', () => ({ +vi.mock('@/ee/access-requests/components/permission-access-boundary', () => ({ useWorkspaceAccessRequestFeatures: discovery, })) -vi.mock('@/components/access-requests/request-access-action', () => ({ +vi.mock('@/ee/access-requests/components/request-access-action', () => ({ RequestAccessModal: ({ label, onClose }: { label: string; onClose: () => void }) => (
Request {label} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index e4323e2c711..09ede00b538 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -24,8 +24,6 @@ import { import { ChevronDown, Lock, Search } from '@sim/emcn/icons' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' -import { useWorkspaceAccessRequestFeatures } from '@/components/access-requests/permission-access-boundary' -import { RequestAccessModal } from '@/components/access-requests/request-access-action' import { captureEvent } from '@/lib/posthog/client' import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { @@ -41,6 +39,8 @@ import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { getCustomBlockTile } from '@/blocks/custom/custom-block-icon' import { getCanonicalBlocksByCategory } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' +import { useWorkspaceAccessRequestFeatures } from '@/ee/access-requests/components/permission-access-boundary' +import { RequestAccessModal } from '@/ee/access-requests/components/request-access-action' import { useOrgBrandConfig } from '@/ee/whitelabeling/components/branding-provider' import { useCustomBlocks } from '@/hooks/queries/custom-blocks' import { usePermissionConfig } from '@/hooks/use-permission-config' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index cd6873ce25e..5f894cfc5f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -27,7 +27,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' -import { RequestAccessModal } from '@/components/access-requests/request-access-action' import { VariableIcon } from '@/components/icons' import { ThinkingLoader } from '@/components/ui' import { requestJson } from '@/lib/api/client/request' @@ -68,7 +67,8 @@ import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId] import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' -import { useDiscoverAccessRequests } from '@/hooks/queries/access-requests' +import { RequestAccessModal } from '@/ee/access-requests/components/request-access-action' +import { useDiscoverAccessRequests } from '@/ee/access-requests/hooks/access-requests' import { useCopilotChatSelection } from '@/hooks/queries/copilot-chat-selection' import { type CopilotChatListItem, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index 8b89a41104f..cc1deb58045 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -68,7 +68,7 @@ vi.mock('@/hooks/use-permission-config', () => ({ }), })) -vi.mock('@/hooks/queries/access-requests', () => ({ +vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ useDiscoverAccessRequests: () => ({ data: { enabled: false, entries: [] }, isPending: false }), })) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 0d6017ea30a..85a47af1cf1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -40,7 +40,6 @@ import { Command } from 'cmdk' import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { createPortal } from 'react-dom' -import { useWorkspaceAccessRequestFeatures } from '@/components/access-requests/permission-access-boundary' import { supportsAtomicBrowserPanelOcclusion } from '@/lib/browser-agent/transport' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' @@ -89,6 +88,7 @@ import { CMDK_SECTION_GAP_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' +import { useWorkspaceAccessRequestFeatures } from '@/ee/access-requests/components/permission-access-boundary' import { useFolderMap } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useTablesList } from '@/hooks/queries/tables' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index bbe633e516a..c9f4f040c84 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -16,7 +16,6 @@ import { import { ArrowUpRight, Building, ChevronLeft, Lock } from '@sim/emcn/icons' import { useQueryClient } from '@tanstack/react-query' import { useParams, usePathname, useRouter } from 'next/navigation' -import { useWorkspaceAccessRequestFeatures } from '@/components/access-requests/permission-access-boundary' import { type DesktopSettingsSurface, getOrganizationSettingsHref, @@ -47,6 +46,7 @@ import { SIDEBAR_RAIL_CHIP_CLASS, SIDEBAR_SECTION_GAP_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { useWorkspaceAccessRequestFeatures } from '@/ee/access-requests/components/permission-access-boundary' import { useSSOProviders } from '@/ee/sso/hooks/sso' import { useForkingAvailable } from '@/ee/workspace-forking/hooks/use-forking-available' import { useGeneralSettings } from '@/hooks/queries/general-settings' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 947d94305e5..2f2d5891a9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -39,7 +39,6 @@ import { import { createLogger } from '@sim/logger' import { useParams, usePathname, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' -import { useWorkspaceAccessRequestFeatures } from '@/components/access-requests/permission-access-boundary' import { useSession } from '@/lib/auth/auth-client' import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions' import { focusVisibleBrowserOmnibox } from '@/lib/browser-agent/renderer-shortcuts' @@ -119,6 +118,7 @@ import { } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' import { useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' +import { useWorkspaceAccessRequestFeatures } from '@/ee/access-requests/components/permission-access-boundary' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useFolderMap, useFolders } from '@/hooks/queries/folders' import { type LogFilters, useLogsList } from '@/hooks/queries/logs' diff --git a/apps/sim/ee/README.md b/apps/sim/ee/README.md index 6a7ee3c8d52..c84b39518a3 100644 --- a/apps/sim/ee/README.md +++ b/apps/sim/ee/README.md @@ -7,6 +7,7 @@ for production use. - **SSO (Single Sign-On)**: OIDC and SAML authentication integration - **Access Control**: Permission groups for fine-grained user access management +- **Access Requests**: Members request restricted features or a higher credit cap; organization admins review and apply them. On for every entitled organization unless it opts out - **Whitelabeling**: Custom branding and theming for enterprise deployments - **Directory provisioning (SCIM)**: SCIM 2.0 user and group provisioning from Okta, Microsoft Entra, and other identity providers, with group-to-access mapping diff --git a/apps/sim/ee/access-control/components/access-control-layout.test.tsx b/apps/sim/ee/access-control/components/access-control-layout.test.tsx index 80ee57e878e..e47f7ebcbab 100644 --- a/apps/sim/ee/access-control/components/access-control-layout.test.tsx +++ b/apps/sim/ee/access-control/components/access-control-layout.test.tsx @@ -11,7 +11,7 @@ vi.mock('next/navigation', () => ({ usePathname: () => '/settings/access-control', })) vi.mock('@/ee/access-control/components/group-detail', () => ({ GroupDetail: () => null })) -vi.mock('@/components/access-requests/access-request-review', () => ({ +vi.mock('@/ee/access-requests/components/access-request-review', () => ({ AccessRequestReview: () => null, })) vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ @@ -23,7 +23,7 @@ vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ vi.mock('@/hooks/queries/organization', () => ({ useOrganizationBilling: () => ({ data: undefined, isPending: false }), })) -vi.mock('@/hooks/queries/access-requests', () => ({ +vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ ACCESS_REQUEST_PAGE_SIZE: 25, useOrganizationAccessRequests: mocks.requests, useAccessRequestSettings: () => ({ data: { allowRequests: true }, isPending: false }), diff --git a/apps/sim/ee/access-control/components/access-control.test.tsx b/apps/sim/ee/access-control/components/access-control.test.tsx index bd2718e1684..a3538511c08 100644 --- a/apps/sim/ee/access-control/components/access-control.test.tsx +++ b/apps/sim/ee/access-control/components/access-control.test.tsx @@ -32,7 +32,7 @@ vi.mock('nuqs', () => ({ useQueryState: () => [null, vi.fn()], useQueryStates: () => [{ 'access-view': 'groups' }, vi.fn()], })) -vi.mock('@/components/access-requests/organization-access-requests', () => ({ +vi.mock('@/ee/access-requests/components/organization-access-requests', () => ({ OrganizationAccessRequests: () => null, })) vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 10869e2f3e3..e013167a580 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -18,11 +18,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' import { useQueryState, useQueryStates } from 'nuqs' -import { OrganizationAccessRequests } from '@/components/access-requests/organization-access-requests' -import { - accessRequestUrlOptions, - accessReviewSearchParams, -} from '@/components/access-requests/search-params' import { isEnterprise } from '@/lib/billing/plan-helpers' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { @@ -54,6 +49,11 @@ import { usePermissionGroups, useUserPermissionConfig, } from '@/ee/access-control/hooks/permission-groups' +import { OrganizationAccessRequests } from '@/ee/access-requests/components/organization-access-requests' +import { + accessRequestUrlOptions, + accessReviewSearchParams, +} from '@/ee/access-requests/components/search-params' import { useOrganizationBilling } from '@/hooks/queries/organization' const logger = createLogger('AccessControl') diff --git a/apps/sim/lib/permission-access-requests/README.md b/apps/sim/ee/access-requests/README.md similarity index 91% rename from apps/sim/lib/permission-access-requests/README.md rename to apps/sim/ee/access-requests/README.md index 0f18a206667..7d0b04e90c6 100644 --- a/apps/sim/lib/permission-access-requests/README.md +++ b/apps/sim/ee/access-requests/README.md @@ -2,12 +2,11 @@ Members request access from locked features, the block picker, or **My access requests**. Organization owners and administrators review requests in **Access control → Requests**, **Review access requests** in the workspace menu, or through an authenticated email link. The same queue handles increases to an administrator-set member credit cap. -## Rollout +## Deployment 1. Apply migration `0349_permission_access_requests.sql` before deploying the application changes. -2. Enable the global AppConfig `permission-access-requests` flag. Outside AppConfig deployments, set `PERMISSION_ACCESS_REQUESTS_ENABLED=true`. -3. Each organization starts with **Allow users to request permissions** enabled. An explicit organization opt-out disables creation and approval and restores existing feature hiding. History, cancellation, and decline remain available. -4. The existing outbox worker delivers notifications. Email links open authenticated review/history; email never applies a change. +2. Each organization starts with **Allow users to request permissions** enabled. An explicit organization opt-out disables creation and approval and restores existing feature hiding. History, cancellation, and decline remain available. +3. The existing outbox worker delivers notifications. Email links open authenticated review/history; email never applies a change. ## Policy and lifecycle diff --git a/apps/sim/components/access-requests/access-request-review.test.tsx b/apps/sim/ee/access-requests/components/access-request-review.test.tsx similarity index 97% rename from apps/sim/components/access-requests/access-request-review.test.tsx rename to apps/sim/ee/access-requests/components/access-request-review.test.tsx index 02c969b500f..dc7366e0aaa 100644 --- a/apps/sim/components/access-requests/access-request-review.test.tsx +++ b/apps/sim/ee/access-requests/components/access-request-review.test.tsx @@ -5,7 +5,7 @@ import { act } from 'react' import { toast } from '@sim/emcn' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AccessRequestReview } from '@/components/access-requests/access-request-review' +import { AccessRequestReview } from '@/ee/access-requests/components/access-request-review' vi.mock('@sim/emcn', async (importOriginal) => ({ ...(await importOriginal()), @@ -13,7 +13,7 @@ vi.mock('@sim/emcn', async (importOriginal) => ({ })) const mocks = vi.hoisted(() => ({ preview: vi.fn(), resolve: vi.fn() })) -vi.mock('@/hooks/queries/access-requests', () => ({ +vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ useAccessRequestPreview: mocks.preview, useResolveAccessRequest: mocks.resolve, })) diff --git a/apps/sim/components/access-requests/access-request-review.tsx b/apps/sim/ee/access-requests/components/access-request-review.tsx similarity index 96% rename from apps/sim/components/access-requests/access-request-review.tsx rename to apps/sim/ee/access-requests/components/access-request-review.tsx index db68369704f..6772705a1fc 100644 --- a/apps/sim/components/access-requests/access-request-review.tsx +++ b/apps/sim/ee/access-requests/components/access-request-review.tsx @@ -11,9 +11,12 @@ import { ChipModalHeader, toast, } from '@sim/emcn' -import { PolicyChanges } from '@/components/access-requests/policy-changes' -import { ACCESS_REQUEST_STATUS_LABELS } from '@/components/access-requests/status' -import { useAccessRequestPreview, useResolveAccessRequest } from '@/hooks/queries/access-requests' +import { PolicyChanges } from '@/ee/access-requests/components/policy-changes' +import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' +import { + useAccessRequestPreview, + useResolveAccessRequest, +} from '@/ee/access-requests/hooks/access-requests' interface AccessRequestReviewProps { organizationId: string diff --git a/apps/sim/components/access-requests/access-requests-loading.tsx b/apps/sim/ee/access-requests/components/access-requests-loading.tsx similarity index 100% rename from apps/sim/components/access-requests/access-requests-loading.tsx rename to apps/sim/ee/access-requests/components/access-requests-loading.tsx diff --git a/apps/sim/components/access-requests/member-limit-request-action.tsx b/apps/sim/ee/access-requests/components/member-limit-request-action.tsx similarity index 82% rename from apps/sim/components/access-requests/member-limit-request-action.tsx rename to apps/sim/ee/access-requests/components/member-limit-request-action.tsx index f174e442c38..f02409de2ad 100644 --- a/apps/sim/components/access-requests/member-limit-request-action.tsx +++ b/apps/sim/ee/access-requests/components/member-limit-request-action.tsx @@ -1,8 +1,8 @@ 'use client' -import { RequestAccessAction } from '@/components/access-requests/request-access-action' import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' -import { useDiscoverAccessRequests } from '@/hooks/queries/access-requests' +import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' +import { useDiscoverAccessRequests } from '@/ee/access-requests/hooks/access-requests' interface MemberLimitRequestActionProps { scope: AccessRequestScope diff --git a/apps/sim/components/access-requests/my-access-request-details.tsx b/apps/sim/ee/access-requests/components/my-access-request-details.tsx similarity index 94% rename from apps/sim/components/access-requests/my-access-request-details.tsx rename to apps/sim/ee/access-requests/components/my-access-request-details.tsx index 5eb44c1e1fc..12b69995577 100644 --- a/apps/sim/components/access-requests/my-access-request-details.tsx +++ b/apps/sim/ee/access-requests/components/my-access-request-details.tsx @@ -11,9 +11,12 @@ import { ChipTag, toast, } from '@sim/emcn' -import { ACCESS_REQUEST_STATUS_LABELS } from '@/components/access-requests/status' import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' -import { useCancelAccessRequest, useMyAccessRequests } from '@/hooks/queries/access-requests' +import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' +import { + useCancelAccessRequest, + useMyAccessRequests, +} from '@/ee/access-requests/hooks/access-requests' interface MyAccessRequestDetailsProps { scope: AccessRequestScope diff --git a/apps/sim/components/access-requests/my-access-requests.test.tsx b/apps/sim/ee/access-requests/components/my-access-requests.test.tsx similarity index 96% rename from apps/sim/components/access-requests/my-access-requests.test.tsx rename to apps/sim/ee/access-requests/components/my-access-requests.test.tsx index 2913b635dd8..b62b94f4232 100644 --- a/apps/sim/components/access-requests/my-access-requests.test.tsx +++ b/apps/sim/ee/access-requests/components/my-access-requests.test.tsx @@ -5,7 +5,7 @@ import { act } from 'react' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { MyAccessRequests } from '@/components/access-requests/my-access-requests' +import { MyAccessRequests } from '@/ee/access-requests/components/my-access-requests' const mocks = vi.hoisted(() => ({ mine: vi.fn(), @@ -13,7 +13,7 @@ const mocks = vi.hoisted(() => ({ cancel: vi.fn(), url: vi.fn(), })) -vi.mock('@/hooks/queries/access-requests', () => ({ +vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ ACCESS_REQUEST_PAGE_SIZE: 25, useMyAccessRequests: mocks.mine, useDiscoverAccessRequests: mocks.discovery, diff --git a/apps/sim/components/access-requests/my-access-requests.tsx b/apps/sim/ee/access-requests/components/my-access-requests.tsx similarity index 93% rename from apps/sim/components/access-requests/my-access-requests.tsx rename to apps/sim/ee/access-requests/components/my-access-requests.tsx index 3504065a114..fe9a579f934 100644 --- a/apps/sim/components/access-requests/my-access-requests.tsx +++ b/apps/sim/ee/access-requests/components/my-access-requests.tsx @@ -3,27 +3,27 @@ import { Chip, ChipInput, ChipLink, ChipSwitch, ChipTag } from '@sim/emcn' import { Lock, Search } from '@sim/emcn/icons' import { useQueryStates } from 'nuqs' -import { MyAccessRequestDetails } from '@/components/access-requests/my-access-request-details' -import { RequestAccessAction } from '@/components/access-requests/request-access-action' -import { - accessRequestSearchParams, - accessRequestUrlOptions, -} from '@/components/access-requests/search-params' -import { ACCESS_REQUEST_STATUS_LABELS } from '@/components/access-requests/status' import { EmptyState } from '@/components/empty-state/empty-state' import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' import { WORKSPACES_PATH } from '@/lib/navigation/paths' -import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/lib/permission-access-requests/constants' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { RESOURCE_LIST_STACK, SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { MyAccessRequestDetails } from '@/ee/access-requests/components/my-access-request-details' +import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' +import { + accessRequestSearchParams, + accessRequestUrlOptions, +} from '@/ee/access-requests/components/search-params' +import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' import { ACCESS_REQUEST_PAGE_SIZE, useDiscoverAccessRequests, useMyAccessRequests, -} from '@/hooks/queries/access-requests' +} from '@/ee/access-requests/hooks/access-requests' +import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/ee/access-requests/lib/constants' import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' diff --git a/apps/sim/components/access-requests/organization-access-requests.test.tsx b/apps/sim/ee/access-requests/components/organization-access-requests.test.tsx similarity index 95% rename from apps/sim/components/access-requests/organization-access-requests.test.tsx rename to apps/sim/ee/access-requests/components/organization-access-requests.test.tsx index 216b03924ed..22a170ba1e4 100644 --- a/apps/sim/components/access-requests/organization-access-requests.test.tsx +++ b/apps/sim/ee/access-requests/components/organization-access-requests.test.tsx @@ -11,17 +11,17 @@ const mocks = vi.hoisted(() => ({ mutate: vi.fn(), refetch: vi.fn(), })) -vi.mock('@/hooks/queries/access-requests', () => ({ +vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ ACCESS_REQUEST_PAGE_SIZE: 25, useAccessRequestSettings: mocks.settings, useOrganizationAccessRequests: mocks.requests, useUpdateAccessRequestSettings: mocks.update, })) -vi.mock('@/components/access-requests/access-request-review', () => ({ +vi.mock('@/ee/access-requests/components/access-request-review', () => ({ AccessRequestReview: () => null, })) -import { OrganizationAccessRequests } from '@/components/access-requests/organization-access-requests' +import { OrganizationAccessRequests } from '@/ee/access-requests/components/organization-access-requests' describe('organization access request settings', () => { let container: HTMLDivElement diff --git a/apps/sim/components/access-requests/organization-access-requests.tsx b/apps/sim/ee/access-requests/components/organization-access-requests.tsx similarity index 95% rename from apps/sim/components/access-requests/organization-access-requests.tsx rename to apps/sim/ee/access-requests/components/organization-access-requests.tsx index 08bb9d4eee8..f31ddb33008 100644 --- a/apps/sim/components/access-requests/organization-access-requests.tsx +++ b/apps/sim/ee/access-requests/components/organization-access-requests.tsx @@ -3,13 +3,6 @@ import { Chip, ChipDropdown, ChipInput, ChipSwitch, ChipTag, toast } from '@sim/emcn' import { Search } from '@sim/emcn/icons' import { useQueryStates } from 'nuqs' -import { AccessRequestReview } from '@/components/access-requests/access-request-review' -import { - accessRequestUrlOptions, - accessReviewSearchParams, -} from '@/components/access-requests/search-params' -import { ACCESS_REQUEST_STATUS_LABELS } from '@/components/access-requests/status' -import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/lib/permission-access-requests/constants' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { SettingsEmptyState, @@ -21,12 +14,19 @@ import { SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { AccessRequestReview } from '@/ee/access-requests/components/access-request-review' +import { + accessRequestUrlOptions, + accessReviewSearchParams, +} from '@/ee/access-requests/components/search-params' +import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' import { ACCESS_REQUEST_PAGE_SIZE, useAccessRequestSettings, useOrganizationAccessRequests, useUpdateAccessRequestSettings, -} from '@/hooks/queries/access-requests' +} from '@/ee/access-requests/hooks/access-requests' +import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/ee/access-requests/lib/constants' import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' diff --git a/apps/sim/components/access-requests/permission-access-boundary.test.tsx b/apps/sim/ee/access-requests/components/permission-access-boundary.test.tsx similarity index 95% rename from apps/sim/components/access-requests/permission-access-boundary.test.tsx rename to apps/sim/ee/access-requests/components/permission-access-boundary.test.tsx index 6dc658227b3..a48b07d88a6 100644 --- a/apps/sim/components/access-requests/permission-access-boundary.test.tsx +++ b/apps/sim/ee/access-requests/components/permission-access-boundary.test.tsx @@ -34,12 +34,14 @@ vi.mock('@sim/emcn/icons', () => ({ BookOpen: () => null, })) vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ useUserPermissionConfig: policy })) -vi.mock('@/hooks/queries/access-requests', () => ({ useDiscoverAccessRequests: discovery })) -vi.mock('@/components/access-requests/request-access-action', () => ({ +vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ + useDiscoverAccessRequests: discovery, +})) +vi.mock('@/ee/access-requests/components/request-access-action', () => ({ RequestAccessAction: () => , })) -import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' describe('PermissionAccessBoundary', () => { let container: HTMLDivElement diff --git a/apps/sim/components/access-requests/permission-access-boundary.tsx b/apps/sim/ee/access-requests/components/permission-access-boundary.tsx similarity index 94% rename from apps/sim/components/access-requests/permission-access-boundary.tsx rename to apps/sim/ee/access-requests/components/permission-access-boundary.tsx index bc111f3e694..77d0f78487b 100644 --- a/apps/sim/components/access-requests/permission-access-boundary.tsx +++ b/apps/sim/ee/access-requests/components/permission-access-boundary.tsx @@ -3,15 +3,15 @@ import type { ReactNode } from 'react' import { Chip } from '@sim/emcn' import { useParams, useRouter } from 'next/navigation' -import { RequestAccessAction } from '@/components/access-requests/request-access-action' import { EmptyState } from '@/components/empty-state/empty-state' import type { BooleanPermissionGroupConfigKey } from '@/lib/permission-groups/features' import { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state' import { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state' import { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state' import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' -import { useDiscoverAccessRequests } from '@/hooks/queries/access-requests' -import { workspaceFeatureDiscoveryQuery } from '@/hooks/queries/utils/access-request-keys' +import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' +import { workspaceFeatureDiscoveryQuery } from '@/ee/access-requests/hooks/access-request-keys' +import { useDiscoverAccessRequests } from '@/ee/access-requests/hooks/access-requests' /** Safe feature metadata shared by navigation and access-required pages. */ export function useWorkspaceAccessRequestFeatures() { diff --git a/apps/sim/components/access-requests/policy-changes.test.ts b/apps/sim/ee/access-requests/components/policy-changes.test.ts similarity index 99% rename from apps/sim/components/access-requests/policy-changes.test.ts rename to apps/sim/ee/access-requests/components/policy-changes.test.ts index ace9e0ae6c6..173bbff5f98 100644 --- a/apps/sim/components/access-requests/policy-changes.test.ts +++ b/apps/sim/ee/access-requests/components/policy-changes.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import { describePolicyChange, describePolicyValue, -} from '@/components/access-requests/policy-changes' +} from '@/ee/access-requests/components/policy-changes' const target = { kind: 'integration', id: 'slack_v2' } as const diff --git a/apps/sim/components/access-requests/policy-changes.tsx b/apps/sim/ee/access-requests/components/policy-changes.tsx similarity index 100% rename from apps/sim/components/access-requests/policy-changes.tsx rename to apps/sim/ee/access-requests/components/policy-changes.tsx diff --git a/apps/sim/components/access-requests/request-access-action.test.tsx b/apps/sim/ee/access-requests/components/request-access-action.test.tsx similarity index 98% rename from apps/sim/components/access-requests/request-access-action.test.tsx rename to apps/sim/ee/access-requests/components/request-access-action.test.tsx index 8a4263d68ff..7a13b40ac72 100644 --- a/apps/sim/components/access-requests/request-access-action.test.tsx +++ b/apps/sim/ee/access-requests/components/request-access-action.test.tsx @@ -4,8 +4,8 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { RequestAccessAction } from '@/components/access-requests/request-access-action' import type { AccessRequestTarget } from '@/lib/api/contracts/access-requests' +import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' const mocks = vi.hoisted(() => ({ discovery: vi.fn(), @@ -17,7 +17,7 @@ vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mocks.push }), usePathname: () => '/workspace/workspace', })) -vi.mock('@/hooks/queries/access-requests', () => ({ +vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ useCreateAccessRequest: () => ({ mutate: mocks.create, isPending: false, error: null }), useDiscoverAccessRequests: mocks.discovery, })) diff --git a/apps/sim/components/access-requests/request-access-action.tsx b/apps/sim/ee/access-requests/components/request-access-action.tsx similarity index 97% rename from apps/sim/components/access-requests/request-access-action.tsx rename to apps/sim/ee/access-requests/components/request-access-action.tsx index 34e5703d77f..49f5641ce3d 100644 --- a/apps/sim/components/access-requests/request-access-action.tsx +++ b/apps/sim/ee/access-requests/components/request-access-action.tsx @@ -17,8 +17,11 @@ import { import { Lock } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' import type { AccessRequestScope, AccessRequestTarget } from '@/lib/api/contracts/access-requests' -import { getAccessRequestTargetKey } from '@/lib/permission-groups/access-requests/targets' -import { useCreateAccessRequest, useDiscoverAccessRequests } from '@/hooks/queries/access-requests' +import { + useCreateAccessRequest, + useDiscoverAccessRequests, +} from '@/ee/access-requests/hooks/access-requests' +import { getAccessRequestTargetKey } from '@/ee/access-requests/lib/targets' interface RequestAccessActionProps { scope: AccessRequestScope diff --git a/apps/sim/components/access-requests/search-params.test.ts b/apps/sim/ee/access-requests/components/search-params.test.ts similarity index 95% rename from apps/sim/components/access-requests/search-params.test.ts rename to apps/sim/ee/access-requests/components/search-params.test.ts index 9a6f1a531b0..98846339a08 100644 --- a/apps/sim/components/access-requests/search-params.test.ts +++ b/apps/sim/ee/access-requests/components/search-params.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import { accessRequestSearchParams, accessReviewSearchParams, -} from '@/components/access-requests/search-params' +} from '@/ee/access-requests/components/search-params' describe('access request URL bounds', () => { it.each(['-1', '1.5', '40001', '1e3', '999999999999999999999'])( diff --git a/apps/sim/components/access-requests/search-params.ts b/apps/sim/ee/access-requests/components/search-params.ts similarity index 97% rename from apps/sim/components/access-requests/search-params.ts rename to apps/sim/ee/access-requests/components/search-params.ts index 639adf68736..efe8d9cdb99 100644 --- a/apps/sim/components/access-requests/search-params.ts +++ b/apps/sim/ee/access-requests/components/search-params.ts @@ -4,7 +4,7 @@ import { ACCESS_REQUEST_MAX_ID_LENGTH, ACCESS_REQUEST_MAX_OFFSET, ACCESS_REQUEST_MAX_SEARCH_LENGTH, -} from '@/lib/permission-access-requests/constants' +} from '@/ee/access-requests/lib/constants' const accessRequestPageParser = createParser({ parse(value) { diff --git a/apps/sim/components/access-requests/status.ts b/apps/sim/ee/access-requests/components/status.ts similarity index 100% rename from apps/sim/components/access-requests/status.ts rename to apps/sim/ee/access-requests/components/status.ts diff --git a/apps/sim/hooks/queries/utils/access-request-keys.ts b/apps/sim/ee/access-requests/hooks/access-request-keys.ts similarity index 100% rename from apps/sim/hooks/queries/utils/access-request-keys.ts rename to apps/sim/ee/access-requests/hooks/access-request-keys.ts diff --git a/apps/sim/hooks/queries/access-requests.test.tsx b/apps/sim/ee/access-requests/hooks/access-requests.test.tsx similarity index 98% rename from apps/sim/hooks/queries/access-requests.test.tsx rename to apps/sim/ee/access-requests/hooks/access-requests.test.tsx index 21d97bf12b7..9ce34d47fc1 100644 --- a/apps/sim/hooks/queries/access-requests.test.tsx +++ b/apps/sim/ee/access-requests/hooks/access-requests.test.tsx @@ -14,12 +14,12 @@ import { listMyAccessRequestsContract, resolveAccessRequestContract, } from '@/lib/api/contracts/access-requests' +import { accessRequestKeys } from '@/ee/access-requests/hooks/access-request-keys' import { useDiscoverAccessRequests, useMyAccessRequests, useResolveAccessRequest, -} from '@/hooks/queries/access-requests' -import { accessRequestKeys } from '@/hooks/queries/utils/access-request-keys' +} from '@/ee/access-requests/hooks/access-requests' import { organizationKeys } from '@/hooks/queries/utils/organization-keys' import { workspaceUsageKeys } from '@/hooks/queries/utils/workspace-usage-keys' diff --git a/apps/sim/hooks/queries/access-requests.ts b/apps/sim/ee/access-requests/hooks/access-requests.ts similarity index 98% rename from apps/sim/hooks/queries/access-requests.ts rename to apps/sim/ee/access-requests/hooks/access-requests.ts index dfa5d75d6d0..9b1d31d5c6d 100644 --- a/apps/sim/hooks/queries/access-requests.ts +++ b/apps/sim/ee/access-requests/hooks/access-requests.ts @@ -22,11 +22,11 @@ import type { WorkspaceCreditAvailability, WorkspaceUsageGate, } from '@/lib/api/contracts/workspaces' -import { ACCESS_REQUEST_LIST_PAGE_SIZE } from '@/lib/permission-access-requests/constants' import { ACCESS_REQUESTS_STALE_TIME, accessRequestKeys, -} from '@/hooks/queries/utils/access-request-keys' +} from '@/ee/access-requests/hooks/access-request-keys' +import { ACCESS_REQUEST_LIST_PAGE_SIZE } from '@/ee/access-requests/lib/constants' import { invalidateWorkspaceUsage } from '@/hooks/queries/utils/invalidate-usage' import { organizationKeys } from '@/hooks/queries/utils/organization-keys' import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' diff --git a/apps/sim/lib/permission-access-requests/application/authorization.test.ts b/apps/sim/ee/access-requests/lib/application/authorization.test.ts similarity index 98% rename from apps/sim/lib/permission-access-requests/application/authorization.test.ts rename to apps/sim/ee/access-requests/lib/application/authorization.test.ts index 679a19206fb..bb0e652f8e8 100644 --- a/apps/sim/lib/permission-access-requests/application/authorization.test.ts +++ b/apps/sim/ee/access-requests/lib/application/authorization.test.ts @@ -18,8 +18,8 @@ import type { DbOrTx } from '@/lib/db/types' import { authorizeAccessRequestScope, loadAccessRequestMembership, -} from '@/lib/permission-access-requests/application/authorization' -import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +} from '@/ee/access-requests/lib/application/authorization' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' const principal: SessionPrincipal = { kind: 'session', userId: 'person', sessionId: 'session' } const workspaceScope = { kind: 'workspace' as const, workspaceId: 'workspace' } diff --git a/apps/sim/lib/permission-access-requests/application/authorization.ts b/apps/sim/ee/access-requests/lib/application/authorization.ts similarity index 96% rename from apps/sim/lib/permission-access-requests/application/authorization.ts rename to apps/sim/ee/access-requests/lib/application/authorization.ts index 0dac5ff2e7c..6e613b6c737 100644 --- a/apps/sim/lib/permission-access-requests/application/authorization.ts +++ b/apps/sim/ee/access-requests/lib/application/authorization.ts @@ -15,8 +15,8 @@ import { } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' -import type { AccessRequestOperation } from '@/lib/permission-access-requests/application/operations' -import type { AccessRequestScope } from '@/lib/permission-groups/access-requests/targets' +import type { AccessRequestOperation } from '@/ee/access-requests/lib/application/operations' +import type { AccessRequestScope } from '@/ee/access-requests/lib/targets' export interface AccessRequestContext { organizationId: string | null diff --git a/apps/sim/lib/permission-access-requests/application/authorized-use-case.test.ts b/apps/sim/ee/access-requests/lib/application/authorized-use-case.test.ts similarity index 96% rename from apps/sim/lib/permission-access-requests/application/authorized-use-case.test.ts rename to apps/sim/ee/access-requests/lib/application/authorized-use-case.test.ts index 607dc7a6963..652f072253b 100644 --- a/apps/sim/lib/permission-access-requests/application/authorized-use-case.test.ts +++ b/apps/sim/ee/access-requests/lib/application/authorized-use-case.test.ts @@ -9,7 +9,7 @@ const mocks = vi.hoisted(() => ({ audit: vi.fn(), outbound: vi.fn(), })) -vi.mock('@/lib/permission-access-requests/application/authorization', () => ({ +vi.mock('@/ee/access-requests/lib/application/authorization', () => ({ authorizeAccessRequestScope: mocks.authorize, })) vi.mock('@/lib/billing/organizations/membership', () => ({ @@ -25,8 +25,8 @@ vi.mock('@/lib/core/network/context.server', () => ({ import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' -import { defineAuthorizedAccessRequestUseCase } from '@/lib/permission-access-requests/application/authorized-use-case' -import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { defineAuthorizedAccessRequestUseCase } from '@/ee/access-requests/lib/application/authorized-use-case' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' const principal: SessionPrincipal = { kind: 'session', userId: 'requester', sessionId: 'session' } const scope: AccessRequestScope = { kind: 'workspace', workspaceId: 'workspace' } diff --git a/apps/sim/lib/permission-access-requests/application/authorized-use-case.ts b/apps/sim/ee/access-requests/lib/application/authorized-use-case.ts similarity index 94% rename from apps/sim/lib/permission-access-requests/application/authorized-use-case.ts rename to apps/sim/ee/access-requests/lib/application/authorized-use-case.ts index 7eea4a76977..3672ee15f84 100644 --- a/apps/sim/lib/permission-access-requests/application/authorized-use-case.ts +++ b/apps/sim/ee/access-requests/lib/application/authorized-use-case.ts @@ -12,9 +12,9 @@ import type { DbOrTx } from '@/lib/db/types' import { type AccessRequestContext, authorizeAccessRequestScope, -} from '@/lib/permission-access-requests/application/authorization' -import type { AccessRequestOperation } from '@/lib/permission-access-requests/application/operations' -import type { AccessRequestScope } from '@/lib/permission-groups/access-requests/targets' +} from '@/ee/access-requests/lib/application/authorization' +import type { AccessRequestOperation } from '@/ee/access-requests/lib/application/operations' +import type { AccessRequestScope } from '@/ee/access-requests/lib/targets' interface AccessRequestPreparationArgs { principal: SessionPrincipal diff --git a/apps/sim/lib/permission-access-requests/application/operations.ts b/apps/sim/ee/access-requests/lib/application/operations.ts similarity index 100% rename from apps/sim/lib/permission-access-requests/application/operations.ts rename to apps/sim/ee/access-requests/lib/application/operations.ts diff --git a/apps/sim/lib/permission-access-requests/application/prepare.ts b/apps/sim/ee/access-requests/lib/application/prepare.ts similarity index 67% rename from apps/sim/lib/permission-access-requests/application/prepare.ts rename to apps/sim/ee/access-requests/lib/application/prepare.ts index a05269b1f9d..11568b99267 100644 --- a/apps/sim/lib/permission-access-requests/application/prepare.ts +++ b/apps/sim/ee/access-requests/lib/application/prepare.ts @@ -1,10 +1,9 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { isAccessControlEnabled, isHosted } from '@/lib/core/config/env-flags' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' -import type { AccessRequestContext } from '@/lib/permission-access-requests/application/authorization' -import { loadAccessRequestCatalog } from '@/lib/permission-access-requests/catalog' -import type { AccessRequestTarget } from '@/lib/permission-groups/access-requests/targets' +import type { AccessRequestContext } from '@/ee/access-requests/lib/application/authorization' +import { loadAccessRequestCatalog } from '@/ee/access-requests/lib/catalog' +import type { AccessRequestTarget } from '@/ee/access-requests/lib/targets' /** Resolve deployment metadata before opening a transaction or taking policy locks. */ export async function prepareAccessRequestPolicy( @@ -17,7 +16,7 @@ export async function prepareAccessRequestPolicy( 'forbidden', 'Access requests require an organization-owned workspace' ) - const [catalog, entitled, globalEnabled] = await Promise.all([ + const [catalog, entitled] = await Promise.all([ loadAccessRequestCatalog( { organizationId: context.organizationId, @@ -29,9 +28,8 @@ export async function prepareAccessRequestPolicy( isHosted ? isOrganizationOnEnterprisePlan(context.organizationId) : Promise.resolve(isAccessControlEnabled), - isFeatureEnabled('permission-access-requests'), ]) - return { catalog, entitled, globalEnabled } + return { catalog, entitled } } export type PreparedAccessRequestPolicy = Awaited> diff --git a/apps/sim/lib/permission-access-requests/application/requests.test.ts b/apps/sim/ee/access-requests/lib/application/requests.test.ts similarity index 96% rename from apps/sim/lib/permission-access-requests/application/requests.test.ts rename to apps/sim/ee/access-requests/lib/application/requests.test.ts index 616c518ca57..c3e20698881 100644 --- a/apps/sim/lib/permission-access-requests/application/requests.test.ts +++ b/apps/sim/ee/access-requests/lib/application/requests.test.ts @@ -11,9 +11,9 @@ import { import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AccessRequestRecord, AccessRequestTarget } from '@/lib/api/contracts/access-requests' -import type { StoredAccessRequest } from '@/lib/permission-access-requests/repository' -import { createAccessRequestCatalog } from '@/lib/permission-groups/access-requests/targets' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import type { StoredAccessRequest } from '@/ee/access-requests/lib/repository' +import { createAccessRequestCatalog } from '@/ee/access-requests/lib/targets' const mocks = vi.hoisted(() => ({ authorize: vi.fn(), @@ -23,7 +23,6 @@ const mocks = vi.hoisted(() => ({ audit: vi.fn(), outbox: vi.fn(), enabled: vi.fn(), - featureEnabled: vi.fn(), enterprise: vi.fn(), catalog: vi.fn(), targets: vi.fn(), @@ -34,7 +33,7 @@ const mocks = vi.hoisted(() => ({ list: vi.fn(), })) -vi.mock('@/lib/permission-access-requests/application/authorization', () => ({ +vi.mock('@/ee/access-requests/lib/application/authorization', () => ({ authorizeAccessRequestScope: mocks.authorize, loadAccessRequestMembership: mocks.membership, })) @@ -46,16 +45,15 @@ vi.mock('@/lib/core/application/authorized-workspace-use-case', () => ({ })) vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: mocks.outbox })) vi.mock('@/lib/permission-groups/locks', () => ({ acquirePermissionGroupOrgLock: mocks.groupLock })) -vi.mock('@/lib/permission-access-requests/settings', () => ({ +vi.mock('@/ee/access-requests/lib/settings', () => ({ isAccessRequestEnabled: mocks.enabled, readAccessRequestSettings: vi.fn(), })) -vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mocks.featureEnabled })) vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: true, isAccessControlEnabled: true })) vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mocks.enterprise, })) -vi.mock('@/lib/permission-access-requests/catalog', () => ({ +vi.mock('@/ee/access-requests/lib/catalog', () => ({ loadAccessRequestCatalog: mocks.catalog, listAccessRequestTargets: mocks.targets, getAccessRequestDeploymentUnavailableReason: mocks.deploymentReason, @@ -64,7 +62,7 @@ vi.mock('@/lib/permission-groups/resolve.server', () => ({ resolveWorkspaceGroup: mocks.group, resolveDefaultGroup: mocks.group, })) -vi.mock('@/lib/permission-access-requests/repository', () => ({ +vi.mock('@/ee/access-requests/lib/repository', () => ({ presentAccessRequest: mocks.present, loadStoredAccessRequest: mocks.stored, listAccessRequestRecords: mocks.list, @@ -75,11 +73,11 @@ import { createAccessRequest, discoverAccessRequests, listMyAccessRequests, -} from '@/lib/permission-access-requests/application/requests' +} from '@/ee/access-requests/lib/application/requests' import { PERMISSION_ACCESS_REQUEST_CREATED_EVENT, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, -} from '@/lib/permission-access-requests/notification-events' +} from '@/ee/access-requests/lib/notification-events' const principal = { kind: 'session', userId: 'requester', sessionId: 'session' } as const const scope = { kind: 'workspace', workspaceId: 'workspace' } as const @@ -152,7 +150,6 @@ beforeEach(() => { mocks.authorize.mockResolvedValue(context) mocks.membership.mockResolvedValue(null) mocks.enabled.mockResolvedValue(true) - mocks.featureEnabled.mockResolvedValue(true) mocks.enterprise.mockResolvedValue(true) mocks.catalog.mockResolvedValue(catalog) mocks.deploymentReason.mockReturnValue(null) diff --git a/apps/sim/lib/permission-access-requests/application/requests.ts b/apps/sim/ee/access-requests/lib/application/requests.ts similarity index 94% rename from apps/sim/lib/permission-access-requests/application/requests.ts rename to apps/sim/ee/access-requests/lib/application/requests.ts index 0f5ed0715ed..e3bf1be02d5 100644 --- a/apps/sim/lib/permission-access-requests/application/requests.ts +++ b/apps/sim/ee/access-requests/lib/application/requests.ts @@ -9,37 +9,44 @@ import { and, count, eq, gte, isNull, or } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import type { DbOrTx } from '@/lib/db/types' -import type { AccessRequestContext } from '@/lib/permission-access-requests/application/authorization' -import { loadAccessRequestMembership } from '@/lib/permission-access-requests/application/authorization' -import { defineAuthorizedAccessRequestUseCase } from '@/lib/permission-access-requests/application/authorized-use-case' -import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' -import { prepareAccessRequestPolicy } from '@/lib/permission-access-requests/application/prepare' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import type { AccessRequestContext } from '@/ee/access-requests/lib/application/authorization' +import { loadAccessRequestMembership } from '@/ee/access-requests/lib/application/authorization' +import { defineAuthorizedAccessRequestUseCase } from '@/ee/access-requests/lib/application/authorized-use-case' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' +import { prepareAccessRequestPolicy } from '@/ee/access-requests/lib/application/prepare' import { listAccessRequestTargets, loadAccessRequestCatalog, -} from '@/lib/permission-access-requests/catalog' +} from '@/ee/access-requests/lib/catalog' import { ACCESS_REQUEST_MAX_DAILY_SUBMISSIONS, ACCESS_REQUEST_MAX_PENDING, ACCESS_REQUEST_SUBMISSION_WINDOW_MS, -} from '@/lib/permission-access-requests/constants' +} from '@/ee/access-requests/lib/constants' import { PERMISSION_ACCESS_REQUEST_CREATED_EVENT, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, -} from '@/lib/permission-access-requests/notification-events' +} from '@/ee/access-requests/lib/notification-events' import { evaluateAccessRequestTarget, loadAccessRequestPolicy, -} from '@/lib/permission-access-requests/policy' +} from '@/ee/access-requests/lib/policy' import { listAccessRequestRecords, loadStoredAccessRequest, presentAccessRequest, -} from '@/lib/permission-access-requests/repository' +} from '@/ee/access-requests/lib/repository' import { isAccessRequestEnabled, readAccessRequestSettings, -} from '@/lib/permission-access-requests/settings' +} from '@/ee/access-requests/lib/settings' +import type { AccessRequestScope } from '@/ee/access-requests/lib/targets' +import { + describeAccessRequestTarget, + getAccessRequestTargetKey, + validateAccessRequestTarget, +} from '@/ee/access-requests/lib/targets' import type { AccessRequestDiscovery, AccessRequestRecord, @@ -47,14 +54,7 @@ import type { AccessRequestStatus, CreateAccessRequestInput, DiscoverAccessRequestsInput, -} from '@/lib/permission-access-requests/types' -import type { AccessRequestScope } from '@/lib/permission-groups/access-requests/targets' -import { - describeAccessRequestTarget, - getAccessRequestTargetKey, - validateAccessRequestTarget, -} from '@/lib/permission-groups/access-requests/targets' -import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +} from '@/ee/access-requests/lib/types' function requireOrganization(organizationId: string | null): string { if (!organizationId) @@ -229,7 +229,7 @@ export const createAccessRequest = defineAuthorizedAccessRequestUseCase({ prepared, }): Promise { const organizationId = requireOrganization(context.organizationId) - if (!(await isAccessRequestEnabled(organizationId, executor, prepared.globalEnabled))) + if (!(await isAccessRequestEnabled(organizationId, executor))) throw new OrchestrationError( 'forbidden', 'Access requests are turned off for this organization' diff --git a/apps/sim/lib/permission-access-requests/application/review.test.ts b/apps/sim/ee/access-requests/lib/application/review.test.ts similarity index 95% rename from apps/sim/lib/permission-access-requests/application/review.test.ts rename to apps/sim/ee/access-requests/lib/application/review.test.ts index cc111932b7f..f4abf9fe9a6 100644 --- a/apps/sim/lib/permission-access-requests/application/review.test.ts +++ b/apps/sim/ee/access-requests/lib/application/review.test.ts @@ -12,9 +12,9 @@ import { import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AccessRequestRecord, AccessRequestTarget } from '@/lib/api/contracts/access-requests' -import type { StoredAccessRequest } from '@/lib/permission-access-requests/repository' -import { createAccessRequestCatalog } from '@/lib/permission-groups/access-requests/targets' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import type { StoredAccessRequest } from '@/ee/access-requests/lib/repository' +import { createAccessRequestCatalog } from '@/ee/access-requests/lib/targets' const mocks = vi.hoisted(() => ({ authorize: vi.fn(), @@ -24,7 +24,6 @@ const mocks = vi.hoisted(() => ({ audit: vi.fn(), outbox: vi.fn(), enabled: vi.fn(), - featureEnabled: vi.fn(), enterprise: vi.fn(), catalog: vi.fn(), deploymentReason: vi.fn(), @@ -35,7 +34,7 @@ const mocks = vi.hoisted(() => ({ setLimit: vi.fn(), })) -vi.mock('@/lib/permission-access-requests/application/authorization', () => ({ +vi.mock('@/ee/access-requests/lib/application/authorization', () => ({ authorizeAccessRequestScope: mocks.authorize, loadAccessRequestMembership: mocks.membership, })) @@ -47,15 +46,14 @@ vi.mock('@/lib/core/application/authorized-workspace-use-case', () => ({ })) vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: mocks.outbox })) vi.mock('@/lib/permission-groups/locks', () => ({ acquirePermissionGroupOrgLock: mocks.groupLock })) -vi.mock('@/lib/permission-access-requests/settings', () => ({ +vi.mock('@/ee/access-requests/lib/settings', () => ({ isAccessRequestEnabled: mocks.enabled, })) -vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mocks.featureEnabled })) vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: true, isAccessControlEnabled: true })) vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mocks.enterprise, })) -vi.mock('@/lib/permission-access-requests/catalog', () => ({ +vi.mock('@/ee/access-requests/lib/catalog', () => ({ loadAccessRequestCatalog: mocks.catalog, getAccessRequestDeploymentUnavailableReason: mocks.deploymentReason, })) @@ -63,10 +61,10 @@ vi.mock('@/lib/permission-groups/resolve.server', () => ({ resolveWorkspaceGroup: mocks.group, resolveDefaultGroup: mocks.group, })) -vi.mock('@/lib/permission-access-requests/impact', () => ({ +vi.mock('@/ee/access-requests/lib/impact', () => ({ loadAccessRequestGroupImpact: mocks.impact, })) -vi.mock('@/lib/permission-access-requests/repository', () => ({ +vi.mock('@/ee/access-requests/lib/repository', () => ({ presentAccessRequest: mocks.present, loadStoredAccessRequest: mocks.stored, })) @@ -77,8 +75,8 @@ vi.mock('@/lib/billing/organizations/member-limits', () => ({ import { previewAccessRequest, resolveAccessRequest, -} from '@/lib/permission-access-requests/application/review' -import { PERMISSION_ACCESS_REQUEST_DECIDED_EVENT } from '@/lib/permission-access-requests/notification-events' +} from '@/ee/access-requests/lib/application/review' +import { PERMISSION_ACCESS_REQUEST_DECIDED_EVENT } from '@/ee/access-requests/lib/notification-events' const principal = { kind: 'session', userId: 'admin', sessionId: 'session' } as const const input = { organizationId: 'organization', requestId: 'request' } @@ -176,7 +174,6 @@ beforeEach(() => { }) mocks.membership.mockResolvedValue({ membershipId: 'membership', role: 'read' }) mocks.enabled.mockResolvedValue(true) - mocks.featureEnabled.mockResolvedValue(true) mocks.enterprise.mockResolvedValue(true) mocks.catalog.mockResolvedValue(catalog) mocks.deploymentReason.mockReturnValue(null) diff --git a/apps/sim/lib/permission-access-requests/application/review.ts b/apps/sim/ee/access-requests/lib/application/review.ts similarity index 93% rename from apps/sim/lib/permission-access-requests/application/review.ts rename to apps/sim/ee/access-requests/lib/application/review.ts index 630cd7d4fca..e1a45306dc3 100644 --- a/apps/sim/lib/permission-access-requests/application/review.ts +++ b/apps/sim/ee/access-requests/lib/application/review.ts @@ -9,36 +9,36 @@ import type { WorkspaceUseCaseAuditEntry } from '@/lib/core/application/authoriz import { OrchestrationError } from '@/lib/core/orchestration/types' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import type { DbOrTx } from '@/lib/db/types' -import { loadAccessRequestMembership } from '@/lib/permission-access-requests/application/authorization' -import { defineAuthorizedAccessRequestUseCase } from '@/lib/permission-access-requests/application/authorized-use-case' -import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { loadAccessRequestMembership } from '@/ee/access-requests/lib/application/authorization' +import { defineAuthorizedAccessRequestUseCase } from '@/ee/access-requests/lib/application/authorized-use-case' +import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations' import { type PreparedAccessRequestPolicy, prepareAccessRequestPolicy, -} from '@/lib/permission-access-requests/application/prepare' -import { loadAccessRequestGroupImpact } from '@/lib/permission-access-requests/impact' -import { PERMISSION_ACCESS_REQUEST_DECIDED_EVENT } from '@/lib/permission-access-requests/notification-events' +} from '@/ee/access-requests/lib/application/prepare' +import { loadAccessRequestGroupImpact } from '@/ee/access-requests/lib/impact' +import { PERMISSION_ACCESS_REQUEST_DECIDED_EVENT } from '@/ee/access-requests/lib/notification-events' import { evaluateAccessRequestTarget, loadAccessRequestPolicy, loadMemberLimit, -} from '@/lib/permission-access-requests/policy' +} from '@/ee/access-requests/lib/policy' import { loadStoredAccessRequest, presentAccessRequest, type StoredAccessRequest, -} from '@/lib/permission-access-requests/repository' +} from '@/ee/access-requests/lib/repository' import { storedAccessRequestDecisionSchema, storedAccessRequestTargetSchema, -} from '@/lib/permission-access-requests/schemas' -import { isAccessRequestEnabled } from '@/lib/permission-access-requests/settings' +} from '@/ee/access-requests/lib/schemas' +import { isAccessRequestEnabled } from '@/ee/access-requests/lib/settings' +import type { AccessRequestScope } from '@/ee/access-requests/lib/targets' import type { AccessRequestPreview, ResolveAccessRequestDecision, -} from '@/lib/permission-access-requests/types' -import type { AccessRequestScope } from '@/lib/permission-groups/access-requests/targets' -import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +} from '@/ee/access-requests/lib/types' interface ReviewInput { organizationId: string @@ -139,7 +139,7 @@ async function loadReviewPreview( catalog, currentPolicy ) - const enabled = await isAccessRequestEnabled(row.organizationId, executor, prepared.globalEnabled) + const enabled = await isAccessRequestEnabled(row.organizationId, executor) const audience = policy.group ? await loadAccessRequestGroupImpact( executor, diff --git a/apps/sim/lib/permission-access-requests/catalog-registry.ts b/apps/sim/ee/access-requests/lib/catalog-registry.ts similarity index 99% rename from apps/sim/lib/permission-access-requests/catalog-registry.ts rename to apps/sim/ee/access-requests/lib/catalog-registry.ts index c3678f3ae4a..bb45379fac7 100644 --- a/apps/sim/lib/permission-access-requests/catalog-registry.ts +++ b/apps/sim/ee/access-requests/lib/catalog-registry.ts @@ -11,14 +11,6 @@ import { isIntegrationDeploymentAvailableForVisibility, isOAuthServiceDeploymentAvailable, } from '@/lib/integrations/availability.server' -import { - type AccessRequestCatalog, - type AccessRequestCatalogItem, - type AccessRequestModelItem, - type AccessRequestTarget, - type AccessRequestToolItem, - createAccessRequestCatalog, -} from '@/lib/permission-groups/access-requests/targets' import { resolveAccessControlBlockType, toAccessControlAllowlist, @@ -26,6 +18,14 @@ import { import { getBlockRegistry } from '@/blocks/registry' import { isHiddenUnder } from '@/blocks/visibility/context' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import { + type AccessRequestCatalog, + type AccessRequestCatalogItem, + type AccessRequestModelItem, + type AccessRequestTarget, + type AccessRequestToolItem, + createAccessRequestCatalog, +} from '@/ee/access-requests/lib/targets' import { getStaticProviderModels, PROVIDER_DEFINITIONS } from '@/providers/models' import { filterBlacklistedModels } from '@/providers/utils' import { getToolMetadata } from '@/tools/metadata' diff --git a/apps/sim/lib/permission-access-requests/catalog.test.ts b/apps/sim/ee/access-requests/lib/catalog.test.ts similarity index 99% rename from apps/sim/lib/permission-access-requests/catalog.test.ts rename to apps/sim/ee/access-requests/lib/catalog.test.ts index ad85beb35d3..c0c81cfc667 100644 --- a/apps/sim/lib/permission-access-requests/catalog.test.ts +++ b/apps/sim/ee/access-requests/lib/catalog.test.ts @@ -94,17 +94,17 @@ vi.mock('@/connectors/registry', () => ({ }, })) +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { getAccessRequestDeploymentUnavailableReason, listAccessRequestTargets, loadAccessRequestCatalog, -} from '@/lib/permission-access-requests/catalog' +} from '@/ee/access-requests/lib/catalog' import { buildAccessRequestPolicyDelta, validateAccessRequestTarget, -} from '@/lib/permission-groups/access-requests/targets' -import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' -import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +} from '@/ee/access-requests/lib/targets' const context = { userId: 'viewer', organizationId: 'org', workspaceId: 'ws' } diff --git a/apps/sim/lib/permission-access-requests/catalog.ts b/apps/sim/ee/access-requests/lib/catalog.ts similarity index 93% rename from apps/sim/lib/permission-access-requests/catalog.ts rename to apps/sim/ee/access-requests/lib/catalog.ts index 983be1eda05..e6442f3b139 100644 --- a/apps/sim/lib/permission-access-requests/catalog.ts +++ b/apps/sim/ee/access-requests/lib/catalog.ts @@ -6,14 +6,14 @@ import { isSandboxesEnabled, isSsoEnabled, } from '@/lib/core/config/env-flags' -import type { AccessRequestCatalogContext } from '@/lib/permission-access-requests/catalog-registry' +import { PLATFORM_FEATURES } from '@/lib/permission-groups/features' +import { FILE_SHARE_AUTH_TYPES } from '@/lib/permission-groups/fields' +import type { AccessRequestCatalogContext } from '@/ee/access-requests/lib/catalog-registry' import { type AccessRequestCatalog, type AccessRequestTarget, createAccessRequestCatalog, -} from '@/lib/permission-groups/access-requests/targets' -import { PLATFORM_FEATURES } from '@/lib/permission-groups/features' -import { FILE_SHARE_AUTH_TYPES } from '@/lib/permission-groups/fields' +} from '@/ee/access-requests/lib/targets' /** Small navigation and credit-limit checks never load or enumerate the integration registries. */ export async function loadAccessRequestCatalog( @@ -32,7 +32,7 @@ export async function loadAccessRequestCatalog( knowledgeConnectors: [], }) const { loadAccessRequestRegistryCatalog } = await import( - '@/lib/permission-access-requests/catalog-registry' + '@/ee/access-requests/lib/catalog-registry' ) return loadAccessRequestRegistryCatalog(context, targetKind) } diff --git a/apps/sim/lib/permission-access-requests/constants.ts b/apps/sim/ee/access-requests/lib/constants.ts similarity index 100% rename from apps/sim/lib/permission-access-requests/constants.ts rename to apps/sim/ee/access-requests/lib/constants.ts diff --git a/apps/sim/lib/permission-access-requests/impact.postgres.test.ts b/apps/sim/ee/access-requests/lib/impact.postgres.test.ts similarity index 99% rename from apps/sim/lib/permission-access-requests/impact.postgres.test.ts rename to apps/sim/ee/access-requests/lib/impact.postgres.test.ts index 3f9eb90359a..bd36ae5dd62 100644 --- a/apps/sim/lib/permission-access-requests/impact.postgres.test.ts +++ b/apps/sim/ee/access-requests/lib/impact.postgres.test.ts @@ -5,7 +5,7 @@ import { generateId } from '@sim/utils/id' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { describe, expect, it, vi } from 'vitest' -import { loadAccessRequestGroupImpact } from '@/lib/permission-access-requests/impact' +import { loadAccessRequestGroupImpact } from '@/ee/access-requests/lib/impact' vi.unmock('@sim/db/schema') vi.unmock('drizzle-orm') diff --git a/apps/sim/lib/permission-access-requests/impact.ts b/apps/sim/ee/access-requests/lib/impact.ts similarity index 98% rename from apps/sim/lib/permission-access-requests/impact.ts rename to apps/sim/ee/access-requests/lib/impact.ts index 2a0ae391b48..66199e0e4a1 100644 --- a/apps/sim/lib/permission-access-requests/impact.ts +++ b/apps/sim/ee/access-requests/lib/impact.ts @@ -8,7 +8,7 @@ import { } from '@sim/db/schema' import { and, count, eq, inArray, isNull, or, type SQL, sql } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' -import type { AccessRequestImpact } from '@/lib/permission-access-requests/types' +import type { AccessRequestImpact } from '@/ee/access-requests/lib/types' /** Order-independent change detector with fixed-size aggregate state instead of sorted row strings. */ function membershipRevision(value: SQL): SQL { diff --git a/apps/sim/lib/permission-access-requests/notification-events.ts b/apps/sim/ee/access-requests/lib/notification-events.ts similarity index 100% rename from apps/sim/lib/permission-access-requests/notification-events.ts rename to apps/sim/ee/access-requests/lib/notification-events.ts diff --git a/apps/sim/lib/permission-access-requests/notifications.test.ts b/apps/sim/ee/access-requests/lib/notifications.test.ts similarity index 97% rename from apps/sim/lib/permission-access-requests/notifications.test.ts rename to apps/sim/ee/access-requests/lib/notifications.test.ts index 9d11dda7fac..2ecee67538f 100644 --- a/apps/sim/lib/permission-access-requests/notifications.test.ts +++ b/apps/sim/ee/access-requests/lib/notifications.test.ts @@ -25,19 +25,19 @@ vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: mockSend, hasEmailService: mockHasEmailService, })) -vi.mock('@/lib/permission-access-requests/application/authorization', () => ({ +vi.mock('@/ee/access-requests/lib/application/authorization', () => ({ loadAccessRequestMembership: mockMembership, })) vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.example' })) -vi.mock('@/lib/permission-access-requests/settings', () => ({ +vi.mock('@/ee/access-requests/lib/settings', () => ({ isAccessRequestEnabled: mockEnabled, })) import { PERMISSION_ACCESS_REQUEST_CREATED_EVENT, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, -} from '@/lib/permission-access-requests/notification-events' -import { permissionAccessRequestOutboxHandlers } from '@/lib/permission-access-requests/notifications' +} from '@/ee/access-requests/lib/notification-events' +import { permissionAccessRequestOutboxHandlers } from '@/ee/access-requests/lib/notifications' const request = { id: 'request-one', diff --git a/apps/sim/lib/permission-access-requests/notifications.ts b/apps/sim/ee/access-requests/lib/notifications.ts similarity index 96% rename from apps/sim/lib/permission-access-requests/notifications.ts rename to apps/sim/ee/access-requests/lib/notifications.ts index 5516dde5dd7..0ef80c6ae7f 100644 --- a/apps/sim/lib/permission-access-requests/notifications.ts +++ b/apps/sim/ee/access-requests/lib/notifications.ts @@ -13,12 +13,12 @@ import { } from '@/lib/core/outbox/service' import { getBaseUrl } from '@/lib/core/utils/urls' import { hasEmailService, sendEmail } from '@/lib/messaging/email/mailer' -import { loadAccessRequestMembership } from '@/lib/permission-access-requests/application/authorization' +import { loadAccessRequestMembership } from '@/ee/access-requests/lib/application/authorization' import { PERMISSION_ACCESS_REQUEST_CREATED_EVENT, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, -} from '@/lib/permission-access-requests/notification-events' -import { isAccessRequestEnabled } from '@/lib/permission-access-requests/settings' +} from '@/ee/access-requests/lib/notification-events' +import { isAccessRequestEnabled } from '@/ee/access-requests/lib/settings' const logger = createLogger('PermissionAccessRequestNotifications') const ADMIN_RECIPIENT_PAGE_SIZE = 50 diff --git a/apps/sim/lib/permission-access-requests/policy.ts b/apps/sim/ee/access-requests/lib/policy.ts similarity index 92% rename from apps/sim/lib/permission-access-requests/policy.ts rename to apps/sim/ee/access-requests/lib/policy.ts index 29c4459f2c3..31bac8dc673 100644 --- a/apps/sim/lib/permission-access-requests/policy.ts +++ b/apps/sim/ee/access-requests/lib/policy.ts @@ -5,12 +5,10 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { isAccessControlEnabled, isHosted } from '@/lib/core/config/env-flags' import type { DbOrTx } from '@/lib/db/types' -import type { AccessRequestContext } from '@/lib/permission-access-requests/application/authorization' -import { getAccessRequestDeploymentUnavailableReason } from '@/lib/permission-access-requests/catalog' -import type { - AccessRequestScope, - AccessRequestTarget, -} from '@/lib/permission-groups/access-requests/targets' +import { resolveDefaultGroup, resolveWorkspaceGroup } from '@/lib/permission-groups/resolve.server' +import type { AccessRequestContext } from '@/ee/access-requests/lib/application/authorization' +import { getAccessRequestDeploymentUnavailableReason } from '@/ee/access-requests/lib/catalog' +import type { AccessRequestScope, AccessRequestTarget } from '@/ee/access-requests/lib/targets' import { type AccessRequestCatalog, buildAccessRequestPolicyDelta, @@ -18,8 +16,7 @@ import { isAccessRequestTargetDenied, isAccessRequestTargetInScope, validateAccessRequestTarget, -} from '@/lib/permission-groups/access-requests/targets' -import { resolveDefaultGroup, resolveWorkspaceGroup } from '@/lib/permission-groups/resolve.server' +} from '@/ee/access-requests/lib/targets' export async function loadMemberLimit( executor: DbOrTx, diff --git a/apps/sim/lib/permission-access-requests/repository.postgres.test.ts b/apps/sim/ee/access-requests/lib/repository.postgres.test.ts similarity index 97% rename from apps/sim/lib/permission-access-requests/repository.postgres.test.ts rename to apps/sim/ee/access-requests/lib/repository.postgres.test.ts index ba9e215772d..cce7027acde 100644 --- a/apps/sim/lib/permission-access-requests/repository.postgres.test.ts +++ b/apps/sim/ee/access-requests/lib/repository.postgres.test.ts @@ -5,7 +5,7 @@ import { and, eq } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { listAccessRequestRecords } from '@/lib/permission-access-requests/repository' +import { listAccessRequestRecords } from '@/ee/access-requests/lib/repository' vi.unmock('@sim/db/schema') vi.unmock('drizzle-orm') diff --git a/apps/sim/lib/permission-access-requests/repository.ts b/apps/sim/ee/access-requests/lib/repository.ts similarity index 95% rename from apps/sim/lib/permission-access-requests/repository.ts rename to apps/sim/ee/access-requests/lib/repository.ts index ce13fc5748a..2e651e0e1d2 100644 --- a/apps/sim/lib/permission-access-requests/repository.ts +++ b/apps/sim/ee/access-requests/lib/repository.ts @@ -3,8 +3,8 @@ import { and, count, desc, eq, ilike, or, type SQL } from 'drizzle-orm' import { escapeLikePattern } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' -import { storedAccessRequestTargetSchema } from '@/lib/permission-access-requests/schemas' -import type { AccessRequestList, AccessRequestRecord } from '@/lib/permission-access-requests/types' +import { storedAccessRequestTargetSchema } from '@/ee/access-requests/lib/schemas' +import type { AccessRequestList, AccessRequestRecord } from '@/ee/access-requests/lib/types' export type StoredAccessRequest = typeof permissionAccessRequest.$inferSelect diff --git a/apps/sim/lib/permission-access-requests/schemas.test.ts b/apps/sim/ee/access-requests/lib/schemas.test.ts similarity index 96% rename from apps/sim/lib/permission-access-requests/schemas.test.ts rename to apps/sim/ee/access-requests/lib/schemas.test.ts index d98cf87162a..234e21edc1c 100644 --- a/apps/sim/lib/permission-access-requests/schemas.test.ts +++ b/apps/sim/ee/access-requests/lib/schemas.test.ts @@ -2,11 +2,11 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { storedAccessRequestPolicyChangeSchema } from '@/lib/permission-access-requests/schemas' import { DEFAULT_PERMISSION_GROUP_CONFIG, PERMISSION_GROUP_FIELDS, } from '@/lib/permission-groups/fields' +import { storedAccessRequestPolicyChangeSchema } from '@/ee/access-requests/lib/schemas' describe('stored access request policy changes', () => { it('accepts unchanged canonical values for every field', () => { diff --git a/apps/sim/lib/permission-access-requests/schemas.ts b/apps/sim/ee/access-requests/lib/schemas.ts similarity index 98% rename from apps/sim/lib/permission-access-requests/schemas.ts rename to apps/sim/ee/access-requests/lib/schemas.ts index 5e17319cd38..2ce98b701af 100644 --- a/apps/sim/lib/permission-access-requests/schemas.ts +++ b/apps/sim/ee/access-requests/lib/schemas.ts @@ -1,7 +1,7 @@ import { z } from 'zod' -import type { AccessRequestTarget as DomainAccessRequestTarget } from '@/lib/permission-groups/access-requests/targets' import { PLATFORM_FEATURES } from '@/lib/permission-groups/features' import { FILE_SHARE_AUTH_TYPES, PERMISSION_GROUP_FIELDS } from '@/lib/permission-groups/fields' +import type { AccessRequestTarget as DomainAccessRequestTarget } from '@/ee/access-requests/lib/targets' const targetIdSchema = z.string().min(1, 'Target ID cannot be empty').max(512) const fingerprintSchema = z.string().min(1, 'A current preview is required').max(128) diff --git a/apps/sim/ee/access-requests/lib/settings.test.ts b/apps/sim/ee/access-requests/lib/settings.test.ts new file mode 100644 index 00000000000..8a540763fcb --- /dev/null +++ b/apps/sim/ee/access-requests/lib/settings.test.ts @@ -0,0 +1,48 @@ +/** + * @vitest-environment node + */ +import { organizationAccessRequestSettings } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { + isAccessRequestEnabled, + readAccessRequestSettings, +} from '@/ee/access-requests/lib/settings' + +beforeEach(() => { + resetDbChainMock() +}) + +describe('permission access request settings', () => { + it('defaults the organization preference on when no settings row exists', async () => { + queueTableRows(organizationAccessRequestSettings, []) + + await expect(readAccessRequestSettings('organization-one')).resolves.toEqual({ + allowRequests: true, + }) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(1) + expect(dbChainMockFns.where).toHaveBeenCalledWith({ + type: 'eq', + left: organizationAccessRequestSettings.organizationId, + right: 'organization-one', + }) + }) + + it('honors an organization opt-out', async () => { + queueTableRows(organizationAccessRequestSettings, [{ allowRequests: false }]) + + await expect(isAccessRequestEnabled('organization-one')).resolves.toBe(false) + }) + + it('preserves an explicit enabled preference', async () => { + queueTableRows(organizationAccessRequestSettings, [{ allowRequests: true }]) + + await expect(isAccessRequestEnabled('organization-one')).resolves.toBe(true) + }) + + it('does not reinterpret a failed settings lookup as permission to submit', async () => { + dbChainMockFns.limit.mockRejectedValueOnce(new Error('database unavailable')) + + await expect(isAccessRequestEnabled('organization-one')).rejects.toThrow('database unavailable') + }) +}) diff --git a/apps/sim/lib/permission-access-requests/settings.ts b/apps/sim/ee/access-requests/lib/settings.ts similarity index 71% rename from apps/sim/lib/permission-access-requests/settings.ts rename to apps/sim/ee/access-requests/lib/settings.ts index f2b35394116..24f7daf2585 100644 --- a/apps/sim/lib/permission-access-requests/settings.ts +++ b/apps/sim/ee/access-requests/lib/settings.ts @@ -1,7 +1,6 @@ import { db } from '@sim/db' import { organizationAccessRequestSettings } from '@sim/db/schema' import { eq } from 'drizzle-orm' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import type { DbOrTx } from '@/lib/db/types' /** Missing settings preserve the default-on organization preference. */ @@ -14,13 +13,10 @@ export async function readAccessRequestSettings(organizationId: string, executor return { allowRequests: row?.allowRequests ?? true } } -/** The rollout flag is evaluated globally; organization preferences can only narrow it. */ +/** Access requests are on unless the organization has opted out. */ export async function isAccessRequestEnabled( organizationId: string, - executor: DbOrTx = db, - enabledAtAdmission?: boolean + executor: DbOrTx = db ): Promise { - if (enabledAtAdmission === false || !(await isFeatureEnabled('permission-access-requests'))) - return false return (await readAccessRequestSettings(organizationId, executor)).allowRequests } diff --git a/apps/sim/lib/permission-groups/access-requests/targets.test.ts b/apps/sim/ee/access-requests/lib/targets.test.ts similarity index 99% rename from apps/sim/lib/permission-groups/access-requests/targets.test.ts rename to apps/sim/ee/access-requests/lib/targets.test.ts index d5ac0b2db25..43d59c1a081 100644 --- a/apps/sim/lib/permission-groups/access-requests/targets.test.ts +++ b/apps/sim/ee/access-requests/lib/targets.test.ts @@ -2,6 +2,8 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { type AccessRequestTarget, buildAccessRequestPolicyDelta, @@ -11,9 +13,7 @@ import { isAccessRequestTargetDenied, isAccessRequestTargetInScope, validateAccessRequestTarget, -} from '@/lib/permission-groups/access-requests/targets' -import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' -import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +} from '@/ee/access-requests/lib/targets' const catalog = createAccessRequestCatalog({ integrations: [ diff --git a/apps/sim/lib/permission-groups/access-requests/targets.ts b/apps/sim/ee/access-requests/lib/targets.ts similarity index 100% rename from apps/sim/lib/permission-groups/access-requests/targets.ts rename to apps/sim/ee/access-requests/lib/targets.ts diff --git a/apps/sim/lib/permission-access-requests/types.ts b/apps/sim/ee/access-requests/lib/types.ts similarity index 90% rename from apps/sim/lib/permission-access-requests/types.ts rename to apps/sim/ee/access-requests/lib/types.ts index 9acb355d778..33f6e18e476 100644 --- a/apps/sim/lib/permission-access-requests/types.ts +++ b/apps/sim/ee/access-requests/lib/types.ts @@ -1,8 +1,5 @@ -import type { AccessRequestDecision } from '@/lib/permission-access-requests/schemas' -import type { - AccessRequestScope, - AccessRequestTarget, -} from '@/lib/permission-groups/access-requests/targets' +import type { AccessRequestDecision } from '@/ee/access-requests/lib/schemas' +import type { AccessRequestScope, AccessRequestTarget } from '@/ee/access-requests/lib/targets' export type AccessRequestStatus = 'pending' | 'fulfilled' | 'declined' | 'cancelled' | 'closed' diff --git a/apps/sim/lib/api/contracts/access-requests.ts b/apps/sim/lib/api/contracts/access-requests.ts index 53f40b4f172..4532bbf6524 100644 --- a/apps/sim/lib/api/contracts/access-requests.ts +++ b/apps/sim/lib/api/contracts/access-requests.ts @@ -1,22 +1,22 @@ import { z } from 'zod' import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' +import { PERMISSION_GROUP_FIELDS } from '@/lib/permission-groups/fields' import { ACCESS_REQUEST_MAX_ID_LENGTH, ACCESS_REQUEST_MAX_OFFSET, ACCESS_REQUEST_MAX_SEARCH_LENGTH, -} from '@/lib/permission-access-requests/constants' +} from '@/ee/access-requests/lib/constants' import { storedAccessRequestDecisionSchema, storedAccessRequestPolicyChangeSchema, storedAccessRequestPolicyValueSchema, storedAccessRequestTargetSchema, -} from '@/lib/permission-access-requests/schemas' +} from '@/ee/access-requests/lib/schemas' import { ACCESS_REQUEST_TARGET_KINDS, type AccessRequestScope as DomainAccessRequestScope, -} from '@/lib/permission-groups/access-requests/targets' -import { PERMISSION_GROUP_FIELDS } from '@/lib/permission-groups/fields' +} from '@/ee/access-requests/lib/targets' export const ACCESS_REQUEST_STATUSES = [ 'pending', diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 8d61dba5005..6f7e4974c84 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -626,7 +626,6 @@ export const env = createEnv({ FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements) TABLES_V2_API: z.boolean().optional(), // Enable the v2 tables HTTP API (public /api/v2/tables + internal /api/table/[tableId]/query predicate-grammar route) TABLE_ROW_TTL: z.boolean().optional(), - PERMISSION_ACCESS_REQUESTS_ENABLED: z.boolean().optional(), CREDENTIAL_GROUPS: z.boolean().optional(), // Enable enterprise Credential Groups globally KNOWLEDGE_MEMBER_ACCESS: z.boolean().optional(), // Enable per-member knowledge connectors and hybrid-by-default retrieval globally diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index bd010a4f949..de18088049a 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { FeatureFlagContext, FeatureFlagName } from '@/lib/core/config/feature-flags' const { mockFetch, mockIsPlatformAdmin, envRef } = vi.hoisted(() => ({ @@ -13,7 +13,6 @@ const { mockFetch, mockIsPlatformAdmin, envRef } = vi.hoisted(() => ({ APPCONFIG_ENVIRONMENT: 'staging' as string | undefined, TABLES_V2_API: undefined as boolean | undefined, TABLE_ROW_TTL: undefined as boolean | undefined, - PERMISSION_ACCESS_REQUESTS_ENABLED: undefined as boolean | undefined, CREDENTIAL_GROUPS: undefined as boolean | undefined, KNOWLEDGE_MEMBER_ACCESS: undefined as boolean | undefined, SLACK_SEARCH_SHARED_APP: undefined as boolean | undefined, @@ -337,26 +336,3 @@ describe('table-row-ttl flag', () => { expect(await isFeatureEnabled('table-row-ttl')).toBe(true) }) }) - -describe('permission access request rollout', () => { - beforeEach(() => { - vi.clearAllMocks() - setEnvFlags({ isAppConfigEnabled: false }) - envRef.PERMISSION_ACCESS_REQUESTS_ENABLED = undefined - }) - afterEach(() => { - envRef.PERMISSION_ACCESS_REQUESTS_ENABLED = undefined - }) - it('defaults off and can be enabled with the fallback secret', async () => { - expect(await isFeatureEnabled('permission-access-requests')).toBe(false) - envRef.PERMISSION_ACCESS_REQUESTS_ENABLED = true - expect(await isFeatureEnabled('permission-access-requests')).toBe(true) - expect(mockFetch).not.toHaveBeenCalled() - }) - it('uses a global AppConfig rule without organization targeting', async () => { - withAppConfig({ 'permission-access-requests': { enabled: false, orgIds: ['org'] } }) - expect(await isFeatureEnabled('permission-access-requests')).toBe(false) - withAppConfig({ 'permission-access-requests': { enabled: true } }) - expect(await isFeatureEnabled('permission-access-requests')).toBe(true) - }) -}) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 597001b5cc2..2a94f48ee09 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -46,11 +46,6 @@ interface FeatureFlagDefinition { /** The single registry of known flags. To add a flag, add one entry here. */ const FEATURE_FLAGS = { - 'permission-access-requests': { - description: - 'Enable permission and member usage-cap requests globally. Organizations can opt out in access control settings.', - fallback: 'PERMISSION_ACCESS_REQUESTS_ENABLED', - }, 'slack-search-shared-app': { description: 'Enable the official shared Slack app for existing Search customers. Supports orgId ' + diff --git a/apps/sim/lib/core/outbox/processor.test.ts b/apps/sim/lib/core/outbox/processor.test.ts index d20af17bc58..6ad93cc80fa 100644 --- a/apps/sim/lib/core/outbox/processor.test.ts +++ b/apps/sim/lib/core/outbox/processor.test.ts @@ -37,7 +37,7 @@ vi.mock('@/lib/mothership/inbox/cleanup-outbox', () => ({ inboxCleanupOutboxHand vi.mock('@/lib/organizations/resource-cleanup', () => ({ organizationResourceCleanupOutboxHandlers: {}, })) -vi.mock('@/lib/permission-access-requests/notifications', () => ({ +vi.mock('@/ee/access-requests/lib/notifications', () => ({ permissionAccessRequestOutboxHandlers: {}, })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox', () => ({ diff --git a/apps/sim/lib/core/outbox/processor.ts b/apps/sim/lib/core/outbox/processor.ts index 464460579bc..41e625b118f 100644 --- a/apps/sim/lib/core/outbox/processor.ts +++ b/apps/sim/lib/core/outbox/processor.ts @@ -20,12 +20,12 @@ import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/docum import { recoverKnowledgeDocumentProcessing } from '@/lib/knowledge/documents/processing-recovery' import { inboxCleanupOutboxHandlers } from '@/lib/mothership/inbox/cleanup-outbox' import { organizationResourceCleanupOutboxHandlers } from '@/lib/organizations/resource-cleanup' -import { permissionAccessRequestOutboxHandlers } from '@/lib/permission-access-requests/notifications' import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox' import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox' import { invitationMigrationOutboxHandlers } from '@/lib/workspaces/admin-move' import { workspaceOperationOutboxHandlers } from '@/lib/workspaces/operations/outbox' +import { permissionAccessRequestOutboxHandlers } from '@/ee/access-requests/lib/notifications' import { forkContentOutboxHandlers } from '@/ee/workspace-forking/application/content-outbox' import { reapStaleBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store' diff --git a/apps/sim/lib/permission-access-requests/settings.test.ts b/apps/sim/lib/permission-access-requests/settings.test.ts deleted file mode 100644 index 69643b15c04..00000000000 --- a/apps/sim/lib/permission-access-requests/settings.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * @vitest-environment node - */ -import { organizationAccessRequestSettings } from '@sim/db/schema' -import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockIsFeatureEnabled } = vi.hoisted(() => ({ mockIsFeatureEnabled: vi.fn() })) - -vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mockIsFeatureEnabled })) - -import { - isAccessRequestEnabled, - readAccessRequestSettings, -} from '@/lib/permission-access-requests/settings' - -beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - mockIsFeatureEnabled.mockResolvedValue(true) -}) - -describe('permission access request settings', () => { - it('defaults the organization preference on when no settings row exists', async () => { - queueTableRows(organizationAccessRequestSettings, []) - - await expect(readAccessRequestSettings('organization-one')).resolves.toEqual({ - allowRequests: true, - }) - expect(dbChainMockFns.limit).toHaveBeenCalledWith(1) - expect(dbChainMockFns.where).toHaveBeenCalledWith({ - type: 'eq', - left: organizationAccessRequestSettings.organizationId, - right: 'organization-one', - }) - }) - - it('does not let the default-on preference bypass the global rollout flag', async () => { - mockIsFeatureEnabled.mockResolvedValue(false) - - await expect(isAccessRequestEnabled('organization-one')).resolves.toBe(false) - - expect(mockIsFeatureEnabled).toHaveBeenCalledExactlyOnceWith('permission-access-requests') - expect(dbChainMockFns.select).not.toHaveBeenCalled() - }) - - it('enables requests when rollout is active and the organization has not opted out', async () => { - queueTableRows(organizationAccessRequestSettings, []) - - await expect(isAccessRequestEnabled('organization-one')).resolves.toBe(true) - }) - - it('rechecks rollout after an enabled admission snapshot', async () => { - mockIsFeatureEnabled.mockResolvedValue(false) - - await expect(isAccessRequestEnabled('organization-one', undefined, true)).resolves.toBe(false) - - expect(mockIsFeatureEnabled).toHaveBeenCalledExactlyOnceWith('permission-access-requests') - expect(dbChainMockFns.select).not.toHaveBeenCalled() - }) - - it('keeps a disabled admission snapshot denied even if rollout is now enabled', async () => { - await expect(isAccessRequestEnabled('organization-one', undefined, false)).resolves.toBe(false) - - expect(mockIsFeatureEnabled).not.toHaveBeenCalled() - expect(dbChainMockFns.select).not.toHaveBeenCalled() - }) - - it('requires the current organization preference after an enabled admission snapshot', async () => { - queueTableRows(organizationAccessRequestSettings, [{ allowRequests: false }]) - - await expect(isAccessRequestEnabled('organization-one', undefined, true)).resolves.toBe(false) - - expect(mockIsFeatureEnabled).toHaveBeenCalledExactlyOnceWith('permission-access-requests') - expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) - }) - - it('honors an organization opt-out while global rollout is active', async () => { - queueTableRows(organizationAccessRequestSettings, [{ allowRequests: false }]) - - await expect(isAccessRequestEnabled('organization-one')).resolves.toBe(false) - }) - - it('preserves an explicit enabled preference', async () => { - queueTableRows(organizationAccessRequestSettings, [{ allowRequests: true }]) - - await expect(isAccessRequestEnabled('organization-one')).resolves.toBe(true) - }) - - it('does not reinterpret a failed settings lookup as permission to submit', async () => { - dbChainMockFns.limit.mockRejectedValueOnce(new Error('database unavailable')) - - await expect(isAccessRequestEnabled('organization-one')).rejects.toThrow('database unavailable') - }) -}) diff --git a/apps/sim/lib/settings/application/workspace-section-access.test.ts b/apps/sim/lib/settings/application/workspace-section-access.test.ts index f1378edf865..77c804cd809 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.test.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.test.ts @@ -59,7 +59,7 @@ vi.mock('@/components/settings/navigation', () => ({ ), WORKSPACE_PERMISSION_CONFIG_KEYS: { secrets: 'hideSecretsTab' }, })) -vi.mock('@/lib/permission-access-requests/settings', () => ({ +vi.mock('@/ee/access-requests/lib/settings', () => ({ isAccessRequestEnabled: mocks.isAccessRequestEnabled, })) vi.mock('@/lib/billing/core/subscription', () => ({ diff --git a/apps/sim/lib/settings/application/workspace-section-access.ts b/apps/sim/lib/settings/application/workspace-section-access.ts index 01cca335dfe..ab8c8e2a6d4 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.ts @@ -12,7 +12,6 @@ import { import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' -import { isAccessRequestEnabled } from '@/lib/permission-access-requests/settings' import type { BooleanPermissionGroupConfigKey } from '@/lib/permission-groups/features' import { isOrganizationPermissionRegimeActive } from '@/lib/permission-groups/resolve.server' import { isPlatformAdmin } from '@/lib/permissions/super-user' @@ -20,6 +19,7 @@ import { authorizeOrganizationSettingsSection } from '@/lib/settings/application import { isCustomBlocksEligibleForOrganization } from '@/lib/workflows/custom-blocks/operations' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { resolveVerifiedUserAccessControlContext } from '@/ee/access-control/utils/permission-check' +import { isAccessRequestEnabled } from '@/ee/access-requests/lib/settings' import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz' export type WorkspaceSettingsSectionAccess = diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index 9be3e4c4193..af6dad97f18 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -77,7 +77,7 @@ "app/workspace/[workspaceId]/access-requests/page.tsx": { "modules": 45, "gateways": { - "apps/sim/components/access-requests/my-access-requests.tsx": 43 + "apps/sim/ee/access-requests/components/my-access-requests.tsx": 43 } }, "app/workspace/[workspaceId]/chat/[chatId]/error.tsx": { From 4b28dff1c1a41503e30382de7fe265f496bd955d Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 18 Sep 2026 15:25:52 -0700 Subject: [PATCH 12/20] feat(mcp): Sim MCP server for the full Sim API (#7985) * feat(mcp): Sim MCP server for the full Sim API * fix(mcp): pick the read or write tool from the operation's declared scope; CORS on the MCP host * fix(mcp): match the MCP host by full authority; keep legacy API keys off the OAuth token prefix --- apps/docs/app/[[...slug]]/page.tsx | 7 +- .../components/docs-layout/docs-sidebar.tsx | 1 + apps/docs/components/navbar/navbar.tsx | 23 +- apps/docs/content/docs/mcp/authentication.mdx | 70 + apps/docs/content/docs/mcp/index.mdx | 109 + apps/docs/content/docs/mcp/meta.json | 5 + apps/docs/content/docs/mcp/tools.mdx | 55 + .../self-hosting/environment-variables.mdx | 1 + apps/docs/lib/integration-navigation.test.ts | 4 +- apps/sim/.env.example | 1 + .../api/mcp/route.test.ts | 31 + .../oauth-protected-resource/api/mcp/route.ts | 10 + .../api/auth/oauth2/authorize/route.test.ts | 78 + .../app/api/auth/oauth2/authorize/route.ts | 56 +- .../api/auth/oauth2/register/route.test.ts | 41 +- .../sim/app/api/auth/oauth2/register/route.ts | 8 +- apps/sim/app/api/auth/oauth2/token/route.ts | 4 +- apps/sim/app/api/mcp/route.ts | 9 + apps/sim/lib/api-key/crypto.test.ts | 14 +- apps/sim/lib/api-key/crypto.ts | 10 +- apps/sim/lib/api/application/operations.ts | 27 + apps/sim/lib/api/contracts/oauth-provider.ts | 32 +- apps/sim/lib/api/contracts/sim-mcp.ts | 10 + apps/sim/lib/api/mcp/catalog.test.ts | 109 + apps/sim/lib/api/mcp/catalog.ts | 269 ++ apps/sim/lib/api/mcp/dispatch.test.ts | 215 ++ apps/sim/lib/api/mcp/dispatch.ts | 189 ++ .../lib/api/mcp/generated/v2-operations.ts | 2258 +++++++++++++++++ apps/sim/lib/api/mcp/host-routing.test.ts | 81 + apps/sim/lib/api/mcp/host-routing.ts | 49 + apps/sim/lib/api/mcp/oauth-metadata.ts | 25 + apps/sim/lib/api/mcp/route-handler.test.ts | 229 ++ apps/sim/lib/api/mcp/route-handler.ts | 114 + apps/sim/lib/api/mcp/server.ts | 169 ++ apps/sim/lib/api/mcp/types.ts | 14 + apps/sim/lib/api/mcp/urls.ts | 18 + .../lib/api/server/routes/mcp-server-route.ts | 65 + .../lib/api/server/routes/v2-json-route.ts | 22 +- apps/sim/lib/auth/auth.ts | 11 +- apps/sim/lib/auth/oauth-access-token.ts | 25 + .../sim/lib/auth/oauth-client-registration.ts | 53 + apps/sim/lib/auth/oauth-protected-resource.ts | 67 + .../auth/oauth-provider-registration.test.ts | 28 +- apps/sim/lib/auth/oauth-provider.ts | 48 +- apps/sim/lib/auth/oauth-resource.test.ts | 54 +- apps/sim/lib/auth/oauth-resource.ts | 84 +- apps/sim/lib/auth/oauth-token-family.ts | 4 +- .../lib/copilot/generated/docs-manifest.ts | 3 + apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/knowledge/mcp/oauth-metadata.ts | 44 +- .../lib/knowledge/mcp/route-handler.test.ts | 2 +- apps/sim/lib/knowledge/mcp/route-handler.ts | 47 +- apps/sim/lib/knowledge/mcp/server.ts | 5 +- apps/sim/lib/mcp/tool-result.ts | 11 + apps/sim/proxy.test.ts | 43 +- apps/sim/proxy.ts | 14 + package.json | 2 + packages/utils/src/client-info.ts | 7 +- scripts/check-api-validation-contracts.ts | 1 + scripts/generate-v2-cli-api.ts | 25 +- scripts/generate-v2-mcp-operations.test.ts | 90 + scripts/generate-v2-mcp-operations.ts | 208 ++ 62 files changed, 5070 insertions(+), 239 deletions(-) create mode 100644 apps/docs/content/docs/mcp/authentication.mdx create mode 100644 apps/docs/content/docs/mcp/index.mdx create mode 100644 apps/docs/content/docs/mcp/meta.json create mode 100644 apps/docs/content/docs/mcp/tools.mdx create mode 100644 apps/sim/app/.well-known/oauth-protected-resource/api/mcp/route.test.ts create mode 100644 apps/sim/app/.well-known/oauth-protected-resource/api/mcp/route.ts create mode 100644 apps/sim/app/api/mcp/route.ts create mode 100644 apps/sim/lib/api/contracts/sim-mcp.ts create mode 100644 apps/sim/lib/api/mcp/catalog.test.ts create mode 100644 apps/sim/lib/api/mcp/catalog.ts create mode 100644 apps/sim/lib/api/mcp/dispatch.test.ts create mode 100644 apps/sim/lib/api/mcp/dispatch.ts create mode 100644 apps/sim/lib/api/mcp/generated/v2-operations.ts create mode 100644 apps/sim/lib/api/mcp/host-routing.test.ts create mode 100644 apps/sim/lib/api/mcp/host-routing.ts create mode 100644 apps/sim/lib/api/mcp/oauth-metadata.ts create mode 100644 apps/sim/lib/api/mcp/route-handler.test.ts create mode 100644 apps/sim/lib/api/mcp/route-handler.ts create mode 100644 apps/sim/lib/api/mcp/server.ts create mode 100644 apps/sim/lib/api/mcp/types.ts create mode 100644 apps/sim/lib/api/mcp/urls.ts create mode 100644 apps/sim/lib/api/server/routes/mcp-server-route.ts create mode 100644 apps/sim/lib/auth/oauth-client-registration.ts create mode 100644 apps/sim/lib/auth/oauth-protected-resource.ts create mode 100644 apps/sim/lib/mcp/tool-result.ts create mode 100644 scripts/generate-v2-mcp-operations.test.ts create mode 100644 scripts/generate-v2-mcp-operations.ts diff --git a/apps/docs/app/[[...slug]]/page.tsx b/apps/docs/app/[[...slug]]/page.tsx index 4d94308ae0e..71853679de9 100644 --- a/apps/docs/app/[[...slug]]/page.tsx +++ b/apps/docs/app/[[...slug]]/page.tsx @@ -125,9 +125,10 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }> // width so the lesson hero/video gets the room (chapters live in-page instead). const isAcademy = slug?.[0] === 'academy' const isCli = slug?.[0] === 'cli' + const isMcp = slug?.[0] === 'mcp' const rawNeighbours = findNeighbour(source.pageTree, page.url) - // Academy, API Reference, and CLI are self-contained sections; keep prev/next + // Academy, API Reference, CLI, and MCP are self-contained sections; keep prev/next // inside the section instead of spilling into the main documentation tree. // Match both the section's pages (`//...`) and its index (`/`). const sectionSlug = isApiReference @@ -136,7 +137,9 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }> ? 'academy' : isCli ? 'cli' - : null + : isMcp + ? 'mcp' + : null const inSection = (url?: string) => url != null && (url.includes(`/${sectionSlug}/`) || url.endsWith(`/${sectionSlug}`)) const neighbours = sectionSlug diff --git a/apps/docs/components/docs-layout/docs-sidebar.tsx b/apps/docs/components/docs-layout/docs-sidebar.tsx index f2d68258adb..24e33677746 100644 --- a/apps/docs/components/docs-layout/docs-sidebar.tsx +++ b/apps/docs/components/docs-layout/docs-sidebar.tsx @@ -103,6 +103,7 @@ export function DocsSidebar() { ['Docs', '/introduction'], ['API Reference', '/api-reference/getting-started'], ['CLI', '/cli'], + ['MCP', '/mcp'], ['Academy', '/academy'], ].map(([label, href]) => ( setOpen(false)}> diff --git a/apps/docs/components/navbar/navbar.tsx b/apps/docs/components/navbar/navbar.tsx index f8b0997cc01..4610c6d392f 100644 --- a/apps/docs/components/navbar/navbar.tsx +++ b/apps/docs/components/navbar/navbar.tsx @@ -9,25 +9,20 @@ import { ThemeToggle } from '@/components/ui/theme-toggle' import { cn } from '@/lib/utils' /** - * Sections that own a tab, in reading order: the main docs, then the two + * Sections that own a tab, in reading order: the main docs, then the three * reference surfaces, then Academy. `Documentation` matches by exclusion, so * every section listed here is one it must not claim. */ -const SECTION_TABS = ['api-reference', 'academy', 'cli'] as const +const SECTION_TABS = ['api-reference', 'academy', 'cli', 'mcp'] as const /** - * Whether a pathname is inside a section, matched by whole path segment. + * Whether a pathname is inside a section, matched on its first path segment. * - * A substring test is wrong: `/integrations/clickup` and - * `/integrations/clickhouse` both contain `/cli`, which lit the CLI tab and - * unlit Documentation on two existing integration pages. + * A substring or suffix test is wrong: `/integrations/clickup` contains `/cli`, + * and `/agents/mcp` ends with `/mcp`, and both belong to Documentation. */ function isInSection(pathname: string, section: string): boolean { - return ( - pathname === `/${section}` || - pathname.endsWith(`/${section}`) || - pathname.includes(`/${section}/`) - ) + return pathname === `/${section}` || pathname.startsWith(`/${section}/`) } const NAV_TABS = [ @@ -49,6 +44,12 @@ const NAV_TABS = [ match: (p: string) => isInSection(p, 'cli'), external: false, }, + { + label: 'MCP', + href: '/mcp', + match: (p: string) => isInSection(p, 'mcp'), + external: false, + }, { label: 'Academy', href: '/academy', diff --git a/apps/docs/content/docs/mcp/authentication.mdx b/apps/docs/content/docs/mcp/authentication.mdx new file mode 100644 index 00000000000..de694a2066c --- /dev/null +++ b/apps/docs/content/docs/mcp/authentication.mdx @@ -0,0 +1,70 @@ +--- +title: Authentication +description: Sign in with OAuth, or connect with an API key +--- + +import { Callout } from 'fumadocs-ui/components/callout' + +## OAuth + +Most apps sign in with OAuth. The first time you connect, your app opens Sim in +the browser, you sign in, and you approve its access. The app then holds a +token that renews itself; you do not copy any secret. + +The approval screen names the app and what it can do: + +| Access | Scope | Allows | +| --- | --- | --- | +| Read-only | `api:read` | Reading workspaces, workflows, runs, tables, files, knowledge bases, and logs | +| Full | `api:write` | Everything above, plus creating, changing, running, deploying, and deleting | + +Most apps request full access. To connect an app for reads only, configure it +to request the `api:read` scope; changes then fail with an insufficient-scope +error. + +Tokens are issued for the Sim MCP server itself. An app cannot take one to +another service and use it there. + +### Revoke access + +Open **Settings → General → Authorized apps** in Sim, find the app, and revoke +it. The app's next request fails, and you can reconnect at any time. Revoking +does not undo changes the app already made. + +## API keys + +Apps that cannot sign in through a browser, such as CI jobs and headless +agents, can send a Sim [API key](/api-reference/authentication) in the +`X-API-Key` header, or as `Authorization: Bearer `. + +```bash +claude mcp add --transport http sim https://mcp.sim.ai/mcp \ + --header "X-API-Key: $SIM_API_KEY" +``` + +```json title="~/.cursor/mcp.json" +{ + "mcpServers": { + "sim": { + "url": "https://mcp.sim.ai/mcp", + "headers": { "X-API-Key": "${env:SIM_API_KEY}" } + } + } +} +``` + +A personal key acts as you in every workspace you can access. A workspace key +reaches only its own workspace, and a few account-level operations refuse it; +`search_operations` marks them `personalCredentialOnly`. + + + An API key does not expire until you revoke it. Prefer OAuth for any app that + can open a browser, and store keys in your app's secret or environment + settings rather than in a shared config file. + + +## Organization policy + +The server follows your organization's access policy. If an administrator turns +off **OAuth apps** or **personal API keys** for your permission group, requests +with that credential are refused in the affected workspaces. diff --git a/apps/docs/content/docs/mcp/index.mdx b/apps/docs/content/docs/mcp/index.mdx new file mode 100644 index 00000000000..8f2a705f0e8 --- /dev/null +++ b/apps/docs/content/docs/mcp/index.mdx @@ -0,0 +1,109 @@ +--- +title: Sim MCP +description: Build, run, and manage everything in your Sim workspace from Claude, Codex, Cursor, and other MCP apps +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +The Sim MCP server gives an AI app the whole Sim API through the +[Model Context Protocol](https://modelcontextprotocol.io). Your agent can list +workspaces, run and deploy workflows, query and edit tables, manage files and +knowledge bases, read run logs, and more. It covers the same operations as the +[API](/api-reference/getting-started) and the [CLI](/cli). + +| Deployment | Server URL | +| --- | --- | +| Sim Cloud | `https://mcp.sim.ai/mcp` | +| Self-hosted | `https:///api/mcp`, or your [`SIM_MCP_URL`](/platform/self-hosting/environment-variables) | + +The server uses the Streamable HTTP transport. Sign in with OAuth, the default +in every app below, or send an [API key](/mcp/authentication#api-keys). + +## Connect an app + + + + ```bash + claude mcp add --transport http sim https://mcp.sim.ai/mcp + ``` + + Open `/mcp` in Claude Code, select **sim**, and sign in to Sim in the + browser. + + + Add `https://mcp.sim.ai/mcp` as a custom connector under **Settings → + Connectors**, then connect it and sign in to Sim. For Team or Enterprise, + an owner first adds it under **Organization settings → Connectors**. See + [Claude's custom connector instructions](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp). + + + ```bash + codex mcp add sim --url https://mcp.sim.ai/mcp + ``` + + Complete the browser sign-in. To sign in again later, run + `codex mcp login sim`. + + + Add `sim` to `mcpServers` in `~/.cursor/mcp.json`, then enable it in + Cursor and sign in to Sim: + + ```json + { + "mcpServers": { + "sim": { "url": "https://mcp.sim.ai/mcp" } + } + } + ``` + + + Add `sim` to `.vscode/mcp.json`, then start it and sign in to Sim: + + ```json + { + "servers": { + "sim": { "type": "http", "url": "https://mcp.sim.ai/mcp" } + } + } + ``` + + + +Any other app that supports remote MCP servers with OAuth works the same way: +give it the server URL and choose **Streamable HTTP** if asked. + + + Claude's hosted connectors call your server from Claude's infrastructure, so a + self-hosted Sim must be reachable from the internet. A `localhost` URL works + only with apps that run on your machine, such as Claude Code, Codex, Cursor, + and VS Code. + + +## Try it + +Ask your app: + +- "List my Sim workspaces and the tables in each." +- "Run the `lead-scoring` workflow with this input and show me the result." +- "Find failed runs from the last day and explain what went wrong." +- "Create a table of our open support tickets and add these rows." + +The agent finds the right operation, reads its inputs, and calls it. See +[Tools](/mcp/tools) for how that works. + +## What your agent can do + +The server acts as you. It sees the workspaces you can see, with your role in +each, and every call is authorized, rate limited, and logged exactly like the +same request to the API. Reads leave your resources unchanged, and apps can ask +you to confirm each change. See [Authentication](/mcp/authentication) to limit +an app to reads. + +## Other Sim MCP surfaces + +This server is for operating Sim. Two other MCP features do different jobs: + +- [Search MCP](/search/mcp) searches your organization's indexed sources. +- [MCP deployment](/workflows/deployment/mcp) exposes your own workflows as + tools, and [MCP tools](/agents/mcp) connect external servers to Sim agents. diff --git a/apps/docs/content/docs/mcp/meta.json b/apps/docs/content/docs/mcp/meta.json new file mode 100644 index 00000000000..f111bfd9ece --- /dev/null +++ b/apps/docs/content/docs/mcp/meta.json @@ -0,0 +1,5 @@ +{ + "title": "MCP", + "root": true, + "pages": ["---Sim MCP---", "index", "authentication", "tools"] +} diff --git a/apps/docs/content/docs/mcp/tools.mdx b/apps/docs/content/docs/mcp/tools.mdx new file mode 100644 index 00000000000..6bf6e0fee84 --- /dev/null +++ b/apps/docs/content/docs/mcp/tools.mdx @@ -0,0 +1,55 @@ +--- +title: Tools +description: How an agent finds, reads, and calls Sim operations through four tools +--- + +The Sim API has more than 200 operations. Instead of one tool per operation, +which would crowd your app's tool list and your agent's context, the server +exposes four tools. The agent searches for an operation, reads its inputs, and +calls it. + +| Tool | Does | +| --- | --- | +| `search_operations` | Finds operations by keyword (`"table rows"`) or by area (`tables`, `workflows`, `knowledge`, …). Returns each operation's name, method, path, summary, and the tool that runs it. | +| `describe_operation` | Returns an operation's description and the JSON Schema of its path parameters, query, body, and headers. | +| `call_read_operation` | Runs an operation that only needs read access, such as `listWorkspaces`, `queryRows`, or `getWorkflowRun`. | +| `call_write_operation` | Runs an operation that needs write access: one that creates, changes, runs, or deletes something, or reaches out to another service, such as `createTable`, `executeWorkflow`, or `listMcpServerTools`. | + +Reads and writes are separate tools so your app can approve reads once and still +ask you before each change. + +## Calling an operation + +Operation names match the [CLI](/cli/reference) and the +[API reference](/api-reference/getting-started). A call names the operation and +fills the parts of the request it needs: + +```json +{ + "operation": "listTableRows", + "params": { "tableId": "tbl_8f2c" }, + "query": { "workspaceId": "ws_91ab", "limit": 50 } +} +``` + +| Field | Holds | +| --- | --- | +| `params` | Path parameters, such as `tableId` or `workflowId` | +| `query` | Query-string parameters; most operations need `workspaceId` | +| `body` | The JSON request body (write operations only) | +| `headers` | Headers the operation declares, such as `upload-token` | + +The result is the same JSON the API returns, usually `{ "data": … }`. List +operations page with `limit` and `cursor`. A failed call returns the API's error, +such as `{ "error": { "code": "NOT_FOUND", "message": "…" } }`, so the agent can +correct its request. + +## Limits + +- **Same rules as the API.** Permissions, rate limits, and request validation + are the API's own; nothing is looser through MCP. +- **1 MiB per result.** Page through larger lists with `limit` and `cursor`. +- **No streaming.** Run a workflow without `stream: true` to wait for its + result, or with `async: true` and poll `getWorkflowRun`. +- **No file bytes.** Downloads, knowledge base exports, and multipart document + uploads are not available over MCP; use the [CLI](/cli/files) or the API. diff --git a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx index d870ef0e354..23c80273d81 100644 --- a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx @@ -34,6 +34,7 @@ import { Callout } from 'fumadocs-ui/components/callout' | `TRUSTED_ORIGINS` | Comma-separated additional origins to trust for auth (apex + `www`, alias domains) | | `AUTH_TRUSTED_PROXIES` | Comma-separated reverse-proxy IPs/CIDRs so the client IP cannot be forged through `X-Forwarded-For` | | `INTERNAL_API_BASE_URL` | Internal URL for server-side self-calls, e.g. `http://sim-app.simstudio.svc.cluster.local:3000`. Optional — falls back to the public base URL. Deliberately ignored inside the Trigger.dev worker runtime, where a cluster-internal address resolves to the worker itself | +| `SIM_MCP_URL` | Public URL of the [Sim MCP server](/mcp) when you serve it on its own host, e.g. `https://mcp.example.com/mcp`. Point that host at the app; Sim serves only the MCP endpoint and its OAuth metadata there, and stops serving `/api/mcp` on the app host so clients use one URL. Optional — defaults to `/api/mcp` | | `DATABASE_REPLICA_URL` | Read-replica connection string for log listing, audit logs, and dashboard aggregations. Falls back to the primary when unset | ## AI Providers diff --git a/apps/docs/lib/integration-navigation.test.ts b/apps/docs/lib/integration-navigation.test.ts index bbe381c7e6a..7b1d3b96e67 100644 --- a/apps/docs/lib/integration-navigation.test.ts +++ b/apps/docs/lib/integration-navigation.test.ts @@ -58,8 +58,8 @@ describe('docs section navigation', () => { } }) - it('keeps root-tab overview pages in the CLI and Academy navigation', () => { - for (const root of ['cli', 'academy']) { + it('keeps root-tab overview pages in the CLI, MCP, and Academy navigation', () => { + for (const root of ['cli', 'mcp', 'academy']) { const folder = folders(source.pageTree.fallback?.children ?? []).find( (node) => node.$ref === `${root}/meta.json` ) diff --git a/apps/sim/.env.example b/apps/sim/.env.example index c8b8583d103..33b321ba57d 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -20,6 +20,7 @@ BETTER_AUTH_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000 # NEXT_PUBLIC_STATUS_NOTICE_PREVIEW=true # Force the sidebar service-status notice into its critical preview state for testing # INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL +# SIM_MCP_URL=https://mcp.example.com/mcp # Optional: dedicated host for the Sim MCP server; defaults to NEXT_PUBLIC_APP_URL/api/mcp # TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins. # AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. Better Auth walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP (prevents forwarded-header spoofing). Use your proxies' actual addresses, not broad private ranges that also cover clients. diff --git a/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/route.test.ts b/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/route.test.ts new file mode 100644 index 00000000000..d7ac5c11576 --- /dev/null +++ b/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/route.test.ts @@ -0,0 +1,31 @@ +/** @vitest-environment node */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { NextRequest } from 'next/server' +import { afterAll, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' })) + +import { GET } from '@/app/.well-known/oauth-protected-resource/api/mcp/route' + +afterAll(resetEnvFlagsMock) + +describe('Sim MCP protected-resource metadata', () => { + it('names the Sim MCP server as a Sim API resource', async () => { + setEnvFlags({ isAuthDisabled: false }) + const response = await GET(new NextRequest('https://sim.test/'), undefined) + expect(await response.json()).toEqual({ + resource: 'https://sim.test/api/mcp', + resource_name: 'Sim', + authorization_servers: ['https://sim.test/api/auth'], + scopes_supported: ['api:read', 'api:write'], + bearer_methods_supported: ['header'], + }) + expect(response.headers.get('access-control-allow-origin')).toBe('*') + }) + + it('does not advertise disabled OAuth', async () => { + setEnvFlags({ isAuthDisabled: true }) + const response = await GET(new NextRequest('https://sim.test/'), undefined) + expect(response.status).toBe(404) + }) +}) diff --git a/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/route.ts b/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/route.ts new file mode 100644 index 00000000000..a6238951e2b --- /dev/null +++ b/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server' +import { simMcpResourceMetadata } from '@/lib/api/mcp/oauth-metadata' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +/** RFC 9728 metadata for the Sim MCP server; `proxy.ts` also serves it on the dedicated MCP host. */ +export const GET = withRouteHandler(async () => { + if (isAuthDisabled) return new NextResponse(null, { status: 404 }) + return simMcpResourceMetadata() +}) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index 4666fcb4415..8fcc368000e 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -27,8 +27,12 @@ const mocks = vi.hoisted(() => ({ decryptQuickBooksClientConfig: vi.fn(), createQuickBooksState: vi.fn(), getCanonicalScopes: vi.fn(), + isPubliclyRegistered: vi.fn(), })) +vi.mock('@/lib/auth/oauth-client-registration', () => ({ + isPubliclyRegisteredOAuthClient: mocks.isPubliclyRegistered, +})) vi.mock('better-auth/next-js', () => ({ toNextJsHandler: () => ({ GET: mocks.betterAuthGET }), })) @@ -96,6 +100,7 @@ describe('OAuth2 authorize route', () => { resetDbChainMock() setEnvFlags({ isAuthDisabled: false }) mocks.getBaseUrl.mockReturnValue(BASE_URL) + mocks.isPubliclyRegistered.mockResolvedValue(false) mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' }, @@ -182,6 +187,79 @@ describe('OAuth2 authorize route', () => { expect(req.nextUrl.searchParams.get('scope')).toContain('api:write') }) + it('binds a publicly registered client Sim API grant to the Sim MCP server', async () => { + mocks.isPubliclyRegistered.mockResolvedValue(true) + const unbound = await GET( + request({ + client_id: 'mcp-client', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + scope: 'api:write offline_access', + }) + ) + expect(unbound.status).toBe(400) + expect(mocks.isPubliclyRegistered).toHaveBeenCalledWith('mcp-client') + expect(mocks.betterAuthGET).not.toHaveBeenCalled() + + const bound = await GET( + request({ + client_id: 'mcp-client', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + scope: 'api:write offline_access', + resource: `${BASE_URL}/api/mcp`, + }) + ) + expect(bound.status).toBe(302) + }) + + it('lets an operator-created client request the Sim API without a resource', async () => { + const response = await GET( + request({ + client_id: 'sim-cli', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + scope: 'api:write offline_access', + }) + ) + expect(response.status).toBe(302) + expect(mocks.isPubliclyRegistered).toHaveBeenCalledWith('sim-cli') + }) + + it('narrows issuer-wide scope requests to the Sim API for the Sim MCP server', async () => { + const req = request({ + client_id: 'mcp-client', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + scope: 'offline_access api:read api:write search:read', + resource: `${BASE_URL}/api/mcp`, + }) + expect((await GET(req)).status).toBe(302) + const forwarded: Request = mocks.betterAuthGET.mock.calls[0][0] + expect(new URL(forwarded.url).searchParams.get('scope')).toBe( + 'api:read api:write offline_access' + ) + expect(new URL(forwarded.url).searchParams.get('resource')).toBe(`${BASE_URL}/api/mcp`) + }) + + it.each([ + { scope: 'search:read offline_access', resource: `${BASE_URL}/api/mcp` }, + { scope: 'offline_access', resource: `${BASE_URL}/api/mcp` }, + { scope: 'api:read unknown', resource: `${BASE_URL}/api/mcp` }, + { scope: 'api:read', resource: `${BASE_URL}/api/mcp/` }, + ])('refuses Sim MCP grants without Sim API scope: %o', async (params) => { + const response = await GET( + request({ + client_id: 'mcp-client', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + ...params, + }) + ) + expect(response.status).toBe(400) + expect(mocks.betterAuthGET).not.toHaveBeenCalled() + }) + it('forwards a provider request without entering the connector flow', async () => { const providerRequest = request({ client_id: 'client-1', diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 713f6c99632..7402d1c03f9 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -5,9 +5,19 @@ import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections' import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' import { oauthAuthorizationErrorResponse } from '@/lib/auth/oauth-authorization-error' +import { isPubliclyRegisteredOAuthClient } from '@/lib/auth/oauth-client-registration' import { validateOAuthPkceAuthorizationRequest } from '@/lib/auth/oauth-protocol-request' -import { narrowSearchOAuthScopes, OAUTH_SEARCH_READ_SCOPE } from '@/lib/auth/oauth-provider' -import { InvalidOAuthResourceError, parseOAuthSearchResource } from '@/lib/auth/oauth-resource' +import { + narrowResourceOAuthScopes, + OAUTH_API_READ_SCOPE, + OAUTH_API_WRITE_SCOPE, + OAUTH_SEARCH_READ_SCOPE, +} from '@/lib/auth/oauth-provider' +import { + InvalidOAuthResourceError, + type OAuthResource, + parseOAuthResource, +} from '@/lib/auth/oauth-resource' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' import { isAuthDisabled } from '@/lib/core/config/env-flags' @@ -105,26 +115,36 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 'The redirect_uri parameter is required.' ) } - const scopes = (params.get('scope') ?? '').split(' ').filter(Boolean) - let resource: string | null + const rawScope = params.get('scope') ?? '' + const scopes = rawScope.split(' ').filter(Boolean) + const invalidRequest = (description: string) => + oauthAuthorizationErrorResponse(request, 'invalid_request', description) + const searchScopeRequired = 'Sim Search requires its server URL and the search:read scope.' + let resource: OAuthResource | null try { - resource = parseOAuthSearchResource(params.get('resource')) + resource = parseOAuthResource(params.get('resource')) } catch (error) { if (!(error instanceof InvalidOAuthResourceError)) throw error - return oauthAuthorizationErrorResponse( - request, - 'invalid_request', - 'The resource must be a Sim Search server URL.' - ) + return invalidRequest(error.message) } - const searchScope = resource ? narrowSearchOAuthScopes(params.get('scope') ?? '') : null - if ((resource && !searchScope) || (!resource && scopes.includes(OAUTH_SEARCH_READ_SCOPE))) { - return oauthAuthorizationErrorResponse( - request, - 'invalid_request', - 'Sim Search requires its server URL and the search:read scope.' + if (!resource && scopes.includes(OAUTH_SEARCH_READ_SCOPE)) { + return invalidRequest(searchScopeRequired) + } + const narrowedScope = resource ? narrowResourceOAuthScopes(rawScope, resource.kind) : null + if (resource && !narrowedScope) { + return invalidRequest( + resource.kind === 'search' + ? searchScopeRequired + : 'The Sim MCP server requires the api:read or api:write scope.' ) } + if ( + !resource && + scopes.some((scope) => scope === OAUTH_API_READ_SCOPE || scope === OAUTH_API_WRITE_SCOPE) && + (await isPubliclyRegisteredOAuthClient(params.get('client_id') ?? '')) + ) { + return invalidRequest('This app must request Sim API access for the Sim MCP server URL.') + } if (params.has('request_uri')) { return oauthAuthorizationErrorResponse( request, @@ -152,9 +172,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return oauthAuthorizationErrorResponse(request, 'invalid_request', pkceError) } let providerRequest: Request = request - if (searchScope && params.get('scope') !== searchScope) { + if (narrowedScope && rawScope !== narrowedScope) { const url = new URL(request.url) - url.searchParams.set('scope', searchScope) + url.searchParams.set('scope', narrowedScope) providerRequest = new Request(url, { headers: request.headers }) } const response = await betterAuthGET(providerRequest) diff --git a/apps/sim/app/api/auth/oauth2/register/route.test.ts b/apps/sim/app/api/auth/oauth2/register/route.test.ts index f01e0d916c8..8f943ce6516 100644 --- a/apps/sim/app/api/auth/oauth2/register/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/register/route.test.ts @@ -3,7 +3,10 @@ import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ register: vi.fn(), rateLimit: vi.fn() })) +const mocks = vi.hoisted(() => ({ register: vi.fn(), rateLimit: vi.fn(), markPublic: vi.fn() })) +vi.mock('@/lib/auth/oauth-client-registration', () => ({ + markPubliclyRegisteredOAuthClient: mocks.markPublic, +})) vi.mock('better-auth/next-js', () => ({ toNextJsHandler: () => ({ POST: mocks.register }) })) vi.mock('@/lib/core/rate-limiter', () => ({ enforceIpRateLimit: mocks.rateLimit })) vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' })) @@ -27,6 +30,7 @@ beforeEach(() => { vi.clearAllMocks() setEnvFlags({ isAuthDisabled: false }) mocks.rateLimit.mockResolvedValue(null) + mocks.markPublic.mockResolvedValue(undefined) mocks.register.mockImplementation(async (req: Request) => Response.json( { @@ -41,7 +45,7 @@ beforeEach(() => { }) describe('MCP public client registration', () => { - it('registers a bounded public Search client without ambient credentials or privileged metadata', async () => { + it('registers a bounded public MCP client without ambient credentials or privileged metadata', async () => { const response = await POST( request( { ...client, skip_consent: true, require_pkce: false, metadata: { elevated: true } }, @@ -57,28 +61,31 @@ describe('MCP public client registration', () => { ...client, client_id: 'client-1', token_endpoint_auth_method: 'none', - scope: 'search:read offline_access', + scope: 'api:read api:write offline_access search:read', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], }) const forwarded: Request = mocks.register.mock.calls[0][0] + expect(mocks.markPublic).toHaveBeenCalledWith('client-1') expect(forwarded.headers.has('cookie')).toBe(false) expect(forwarded.headers.has('authorization')).toBe(false) expect(forwarded.headers.get('x-forwarded-for')).toBe('203.0.113.10') expect(response.headers.get('cache-control')).toBe('no-store') }) - it('returns only registered Search scopes when clients request all issuer scopes', async () => { - const response = await POST( - request({ ...client, scope: 'offline_access api:read api:write search:read' }) - ) + it.each([ + [ + 'offline_access api:read api:write search:read', + 'api:read api:write offline_access search:read', + ], + ['api:read offline_access', 'api:read offline_access'], + ['search:read offline_access', 'search:read offline_access'], + ])('registers the registrable scope families a client requests: %s', async (scope, granted) => { + const response = await POST(request({ ...client, scope })) expect(response.status).toBe(201) - expect(await response.json()).toMatchObject({ scope: 'search:read offline_access' }) + expect(await response.json()).toMatchObject({ scope: granted }) const forwarded: Request = mocks.register.mock.calls[0][0] - expect(await forwarded.json()).toMatchObject({ - scope: 'search:read offline_access', - require_pkce: true, - }) + expect(await forwarded.json()).toMatchObject({ scope: granted, require_pkce: true }) }) it('registers Cursor browser and native callbacks together with PKCE required', async () => { @@ -131,7 +138,8 @@ describe('MCP public client registration', () => { ) it.each([ - { ...client, scope: 'api:write' }, + { ...client, scope: 'offline_access' }, + { ...client, scope: 'openid api:read' }, { ...client, token_endpoint_auth_method: 'private_key_jwt' }, { ...client, token_endpoint_auth_method: 'unsupported' }, { ...client, grant_types: ['client_credentials'] }, @@ -153,6 +161,13 @@ describe('MCP public client registration', () => { expect(mocks.register).not.toHaveBeenCalled() }) + it('discloses no client ID when the client cannot be marked as publicly registered', async () => { + mocks.markPublic.mockRejectedValue(new Error('write failed')) + const response = await POST(request()) + expect(response.status).toBe(500) + expect(await response.text()).not.toContain('client-1') + }) + it('admits before reading metadata or creating a client', async () => { mocks.rateLimit.mockResolvedValue(Response.json({ error: 'Rate limited' }, { status: 429 })) expect((await POST(request())).status).toBe(429) diff --git a/apps/sim/app/api/auth/oauth2/register/route.ts b/apps/sim/app/api/auth/oauth2/register/route.ts index 4591aa16956..0d9b285cc8c 100644 --- a/apps/sim/app/api/auth/oauth2/register/route.ts +++ b/apps/sim/app/api/auth/oauth2/register/route.ts @@ -1,8 +1,9 @@ import { toNextJsHandler } from 'better-auth/next-js' import { type NextRequest, NextResponse } from 'next/server' -import { registerSearchOAuthClientContract } from '@/lib/api/contracts/oauth-provider' +import { registerOAuthClientContract } from '@/lib/api/contracts/oauth-provider' import { parseRequest } from '@/lib/api/server' import { auth } from '@/lib/auth' +import { markPubliclyRegisteredOAuthClient } from '@/lib/auth/oauth-client-registration' import { isAuthDisabled } from '@/lib/core/config/env-flags' import { enforceIpRateLimit } from '@/lib/core/rate-limiter' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -36,7 +37,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return invalidMetadata('Client metadata must be sent as application/json.', 415) } const parsed = await parseRequest( - registerSearchOAuthClientContract, + registerOAuthClientContract, request, {}, { @@ -63,6 +64,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) ) if (!response.ok) return response - const body = registerSearchOAuthClientContract.response.schema.parse(await response.json()) + const body = registerOAuthClientContract.response.schema.parse(await response.json()) + await markPubliclyRegisteredOAuthClient(body.client_id) return NextResponse.json(body, { status: 201, headers: HEADERS }) }) diff --git a/apps/sim/app/api/auth/oauth2/token/route.ts b/apps/sim/app/api/auth/oauth2/token/route.ts index f5eb7ee3062..a0e09ae3fb4 100644 --- a/apps/sim/app/api/auth/oauth2/token/route.ts +++ b/apps/sim/app/api/auth/oauth2/token/route.ts @@ -17,7 +17,7 @@ import { import { withOAuthProviderIssuanceCompensation } from '@/lib/auth/oauth-provider-adapter-guard' import { InvalidOAuthResourceError, - parseOAuthSearchResource, + parseOAuthResource, withOAuthResourceIssuance, } from '@/lib/auth/oauth-resource' import { @@ -66,7 +66,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (grantType !== 'authorization_code' && grantType !== 'refresh_token') { return unsupportedGrantResponse(grantType) } - const resource = parseOAuthSearchResource(parsed.value.form.get('resource')) + const resource = parseOAuthResource(parsed.value.form.get('resource'))?.url ?? null if (grantType === 'authorization_code') { const codeVerifier = parsed.value.form.get('code_verifier') if (codeVerifier !== null && !isValidOAuthCodeVerifier(codeVerifier)) { diff --git a/apps/sim/app/api/mcp/route.ts b/apps/sim/app/api/mcp/route.ts new file mode 100644 index 00000000000..83d628f2c69 --- /dev/null +++ b/apps/sim/app/api/mcp/route.ts @@ -0,0 +1,9 @@ +import { createSimMcpHandlers } from '@/lib/api/mcp/route-handler' + +export const dynamic = 'force-dynamic' + +const handlers = createSimMcpHandlers() + +export const POST = handlers.POST +export const GET = handlers.GET +export const DELETE = handlers.DELETE diff --git a/apps/sim/lib/api-key/crypto.test.ts b/apps/sim/lib/api-key/crypto.test.ts index 334a90ac9c7..26dbbba187d 100644 --- a/apps/sim/lib/api-key/crypto.test.ts +++ b/apps/sim/lib/api-key/crypto.test.ts @@ -10,7 +10,10 @@ */ import { randomBytes } from 'crypto' import { resetEnvMock, setEnv } from '@sim/testing' -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerateSecureToken } = vi.hoisted(() => ({ mockGenerateSecureToken: vi.fn() })) +vi.mock('@sim/security/tokens', () => ({ generateSecureToken: mockGenerateSecureToken })) beforeAll(() => { setEnv({ API_ENCRYPTION_KEY: undefined }) @@ -21,6 +24,7 @@ afterAll(resetEnvMock) import { decryptApiKey, encryptApiKey, + generateApiKey, hashApiKey, isEncryptedApiKeyFormat, isLegacyApiKeyFormat, @@ -86,3 +90,11 @@ describe('api-key format helpers', () => { expect(isEncryptedApiKeyFormat('sim_abc')).toBe(false) }) }) + +describe('generateApiKey', () => { + it('never issues a legacy key that reads as an OAuth access token', () => { + mockGenerateSecureToken.mockReturnValueOnce('oat_collision').mockReturnValueOnce('plain_token') + expect(generateApiKey()).toBe('sim_plain_token') + expect(mockGenerateSecureToken).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/api-key/crypto.ts b/apps/sim/lib/api-key/crypto.ts index b8e89ecb030..2a9233313f4 100644 --- a/apps/sim/lib/api-key/crypto.ts +++ b/apps/sim/lib/api-key/crypto.ts @@ -3,6 +3,7 @@ import { decrypt, encrypt } from '@sim/security/encryption' import { sha256Hex } from '@sim/security/hash' import { generateSecureToken } from '@sim/security/tokens' import { toError } from '@sim/utils/errors' +import { OAUTH_ACCESS_TOKEN_PREFIX } from '@/lib/auth/oauth-provider' import { env } from '@/lib/core/config/env' const logger = createLogger('ApiKeyCrypto') @@ -60,10 +61,17 @@ export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted /** * Generates a standardized API key with the 'sim_' prefix (legacy format) + * + * Never one starting with the OAuth access-token prefix: base64url can spell + * `sim_oat_`, and a bearer credential's prefix is what tells an OAuth token + * from an API key. * @returns A new API key string */ export function generateApiKey(): string { - return `sim_${generateSecureToken(24)}` + for (;;) { + const key = `sim_${generateSecureToken(24)}` + if (!key.startsWith(OAUTH_ACCESS_TOKEN_PREFIX)) return key + } } /** diff --git a/apps/sim/lib/api/application/operations.ts b/apps/sim/lib/api/application/operations.ts index 265033e2b92..8c5c1245476 100644 --- a/apps/sim/lib/api/application/operations.ts +++ b/apps/sim/lib/api/application/operations.ts @@ -18,3 +18,30 @@ export const v2MetaOperations = { principalKinds: ['personal_api_key', 'oauth_access_token', 'workspace_api_key'], }), } as const + +/** + * Connecting to the Sim MCP server. Admission only: every tool call is then a v2 + * operation of its own, authorized and rate-limited by its route exactly as the + * same request over HTTP would be. + */ +export const v2McpOperations = { + // permission-group-exempt: connecting reveals only the static catalog of v2 operations; each tool call is its own v2 operation and enforces that operation's capability + connect: defineOperation({ + id: 'mcp.api.connect', + oauthScope: 'api:read', + capability: 'none', + principalKinds: ['personal_api_key', 'oauth_access_token', 'workspace_api_key'], + }), + /** + * The scope a tool call needs when it runs a raw v2 route, which declares no + * operation of its own (chat, workflow execution, resume); each changes + * something. Declared-operation routes are checked against their own scope. + */ + // permission-group-exempt: a scope gate only; the dispatched v2 route enforces its own capability + rawRoute: defineOperation({ + id: 'mcp.api.raw-route', + oauthScope: 'api:write', + capability: 'none', + principalKinds: ['personal_api_key', 'oauth_access_token', 'workspace_api_key'], + }), +} as const diff --git a/apps/sim/lib/api/contracts/oauth-provider.ts b/apps/sim/lib/api/contracts/oauth-provider.ts index c1384bc24c4..a8b8769fbe6 100644 --- a/apps/sim/lib/api/contracts/oauth-provider.ts +++ b/apps/sim/lib/api/contracts/oauth-provider.ts @@ -1,6 +1,9 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' -import { narrowSearchOAuthScopes, OAUTH_SEARCH_SCOPES } from '@/lib/auth/oauth-provider' +import { + narrowRegistrationOAuthScopes, + OAUTH_PUBLIC_REGISTRATION_SCOPES, +} from '@/lib/auth/oauth-provider' /** Reviewed native callbacks; never accept arbitrary executable or custom URI schemes. */ const NATIVE_MCP_CALLBACKS = new Set(['cursor://anysphere.cursor-mcp/oauth/callback']) @@ -28,7 +31,7 @@ const redirectUriSchema = z } }, 'Redirect URIs must use HTTPS, loopback HTTP, or a supported native app callback, without wildcards or fragments') -export const registerSearchOAuthClientBodySchema = z.object({ +export const registerOAuthClientBodySchema = z.object({ client_name: z.string().trim().min(1).max(128).default('MCP client'), redirect_uris: z.array(redirectUriSchema).min(1).max(10), /** Better Auth negotiates unauthenticated registration to public clients without secrets. */ @@ -48,19 +51,19 @@ export const registerSearchOAuthClientBodySchema = z.object({ scope: z .string() .max(128) - .default(OAUTH_SEARCH_SCOPES.join(' ')) + .default(OAUTH_PUBLIC_REGISTRATION_SCOPES.join(' ')) .transform((scope, context) => { - const granted = narrowSearchOAuthScopes(scope) + const granted = narrowRegistrationOAuthScopes(scope) if (granted !== null) return granted context.addIssue({ code: 'custom', - message: 'Only Sim Search access can be registered automatically', + message: 'Only Sim MCP access can be registered automatically', }) return z.NEVER }), }) -export const registerSearchOAuthClientResponseSchema = z.object({ +export const registerOAuthClientResponseSchema = z.object({ client_id: z.string().min(1).max(255), client_name: z.string().min(1).max(128), redirect_uris: z.array(redirectUriSchema).min(1).max(10), @@ -71,15 +74,16 @@ export const registerSearchOAuthClientResponseSchema = z.object({ client_id_issued_at: z.number().int().nonnegative(), }) -/** Public RFC 7591 registration is limited to read-only Search clients. */ -export const registerSearchOAuthClientContract = defineRouteContract({ +/** + * Public RFC 7591 registration for MCP clients. A registered client holds no + * access by itself: every grant is narrowed to its MCP resource and consented to. + */ +export const registerOAuthClientContract = defineRouteContract({ method: 'POST', path: '/api/auth/oauth2/register', - body: registerSearchOAuthClientBodySchema, - response: { mode: 'json', schema: registerSearchOAuthClientResponseSchema }, + body: registerOAuthClientBodySchema, + response: { mode: 'json', schema: registerOAuthClientResponseSchema }, }) -export type RegisterSearchOAuthClientBody = z.input -export type RegisterSearchOAuthClientResponse = z.output< - typeof registerSearchOAuthClientResponseSchema -> +export type RegisterOAuthClientBody = z.input +export type RegisterOAuthClientResponse = z.output diff --git a/apps/sim/lib/api/contracts/sim-mcp.ts b/apps/sim/lib/api/contracts/sim-mcp.ts new file mode 100644 index 00000000000..a83e367fecf --- /dev/null +++ b/apps/sim/lib/api/contracts/sim-mcp.ts @@ -0,0 +1,10 @@ +import { mcpJsonRpcMessageSchema } from '@/lib/api/contracts/mcp' +import { defineRouteContract } from '@/lib/api/contracts/types' + +/** The Sim MCP server: one stateless Streamable HTTP endpoint carrying JSON-RPC. */ +export const simMcpContract = defineRouteContract({ + method: 'POST', + path: '/api/mcp', + body: mcpJsonRpcMessageSchema, + response: { mode: 'json', schema: mcpJsonRpcMessageSchema }, +}) diff --git a/apps/sim/lib/api/mcp/catalog.test.ts b/apps/sim/lib/api/mcp/catalog.test.ts new file mode 100644 index 00000000000..934bf20222b --- /dev/null +++ b/apps/sim/lib/api/mcp/catalog.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + callerHeaderNames, + describeOperation, + getMcpOperation, + OPERATION_DOMAINS, + resolveOperation, + searchOperations, +} from '@/lib/api/mcp/catalog' +import { V2_MCP_OPERATIONS, type V2McpOperationName } from '@/lib/api/mcp/generated/v2-operations' +import { v2RouteOperation } from '@/lib/api/server/routes/v2-json-route' + +const ALL_OPERATION_NAMES = Object.keys(V2_MCP_OPERATIONS) as V2McpOperationName[] + +describe('Sim MCP catalog', () => { + /** + * Loads every route the catalog names, so a handler that is missing, or a + * declared operation the tool split cannot read, fails here rather than on a + * caller's first call. + */ + it('gives every operation exactly one tool, from the scope its route declares', async () => { + for (const name of ALL_OPERATION_NAMES) { + const route = await getMcpOperation(name).handler() + expect(typeof route, name).toBe('function') + const scope = v2RouteOperation(route)?.oauthScope + const tool = scope === 'api:read' || scope === 'search:read' ? 'read' : 'write' + const other = tool === 'read' ? 'write' : 'read' + expect(await resolveOperation(name, tool), name).toEqual({ operation: name }) + expect(await resolveOperation(name, other), name).toEqual({ error: expect.any(String) }) + } + }, 120_000) + + it('classifies by declared scope, not HTTP method', async () => { + expect(await resolveOperation('listMcpServerTools', 'read')).toEqual({ + error: 'listMcpServerTools needs write access; run it with call_write_operation.', + }) + expect(await resolveOperation('queryRows', 'read')).toEqual({ operation: 'queryRows' }) + expect(await resolveOperation('executeWorkflow', 'write')).toEqual({ + operation: 'executeWorkflow', + }) + }) + + it('suggests the closest operations for an unknown name', async () => { + expect(await resolveOperation('createTableRow', 'write')).toEqual({ + error: expect.stringContaining('createTableRows'), + }) + expect(await resolveOperation('toString', 'any')).toEqual({ + error: expect.stringContaining('Unknown operation "toString"'), + }) + }) + + it('leaves out operations a JSON tool call cannot carry', () => { + const names: readonly string[] = ALL_OPERATION_NAMES + expect(names).not.toContain('downloadFile') + expect(names).not.toContain('uploadKnowledgeDocument') + expect(names).toContain('executeWorkflow') + }) + + it('describes every operation as JSON Schema', async () => { + for (const name of ALL_OPERATION_NAMES) { + const description = await describeOperation(name) + expect(description.operation).toBe(name) + const { contract } = V2_MCP_OPERATIONS[name] + for (const slot of ['params', 'query', 'body'] as const) { + if (!contract[slot]) continue + expect( + Object.keys(description.input[slot] ?? {}).length, + `${name} ${slot}` + ).toBeGreaterThan(0) + expect(description.input[slot]).not.toHaveProperty('$schema') + } + } + }, 120_000) + + it('describes path parameters, query, body, and the tool to use', async () => { + const { input, tool, domain, description } = await describeOperation('createTable') + expect(description).toEqual(expect.any(String)) + expect(tool).toBe('call_write_operation') + expect(domain).toBe('tables') + expect(input.body).toMatchObject({ type: 'object' }) + expect(input.body?.properties).toHaveProperty('workspaceId') + expect((await describeOperation('getTable')).input.params?.properties).toHaveProperty('tableId') + }) + + it('exposes only the contract headers a caller may set', async () => { + expect(callerHeaderNames('completeFileUpload')).toEqual(['upload-token']) + expect(callerHeaderNames('listTables')).toEqual([]) + expect((await describeOperation('listTables')).input.headers).toBeUndefined() + }) + + it('ranks name matches first and names each result’s tool', async () => { + const { operations } = await searchOperations({ query: 'table rows', limit: 50 }) + expect(operations.length).toBeGreaterThan(0) + expect(operations[0].operation.toLowerCase()).toContain('rows') + expect(operations[0].operation.toLowerCase()).toContain('table') + expect(operations.every((entry) => entry.tool.startsWith('call_'))).toBe(true) + }) + + it('filters by domain and bounds the page', async () => { + expect(OPERATION_DOMAINS).toContain('workflows') + const { total, operations } = await searchOperations({ domain: 'workflows', limit: 3 }) + expect(operations).toHaveLength(3) + expect(total).toBeGreaterThan(3) + expect(operations.every((entry) => entry.domain === 'workflows')).toBe(true) + }) +}) diff --git a/apps/sim/lib/api/mcp/catalog.ts b/apps/sim/lib/api/mcp/catalog.ts new file mode 100644 index 00000000000..283fcbae93f --- /dev/null +++ b/apps/sim/lib/api/mcp/catalog.ts @@ -0,0 +1,269 @@ +import { omit, toRecord } from '@sim/utils/object' +import { z } from 'zod' +import type { ApiSchema, HttpMethod } from '@/lib/api/contracts/types' +import { V2_MCP_OPERATIONS, type V2McpOperationName } from '@/lib/api/mcp/generated/v2-operations' +import type { V2McpOperation } from '@/lib/api/mcp/types' +import { v2RouteOperation } from '@/lib/api/server/routes/v2-json-route' +import { OAUTH_API_READ_SCOPE, oauthScopeSatisfies } from '@/lib/auth/oauth-provider' + +/** Headers the dispatcher owns; a contract declaring one still never takes it from a tool call. */ +const MANAGED_HEADERS: ReadonlySet = new Set([ + 'x-api-key', + 'authorization', + 'accept', + 'content-type', + 'user-agent', +]) + +/** The tool that runs each kind of operation. */ +export const TOOL_NAMES = { read: 'call_read_operation', write: 'call_write_operation' } as const +type ToolKind = keyof typeof TOOL_NAMES + +const REQUEST_SLOTS = ['params', 'query', 'body', 'headers'] as const +type RequestSlot = (typeof REQUEST_SLOTS)[number] +type JsonSchema = Record + +/** One catalog row: enough to choose an operation, not to call it. */ +interface McpOperationEntry { + operation: V2McpOperationName + method: HttpMethod + path: string + domain: string + summary: string + /** The tool that runs it: the read tool only for operations that need nothing beyond read access. */ + /** Refuses workspace API keys; call it with a personal key or an OAuth connection. */ + personalCredentialOnly?: true +} + +interface McpOperationSummary extends McpOperationEntry { + /** The tool that runs it: the read tool only for operations that need nothing beyond read access. */ + tool: (typeof TOOL_NAMES)[ToolKind] +} + +/** Everything needed to call one operation: its documentation plus the JSON Schema of each request slot. */ +interface McpOperationDescription extends McpOperationSummary { + description?: string + input: Partial> +} + +const OPERATION_NAMES = Object.keys(V2_MCP_OPERATIONS) as V2McpOperationName[] + +export function getMcpOperation(name: V2McpOperationName): V2McpOperation { + return V2_MCP_OPERATIONS[name] +} + +/** Memo of each operation's tool; a failed load is dropped so the next call retries it. */ +const toolKinds = new Map>() + +/** + * Which tool runs an operation, from the OAuth scope its route declares rather + * than its HTTP method: a GET can need `api:write` and reach out to another + * system, and a POST can be a pure query. Raw routes declare no operation and + * all change something, so they run through the write tool. + */ +function operationToolKind(name: V2McpOperationName): Promise { + const cached = toolKinds.get(name) + if (cached) return cached + const kind = getMcpOperation(name) + .handler() + .then((route): ToolKind => { + const scope = v2RouteOperation(route)?.oauthScope + return scope && oauthScopeSatisfies([OAUTH_API_READ_SCOPE], scope) ? 'read' : 'write' + }) + .catch((error: unknown) => { + toolKinds.delete(name) + throw error + }) + toolKinds.set(name, kind) + return kind +} + +async function withTool(entry: McpOperationEntry): Promise { + return { ...entry, tool: TOOL_NAMES[await operationToolKind(entry.operation)] } +} + +function isOperationName(name: string): name is V2McpOperationName { + return Object.hasOwn(V2_MCP_OPERATIONS, name) +} + +/** `/api/v2/tables/[tableId]/rows` → `tables`. */ +function domainOf(path: string): string { + return path.split('/')[3] ?? 'v2' +} + +function summarize(name: V2McpOperationName): McpOperationEntry { + const { contract, summary, workspaceKeyUnsupported } = getMcpOperation(name) + return { + operation: name, + method: contract.method, + path: contract.path, + domain: domainOf(contract.path), + summary: summary ?? `${contract.method} ${contract.path}`, + ...(workspaceKeyUnsupported ? { personalCredentialOnly: true } : {}), + } +} + +/** Each summary with its lower-cased search fields, built once for the fixed catalog. */ +const SEARCH_ENTRIES = OPERATION_NAMES.map((name) => { + const entry = summarize(name) + return { + entry, + name: name.toLowerCase(), + summary: entry.summary.toLowerCase(), + path: entry.path.toLowerCase(), + description: getMcpOperation(name).description?.toLowerCase() ?? '', + } +}) + +/** Every domain the catalog covers, e.g. `tables`, `workflows`, `knowledge`. */ +const DOMAINS = [...new Set(SEARCH_ENTRIES.map(({ entry }) => entry.domain))].sort() +const [FIRST_DOMAIN, ...OTHER_DOMAINS] = DOMAINS +if (!FIRST_DOMAIN) throw new Error('The Sim MCP catalog has no operations') +export const OPERATION_DOMAINS: [string, ...string[]] = [FIRST_DOMAIN, ...OTHER_DOMAINS] + +/** + * Ranks operations by keyword and domain. Every term must appear in the + * operation's name, summary, path, or description; hits in the name rank first. + */ +function rankOperations( + query: string | undefined, + domain: string | undefined +): McpOperationEntry[] { + const terms = (query ?? '').toLowerCase().split(/\s+/).filter(Boolean) + const ranked: Array<{ entry: McpOperationEntry; score: number }> = [] + for (const { entry, name, summary, path, description } of SEARCH_ENTRIES) { + if (domain && entry.domain !== domain) continue + let score = 0 + for (const term of terms) { + const termScore = + (name.includes(term) ? 4 : 0) + + (summary.includes(term) ? 3 : 0) + + (path.includes(term) ? 2 : 0) + + (description.includes(term) ? 1 : 0) + if (termScore === 0) { + score = 0 + break + } + score += termScore + } + if (score > 0 || terms.length === 0) ranked.push({ entry, score }) + } + ranked.sort((a, b) => b.score - a.score || a.entry.operation.localeCompare(b.entry.operation)) + return ranked.map(({ entry }) => entry) +} + +export async function searchOperations(options: { + query?: string + domain?: string + limit: number +}): Promise<{ total: number; operations: McpOperationSummary[] }> { + const ranked = rankOperations(options.query, options.domain) + return { + total: ranked.length, + operations: await Promise.all(ranked.slice(0, options.limit).map(withTool)), + } +} + +function toJsonSchema(schema: ApiSchema): JsonSchema { + const { $schema: _, ...json } = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) + return json +} + +/** + * The contract's header schema without the headers the dispatcher owns, or + * `null` when nothing is left for a caller to set. + */ +function callerHeaderSchema(schema: ApiSchema): JsonSchema | null { + const json = toJsonSchema(schema) + const properties = toRecord(json.properties) + const managed = Object.keys(properties).filter((header) => + MANAGED_HEADERS.has(header.toLowerCase()) + ) + const allowed = omit(properties, managed) + if (Object.keys(allowed).length === 0) return null + const required = Array.isArray(json.required) + ? json.required.filter((header) => !managed.includes(header)) + : undefined + return { ...json, properties: allowed, ...(required ? { required } : {}) } +} + +/** Memo over a fixed catalog: at most one entry per operation, never evicted. */ +const inputs = new Map() + +/** The JSON Schema of each request slot an operation takes. */ +function describeInput(name: V2McpOperationName): McpOperationDescription['input'] { + const cached = inputs.get(name) + if (cached) return cached + const { contract } = getMcpOperation(name) + const input: McpOperationDescription['input'] = {} + for (const slot of REQUEST_SLOTS) { + const schema = contract[slot] + if (!schema) continue + if (slot === 'headers') { + const headers = callerHeaderSchema(schema) + if (headers) input.headers = headers + continue + } + input[slot] = toJsonSchema(schema) + } + inputs.set(name, input) + return input +} + +/** Contract headers a tool call may set. */ +export function callerHeaderNames(name: V2McpOperationName): string[] { + return Object.keys(toRecord(describeInput(name).headers?.properties)) +} + +export async function describeOperation( + name: V2McpOperationName +): Promise { + const { description } = getMcpOperation(name) + return { + ...(await withTool(summarize(name))), + ...(description ? { description } : {}), + input: describeInput(name), + } +} + +/** Catalog names close to an unknown one, found by searching its camelCase words. */ +function suggestOperations(name: string): string[] { + const words = name + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) + for (let count = words.length; count > 0; count--) { + const ranked = rankOperations(words.slice(0, count).join(' '), undefined) + if (ranked.length > 0) return ranked.slice(0, 5).map((entry) => entry.operation) + } + return [] +} + +/** + * Resolves the operation a tool call names, or explains what to call instead: + * the closest names for an unknown one, or the other tool for the wrong kind. + * `read` and `write` are the read and write tools; `any` is describe_operation. + */ +export async function resolveOperation( + name: string, + tool: ToolKind | 'any' +): Promise<{ operation: V2McpOperationName } | { error: string }> { + if (!isOperationName(name)) { + const suggestions = suggestOperations(name) + return { + error: `Unknown operation "${name}".${ + suggestions.length > 0 ? ` Closest matches: ${suggestions.join(', ')}.` : '' + } Use search_operations to find operations.`, + } + } + if (tool === 'any') return { operation: name } + const kind = await operationToolKind(name) + if (kind === tool) return { operation: name } + return { + error: + kind === 'write' + ? `${name} needs write access; run it with ${TOOL_NAMES.write}.` + : `${name} only reads; run it with ${TOOL_NAMES.read}.`, + } +} diff --git a/apps/sim/lib/api/mcp/dispatch.test.ts b/apps/sim/lib/api/mcp/dispatch.test.ts new file mode 100644 index 00000000000..e84924a743e --- /dev/null +++ b/apps/sim/lib/api/mcp/dispatch.test.ts @@ -0,0 +1,215 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' + +const mocks = vi.hoisted(() => ({ route: vi.fn(), audiences: [] as unknown[] })) + +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' })) +vi.mock('@/lib/api/mcp/catalog', () => { + const contracts = { + getTableRow: { + method: 'GET', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + response: { mode: 'json' }, + }, + completeFileUpload: { + method: 'POST', + path: '/api/v2/files/uploads/[uploadId]/complete', + body: z.object({ workspaceId: z.string() }), + headers: z.object({ 'upload-token': z.string() }), + response: { mode: 'json' }, + }, + } + return { + getMcpOperation: (name: keyof typeof contracts) => ({ + contract: contracts[name], + handler: async () => mocks.route, + }), + callerHeaderNames: (name: string) => (name === 'completeFileUpload' ? ['upload-token'] : []), + } +}) + +import { dispatchMcpOperation } from '@/lib/api/mcp/dispatch' +import { getOAuthAccessTokenAudience } from '@/lib/auth/oauth-access-token' + +const audience = { resource: 'https://mcp.sim.test/mcp', allowUnboundApiTokens: true } +const context = { + inbound: new NextRequest('https://mcp.sim.test/mcp', { + method: 'POST', + headers: { 'x-forwarded-for': '203.0.113.7', cookie: 'session=private' }, + }), + credential: { apiKey: null, bearer: 'sim_oat_token' }, + audience, + signal: new AbortController().signal, +} + +function jsonResponse(body: unknown, status = 200) { + return Response.json(body, { status }) +} + +function dispatched(): NextRequest { + return mocks.route.mock.calls[0][0] +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.audiences.length = 0 + mocks.route.mockImplementation(async () => { + mocks.audiences.push(getOAuthAccessTokenAudience()) + return jsonResponse({ data: { ok: true } }) + }) +}) + +describe('dispatchMcpOperation', () => { + it('builds the HTTP request the route would receive', async () => { + const result = await dispatchMcpOperation( + { + operation: 'getTableRow', + params: { tableId: 'tbl 1', rowId: 'row-1' }, + query: { workspaceId: 'ws-1', includeDeleted: false, limit: 5 }, + }, + context + ) + expect(result).toEqual({ content: [{ type: 'text', text: '{"data":{"ok":true}}' }] }) + const request = dispatched() + expect(request.method).toBe('GET') + expect(request.url).toBe( + 'https://sim.test/api/v2/tables/tbl%201/rows/row-1?workspaceId=ws-1&includeDeleted=false&limit=5' + ) + expect(await mocks.route.mock.calls[0][1].params).toEqual({ tableId: 'tbl 1', rowId: 'row-1' }) + }) + + it('carries the verified credential and inherited context, never ambient cookies', async () => { + await dispatchMcpOperation( + { operation: 'getTableRow', params: { tableId: 't', rowId: 'r' } }, + context + ) + const headers = dispatched().headers + expect(headers.get('authorization')).toBe('Bearer sim_oat_token') + expect(headers.get('x-api-key')).toBeNull() + expect(headers.get('x-forwarded-for')).toBe('203.0.113.7') + expect(headers.get('x-sim-client-info')).toBe('mcp') + expect(headers.get('cookie')).toBeNull() + }) + + it('sends an API key as x-api-key', async () => { + await dispatchMcpOperation( + { operation: 'getTableRow', params: { tableId: 't', rowId: 'r' } }, + { ...context, credential: { apiKey: 'sk-sim-key', bearer: null } } + ) + expect(dispatched().headers.get('x-api-key')).toBe('sk-sim-key') + expect(dispatched().headers.get('authorization')).toBeNull() + }) + + it('runs the route under the MCP token audience only', async () => { + await dispatchMcpOperation( + { operation: 'getTableRow', params: { tableId: 't', rowId: 'r' } }, + context + ) + expect(mocks.audiences).toEqual([audience]) + expect(getOAuthAccessTokenAudience()).toEqual({}) + }) + + it('sends a JSON body and the headers the contract declares', async () => { + await dispatchMcpOperation( + { + operation: 'completeFileUpload', + params: { uploadId: 'up-1' }, + body: { workspaceId: 'ws-1' }, + headers: { 'upload-token': 'signed' }, + }, + context + ) + const request = dispatched() + expect(request.method).toBe('POST') + expect(request.headers.get('content-type')).toBe('application/json') + expect(request.headers.get('upload-token')).toBe('signed') + expect(await request.json()).toEqual({ workspaceId: 'ws-1' }) + }) + + it.each([ + [{ tableId: 't' }, 'Missing path parameter rowId.'], + [{ tableId: 't', rowId: 'r', extra: 'x' }, 'Unknown path parameter extra'], + ])('rejects wrong path parameters %j', async (params, message) => { + const result = await dispatchMcpOperation({ operation: 'getTableRow', params }, context) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ text: expect.stringContaining(message) }) + expect(mocks.route).not.toHaveBeenCalled() + }) + + it.each([ + [{ operation: 'getTableRow', params: { tableId: '..', rowId: 'r' } }, 'cannot be "." or ".."'], + [ + { operation: 'getTableRow', params: { tableId: 't', rowId: 'r' }, body: { x: 1 } }, + 'takes no request body', + ], + [ + { + operation: 'completeFileUpload', + params: { uploadId: 'up-1' }, + body: { workspaceId: 'ws-1', stream: true }, + }, + 'Streaming is not supported', + ], + ] as const)('refuses a request the route would mishandle: %j', async (call, message) => { + const result = await dispatchMcpOperation(call, context) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ text: expect.stringContaining(message) }) + expect(mocks.route).not.toHaveBeenCalled() + }) + + it.each(['authorization', 'x-api-key', 'x-forwarded-for'])( + 'refuses to let a tool call set %s', + async (header) => { + const result = await dispatchMcpOperation( + { + operation: 'completeFileUpload', + params: { uploadId: 'up-1' }, + headers: { [header]: 'forged' }, + }, + context + ) + expect(result.isError).toBe(true) + expect(mocks.route).not.toHaveBeenCalled() + } + ) + + it('returns a route error as a tool error', async () => { + mocks.route.mockResolvedValue( + jsonResponse({ error: { code: 'NOT_FOUND', message: 'Row not found' } }, 404) + ) + const result = await dispatchMcpOperation( + { operation: 'getTableRow', params: { tableId: 't', rowId: 'r' } }, + context + ) + expect(result).toEqual({ + isError: true, + content: [{ type: 'text', text: '{"error":{"code":"NOT_FOUND","message":"Row not found"}}' }], + }) + }) + + it('refuses a streaming response', async () => { + mocks.route.mockResolvedValue( + new Response('data: {}\n\n', { headers: { 'content-type': 'text/event-stream' } }) + ) + const result = await dispatchMcpOperation( + { operation: 'getTableRow', params: { tableId: 't', rowId: 'r' } }, + context + ) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ text: expect.stringContaining('text/event-stream') }) + }) + + it('refuses a result too large to return', async () => { + mocks.route.mockResolvedValue(jsonResponse({ data: 'x'.repeat(1024 * 1024) })) + const result = await dispatchMcpOperation( + { operation: 'getTableRow', params: { tableId: 't', rowId: 'r' } }, + context + ) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ text: expect.stringContaining('too large') }) + }) +}) diff --git a/apps/sim/lib/api/mcp/dispatch.ts b/apps/sim/lib/api/mcp/dispatch.ts new file mode 100644 index 00000000000..06c71f1b7f8 --- /dev/null +++ b/apps/sim/lib/api/mcp/dispatch.ts @@ -0,0 +1,189 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' +import { createLogger } from '@sim/logger' +import { CLIENT_INFO_HEADER } from '@sim/utils/client-info' +import { isPlainRecord } from '@sim/utils/object' +import { NextRequest } from 'next/server' +import { callerHeaderNames, getMcpOperation } from '@/lib/api/mcp/catalog' +import type { V2McpOperationName } from '@/lib/api/mcp/generated/v2-operations' +import { API_KEY_HEADER } from '@/lib/api/server/credential-headers' +import type { V2CredentialHeaders } from '@/lib/api/server/routes/v2-credential-headers' +import { + type OAuthAccessTokenOptions, + withOAuthAccessTokenAudience, +} from '@/lib/auth/oauth-access-token' +import { + consumeOrCancelBody, + isPayloadSizeLimitError, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { toolError } from '@/lib/mcp/tool-result' + +const logger = createLogger('SimMcpDispatch') + +/** Bounds one tool result; list operations page well below this. */ +const MAX_RESULT_BYTES = 1024 * 1024 + +/** + * Headers the dispatched request inherits from the MCP request: the client + * address the v2 pre-authentication limit is keyed on, and trace context. + */ +const INHERITED_HEADERS = ['x-forwarded-for', 'traceparent', 'user-agent'] as const + +type V2RouteHandler = ( + request: NextRequest, + context: { params: Promise> } +) => Promise + +/** One tool call, as the read and write tools accept it. */ +export interface McpOperationCall { + operation: V2McpOperationName + params?: Record + query?: Record + body?: unknown + headers?: Record +} + +/** What the MCP request established: who is calling, and the audience their token was verified for. */ +export interface McpDispatchContext { + /** The MCP HTTP request the tool call arrived on. */ + inbound: NextRequest + credential: V2CredentialHeaders + audience: OAuthAccessTokenOptions + signal: AbortSignal +} + +function isRouteHandler(value: unknown): value is V2RouteHandler { + return typeof value === 'function' +} + +/** `/api/v2/tables/[tableId]` + `{ tableId }` → `/api/v2/tables/t_1`, or an error naming what is wrong. */ +function resolvePath( + template: string, + params: Record +): { path: string } | { error: string } { + const expected = [...template.matchAll(/\[([^\]]+)\]/g)].map((match) => match[1]) + const unknown = Object.keys(params).filter((name) => !expected.includes(name)) + if (unknown.length > 0) { + return { + error: `Unknown path parameter ${unknown.join(', ')}. This operation takes: ${expected.join(', ') || 'none'}.`, + } + } + const missing = expected.filter((name) => !params[name]) + if (missing.length > 0) return { error: `Missing path parameter ${missing.join(', ')}.` } + const dotSegment = expected.find((name) => params[name] === '.' || params[name] === '..') + if (dotSegment) return { error: `Path parameter ${dotSegment} cannot be "." or "..".` } + return { + path: template.replace(/\[([^\]]+)\]/g, (_, name: string) => encodeURIComponent(params[name])), + } +} + +/** The dispatched request's headers: inherited context, the verified credential, and caller-settable contract headers. */ +function buildHeaders( + call: McpOperationCall, + context: McpDispatchContext, + hasBody: boolean +): Headers | { error: string } { + const headers = new Headers({ accept: 'application/json', [CLIENT_INFO_HEADER]: 'mcp' }) + if (hasBody) headers.set('content-type', 'application/json') + for (const name of INHERITED_HEADERS) { + const value = context.inbound.headers.get(name) + if (value) headers.set(name, value) + } + if (context.credential.apiKey) headers.set(API_KEY_HEADER, context.credential.apiKey) + if (context.credential.bearer) headers.set('authorization', `Bearer ${context.credential.bearer}`) + + const allowed = callerHeaderNames(call.operation) + for (const [name, value] of Object.entries(call.headers ?? {})) { + if (!allowed.includes(name.toLowerCase())) { + return { + error: `Header ${name} cannot be set on ${call.operation}. Settable headers: ${allowed.join(', ') || 'none'}.`, + } + } + headers.set(name, value) + } + return headers +} + +/** Returns the route's JSON answer as tool content; a v2 error envelope becomes a tool error. */ +async function toToolResult( + operation: V2McpOperationName, + response: Response +): Promise { + const contentType = response.headers.get('content-type') ?? '' + if (!/[/+]json\b/i.test(contentType)) { + await consumeOrCancelBody(response) + return toolError( + `${operation} answered with ${contentType || 'an empty body'} (HTTP ${response.status}). Only JSON responses can be returned over MCP.` + ) + } + let text: string + try { + text = await readResponseTextWithLimit(response, { + maxBytes: MAX_RESULT_BYTES, + label: `${operation} result`, + }) + } catch (error) { + if (!isPayloadSizeLimitError(error)) throw error + return toolError('Result is too large. Request a smaller page with limit or cursor.') + } + return response.ok ? { content: [{ type: 'text', text }] } : toolError(text) +} + +/** + * Serves a tool call through the v2 route that owns the operation. + * + * The call becomes the HTTP request the route would receive — same path, query, + * body, and credential — and the route handles it end to end: authentication, + * OAuth scope, rate limit, validation, the application use case, and the error + * envelope. MCP adds no authorization of its own, so no operation can be looser + * here than over HTTP. + */ +export async function dispatchMcpOperation( + call: McpOperationCall, + context: McpDispatchContext +): Promise { + const startedAt = performance.now() + const { contract, handler } = getMcpOperation(call.operation) + + const resolved = resolvePath(contract.path, call.params ?? {}) + if ('error' in resolved) return toolError(resolved.error) + + const url = new URL(resolved.path, getBaseUrl()) + for (const [name, value] of Object.entries(call.query ?? {})) { + url.searchParams.set(name, String(value)) + } + + const hasBody = contract.method !== 'GET' && contract.body !== undefined + if (!hasBody && call.body !== undefined) { + return toolError(`${call.operation} takes no request body. Use params and query instead.`) + } + if (isPlainRecord(call.body) && call.body.stream === true) { + return toolError( + 'Streaming is not supported over MCP. Omit stream to wait for the result, or set async: true and poll getWorkflowRun.' + ) + } + const headers = buildHeaders(call, context, hasBody) + if ('error' in headers) return toolError(headers.error) + + const route = await handler() + if (!isRouteHandler(route)) { + throw new Error(`${call.operation} has no ${contract.method} handler at ${contract.path}`) + } + + const request = new NextRequest(url, { + method: contract.method, + headers, + body: hasBody ? JSON.stringify(call.body ?? {}) : undefined, + signal: context.signal, + }) + const response = await withOAuthAccessTokenAudience(context.audience, () => + route(request, { params: Promise.resolve(call.params ?? {}) }) + ) + logger.info('Sim MCP operation dispatched', { + operation: call.operation, + status: response.status, + durationMs: Math.round(performance.now() - startedAt), + }) + return toToolResult(call.operation, response) +} diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts new file mode 100644 index 00000000000..37a8e4cd58a --- /dev/null +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -0,0 +1,2258 @@ +/** + * GENERATED FILE — DO NOT EDIT. + * + * Emitted from the Zod route contracts in `apps/sim/lib/api/contracts/v2/**` + * by `scripts/generate-v2-mcp-operations.ts`. Regenerate with + * `bun run generate:mcp-operations`; CI fails when this file is stale. + */ + +import { v2GetAuditLogContract, v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' +import { + v2GetBillingStatusContract, + v2ListBillingLogsContract, +} from '@/lib/api/contracts/v2/billing' +import { + v2ExecuteToolContract, + v2GetBlockContract, + v2GetToolContract, + v2ListBlocksContract, + v2ListConnectorTypesContract, + v2ListToolsContract, +} from '@/lib/api/contracts/v2/catalog' +import { v2ChatContract } from '@/lib/api/contracts/v2/chat' +import { + v2DeleteWorkflowChatDeploymentContract, + v2GetWorkflowChatDeploymentContract, + v2ListChatDeploymentsContract, + v2ReplaceWorkflowChatDeploymentContract, +} from '@/lib/api/contracts/v2/chat-deployments' +import { + v2CreateCredentialConnectionContract, + v2CreateServiceAccountCredentialContract, + v2DeleteCredentialContract, + v2ListCredentialProvidersContract, + v2ListCredentialsContract, + v2UpdateCredentialContract, +} from '@/lib/api/contracts/v2/credentials' +import { + v2CreateCustomToolContract, + v2DeleteCustomToolContract, + v2GetCustomToolContract, + v2ListCustomToolsContract, + v2UpdateCustomToolContract, +} from '@/lib/api/contracts/v2/custom-tools' +import { + v2AbortFileUploadContract, + v2BulkDeleteFilesContract, + v2CompleteFileUploadContract, + v2CreateFileContract, + v2CreateFileFolderContract, + v2CreateFileUploadContract, + v2CreateFileUploadPartUrlsContract, + v2DeleteFileContract, + v2DeleteFileFolderContract, + v2EditFileContentContract, + v2GetFileContract, + v2GetFileShareContract, + v2GetFileUploadContract, + v2ListFileFoldersContract, + v2ListFilesContract, + v2MoveFileItemsContract, + v2ReadFileTextContract, + v2RelocateFileFolderContract, + v2RenameFileContract, + v2RestoreFileContract, + v2RestoreFileFolderContract, + v2SearchFileContentContract, + v2UnzipFileContract, + v2UpdateFileContentContract, + v2UpsertFileShareContract, +} from '@/lib/api/contracts/v2/files' +import { + v2AbortKnowledgeDocumentUploadContract, + v2AddWorkspaceFilesToKnowledgeBaseContract, + v2BulkUpdateKnowledgeDocumentsContract, + v2CompleteKnowledgeDocumentUploadContract, + v2CreateKnowledgeBaseContract, + v2CreateKnowledgeConnectorContract, + v2CreateKnowledgeDocumentUploadContract, + v2CreateKnowledgeDocumentUploadPartUrlsContract, + v2CreateKnowledgeFolderContract, + v2DeleteKnowledgeBaseContract, + v2DeleteKnowledgeConnectorContract, + v2DeleteKnowledgeDocumentContract, + v2DeleteKnowledgeFolderContract, + v2GetKnowledgeBaseContract, + v2GetKnowledgeConnectorContract, + v2GetKnowledgeDocumentContract, + v2ListKnowledgeBasesContract, + v2ListKnowledgeConnectorDocumentsContract, + v2ListKnowledgeConnectorsContract, + v2ListKnowledgeDocumentsContract, + v2ListKnowledgeFoldersContract, + v2ListKnowledgeTagsContract, + v2RelocateKnowledgeFolderContract, + v2RestoreKnowledgeBaseContract, + v2SearchKnowledgeContract, + v2SyncKnowledgeConnectorContract, + v2UpdateKnowledgeBaseContract, + v2UpdateKnowledgeConnectorContract, + v2UpdateKnowledgeConnectorDocumentsContract, + v2UpdateKnowledgeDocumentContract, +} from '@/lib/api/contracts/v2/knowledge' +import { + v2BulkUpdateKnowledgeChunksContract, + v2CreateKnowledgeChunkContract, + v2DeleteKnowledgeChunkContract, + v2GetKnowledgeChunkContract, + v2ListKnowledgeChunksContract, + v2UpdateKnowledgeChunkContract, +} from '@/lib/api/contracts/v2/knowledge-chunks' +import { + v2BulkSaveKnowledgeTagDefinitionsContract, + v2CreateKnowledgeTagContract, + v2DeleteKnowledgeTagContract, + v2DeleteKnowledgeTagDefinitionsContract, + v2GetNextKnowledgeTagSlotContract, + v2ListKnowledgeTagUsageContract, + v2UpdateKnowledgeTagContract, +} from '@/lib/api/contracts/v2/knowledge-tags' +import { v2GetLogContract, v2ListLogsContract } from '@/lib/api/contracts/v2/logs' +import { v2GetLogStatsContract } from '@/lib/api/contracts/v2/logs-stats' +import { + v2CreateMcpServerContract, + v2DeleteMcpServerContract, + v2GetMcpServerContract, + v2ListMcpServersContract, + v2ListMcpServerToolsContract, + v2UpdateMcpServerContract, +} from '@/lib/api/contracts/v2/mcp-servers' +import { v2GetMetaContract } from '@/lib/api/contracts/v2/meta' +import { + v2CreateSandboxContract, + v2DeleteSandboxContract, + v2GetSandboxContract, + v2ListSandboxesContract, + v2UpdateSandboxContract, +} from '@/lib/api/contracts/v2/sandboxes' +import { + v2DeleteSecretContract, + v2ListSecretsContract, + v2SetSecretContract, +} from '@/lib/api/contracts/v2/secrets' +import { v2GetSelectorContract, v2ListSelectorContract } from '@/lib/api/contracts/v2/selectors' +import { + v2CreateSkillContract, + v2DeleteSkillContract, + v2GetSkillContract, + v2GrantSkillEditorContract, + v2ListSkillEditorsContract, + v2ListSkillsContract, + v2RevokeSkillEditorContract, + v2UpdateSkillContract, +} from '@/lib/api/contracts/v2/skills' +import { + v2AddTableColumnContract, + v2AddWorkflowGroupContract, + v2BulkDeleteTablesContract, + v2BulkUpdateTableRowsContract, + v2CancelTableDispatchContract, + v2CancelTableExportContract, + v2CancelTableImportContract, + v2CancelTableRunsContract, + v2CompleteTableImportContract, + v2CreateTableContract, + v2CreateTableDispatchContract, + v2CreateTableExportContract, + v2CreateTableFolderContract, + v2CreateTableImportContract, + v2CreateTableImportPartUrlsContract, + v2CreateTableRowsContract, + v2CreateTableViewContract, + v2DeleteTableColumnContract, + v2DeleteTableContract, + v2DeleteTableFolderContract, + v2DeleteTableRowContract, + v2DeleteTableRowsContract, + v2DeleteTableViewContract, + v2DeleteWorkflowGroupContract, + v2GetRowEnrichmentContract, + v2GetTableContract, + v2GetTableDispatchContract, + v2GetTableExportContract, + v2GetTableImportContract, + v2GetTableRowContract, + v2GetTableViewContract, + v2ListTableDispatchesContract, + v2ListTableFoldersContract, + v2ListTableRowsContract, + v2ListTablesContract, + v2ListTableViewsContract, + v2ListWorkflowGroupsContract, + v2MoveTablesContract, + v2QueryRowsContract, + v2QueryRowsCountContract, + v2RelocateTableFolderContract, + v2RestoreTableContract, + v2RestoreTableFolderContract, + v2RunRowEnrichmentContract, + v2SearchTableRowsContract, + v2TableExportDownloadContract, + v2UpdateRowsByFilterContract, + v2UpdateTableColumnContract, + v2UpdateTableContract, + v2UpdateTableRowContract, + v2UpdateTableViewContract, + v2UpdateWorkflowGroupContract, + v2UpsertTableRowContract, +} from '@/lib/api/contracts/v2/tables' +import { + v2CreateWorkflowMcpServerContract, + v2DeleteWorkflowMcpServerContract, + v2DeployWorkflowMcpToolContract, + v2GetWorkflowMcpServerContract, + v2ListWorkflowMcpServersContract, + v2ListWorkflowMcpToolsContract, + v2UndeployWorkflowMcpToolContract, + v2UpdateWorkflowMcpServerContract, +} from '@/lib/api/contracts/v2/workflow-mcp-servers' +import { + v2ActivateWorkflowVersionContract, + v2ApplyWorkflowOperationsContract, + v2ApplyWorkflowVariablesContract, + v2CancelWorkflowRunContract, + v2CreateWorkflowContract, + v2CreateWorkflowFolderContract, + v2DeleteWorkflowContract, + v2DeleteWorkflowFolderContract, + v2DeployWorkflowContract, + v2DuplicateWorkflowContract, + v2ExecuteWorkflowContract, + v2ExportWorkflowContract, + v2GetWorkflowContract, + v2GetWorkflowDeploymentContract, + v2GetWorkflowRunContract, + v2GetWorkflowStateContract, + v2GetWorkflowVersionContract, + v2ImportWorkflowContract, + v2ListWorkflowFoldersContract, + v2ListWorkflowRunsContract, + v2ListWorkflowsContract, + v2ListWorkflowVersionsContract, + v2MoveWorkflowsContract, + v2PreviewWorkflowImportContract, + v2RelocateWorkflowFolderContract, + v2ReplaceWorkflowStateContract, + v2RestoreWorkflowContract, + v2ResumeWorkflowContract, + v2RevertWorkflowVersionContract, + v2RollbackWorkflowContract, + v2UndeployWorkflowContract, + v2UpdateWorkflowContract, + v2UpdateWorkflowPublicApiContract, + v2UpdateWorkflowVersionContract, +} from '@/lib/api/contracts/v2/workflows' +import { + v2ForkWorkspaceContract, + v2GetWorkspaceForkAvailabilityContract, + v2GetWorkspaceForkLineageContract, + v2GetWorkspaceForkMappingsContract, + v2ListWorkspaceForkChildrenContract, + v2ListWorkspaceForkResourcesContract, + v2PreviewWorkspaceForkContract, + v2PreviewWorkspacePullContract, + v2PreviewWorkspacePushContract, + v2PullWorkspaceContract, + v2PushWorkspaceContract, + v2RollbackWorkspaceForkContract, + v2UnlinkWorkspaceForkContract, + v2UpdateWorkspaceForkExclusionsContract, + v2UpdateWorkspaceForkMappingsContract, +} from '@/lib/api/contracts/v2/workspace-fork' +import { + v2GetWorkspaceOperationContract, + v2ListWorkspaceOperationsContract, +} from '@/lib/api/contracts/v2/workspace-operations' +import { + v2GetWorkspaceContract, + v2ListWorkspaceMembersContract, + v2ListWorkspacesContract, +} from '@/lib/api/contracts/v2/workspaces' +import type { V2McpOperation } from '@/lib/api/mcp/types' + +export const V2_MCP_OPERATIONS = { + abortFileUpload: { + contract: v2AbortFileUploadContract, + summary: 'Abort File Upload', + description: + 'Abort an incomplete upload session and discard its uploaded data. Completed uploads cannot be aborted.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/files/uploads/[uploadId]/route').then((route) => route.DELETE), + }, + abortKnowledgeDocumentUpload: { + contract: v2AbortKnowledgeDocumentUploadContract, + summary: 'Abort Document Upload', + description: + 'Abort an incomplete upload session and discard its uploaded data. Completed uploads cannot be aborted.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/route').then( + (route) => route.DELETE + ), + }, + activateWorkflowVersion: { + contract: v2ActivateWorkflowVersionContract, + summary: 'Activate Workflow Version', + description: + 'Asynchronously activate a specific deployment version, including when the workflow is not currently deployed. The draft remains unchanged. Read Get Workflow Deployment for `isDeployed` and `latestDeploymentAttempt`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/versions/[version]/activate/route').then( + (route) => route.POST + ), + }, + addTableColumn: { + contract: v2AddTableColumnContract, + summary: 'Add Column', + description: + 'Add a typed column and return the complete resulting table schema.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/columns/route').then((route) => route.POST), + }, + addWorkflowGroup: { + contract: v2AddWorkflowGroupContract, + summary: 'Add Workflow Group', + description: + 'Bind a workflow or enrichment to the table and create the columns populated by its outputs.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/[tableId]/groups/route').then((route) => route.POST), + }, + addWorkspaceFilesToKnowledgeBase: { + contract: v2AddWorkspaceFilesToKnowledgeBaseContract, + summary: 'Index Workspace Files', + description: + 'Queue stored workspace files for indexing without re-uploading bytes. Unreadable, unsupported, or over-100 MB files appear in `failed`; valid files are queued. Partial success returns `200`. Use Get Document to poll processing after receiving document IDs. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/from-workspace-files/route').then( + (route) => route.POST + ), + }, + applyWorkflowOperations: { + contract: v2ApplyWorkflowOperationsContract, + summary: 'Apply Workflow Operations', + description: + 'Edit the draft graph and block enablement in one write. Inspect `skipped` for failures; do not retry `deferred` edges. With `atomic=true`, skipped operations or dropped inputs return `409` (`OPERATIONS_NOT_APPLIED`) without saving. `mintedBlockIds` maps labels to generated IDs. Lint is advisory; `dryRun=true` validates without saving, auditing, or notifying. The live deployment is unchanged. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/operations/route').then((route) => route.POST), + }, + applyWorkflowVariables: { + contract: v2ApplyWorkflowVariablesContract, + summary: 'Update Workflow Variables', + description: + 'Add, edit, or delete variables by name, applying operations in order. Values are coerced to their declared type when possible; otherwise they are stored as supplied. A batch with no changes returns `200` with `changed: false`. Read current variables with Get Workflow.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/variables/route').then((route) => route.PATCH), + }, + bulkDeleteFiles: { + contract: v2BulkDeleteFilesContract, + summary: 'Delete Files', + description: + 'Archive up to 1,000 workspace files while retaining their stored bytes. Use Restore File to recover each file.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/bulk-delete/route').then((route) => route.POST), + }, + bulkDeleteTables: { + contract: v2BulkDeleteTablesContract, + summary: 'Bulk Delete Tables and Folders', + description: + 'Archive up to 100 selected tables and folders, including folder contents. Items succeed or fail independently, with `skipped`, `notFound`, and `failed` outcomes. `deletedItems` includes all descendants. Use Restore Table or Restore Folder to recover archived items.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/bulk-delete/route').then((route) => route.POST), + }, + bulkSaveKnowledgeTagDefinitions: { + contract: v2BulkSaveKnowledgeTagDefinitionsContract, + summary: 'Bulk Save Tag Definitions', + description: + 'Create or update tag definitions, preserving unspecified slots. Updates require `originalDisplayName`; other entries create tags. Slot and name conflicts appear in per-definition `errors` with HTTP `200`, leaving conflicting values unchanged. Use Update Document to set tag values. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/tags/route').then((route) => route.PUT), + }, + bulkUpdateKnowledgeChunks: { + contract: v2BulkUpdateKnowledgeChunksContract, + summary: 'Bulk Update Chunks', + description: + 'Enable, disable, or delete multiple chunks in one best-effort request. Unknown chunk IDs appear in `errors` without failing the request; `processed` counts matched chunks, not changes. Connector-synced chunks are read-only and return `403` with `error.details.code: "CONNECTOR_MANAGED_RESOURCE_READ_ONLY"`; change the source and re-sync, or exclude the document. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route').then( + (route) => route.PATCH + ), + }, + bulkUpdateKnowledgeDocuments: { + contract: v2BulkUpdateKnowledgeDocumentsContract, + summary: 'Bulk Enable or Disable Documents', + description: + 'Enable or disable selected documents, or use `selectAll` for the entire knowledge base. Use Delete Document to remove documents individually. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/route').then( + (route) => route.PATCH + ), + }, + bulkUpdateTableRows: { + contract: v2BulkUpdateTableRowsContract, + summary: 'Bulk Update Rows', + description: + 'Apply separate partial patches to up to 1,000 rows, preserving omitted columns. A row outside the table rejects the entire request with `400` and lists missing IDs. Use Update Rows by Filter to apply one patch to every matching row.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/rows/bulk-update/route').then((route) => route.POST), + }, + cancelTableDispatch: { + contract: v2CancelTableDispatchContract, + summary: 'Cancel Run Dispatch', + description: + 'Stop a dispatch from scheduling more cells. Already queued or running cells continue; use Cancel Column Runs to stop them. Completed or canceled dispatches return unchanged.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/dispatches/[dispatchId]/route').then( + (route) => route.DELETE + ), + }, + cancelTableExport: { + contract: v2CancelTableExportContract, + summary: 'Cancel Table Export', + description: 'Cancel an export that is still in progress.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/exports/[exportId]/route').then( + (route) => route.DELETE + ), + }, + cancelTableImport: { + contract: v2CancelTableImportContract, + summary: 'Cancel Table Import', + description: + 'Cancel an upload or processing import. Committed row batches remain. Non-cancelable states, including `expired`, return `409`; unknown or purged imports return `404`.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/imports/[importId]/route').then((route) => route.DELETE), + }, + cancelTableRuns: { + contract: v2CancelTableRunsContract, + summary: 'Cancel Column Runs', + description: + 'Stop in-flight and pending workflow or enrichment cell runs across the table or one selected row.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/cancel-runs/route').then((route) => route.POST), + }, + cancelWorkflowRun: { + contract: v2CancelWorkflowRunContract, + summary: 'Cancel Workflow Run', + description: + 'Request cancellation of a running, queued, or paused workflow run. Terminal runs return successfully without changes. A table workflow-group run returns `409` if its cell can no longer accept cancellation.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route').then( + (route) => route.POST + ), + }, + chat: { + contract: v2ChatContract, + handler: () => import('@/app/api/v2/chat/route').then((route) => route.POST), + }, + completeFileUpload: { + contract: v2CompleteFileUploadContract, + summary: 'Complete File Upload', + description: + 'Finalize an upload and register its workspace file. Repeating a completed upload returns the existing file.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/files/uploads/[uploadId]/complete/route').then((route) => route.POST), + }, + completeKnowledgeDocumentUpload: { + contract: v2CompleteKnowledgeDocumentUploadContract, + summary: 'Complete Document Upload', + description: + 'Verify a direct upload or assemble multipart parts, create the knowledge document, and queue asynchronous processing.\n\nOAuth scope: `api:write`.', + handler: () => + import( + '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route' + ).then((route) => route.POST), + }, + completeTableImport: { + contract: v2CompleteTableImportContract, + summary: 'Complete Table Import Upload', + description: + 'Verify or assemble uploaded CSV bytes and start processing under the same import ID. Requires an import awaiting upload completion; other states return `409`. Unknown or purged imports return `404`.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/imports/[importId]/complete/route').then((route) => route.POST), + }, + createCredentialConnection: { + contract: v2CreateCredentialConnectionContract, + summary: 'Create Credential Connection', + description: + 'Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL, sign in as the authenticated user, complete provider authorization, then refresh the credentials list. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/credentials/connections/route').then((route) => route.POST), + }, + createCustomTool: { + contract: v2CreateCustomToolContract, + summary: 'Create Custom Tool', + description: + 'Create a code-backed custom tool in a workspace. Its title must be unique because tools resolve by title at call time.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/custom-tools/route').then((route) => route.POST), + }, + createFile: { + contract: v2CreateFileContract, + summary: 'Create File', + description: + 'Create a workspace file from inline UTF-8 or base64 content. Use an upload session for streamed or larger files.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/route').then((route) => route.POST), + }, + createFileFolder: { + contract: v2CreateFileFolderContract, + summary: 'Create Folder', + description: 'Create a folder at the supplied workspace path.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/folders/route').then((route) => route.POST), + }, + createFileUpload: { + contract: v2CreateFileUploadContract, + summary: 'Create File Upload', + description: + 'Create a resumable upload session and receive either a signed PUT URL or multipart instructions.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/uploads/route').then((route) => route.POST), + }, + createFileUploadPartUrls: { + contract: v2CreateFileUploadPartUrlsContract, + summary: 'Create File Upload Part URLs', + description: + 'Create signed URLs for a bounded set of multipart upload part numbers.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/files/uploads/[uploadId]/parts/route').then((route) => route.POST), + }, + createKnowledgeBase: { + contract: v2CreateKnowledgeBaseContract, + summary: 'Create Knowledge Base', + description: + 'Create a knowledge base in a workspace with optional folder placement and chunking configuration. An unknown `folderPath` returns `404`. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/knowledge/route').then((route) => route.POST), + }, + createKnowledgeChunk: { + contract: v2CreateKnowledgeChunkContract, + summary: 'Create Chunk', + description: + 'Append a chunk, embedding it before the response so it is immediately searchable. It inherits the document\'s tags and next `chunkIndex`. Connector-synced chunks are read-only and return `403` with `error.details.code: "CONNECTOR_MANAGED_RESOURCE_READ_ONLY"`; change the source and re-sync, or exclude the document. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route').then( + (route) => route.POST + ), + }, + createKnowledgeConnector: { + contract: v2CreateKnowledgeConnectorContract, + summary: 'Create Knowledge Connector', + description: + 'Validate and connect an external source, then queue its initial synchronization. The `apiKey` field is never returned. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/route').then( + (route) => route.POST + ), + }, + createKnowledgeDocumentUpload: { + contract: v2CreateKnowledgeDocumentUploadContract, + summary: 'Create Document Upload', + description: + 'Create a resumable upload session and receive direct PUT or multipart transfer instructions.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/route').then( + (route) => route.POST + ), + }, + createKnowledgeDocumentUploadPartUrls: { + contract: v2CreateKnowledgeDocumentUploadPartUrlsContract, + summary: 'Create Document Upload Part URLs', + description: + 'Create short-lived signed PUT URLs for up to 100 multipart part numbers.\n\nOAuth scope: `api:write`.', + handler: () => + import( + '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/parts/route' + ).then((route) => route.POST), + }, + createKnowledgeFolder: { + contract: v2CreateKnowledgeFolderContract, + summary: 'Create Folder', + description: + 'Create a folder in the knowledge-base folder tree. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/knowledge/folders/route').then((route) => route.POST), + }, + createKnowledgeTag: { + contract: v2CreateKnowledgeTagContract, + summary: 'Create Tag', + description: + 'Create a tag definition. Write document values by `tagSlot` and filter by `displayName`. Omitting `tagSlot` selects a free slot; exhaustion returns `400`. An occupied slot or duplicate name returns `409`. Use Bulk Save Tag Definitions for multiple definitions. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/tags/route').then((route) => route.POST), + }, + createMcpServer: { + contract: v2CreateMcpServerContract, + summary: 'Create MCP Server', + description: + 'Register an external MCP server without connecting to it. A duplicate URL returns `409`; use Update MCP Server to change the existing registration. The server remains disconnected until List MCP Server Tools succeeds.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/mcp-servers/route').then((route) => route.POST), + }, + createSandbox: { + contract: v2CreateSandboxContract, + summary: 'Create Sandbox', + description: + 'Create a uniquely named dependency environment. If a build is needed, track readiness with `buildStatus`; null means no build is required. Invalid dependencies return `400` with field details. Requires workspace admin access on Max or Enterprise. Creates and updates share a rate limit; respect `Retry-After`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/sandboxes/route').then((route) => route.POST), + }, + createServiceAccountCredential: { + contract: v2CreateServiceAccountCredentialContract, + summary: 'Create Service-Account Credential', + description: + 'Verify and store a service-account credential using the fields from List Credential Providers, encoded as a JSON object string in `credentials`. Secrets are never returned. A matching source returns the existing credential with `200`; creation returns `201`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/credentials/route').then((route) => route.POST), + }, + createSkill: { + contract: v2CreateSkillContract, + summary: 'Create Skill', + description: + 'Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/skills/route').then((route) => route.POST), + }, + createTable: { + contract: v2CreateTableContract, + summary: 'Create Table', + description: + 'Create a table with a typed column schema and optional folder placement.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/route').then((route) => route.POST), + }, + createTableDispatch: { + contract: v2CreateTableDispatchContract, + summary: 'Create Run Dispatch', + description: + 'Start workflow or enrichment groups across all rows or selected rows. Poll Get Run Dispatch until `complete` or `canceled`. A null `dispatchId` means no dispatch is available to poll; check row outcomes with `includeRunState`. Use Cancel Run Dispatch to stop further scheduling.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/dispatches/route').then((route) => route.POST), + }, + createTableExport: { + contract: v2CreateTableExportContract, + summary: 'Create Table Export', + description: + 'Create a CSV or JSON export. Exports of small tables finish during the request; larger exports run asynchronously.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/exports/route').then((route) => route.POST), + }, + createTableFolder: { + contract: v2CreateTableFolderContract, + summary: 'Create Folder', + description: + 'Create one table-folder leaf whose parent path already exists.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/folders/route').then((route) => route.POST), + }, + createTableImport: { + contract: v2CreateTableImportContract, + summary: 'Create Table Import', + description: + 'Create a CSV import. Upload sources receive signed transfer instructions; workspace-file sources start processing directly.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/imports/route').then((route) => route.POST), + }, + createTableImportPartUrls: { + contract: v2CreateTableImportPartUrlsContract, + summary: 'Create Table Import Part URLs', + description: + 'Create signed URLs for multipart upload parts. Requires the `uploading` state; other states return `409`. Unknown or purged imports return `404`.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/imports/[importId]/parts/route').then((route) => route.POST), + }, + createTableRows: { + contract: v2CreateTableRowsContract, + summary: 'Create Rows', + description: + 'Insert one row with a data object or insert a bounded batch with a rows array. Cell keys are column names.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/[tableId]/rows/route').then((route) => route.POST), + }, + createTableView: { + contract: v2CreateTableViewContract, + summary: 'Create View', + description: + 'Save a filter, sort, and column layout as a named presentation of a table.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/[tableId]/views/route').then((route) => route.POST), + }, + createWorkflow: { + contract: v2CreateWorkflowContract, + summary: 'Create Workflow', + description: + 'Create a workflow at the workspace root or in a workflow folder. The response includes seeded blocks and their IDs for attaching edges. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/workflows/route').then((route) => route.POST), + }, + createWorkflowFolder: { + contract: v2CreateWorkflowFolderContract, + summary: 'Create Workflow Folder', + description: + 'Create a workflow folder in a workspace. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/workflows/folders/route').then((route) => route.POST), + }, + createWorkflowMcpServer: { + contract: v2CreateWorkflowMcpServerContract, + summary: 'Create Workflow MCP Server', + description: + 'Create an MCP server that exposes deployed workflows as tools. Every supplied workflow must already be deployed. With `isPublic: true`, anyone with the server URL can execute its workflows without a Sim API key. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/workflow-mcp-servers/route').then((route) => route.POST), + }, + deleteCredential: { + contract: v2DeleteCredentialContract, + summary: 'Disconnect Credential', + description: + 'Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/credentials/[credentialId]/route').then((route) => route.DELETE), + }, + deleteCustomTool: { + contract: v2DeleteCustomToolContract, + summary: 'Delete Custom Tool', + description: + 'Delete a custom tool. Agent blocks retain their configuration but can no longer call the deleted tool.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/custom-tools/[customToolId]/route').then((route) => route.DELETE), + }, + deleteFile: { + contract: v2DeleteFileContract, + summary: 'Delete File', + description: + 'Archive a workspace file, retaining its stored bytes and removing API read access. List Files with `scope=archived` finds it; Restore File recovers it. Archiving an already archived file returns `404`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/[fileId]/route').then((route) => route.DELETE), + }, + deleteFileFolder: { + contract: v2DeleteFileFolderContract, + summary: 'Delete Folder', + description: + 'Archive an empty folder, or set `recursive=true` to archive its files and subfolders. Use Restore Folder to recover the archived contents.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/folders/route').then((route) => route.DELETE), + }, + deleteKnowledgeBase: { + contract: v2DeleteKnowledgeBaseContract, + summary: 'Delete Knowledge Base', + description: + 'Archive a knowledge base, its documents, and its connectors, pausing synchronization. Use List Knowledge Bases with `scope=archived` to find it and Restore Knowledge Base to recover it.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/route').then((route) => route.DELETE), + }, + deleteKnowledgeChunk: { + contract: v2DeleteKnowledgeChunkContract, + summary: 'Delete Chunk', + description: + 'Permanently remove one chunk and subtract it from document counts. Remaining `chunkIndex` values stay stable and may become non-contiguous. Connector-synced chunks are read-only and return `403` with `error.details.code: "CONNECTOR_MANAGED_RESOURCE_READ_ONLY"`; change the source and re-sync, or exclude the document. Documents that have not finished processing return `409` with their current status. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]/route' + ).then((route) => route.DELETE), + }, + deleteKnowledgeConnector: { + contract: v2DeleteKnowledgeConnectorContract, + summary: 'Delete Knowledge Connector', + description: + 'Delete a connector and optionally its synchronized documents. Documents are retained by default. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/route').then( + (route) => route.DELETE + ), + }, + deleteKnowledgeDocument: { + contract: v2DeleteKnowledgeDocumentContract, + summary: 'Delete Document', + description: + 'Remove a document from listings and search. Uploaded documents and their chunks are deleted. Connector documents are excluded while retaining their stored data; later synchronization does not re-add them.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route').then( + (route) => route.DELETE + ), + }, + deleteKnowledgeFolder: { + contract: v2DeleteKnowledgeFolderContract, + summary: 'Delete Folder', + description: + 'Archive an empty folder, or set `recursive=true` to archive its subfolders and knowledge bases. Use Restore Knowledge Base to recover knowledge bases.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/knowledge/folders/route').then((route) => route.DELETE), + }, + deleteKnowledgeTag: { + contract: v2DeleteKnowledgeTagContract, + summary: 'Delete Tag', + description: + 'Permanently delete a tag definition and its values from every document and chunk in the knowledge base. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/tags/[tagId]/route').then( + (route) => route.DELETE + ), + }, + deleteKnowledgeTagDefinitions: { + contract: v2DeleteKnowledgeTagDefinitionsContract, + summary: 'Delete Tag Definitions', + description: + 'Delete unused tag definitions by default. With `unused=false`, permanently delete all definitions and their values from documents and chunks. Use Delete Tag to remove one definition. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/tags/route').then((route) => route.DELETE), + }, + deleteMcpServer: { + contract: v2DeleteMcpServerContract, + summary: 'Delete MCP Server', + description: + "Remove an MCP server and revoke its OAuth tokens. Workflows retain blocks that referenced the server's tools, but those tools can no longer be called.\n\nOAuth scope: `api:write`.", + handler: () => + import('@/app/api/v2/mcp-servers/[mcpServerId]/route').then((route) => route.DELETE), + }, + deleteSandbox: { + contract: v2DeleteSandboxContract, + summary: 'Delete Sandbox', + description: + 'Delete a sandbox. Function blocks using it fail until reconfigured. Requires workspace admin access on Max or Enterprise. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/sandboxes/[sandboxId]/route').then((route) => route.DELETE), + }, + deleteSecret: { + contract: v2DeleteSecretContract, + summary: 'Delete Secret', + description: + 'Delete a workspace or caller-owned personal secret without reading or returning its stored value. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/secrets/[name]/route').then((route) => route.DELETE), + }, + deleteSkill: { + contract: v2DeleteSkillContract, + summary: 'Delete Skill', + description: + 'Delete a workspace skill. Built-in skills are read-only and cannot be deleted. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/skills/[skillId]/route').then((route) => route.DELETE), + }, + deleteTable: { + contract: v2DeleteTableContract, + summary: 'Delete Table', + description: + 'Archive a table while retaining its rows. Use List Tables with `scope=archived` to find it and Restore Table to recover it.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/[tableId]/route').then((route) => route.DELETE), + }, + deleteTableColumn: { + contract: v2DeleteTableColumnContract, + summary: 'Delete Column', + description: + 'Delete a column by name while preserving at least one table column.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/columns/route').then((route) => route.DELETE), + }, + deleteTableFolder: { + contract: v2DeleteTableFolderContract, + summary: 'Delete Folder', + description: + 'Archive an empty folder, or set `recursive=true` to archive its tables and subfolders. Use Restore Folder to recover the archived contents.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/folders/route').then((route) => route.DELETE), + }, + deleteTableRow: { + contract: v2DeleteTableRowContract, + summary: 'Delete Row', + description: 'Delete one row by identifier.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/rows/[rowId]/route').then((route) => route.DELETE), + }, + deleteTableRows: { + contract: v2DeleteTableRowsContract, + summary: 'Delete Rows', + description: + 'Delete rows by a non-empty predicate or an explicit bounded list of row identifiers.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/[tableId]/rows/route').then((route) => route.DELETE), + }, + deleteTableView: { + contract: v2DeleteTableViewContract, + summary: 'Delete View', + description: + 'Delete a saved presentation without changing any table rows.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/views/[viewId]/route').then((route) => route.DELETE), + }, + deleteWorkflow: { + contract: v2DeleteWorkflowContract, + summary: 'Delete Workflow', + description: + 'Archive a workflow and stop its schedules, webhooks, MCP tools, and chats. Use List Workflows with `scope=archived` to find it and Restore Workflow to recover it and its archived resources. Both `deleted` and `archived` acknowledge archival.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/route').then((route) => route.DELETE), + }, + deleteWorkflowChatDeployment: { + contract: v2DeleteWorkflowChatDeploymentContract, + summary: 'Delete Workflow Chat Deployment', + description: + "Remove a workflow's hosted chat and release its URL identifier. The workflow API deployment remains active; use Undeploy Workflow to stop it. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/deployments/chat/route').then( + (route) => route.DELETE + ), + }, + deleteWorkflowFolder: { + contract: v2DeleteWorkflowFolderContract, + summary: 'Delete Workflow Folder', + description: + 'Archive an empty workflow folder, or set `recursive=true` to archive its subfolders and workflows. Use Restore Workflow to recover workflows.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/workflows/folders/route').then((route) => route.DELETE), + }, + deleteWorkflowGroup: { + contract: v2DeleteWorkflowGroupContract, + summary: 'Delete Workflow Group', + description: + 'Delete a workflow group and every table column populated by that group.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/groups/route').then((route) => route.DELETE), + }, + deleteWorkflowMcpServer: { + contract: v2DeleteWorkflowMcpServerContract, + summary: 'Delete Workflow MCP Server', + description: + 'Delete a workflow MCP server and stop serving its tools. The underlying workflows remain deployed and executable through the workflow API. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflow-mcp-servers/[serverId]/route').then((route) => route.DELETE), + }, + deployWorkflow: { + contract: v2DeployWorkflowContract, + summary: 'Deploy Workflow', + description: + 'Create and asynchronously activate a deployment version. Every call creates a new version; retrying after a timeout can create a duplicate. Read Get Workflow Deployment to check activation. A conflicting webhook path returns `409`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/deploy/route').then((route) => route.POST), + }, + deployWorkflowMcpTool: { + contract: v2DeployWorkflowMcpToolContract, + summary: 'Publish Workflow As MCP Tool', + description: + 'Publish a deployed workflow as an MCP tool using its deployed input schema. Each server has at most one tool per workflow; repeating the call replaces that tool and returns `200` with `updated: true`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflow-mcp-servers/[serverId]/tools/route').then( + (route) => route.POST + ), + }, + duplicateWorkflow: { + contract: v2DuplicateWorkflowContract, + summary: 'Duplicate Workflow', + description: + "Copy a workflow's graph and variables into the same workspace. Omit `name` to reuse the source name; name collisions in the destination folder are resolved automatically. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + handler: () => + import('@/app/api/v2/workflows/[workflowId]/duplicate/route').then((route) => route.POST), + }, + editFileContent: { + contract: v2EditFileContentContract, + summary: 'Edit File Content', + description: + 'Edit part of a UTF-8 file; use Replace File Content to replace it entirely. Search-and-replace requires one exact match unless `replaceAll` is true. Anchored modes match trimmed complete lines; their input descriptions specify boundary handling. Non-UTF-8 files return `400`. Concurrent writes return `409`; re-read before retrying.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/[fileId]/content/route').then((route) => route.PATCH), + }, + executeTool: { + contract: v2ExecuteToolContract, + summary: 'Run Tool', + description: + 'Run a built-in tool using published parameter IDs. Sim resolves `credentialId`, hosted keys, and whole-value `{{VAR_NAME}}` references for `user-only` parameters; other values pass through verbatim. Third-party refusal returns `200` with `status: "failed"`; the error envelope covers API failures. Hidden or missing tools return `404`; disallowed integrations return `403` with `error.details.code: INTEGRATION_NOT_ALLOWED`. Hosted-key use is billed to the workspace. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/tools/[toolId]/execute/route').then((route) => route.POST), + }, + executeWorkflow: { + contract: v2ExecuteWorkflowContract, + summary: 'Execute Workflow', + description: + 'Execute a deployment or use `run.source: "manual"` for the draft. Manual runs require personal or OAuth write access and reject async. Public deployments allow anonymous sync or streaming. Request `application/x-ndjson` for heartbeats and the final result. Timeouts return `200` with failed status and `TIMEOUT`. Supply `X-Run-Id` to prevent duplicate execution; reuse returns `409`, never a replay. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/execute/route').then((route) => route.POST), + }, + exportWorkflow: { + contract: v2ExportWorkflowContract, + summary: 'Export Workflow', + description: + 'Export a portable, secret-sanitized workflow; Set includeReferences=true to include non-secret source reference identities for mapped import; default exports keep their existing sanitized shape. Exporting records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/export/route').then((route) => route.GET), + }, + forkWorkspace: { + contract: v2ForkWorkspaceContract, + summary: 'Fork Workspace', + description: + 'Create a child workspace with undeployed workflow drafts. Requires the reviewed preview fingerprint and a stable request ID. Identical retries return the same operation; reuse with different inputs returns 409. Poll Get Workspace Operation until selected resource copies complete. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/route').then((route) => route.POST), + }, + getAuditLog: { + contract: v2GetAuditLogContract, + summary: 'Get Audit Log', + description: + 'Get one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/audit-logs/[auditLogId]/route').then((route) => route.GET), + }, + getBillingStatus: { + contract: v2GetBillingStatusContract, + summary: 'Get Billing Status', + description: + "Get the current plan, billing standing, credit allowance, and storage quota. Pooled `credits` and `storage` are visible only to callers who can manage the payer's billing; workspace API keys receive null for both. Use List Billing Logs for credit history.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/billing/status/route').then((route) => route.GET), + }, + getBlock: { + contract: v2GetBlockContract, + summary: 'Get Block', + description: + "Get a block's fields, conditions, operations, tool schemas, and triggers. Unversioned types resolve to the newest visible version; the returned `id` identifies that version. Hidden or missing blocks return `404`.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/blocks/[blockId]/route').then((route) => route.GET), + }, + getCustomTool: { + contract: v2GetCustomToolContract, + summary: 'Get Custom Tool', + description: + 'Get one custom tool by identifier, scoped to its workspace.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/custom-tools/[customToolId]/route').then((route) => route.GET), + }, + getFile: { + contract: v2GetFileContract, + summary: 'Get File Metadata', + description: + 'Get file metadata and its public-share configuration. The `share` field is null when the file has never been shared.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/files/[fileId]/metadata/route').then((route) => route.GET), + }, + getFileShare: { + contract: v2GetFileShareContract, + summary: 'Get File Share', + description: + "Get a file's public-share configuration. An unshared file returns `data: null`; a disabled share returns its configuration with `isActive: false`.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/files/[fileId]/share/route').then((route) => route.GET), + }, + getFileUpload: { + contract: v2GetFileUploadContract, + summary: 'Get File Upload', + description: + "Get an upload session's state to determine whether an interrupted transfer can resume. Requires the signed upload token and current workspace access.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/files/uploads/[uploadId]/route').then((route) => route.GET), + }, + getKnowledgeBase: { + contract: v2GetKnowledgeBaseContract, + summary: 'Get Knowledge Base', + description: + "Get a knowledge base's metadata and document counts. Inaccessible knowledge bases return `404`. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/route').then((route) => route.GET), + }, + getKnowledgeChunk: { + contract: v2GetKnowledgeChunkContract, + summary: 'Get Chunk', + description: + 'Get one chunk of a document, including the exact text that was embedded. Documents that have not finished processing return `409` with their current status. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]/route' + ).then((route) => route.GET), + }, + getKnowledgeConnector: { + contract: v2GetKnowledgeConnectorContract, + summary: 'Get Knowledge Connector', + description: + 'Get one connector and its ten most recent synchronization attempts. Stored API keys are never returned. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/route').then( + (route) => route.GET + ), + }, + getKnowledgeDocument: { + contract: v2GetKnowledgeDocumentContract, + summary: 'Get Document', + description: + 'Get document metadata, processing status, and source connector details.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route').then( + (route) => route.GET + ), + }, + getLog: { + contract: v2GetLogContract, + summary: 'Get Log', + description: + "Get a run's workflow graph, trace spans, final output, and cost. Trace spans expire separately, so an empty `traceSpans` array does not prove none were recorded. Expired runs are permanently deleted. Retention is 30 days from run start on Free, unlimited on Pro and Team, and configured per organization on Enterprise with workspace overrides. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/logs/[runId]/route').then((route) => route.GET), + }, + getLogStats: { + contract: v2GetLogStatsContract, + summary: 'Get Log Statistics', + description: + 'Get run counts, success and error counts, and latency by workspace or workflow. Default bounds span recorded runs, or the last 24 hours when empty. Buckets may extend past the end. Folder filters include descendants; `workflowsTruncated` affects series, not totals. Expired runs are permanently deleted. Retention is 30 days from run start on Free, unlimited on Pro and Team, and configured per organization on Enterprise with workspace overrides. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/logs/stats/route').then((route) => route.GET), + }, + getMcpServer: { + contract: v2GetMcpServerContract, + summary: 'Get MCP Server', + description: + 'Get one MCP server by identifier. Request-header values and OAuth client secrets are never returned.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/mcp-servers/[mcpServerId]/route').then((route) => route.GET), + }, + getMeta: { + contract: v2GetMetaContract, + summary: 'Get API Capabilities', + description: + 'Get whether v2 is available, what kind of API credential is calling, and when it expires. Requires a valid API key or OAuth access token.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/meta/route').then((route) => route.GET), + }, + getNextKnowledgeTagSlot: { + contract: v2GetNextKnowledgeTagSlotContract, + summary: 'Get Next Tag Slot', + description: + 'Get the next available slot and remaining capacity for a field type. This does not reserve a slot. Create Tag selects a free slot when `tagSlot` is omitted. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/tags/next-slot/route').then( + (route) => route.GET + ), + }, + getRowEnrichment: { + contract: v2GetRowEnrichmentContract, + summary: 'Get Enrichment Run Detail', + description: + "Get an enrichment cell's provider attempts, statuses, hosted-key costs, durations, and matching provider. Null means no run detail was recorded; `404` means the table, row, or group does not exist.\n\nOAuth scope: `api:read`.", + handler: () => + import('@/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route').then( + (route) => route.GET + ), + }, + getSandbox: { + contract: v2GetSandboxContract, + summary: 'Get Sandbox', + description: + 'Get one sandbox by identifier, scoped to its workspace, including its current build state and any build failure.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/sandboxes/[sandboxId]/route').then((route) => route.GET), + }, + getSelector: { + contract: v2GetSelectorContract, + summary: 'Get Selector Option', + description: + 'Resolve a workspace configuration option by its provider identifier and declared dependencies. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/selectors/get/route').then((route) => route.POST), + }, + getSkill: { + contract: v2GetSkillContract, + summary: 'Get Skill', + description: + 'Get one workspace or built-in skill, including its full content. Built-in skills are marked read-only.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/skills/[skillId]/route').then((route) => route.GET), + }, + getTable: { + contract: v2GetTableContract, + summary: 'Get Table', + description: + 'Get a table with its metadata, column schema, locks, and current job. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/tables/[tableId]/route').then((route) => route.GET), + }, + getTableDispatch: { + contract: v2GetTableDispatchContract, + summary: 'Get Run Dispatch', + description: + "Get a dispatch's current state. Poll until `complete` or `canceled`; use row reads with `includeRunState` for per-cell outcomes.\n\nOAuth scope: `api:read`.", + handler: () => + import('@/app/api/v2/tables/[tableId]/dispatches/[dispatchId]/route').then( + (route) => route.GET + ), + }, + getTableExport: { + contract: v2GetTableExportContract, + summary: 'Get Table Export', + description: "Get a table export's progress and status.\n\nOAuth scope: `api:read`.", + handler: () => + import('@/app/api/v2/tables/[tableId]/exports/[exportId]/route').then((route) => route.GET), + }, + getTableImport: { + contract: v2GetTableImportContract, + summary: 'Get Table Import', + description: + "Get an import's progress and status. During `uploading`, the signed upload token is required; omitting it returns `404`.\n\nOAuth scope: `api:read`.", + handler: () => + import('@/app/api/v2/tables/imports/[importId]/route').then((route) => route.GET), + }, + getTableRow: { + contract: v2GetTableRowContract, + summary: 'Get Row', + description: + "Get one row by identifier. Set `includeRunState=true` to attach the row's per-workflow-group run outcomes.\n\nOAuth scope: `api:read`.", + handler: () => + import('@/app/api/v2/tables/[tableId]/rows/[rowId]/route').then((route) => route.GET), + }, + getTableView: { + contract: v2GetTableViewContract, + summary: 'Get View', + description: 'Get one saved table view by identifier.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/views/[viewId]/route').then((route) => route.GET), + }, + getTool: { + contract: v2GetToolContract, + summary: 'Get Tool', + description: + "Get a built-in tool's parameters and outputs. Registered IDs resolve exactly; other names resolve to the newest family version. The returned `id` identifies the resolved tool. Hidden or missing tools return `404`.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/tools/[toolId]/route').then((route) => route.GET), + }, + getWorkflow: { + contract: v2GetWorkflowContract, + summary: 'Get Workflow', + description: + 'Get a workflow with its variables and deployed API-trigger inputs. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/workflows/[workflowId]/route').then((route) => route.GET), + }, + getWorkflowChatDeployment: { + contract: v2GetWorkflowChatDeploymentContract, + summary: 'Get Workflow Chat Deployment', + description: + "Get a workflow's hosted chat and visitor access settings. Requires workspace admin access; a missing chat returns `404`. Passwords are never returned; `hasPassword` indicates whether one is set. Hosted chat and workflow API deployment are managed separately. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/deployments/chat/route').then( + (route) => route.GET + ), + }, + getWorkflowDeployment: { + contract: v2GetWorkflowDeploymentContract, + summary: 'Get Workflow Deployment', + description: + 'Get the live version, latest deployment attempt, readiness, draft changes (`needsRedeployment`), and public API access. With `isPublicApi: true`, anyone with the execution URL can run the workflow and consume billed usage without an API key. Hosted chat is managed separately.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/deployment/route').then((route) => route.GET), + }, + getWorkflowMcpServer: { + contract: v2GetWorkflowMcpServerContract, + summary: 'Get Workflow MCP Server', + description: + "Get a published workflow MCP server's metadata and client endpoint. Use List Workflow MCP Tools for its tool inventory. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflow-mcp-servers/[serverId]/route').then((route) => route.GET), + }, + getWorkflowRun: { + contract: v2GetWorkflowRunContract, + summary: 'Get Workflow Run', + description: + 'Get current run state with optional final and block outputs. With `includeOutput`, `files` includes download paths; `includeFileBase64` inlines file bytes and returns `413` with the download path when one file or the total exceeds 16 MiB. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/runs/[runId]/route').then((route) => route.GET), + }, + getWorkflowState: { + contract: v2GetWorkflowStateContract, + summary: 'Get Workflow State', + description: + 'Get the editable draft graph, including blocks, edges, loop and parallel containers, and variables. Use this state with Replace Workflow State to preserve workspace bindings; Export Workflow removes those bindings for portability. This read records no audit event, and `HEAD` mirrors `GET`.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/state/route').then((route) => route.GET), + }, + getWorkflowVersion: { + contract: v2GetWorkflowVersionContract, + summary: 'Get Workflow Version', + description: + 'Get an immutable deployment version and its pinned workflow graph snapshot.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/versions/[version]/route').then( + (route) => route.GET + ), + }, + getWorkspace: { + contract: v2GetWorkspaceContract, + summary: 'Get Workspace', + description: 'Get metadata for an accessible workspace.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/workspaces/[workspaceId]/route').then((route) => route.GET), + }, + getWorkspaceForkAvailability: { + contract: v2GetWorkspaceForkAvailabilityContract, + summary: 'Get Workspace Fork Availability', + description: + 'Inspect workspace fork information and copyable resources. Lineage does not grant access to the other workspace; fork creation requires source admin and sync requires admin on both sides. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/availability/route').then( + (route) => route.GET + ), + }, + getWorkspaceForkLineage: { + contract: v2GetWorkspaceForkLineageContract, + summary: 'Get Workspace Fork Lineage', + description: + 'Inspect workspace fork information and copyable resources. Lineage does not grant access to the other workspace; fork creation requires source admin and sync requires admin on both sides. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/lineage/route').then((route) => route.GET), + }, + getWorkspaceForkMappings: { + contract: v2GetWorkspaceForkMappingsContract, + summary: 'Get Workspace Fork Mappings', + description: + 'Read persisted mappings in the requested source-to-target direction. Candidate discovery uses the destination resource and selector listing operations. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/mappings/route').then( + (route) => route.GET + ), + }, + getWorkspaceOperation: { + contract: v2GetWorkspaceOperationContract, + summary: 'Get Workspace Operation', + description: + 'Read a committed operation, copy progress, exact deployment readiness, and structured issues. A failed follow-up does not mean the business transaction was rolled back.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/operations/[operationId]/route').then( + (route) => route.GET + ), + }, + grantSkillEditor: { + contract: v2GrantSkillEditorContract, + summary: 'Grant Skill Editor', + description: + 'Grant skill editor access to a workspace member by email. Requires an existing editor or workspace admin; admins already have access and cannot receive explicit grants. Existing grants return `200`; new grants return `201`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/skills/[skillId]/editors/route').then((route) => route.POST), + }, + importWorkflow: { + contract: v2ImportWorkflowContract, + summary: 'Import Workflow', + description: + 'Create an undeployed workflow from a portable export object, bare state, or JSON string. Mapping options require a preview fingerprint and stable request ID; unresolved required configuration creates nothing. Mapped imports return source-to-imported block IDs and an operation receipt. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/workflows/import/route').then((route) => route.POST), + }, + listAuditLogs: { + contract: v2ListAuditLogsContract, + summary: 'List Audit Logs', + description: + 'List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/audit-logs/route').then((route) => route.GET), + }, + listBillingLogs: { + contract: v2ListBillingLogsContract, + summary: 'List Billing Logs', + description: + 'List credit usage with source filtering and cursor pagination. The default `period` is `30d`; pagination covers only the selected time window. An inverted custom window returns `400`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/billing/logs/route').then((route) => route.GET), + }, + listBlocks: { + contract: v2ListBlocksContract, + summary: 'List Blocks', + description: + 'List built-in and workspace-deployed blocks visible to the caller. Integration allowlists and preview visibility restrict results. Use `capability=trigger` for workflow starters and Get Block or Get Tool to resolve operation and tool IDs.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/blocks/route').then((route) => route.GET), + }, + listChatDeployments: { + contract: v2ListChatDeploymentsContract, + summary: 'List Chat Deployments', + description: + "List hosted chats and their public URLs with cursor pagination. Filter by `workflowId` for one workflow's chat. The list requires workspace read access; Get Workflow Chat Deployment requires admin access and includes visitor access settings and customizations. Passwords are never returned.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/chat-deployments/route').then((route) => route.GET), + }, + listConnectorTypes: { + contract: v2ListConnectorTypesContract, + summary: 'List Connector Types', + description: + 'List connector types and accepted source configuration. A field with `multi: true` stores `string[]`. `canonicalParamId` links picker and manual fields that write the same key; send exactly one, keyed by `canonicalParamId` rather than its own `id`. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/connector-types/route').then((route) => route.GET), + }, + listCredentialProviders: { + contract: v2ListCredentialProvidersContract, + summary: 'List Credential Providers', + description: + 'List OAuth and service-account connection methods and their availability. OAuth options provide provider IDs for browser connections; service-account methods declare required fields and write-only secrets. Supports provider-name search. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/credentials/providers/route').then((route) => route.GET), + }, + listCredentials: { + contract: v2ListCredentialsContract, + summary: 'List Credentials', + description: + 'List OAuth and service-account connections visible to the caller. Secret material is never returned.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/credentials/route').then((route) => route.GET), + }, + listCustomTools: { + contract: v2ListCustomToolsContract, + summary: 'List Custom Tools', + description: + 'List code-backed custom tools in a workspace with cursor pagination.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/custom-tools/route').then((route) => route.GET), + }, + listFileFolders: { + contract: v2ListFileFoldersContract, + summary: 'List Folders', + description: + 'List workspace file folders with parent-path filtering and sorting. Use `scope=archived` to find paths accepted by Restore Folder. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/files/folders/route').then((route) => route.GET), + }, + listFiles: { + contract: v2ListFilesContract, + summary: 'List Files', + description: + 'List active workspace files with folder filtering, search, sorting, and cursor pagination. Use `scope=archived` to find files available for restoration. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/files/route').then((route) => route.GET), + }, + listKnowledgeBases: { + contract: v2ListKnowledgeBasesContract, + summary: 'List Knowledge Bases', + description: + 'List active knowledge bases in a workspace with folder filtering, search, sorting, and cursor pagination. Use `scope=archived` to find knowledge bases available for restoration. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/knowledge/route').then((route) => route.GET), + }, + listKnowledgeChunks: { + contract: v2ListKnowledgeChunksContract, + summary: 'List Chunks', + description: + 'List document chunks with content search, enabled filtering, sorting, and cursor pagination. Tags use slots; use List Tags to resolve display names. Documents that have not finished processing return `409` with their current status. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route').then( + (route) => route.GET + ), + }, + listKnowledgeConnectorDocuments: { + contract: v2ListKnowledgeConnectorDocumentsContract, + summary: 'List Knowledge Connector Documents', + description: + 'List documents produced by one connector with opaque cursor pagination. Excluded documents are omitted unless explicitly requested. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/documents/route' + ).then((route) => route.GET), + }, + listKnowledgeConnectors: { + contract: v2ListKnowledgeConnectorsContract, + summary: 'List Knowledge Connectors', + description: + 'List external sources connected to a knowledge base with cursor pagination. Stored API keys are never returned. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/route').then( + (route) => route.GET + ), + }, + listKnowledgeDocuments: { + contract: v2ListKnowledgeDocumentsContract, + summary: 'List Documents', + description: + 'List documents with filename search, state and tag filters, sorting, and cursor pagination. Tag values use display names; use List Tags to resolve the slots required for writes.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/route').then((route) => route.GET), + }, + listKnowledgeFolders: { + contract: v2ListKnowledgeFoldersContract, + summary: 'List Folders', + description: + 'List folders in the knowledge-base folder tree with filtering and sorting. Returns the complete set in one page; `nextCursor` is always null. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/knowledge/folders/route').then((route) => route.GET), + }, + listKnowledgeTags: { + contract: v2ListKnowledgeTagsContract, + summary: 'List Tags', + description: + "List the knowledge base's tag definitions with display names, write slots, and field types. Filters and document reads use display names; document writes use slots. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.", + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/tags/route').then((route) => route.GET), + }, + listKnowledgeTagUsage: { + contract: v2ListKnowledgeTagUsageContract, + summary: 'List Tag Usage', + description: + 'Count the documents and chunks with a value for each defined tag. Returns the complete set in one page; `nextCursor` is always null. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/tags/usage/route').then( + (route) => route.GET + ), + }, + listLogs: { + contract: v2ListLogsContract, + summary: 'List Logs', + description: + 'List logs with filters, selectable detail, sorting, and cursor pagination. `includeJobRuns=true` includes chat and Sim-agent jobs only with `sortBy=startedAt`, because other orderings are unsupported. `files` contains only run-produced files; use the files API for input attachments. Expired runs are permanently deleted. Retention is 30 days from run start on Free, unlimited on Pro and Team, and configured per organization on Enterprise with workspace overrides. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/logs/route').then((route) => route.GET), + }, + listMcpServers: { + contract: v2ListMcpServersContract, + summary: 'List MCP Servers', + description: + 'List MCP servers registered in a workspace, excluding request-header values and OAuth secrets. Connection metadata remains at registration defaults until List MCP Server Tools performs discovery.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/mcp-servers/route').then((route) => route.GET), + }, + listMcpServerTools: { + contract: v2ListMcpServerToolsContract, + summary: 'List MCP Server Tools', + description: + 'Discover up to 1,000 tools within 5 MB, connect to the server, and update connection metadata. Results are unpaginated. Invalid OAuth returns `409` with `MCP_SERVER_REAUTHORIZATION_REQUIRED`; reauthorize through the browser. Unavailable servers return `503`. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/mcp-servers/[mcpServerId]/tools/route').then((route) => route.GET), + }, + listSandboxes: { + contract: v2ListSandboxesContract, + summary: 'List Sandboxes', + description: + 'List reusable dependency environments for Function blocks, including language packages, managed CLIs, and system packages. Sandboxes remain visible after a plan downgrade.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/sandboxes/route').then((route) => route.GET), + }, + listSecrets: { + contract: v2ListSecretsContract, + summary: 'List Secrets', + description: + 'List workspace and caller-owned personal secrets with cursor pagination. Only workspace secrets marked `unredacted` include values; all other entries contain metadata only. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/secrets/route').then((route) => route.GET), + }, + listSelector: { + contract: v2ListSelectorContract, + summary: 'List Selector Options', + description: + 'List workspace-scoped configuration choices using the selector key and dependencies from an import or sync preview. Missing OAuth connections require human authorization before provider choices can be discovered. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/selectors/list/route').then((route) => route.POST), + }, + listSkillEditors: { + contract: v2ListSkillEditorsContract, + summary: 'List Skill Editors', + description: + 'List skill editors and workspace administrators with cursor pagination.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/skills/[skillId]/editors/route').then((route) => route.GET), + }, + listSkills: { + contract: v2ListSkillsContract, + summary: 'List Skills', + description: + 'List workspace and built-in skills with cursor pagination. Built-in skills are read-only. The list omits skill bodies; use Get Skill to read content.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/skills/route').then((route) => route.GET), + }, + listTableDispatches: { + contract: v2ListTableDispatchesContract, + summary: 'List Active Run Dispatches', + description: + 'List in-flight run dispatches for a table in one page; `nextCursor` is always null. Use Get Run Dispatch to read a settled dispatch.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/dispatches/route').then((route) => route.GET), + }, + listTableFolders: { + contract: v2ListTableFoldersContract, + summary: 'List Folders', + description: + 'List table folders, optionally limiting results to direct children of a parent path. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/tables/folders/route').then((route) => route.GET), + }, + listTableRows: { + contract: v2ListTableRowsContract, + summary: 'List Rows', + description: + 'List rows in default order with cursor pagination. Pages default to a 5 MB limit and may contain fewer rows than requested; continue until `nextCursor` is null. Use Query Rows for filtering and sorting. `includeRunState=true` adds per-group run outcomes and reduces the row limit.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/tables/[tableId]/rows/route').then((route) => route.GET), + }, + listTables: { + contract: v2ListTablesContract, + summary: 'List Tables', + description: + 'List active tables with folder filtering, search, sorting, and cursor pagination. Use `scope=archived` to find tables available for restoration. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/tables/route').then((route) => route.GET), + }, + listTableViews: { + contract: v2ListTableViewsContract, + summary: 'List Views', + description: + 'List saved table views, omitting references to removed columns. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/tables/[tableId]/views/route').then((route) => route.GET), + }, + listTools: { + contract: v2ListToolsContract, + summary: 'List Tools', + description: + "List built-in tools exposed by blocks visible to the caller. Use List MCP Server Tools for an external server's tools and List Custom Tools for workspace code-backed tools.\n\nOAuth scope: `api:read`.", + handler: () => import('@/app/api/v2/tools/route').then((route) => route.GET), + }, + listWorkflowFolders: { + contract: v2ListWorkflowFoldersContract, + summary: 'List Workflow Folders', + description: + 'List workflow folders in a workspace. Returns the complete set in one page; `nextCursor` is always null. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/workflows/folders/route').then((route) => route.GET), + }, + listWorkflowGroups: { + contract: v2ListWorkflowGroupsContract, + summary: 'List Workflow Groups', + description: + 'List the workflow and enrichment groups that can be dispatched for a table. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/tables/[tableId]/groups/route').then((route) => route.GET), + }, + listWorkflowMcpServers: { + contract: v2ListWorkflowMcpServersContract, + summary: 'List Workflow MCP Servers', + description: + "List MCP servers that expose deployed workflows to external clients. Use List MCP Servers for external servers Sim calls. Tool names share a 2,000-name page limit; inspect `toolNamesTruncated` and use List Workflow MCP Tools for a server's inventory. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/workflow-mcp-servers/route').then((route) => route.GET), + }, + listWorkflowMcpTools: { + contract: v2ListWorkflowMcpToolsContract, + summary: 'List Workflow MCP Tools', + description: + "List a server's published tools by name, including workflow IDs used to unpublish them. Returns up to 2,000 tools with `nextCursor: null`; `truncated` indicates an incomplete inventory that cannot be paginated. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflow-mcp-servers/[serverId]/tools/route').then((route) => route.GET), + }, + listWorkflowRuns: { + contract: v2ListWorkflowRunsContract, + summary: 'List Workflow Runs', + description: + 'List recorded runs of a workflow with filtering and opaque cursor pagination. Expired runs are permanently deleted. Retention is 30 days from run start on Free, unlimited on Pro and Team, and configured per organization on Enterprise with workspace overrides.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/runs/route').then((route) => route.GET), + }, + listWorkflows: { + contract: v2ListWorkflowsContract, + summary: 'List Workflows', + description: + 'List active workflows in a workspace. Use `scope=archived` to find workflows available for restoration. Supports folder and deployment filters, search, sorting, and cursor pagination. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/workflows/route').then((route) => route.GET), + }, + listWorkflowVersions: { + contract: v2ListWorkflowVersionsContract, + summary: 'List Workflow Versions', + description: + 'List immutable deployment versions of a workflow, newest first.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/versions/route').then((route) => route.GET), + }, + listWorkspaceForkChildren: { + contract: v2ListWorkspaceForkChildrenContract, + summary: 'List Workspace Fork Children', + description: + 'Inspect workspace fork information and copyable resources. Lineage does not grant access to the other workspace; fork creation requires source admin and sync requires admin on both sides. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/children/route').then( + (route) => route.GET + ), + }, + listWorkspaceForkResources: { + contract: v2ListWorkspaceForkResourcesContract, + summary: 'List Workspace Fork Resources', + description: + 'Inspect workspace fork information and copyable resources. Lineage does not grant access to the other workspace; fork creation requires source admin and sync requires admin on both sides. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/resources/route').then( + (route) => route.GET + ), + }, + listWorkspaceMembers: { + contract: v2ListWorkspaceMembersContract, + summary: 'List Workspace Members', + description: + 'List workspace members by email, including explicit grants and inherited organization admin access.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/members/route').then((route) => route.GET), + }, + listWorkspaceOperations: { + contract: v2ListWorkspaceOperationsContract, + summary: 'List Workspace Operations', + description: + 'Page committed operations newest first. Filter by the original request ID to reconcile an uncertain mutation response.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/operations/route').then((route) => route.GET), + }, + listWorkspaces: { + contract: v2ListWorkspacesContract, + summary: 'List Workspaces', + description: + 'List active workspaces available to the calling credential with opaque cursor pagination. A personal API key or OAuth token sees accessible workspaces that permit user-held API credentials; a workspace API key sees only its bound workspace.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/workspaces/route').then((route) => route.GET), + }, + moveFileItems: { + contract: v2MoveFileItemsContract, + summary: 'Move Files', + description: + 'Move up to 1,000 files to a folder path or the workspace root.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/move/route').then((route) => route.POST), + }, + moveTables: { + contract: v2MoveTablesContract, + summary: 'Move Tables and Folders', + description: + 'Move up to 100 tables and folders to one destination. Items succeed or fail independently: covered tables are `skipped`, missing items are `notFound`, and lock or cycle failures include reasons in `failed`. An invalid destination rejects the request before any move.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/move/route').then((route) => route.POST), + }, + moveWorkflows: { + contract: v2MoveWorkflowsContract, + summary: 'Move Workflows', + description: + 'Move up to 100 workflows into one folder. Moves succeed or fail independently; missing, archived, or locked workflows appear in `failed`. Duplicate IDs are ignored. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/workflows/move/route').then((route) => route.POST), + }, + previewWorkflowImport: { + contract: v2PreviewWorkflowImportContract, + summary: 'Preview Workflow Import', + description: + 'Validate destination mappings and dependent choices without creating a workflow. Returns unresolved fields, discovery instructions, and a fingerprint required by mapped import. No source workspace is queried from imported provenance.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/workflows/import/preview/route').then((route) => route.POST), + }, + previewWorkspaceFork: { + contract: v2PreviewWorkspaceForkContract, + summary: 'Preview Workspace Fork', + description: + 'Preview the deployed workflows and explicitly selected resources that a new workspace fork would copy. The result is read-only and supplies the fingerprint required by Fork Workspace. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/preview/route').then( + (route) => route.POST + ), + }, + previewWorkspacePull: { + contract: v2PreviewWorkspacePullContract, + summary: 'Preview Workspace Pull', + description: + 'Preview deployed source workflows replacing mapped targets along a direct fork edge. Push sends the current workspace to the other; pull brings the other into the current workspace. Proposed mappings are not saved. Dependent choices use source workflow, block, and field identities. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/pull/preview/route').then( + (route) => route.POST + ), + }, + previewWorkspacePush: { + contract: v2PreviewWorkspacePushContract, + summary: 'Preview Workspace Push', + description: + 'Preview deployed source workflows replacing mapped targets along a direct fork edge. Push sends the current workspace to the other; pull brings the other into the current workspace. Proposed mappings are not saved. Dependent choices use source workflow, block, and field identities. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/push/preview/route').then( + (route) => route.POST + ), + }, + pullWorkspace: { + contract: v2PullWorkspaceContract, + summary: 'Pull Workspace', + description: + 'Apply a reviewed push or pull with inline mappings in one transaction. Requires confirmation, the preview fingerprint, and a stable request ID. Unresolved or changed plans return 409 without applying. The receipt distinguishes committed changes from copy and deployment readiness; poll Get Workspace Operation before treating the target as ready. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/pull/route').then((route) => route.POST), + }, + pushWorkspace: { + contract: v2PushWorkspaceContract, + summary: 'Push Workspace', + description: + 'Apply a reviewed push or pull with inline mappings in one transaction. Requires confirmation, the preview fingerprint, and a stable request ID. Unresolved or changed plans return 409 without applying. The receipt distinguishes committed changes from copy and deployment readiness; poll Get Workspace Operation before treating the target as ready. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/push/route').then((route) => route.POST), + }, + queryRows: { + contract: v2QueryRowsContract, + summary: 'Query Rows', + description: + 'Query rows with typed predicates, sorting, and cursor pagination. Omit the predicate to match all rows. Pages default to a 5 MB limit; continue until `nextCursor` is null. Oversized predicates return `413`. `includeRunState` adds per-group outcomes and reduces the row limit. Counts are read separately and can differ from paged results if rows change.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/tables/[tableId]/query/route').then((route) => route.POST), + }, + queryRowsCount: { + contract: v2QueryRowsCountContract, + summary: 'Count Rows', + description: + 'Count rows matching a typed predicate, or omit the predicate to count all rows. The count is read separately from row pages and can change between requests. Oversized predicates return `413`.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/query/count/route').then((route) => route.POST), + }, + readFileText: { + contract: v2ReadFileTextContract, + summary: 'Read File Text', + description: + 'Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/files/[fileId]/text/route').then((route) => route.GET), + }, + relocateFileFolder: { + contract: v2RelocateFileFolderContract, + summary: 'Rename or Move Folder', + description: + 'Rename or move a folder and atomically update all descendant paths.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/folders/route').then((route) => route.PATCH), + }, + relocateKnowledgeFolder: { + contract: v2RelocateKnowledgeFolderContract, + summary: 'Rename or Move Folder', + description: + 'Rename or move a folder and atomically rewrite descendant paths. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/knowledge/folders/route').then((route) => route.PATCH), + }, + relocateTableFolder: { + contract: v2RelocateTableFolderContract, + summary: 'Rename or Move Folder', + description: + 'Rename or move a table folder and update all descendant paths.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/folders/route').then((route) => route.PATCH), + }, + relocateWorkflowFolder: { + contract: v2RelocateWorkflowFolderContract, + summary: 'Rename or Move Workflow Folder', + description: + 'Rename or move a workflow folder and update all descendant paths. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/workflows/folders/route').then((route) => route.PATCH), + }, + renameFile: { + contract: v2RenameFileContract, + summary: 'Rename File', + description: + 'Rename a workspace file without changing its containing folder.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/[fileId]/route').then((route) => route.PATCH), + }, + replaceWorkflowChatDeployment: { + contract: v2ReplaceWorkflowChatDeploymentContract, + summary: 'Create or Replace Workflow Chat Deployment', + description: + "Create or replace a workflow's hosted chat and deploy its draft. Omitted fields reset to defaults except per-field customizations. Password authentication requires `password`; email or SSO requires non-empty `allowedEmails`. Public authentication allows anyone with the chat URL to use it. A duplicate identifier or pending deployment returns `409`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/deployments/chat/route').then( + (route) => route.PUT + ), + }, + replaceWorkflowState: { + contract: v2ReplaceWorkflowStateContract, + summary: 'Replace Workflow State', + description: + 'Replace the draft graph atomically; concurrent writes are last-write-wins. Recompute containers from blocks and preserve omitted variables. Foreign IDs return `409`; lint is advisory. The live deployment is unchanged. `dryRun=true` validates without saving, auditing, or notifying; `needsRedeployment` describes the pre-write state. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/state/route').then((route) => route.PUT), + }, + restoreFile: { + contract: v2RestoreFileContract, + summary: 'Restore File', + description: + 'Restore an archived file to the workspace root. Name collisions add a `_restored` suffix; use the returned `name` and `folderPath`. An active file returns unchanged. An archived workspace returns `400`; an unresolved name collision returns `409`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/[fileId]/restore/route').then((route) => route.POST), + }, + restoreFileFolder: { + contract: v2RestoreFileFolderContract, + summary: 'Restore Folder', + description: + 'Restore a folder and the files and subfolders archived with it. Use the path from List Folders with `scope=archived`. A path that is not archived returns `404`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/folders/restore/route').then((route) => route.POST), + }, + restoreKnowledgeBase: { + contract: v2RestoreKnowledgeBaseContract, + summary: 'Restore Knowledge Base', + description: + 'Restore a knowledge base and the documents and connectors archived with it. Active knowledge bases return unchanged without a new audit event. An archived workspace returns `409`; an archived containing folder moves the restored knowledge base to the workspace root. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/restore/route').then((route) => route.POST), + }, + restoreTable: { + contract: v2RestoreTableContract, + summary: 'Restore Table', + description: + 'Restore a table and its archived rows, views, and workflow groups. Active tables return unchanged without a new audit event. Name conflicts may change the returned `name`. Find archived tables with List Tables and `scope=archived`.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/restore/route').then((route) => route.POST), + }, + restoreTableFolder: { + contract: v2RestoreTableFolderContract, + summary: 'Restore Folder', + description: + 'Restore an archived table folder, its descendants, and tables using its former path. An archived parent moves it to the root; name conflicts may change the returned `path`. Non-archived paths return `404`. Save the path from Delete Folder, because List Folders does not include archived table folders.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/folders/restore/route').then((route) => route.POST), + }, + restoreWorkflow: { + contract: v2RestoreWorkflowContract, + summary: 'Restore Workflow', + description: + 'Restore an archived workflow and the schedules, webhooks, MCP tools, and chats archived with it. An active workflow returns `409`. If its folder is archived, the workflow returns to the workspace root. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/restore/route').then((route) => route.POST), + }, + resumeWorkflow: { + contract: v2ResumeWorkflowContract, + summary: 'Resume Workflow Run', + description: + 'Resume one human-in-the-loop pause. The resumed attempt receives a new run ID and returns either a synchronous result or a queue receipt.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route').then( + (route) => route.POST + ), + }, + revertWorkflowVersion: { + contract: v2RevertWorkflowVersionContract, + summary: 'Revert Workflow To Version', + description: + 'Replace the editable draft with a deployment version, discarding current draft edits. Use `active` for the live version. The live deployment remains unchanged; Activate Workflow Version or Rollback Workflow changes it. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/versions/[version]/revert/route').then( + (route) => route.POST + ), + }, + revokeSkillEditor: { + contract: v2RevokeSkillEditorContract, + summary: 'Revoke Skill Editor', + description: + 'Revoke an explicit editor grant by email. The caller must already be a skill editor or workspace administrator. Workspace administrators have derived access that cannot be revoked. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/skills/[skillId]/editors/route').then((route) => route.DELETE), + }, + rollbackWorkflow: { + contract: v2RollbackWorkflowContract, + summary: 'Rollback Workflow', + description: + 'Asynchronously activate a previous deployment version, defaulting to the preceding active version. Requires a deployed workflow and leaves the draft unchanged. Use Activate Workflow Version to select a version when the workflow is undeployed. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/rollback/route').then((route) => route.POST), + }, + rollbackWorkspaceFork: { + contract: v2RollbackWorkspaceForkContract, + summary: 'Rollback Workspace Fork', + description: + 'Restore the latest sync into this workspace using its prior deployed versions. Requires target admin. It does not restore arbitrary drafts or remove every copied resource. Pending activations are reported. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/rollback/route').then( + (route) => route.POST + ), + }, + runRowEnrichment: { + contract: v2RunRowEnrichmentContract, + summary: 'Run Enrichment For One Row', + description: + 'Start one workflow or enrichment group for a table row. Poll Get Run Dispatch using the returned `dispatchId`. A null `dispatchId` means no dispatch is available to poll; check row outcomes with `includeRunState`.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route').then( + (route) => route.POST + ), + }, + searchFileContent: { + contract: v2SearchFileContentContract, + summary: 'Search File Content', + description: + 'Search indexed text in active workspace files and return matching lines with file IDs and line numbers. `folderPaths` limits both results and reported coverage. Missing matches are inconclusive if `complete` is false or `indexStatus.skippedFiles` or `indexStatus.partialFiles` is nonzero. `truncated` means additional matches exist beyond `maxResults`.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/files/search/route').then((route) => route.GET), + }, + searchKnowledge: { + contract: v2SearchKnowledgeContract, + summary: 'Search Knowledge', + description: + 'Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`. Reranking returns `409` when the stored results cannot pass secret-provenance enforcement.\n\nOAuth scope: `api:read`.', + handler: () => import('@/app/api/v2/knowledge/search/route').then((route) => route.POST), + }, + searchTableRows: { + contract: v2SearchTableRowsContract, + summary: 'Search Rows', + description: + 'Search cell text for a case-insensitive substring within an optional filtered and sorted view. Returns cell coordinates, not row data; `ordinal` matches the view used by Query Rows. Results are unpaginated and capped at 1000. If `truncated` is true, narrow the search or predicate.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/rows/search/route').then((route) => route.POST), + }, + setSecret: { + contract: v2SetSecretContract, + summary: 'Set Secret', + description: + 'Create or replace a workspace or personal secret without returning its value. For existing workspace secrets, omit `value` to update metadata only; this returns `404` if absent. Personal secrets always require `value`. List Secrets can reveal workspace values marked `unredacted`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/secrets/[name]/route').then((route) => route.PUT), + }, + syncKnowledgeConnector: { + contract: v2SyncKnowledgeConnectorContract, + summary: 'Sync Knowledge Connector', + description: + 'Queue a connector synchronization. Rehydration forces existing documents to be fetched and indexed again. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/sync/route').then( + (route) => route.POST + ), + }, + tableExportDownload: { + contract: v2TableExportDownloadContract, + summary: 'Download Table Export', + description: + 'Get a short-lived signed download URL for a completed export. Other states return `409`; an unavailable export file returns `404`.\n\nOAuth scope: `api:read`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/exports/[exportId]/download/route').then( + (route) => route.GET + ), + }, + undeployWorkflow: { + contract: v2UndeployWorkflowContract, + summary: 'Undeploy Workflow', + description: + 'Deactivate the currently serving workflow version. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/deploy/route').then((route) => route.DELETE), + }, + undeployWorkflowMcpTool: { + contract: v2UndeployWorkflowMcpToolContract, + summary: 'Unpublish Workflow MCP Tool', + description: + "Unpublish an MCP tool by its workflow ID. The workflow's API deployment remains active. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflow-mcp-servers/[serverId]/tools/[workflowId]/route').then( + (route) => route.DELETE + ), + }, + unlinkWorkspaceFork: { + contract: v2UnlinkWorkspaceForkContract, + summary: 'Unlink Workspace Fork', + description: + 'Remove the direct fork relationship and its mappings. Requires admin on the acting workspace. Existing workflow and resource content remains available. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/unlink/route').then((route) => route.POST), + }, + unzipFile: { + contract: v2UnzipFileContract, + summary: 'Unzip File', + description: + 'Extract a ZIP archive into a new sibling folder and return counts and the destination path. Use List Files to inspect its contents. Large archives can take minutes; concurrent extraction of the same archive returns `409`. Size or processing-time limits return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/[fileId]/unzip/route').then((route) => route.POST), + }, + updateCredential: { + contract: v2UpdateCredentialContract, + summary: 'Update Credential', + description: + 'Rename a service-account credential or rotate its secret fields, preserving omitted values and the credential ID. Requires credential admin access. Provider rejection preserves the old secret and returns `400` with `providerErrorCode`; outages return `503`. Fields for a different credential type return `400`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/credentials/[credentialId]/route').then((route) => route.PATCH), + }, + updateCustomTool: { + contract: v2UpdateCustomToolContract, + summary: 'Update Custom Tool', + description: + 'Update a custom tool. Omitted fields remain unchanged; titles must remain unique within the workspace.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/custom-tools/[customToolId]/route').then((route) => route.PATCH), + }, + updateFileContent: { + contract: v2UpdateFileContentContract, + summary: 'Replace File Content', + description: + 'Replace the complete contents of an existing file from UTF-8 or base64 input.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/files/[fileId]/content/route').then((route) => route.PUT), + }, + updateKnowledgeBase: { + contract: v2UpdateKnowledgeBaseContract, + summary: 'Update Knowledge Base', + description: + "Update a knowledge base's name, description, chunking configuration, or folder placement. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/route').then((route) => route.PATCH), + }, + updateKnowledgeChunk: { + contract: v2UpdateKnowledgeChunkContract, + summary: 'Update Chunk', + description: + 'Correct chunk text or disable it from search. Changing `content` re-embeds immediately and recalculates document token and character counts; disabling retains the index. Connector-synced chunks are read-only and return `403` with `error.details.code: "CONNECTOR_MANAGED_RESOURCE_READ_ONLY"`; change the source and re-sync, or exclude the document. Documents that have not finished processing return `409` with their current status. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/[chunkId]/route' + ).then((route) => route.PATCH), + }, + updateKnowledgeConnector: { + contract: v2UpdateKnowledgeConnectorContract, + summary: 'Update Knowledge Connector', + description: + 'Update connector source configuration, schedule, or active state. Replacing source configuration on a runnable connector queues an immediate synchronization; paused connectors retain the change without synchronizing until resumed. Source configuration cannot be replaced while synchronization is already in progress. Authentication material cannot be changed through this operation. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/route').then( + (route) => route.PATCH + ), + }, + updateKnowledgeConnectorDocuments: { + contract: v2UpdateKnowledgeConnectorDocumentsContract, + summary: 'Update Knowledge Connector Documents', + description: + 'Exclude connector documents from knowledge search or restore previously excluded documents. Only documents produced by the selected connector can change. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/documents/route' + ).then((route) => route.PATCH), + }, + updateKnowledgeDocument: { + contract: v2UpdateKnowledgeDocumentContract, + summary: 'Update Document', + description: + 'Rename a document, change search availability, update tag slots, or requeue processing. Omitted fields remain unchanged; indexing state is read-only. Use List Tags to resolve names to slots and Get Document for source connector details, which this response omits. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route').then( + (route) => route.PATCH + ), + }, + updateKnowledgeTag: { + contract: v2UpdateKnowledgeTagContract, + summary: 'Update Tag', + description: + "Rename a tag or change its slot-compatible `fieldType`. Renaming changes read and filter names without moving the slot or its values. Slots are fixed for a tag's lifetime; an incompatible type returns `400` and requires creating a new tag. A duplicate display name returns `409`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/knowledge/[knowledgeBaseId]/tags/[tagId]/route').then( + (route) => route.PATCH + ), + }, + updateMcpServer: { + contract: v2UpdateMcpServerContract, + summary: 'Update MCP Server', + description: + "Update an MCP server's supplied fields. Omitted fields remain unchanged unless the field specifies otherwise. Authentication changes revoke the stored OAuth grant and reset connection metadata. Use List MCP Server Tools to reconnect.\n\nOAuth scope: `api:write`.", + handler: () => + import('@/app/api/v2/mcp-servers/[mcpServerId]/route').then((route) => route.PATCH), + }, + updateRowsByFilter: { + contract: v2UpdateRowsByFilterContract, + summary: 'Update Rows by Filter', + description: + 'Apply the same partial data patch to every row matching a non-empty predicate.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/[tableId]/rows/route').then((route) => route.PATCH), + }, + updateSandbox: { + contract: v2UpdateSandboxContract, + summary: 'Update Sandbox', + description: + 'Update a sandbox, preserving omitted fields and replacing supplied lists. Dependency changes may start a build; resending a failed specification retries its build. `buildStatus: null` means no build is required. Requires workspace admin access on Max or Enterprise. Creates and updates share a rate limit; respect `Retry-After`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/sandboxes/[sandboxId]/route').then((route) => route.PATCH), + }, + updateSkill: { + contract: v2UpdateSkillContract, + summary: 'Update Skill', + description: + 'Update a workspace skill. Omitted fields remain unchanged. Built-in skills are read-only. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/skills/[skillId]/route').then((route) => route.PATCH), + }, + updateTable: { + contract: v2UpdateTableContract, + summary: 'Update Table', + description: + 'Rename a table, edit its description, or move it to a folder. Fields are saved independently: a failed request may leave partial changes. `error.details.applied` lists saved fields; retry only the remaining fields. If absent, nothing changed. Lock flags are read-only. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', + handler: () => import('@/app/api/v2/tables/[tableId]/route').then((route) => route.PATCH), + }, + updateTableColumn: { + contract: v2UpdateTableColumnContract, + summary: 'Update Column', + description: + 'Update a column by name and return the complete resulting table schema.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/columns/route').then((route) => route.PATCH), + }, + updateTableRow: { + contract: v2UpdateTableRowContract, + summary: 'Update Row', + description: + 'Merge a partial data patch into one row by identifier.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/rows/[rowId]/route').then((route) => route.PATCH), + }, + updateTableView: { + contract: v2UpdateTableViewContract, + summary: 'Update View', + description: + 'Rename a view, replace or shallow-merge its configuration, or promote it to the table default.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/views/[viewId]/route').then((route) => route.PATCH), + }, + updateWorkflow: { + contract: v2UpdateWorkflowContract, + summary: 'Update Workflow', + description: + "Update a workflow's name, description, or folder path. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + handler: () => import('@/app/api/v2/workflows/[workflowId]/route').then((route) => route.PATCH), + }, + updateWorkflowGroup: { + contract: v2UpdateWorkflowGroupContract, + summary: 'Update Workflow Group', + description: + 'Restructure a workflow group, its producer, outputs, or execution behavior. Repointing the group at a different workflow concurrently invalidates the resolved output types and returns `409` — retry the update.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/groups/route').then((route) => route.PATCH), + }, + updateWorkflowMcpServer: { + contract: v2UpdateWorkflowMcpServerContract, + summary: 'Update Workflow MCP Server', + description: + "Update a workflow MCP server's name, description, or public access. Omitted fields remain unchanged; `description: null` clears the description. Publish or unpublish tools separately. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflow-mcp-servers/[serverId]/route').then((route) => route.PATCH), + }, + updateWorkflowPublicApi: { + contract: v2UpdateWorkflowPublicApiContract, + summary: 'Update Workflow Public API Access', + description: + 'Enable or disable unauthenticated execution of the deployed workflow. Enabling allows anyone with the execution URL to consume billed usage. Organization sharing restrictions return `403` with `PUBLIC_SHARING_NOT_ALLOWED`. Hosted chat is managed separately. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workflows/[workflowId]/deployment/route').then((route) => route.PATCH), + }, + updateWorkflowVersion: { + contract: v2UpdateWorkflowVersionContract, + summary: 'Update Workflow Version', + description: + "Update a deployment version's name or release note. Omitted fields remain unchanged; `description: null` clears the note. The graph and live version remain unchanged. Use Activate Workflow Version to make this version live.\n\nOAuth scope: `api:write`.", + handler: () => + import('@/app/api/v2/workflows/[workflowId]/versions/[version]/route').then( + (route) => route.PATCH + ), + }, + updateWorkspaceForkExclusions: { + contract: v2UpdateWorkspaceForkExclusionsContract, + summary: 'Update Workspace Fork Exclusions', + description: + 'Include or exclude selected workflows from fork sync. Excluded workflows are skipped as sources and targets. Missing, archived, and unchanged workflow IDs are skipped. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/exclusions/route').then( + (route) => route.PUT + ), + }, + updateWorkspaceForkMappings: { + contract: v2UpdateWorkspaceForkMappingsContract, + summary: 'Update Workspace Fork Mappings', + description: + 'Update edge mappings after validating destination resource membership and credential provider compatibility. Push addresses current-to-other mappings; pull addresses other-to-current mappings. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/fork/mappings/route').then( + (route) => route.PUT + ), + }, + upsertFileShare: { + contract: v2UpsertFileShareContract, + summary: 'Enable or Disable File Share', + description: + "Create or update a file's public share. `isActive` is required; other fields describe their behavior when access modes change. Enabling a protected mode on a previously unshared file requires its credential in the same request. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/files/[fileId]/share/route').then((route) => route.PATCH), + }, + upsertTableRow: { + contract: v2UpsertTableRowContract, + summary: 'Upsert Row', + description: + 'Insert a row or replace the row matching a selected unique column. On replacement, omitted columns are cleared; send the complete row. Use Update Row for a partial patch.\n\nOAuth scope: `api:write`.', + handler: () => + import('@/app/api/v2/tables/[tableId]/rows/upsert/route').then((route) => route.POST), + }, +} as const satisfies Record + +export type V2McpOperationName = keyof typeof V2_MCP_OPERATIONS diff --git a/apps/sim/lib/api/mcp/host-routing.test.ts b/apps/sim/lib/api/mcp/host-routing.test.ts new file mode 100644 index 00000000000..2ddf32a14e9 --- /dev/null +++ b/apps/sim/lib/api/mcp/host-routing.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ mcpUrl: undefined as string | undefined })) + +vi.mock('@/lib/core/config/env', () => ({ + getEnv: (name: string) => (name === 'SIM_MCP_URL' ? mocks.mcpUrl : undefined), +})) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.ai' })) + +import { resolveSimMcpHostPath } from '@/lib/api/mcp/host-routing' +import { getSimMcpUrl } from '@/lib/api/mcp/urls' + +describe('Sim MCP host routing', () => { + beforeEach(() => { + mocks.mcpUrl = undefined + }) + + it('serves the MCP server from the app origin by default', () => { + expect(getSimMcpUrl()).toBe('https://sim.ai/api/mcp') + expect(resolveSimMcpHostPath('sim.ai', '/api/mcp')).toBe('/api/mcp') + expect(resolveSimMcpHostPath('sim.ai', '/workspace')).toBeNull() + expect(resolveSimMcpHostPath('mcp.sim.ai', '/mcp')).toBeNull() + }) + + describe('on a dedicated host', () => { + beforeEach(() => { + mocks.mcpUrl = 'https://mcp.sim.ai/mcp/' + }) + + it('uses the configured URL as the canonical resource', () => { + expect(getSimMcpUrl()).toBe('https://mcp.sim.ai/mcp') + }) + + it.each([ + ['/mcp', '/api/mcp'], + [ + '/.well-known/oauth-protected-resource/mcp', + '/.well-known/oauth-protected-resource/api/mcp', + ], + ['/.well-known/oauth-authorization-server', '/.well-known/oauth-authorization-server'], + ])('maps %s to %s', (pathname, target) => { + expect(resolveSimMcpHostPath('mcp.sim.ai', pathname)).toBe(target) + expect(resolveSimMcpHostPath('MCP.SIM.AI', pathname)).toBe(target) + }) + + it.each(['/', '/login', '/workspace/ws-1', '/api/mcp', '/api/v2/workspaces', '/mcp/'])( + 'exposes nothing else: %s', + (pathname) => { + expect(resolveSimMcpHostPath('mcp.sim.ai', pathname)).toBe('not_found') + } + ) + + it.each(['mcp.sim.ai:443', 'mcp.sim.ai.', 'MCP.SIM.AI.:443'])( + 'recognizes the host spelled %s', + (host) => { + expect(resolveSimMcpHostPath(host, '/login')).toBe('not_found') + expect(resolveSimMcpHostPath(host, '/mcp')).toBe('/api/mcp') + } + ) + + it('tells the MCP host from an app on the same hostname but another port', () => { + mocks.mcpUrl = 'http://localhost:3001/mcp' + expect(resolveSimMcpHostPath('localhost:3000', '/workspace')).toBeNull() + expect(resolveSimMcpHostPath('localhost:3001', '/mcp')).toBe('/api/mcp') + expect(resolveSimMcpHostPath('localhost:3001', '/workspace')).toBe('not_found') + }) + + it('serves the app host as before, without a second MCP URL', () => { + expect(resolveSimMcpHostPath('sim.ai', '/mcp')).toBeNull() + expect(resolveSimMcpHostPath('sim.ai', '/workspace')).toBeNull() + expect(resolveSimMcpHostPath('sim.ai', '/api/mcp')).toBe('not_found') + expect(resolveSimMcpHostPath('sim.ai', '/.well-known/oauth-protected-resource/api/mcp')).toBe( + 'not_found' + ) + expect(resolveSimMcpHostPath('sim.ai', '/api/mcp/search/organizations/org-1')).toBeNull() + }) + }) +}) diff --git a/apps/sim/lib/api/mcp/host-routing.ts b/apps/sim/lib/api/mcp/host-routing.ts new file mode 100644 index 00000000000..0a0311900c4 --- /dev/null +++ b/apps/sim/lib/api/mcp/host-routing.ts @@ -0,0 +1,49 @@ +import { getSimMcpUrl, SIM_MCP_ROUTE_PATH } from '@/lib/api/mcp/urls' +import { getBaseUrl } from '@/lib/core/utils/urls' + +const PROTECTED_RESOURCE_METADATA = '/.well-known/oauth-protected-resource' +const AUTHORIZATION_SERVER_METADATA = '/.well-known/oauth-authorization-server' + +/** + * A `Host` header as a URL authority under `protocol`: lower-cased, without the + * trailing root dot, and without the scheme's default port, so it compares + * equal to `URL.host`. `null` when the header is not a valid authority. + */ +function authorityOf(host: string, protocol: string): string | null { + const normalized = host.replace(/\.(?=:\d+$|$)/, '') + return URL.canParse(`${protocol}//${normalized}`) + ? new URL(`${protocol}//${normalized}`).host + : null +} + +/** + * Routes requests for the Sim MCP server's canonical URL. + * + * On the MCP URL's host, the MCP path and its RFC 9728 metadata map onto the + * app routes that serve them. When that host is dedicated (`mcp.sim.ai`), it + * also serves the authorization-server metadata older clients look for at the + * resource origin, and every other path the proxy sees is `not_found`; the app + * host in turn answers `not_found` for the internal MCP paths, so the server has + * exactly one URL and every client binds its tokens to it. `null` leaves the + * request to the rest of the proxy. + * + * Reads `Host` rather than `X-Forwarded-Host`, which a client could set to + * reach the rest of the app through the dedicated host. + */ +export function resolveSimMcpHostPath( + host: string | null, + pathname: string +): string | 'not_found' | null { + const mcp = new URL(getSimMcpUrl()) + const dedicated = mcp.origin !== new URL(getBaseUrl()).origin + const internalMetadataPath = `${PROTECTED_RESOURCE_METADATA}${SIM_MCP_ROUTE_PATH}` + if (!host || authorityOf(host, mcp.protocol) !== mcp.host) { + return dedicated && (pathname === SIM_MCP_ROUTE_PATH || pathname === internalMetadataPath) + ? 'not_found' + : null + } + if (pathname === mcp.pathname) return SIM_MCP_ROUTE_PATH + if (pathname === `${PROTECTED_RESOURCE_METADATA}${mcp.pathname}`) return internalMetadataPath + if (!dedicated) return null + return pathname === AUTHORIZATION_SERVER_METADATA ? pathname : 'not_found' +} diff --git a/apps/sim/lib/api/mcp/oauth-metadata.ts b/apps/sim/lib/api/mcp/oauth-metadata.ts new file mode 100644 index 00000000000..b821cc531c6 --- /dev/null +++ b/apps/sim/lib/api/mcp/oauth-metadata.ts @@ -0,0 +1,25 @@ +import { getSimMcpUrl } from '@/lib/api/mcp/urls' +import { + type OAuthProtectedResource, + protectedResourceMetadataResponse, + withOAuthResourceChallenge, +} from '@/lib/auth/oauth-protected-resource' +import { OAUTH_API_READ_SCOPE, OAUTH_API_WRITE_SCOPE } from '@/lib/auth/oauth-provider' + +/** + * `offline_access` is the authorization server's to grant, not the resource's + * to advertise (MCP authorization, scope selection), so it is left out here. + */ +const SIM_MCP_SCOPES = [OAUTH_API_READ_SCOPE, OAUTH_API_WRITE_SCOPE] as const + +function simMcpResource(): OAuthProtectedResource { + return { resource: getSimMcpUrl(), name: 'Sim', scopes: SIM_MCP_SCOPES } +} + +export function simMcpResourceMetadata() { + return protectedResourceMetadataResponse(simMcpResource()) +} + +export function withSimMcpAuthChallenge(response: T): T { + return withOAuthResourceChallenge(response, simMcpResource()) +} diff --git a/apps/sim/lib/api/mcp/route-handler.test.ts b/apps/sim/lib/api/mcp/route-handler.test.ts new file mode 100644 index 00000000000..271ea4af8ec --- /dev/null +++ b/apps/sim/lib/api/mcp/route-handler.test.ts @@ -0,0 +1,229 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) + +import { createSimMcpHandlers } from '@/lib/api/mcp/route-handler' +import { getBaseUrl } from '@/lib/core/utils/urls' + +const BASE = getBaseUrl() + +const handlers = createSimMcpHandlers() +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, + keyExpiresAt: null, +} +const audience = { resource: `${BASE}/api/mcp`, allowUnboundApiTokens: true } + +function rpc( + message: Record, + headers: Record = { authorization: 'Bearer sk-sim-personal' } +) { + return new NextRequest(`${BASE}/api/mcp`, { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + 'x-forwarded-for': '203.0.113.7', + ...headers, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, ...message }), + }) +} + +async function callTool( + name: string, + args: Record, + headers?: Record +) { + const response = await handlers.POST( + rpc({ method: 'tools/call', params: { name, arguments: args } }, headers), + undefined + ) + expect(response.status).toBe(200) + const body = await response.json() + return body.result as { isError?: boolean; content: Array<{ type: string; text: string }> } +} + +beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) +}) + +describe('Sim MCP admission', () => { + it.each(['GET', 'POST', 'DELETE'] as const)( + 'points an unauthenticated %s at the protected-resource metadata', + async (method) => { + v2RouteMocks.authenticate.mockRejectedValue(new MockV2ApiKeyUnauthenticatedError()) + const response = await handlers[method](rpc({ method: 'tools/list' }, {}), undefined) + expect(response.status).toBe(401) + expect(response.headers.get('WWW-Authenticate')).toBe( + `Bearer resource_metadata="${BASE}/.well-known/oauth-protected-resource/api/mcp", scope="api:read api:write"` + ) + } + ) + + it('verifies OAuth tokens against the Sim MCP audience', async () => { + await handlers.POST( + rpc({ method: 'tools/list' }, { authorization: 'Bearer sim_oat_abc' }), + undefined + ) + expect(v2RouteMocks.authenticate).toHaveBeenCalledWith( + { apiKey: null, bearer: 'sim_oat_abc' }, + audience + ) + }) + + it('refuses browser requests from other origins', async () => { + const response = await handlers.POST( + rpc( + { method: 'tools/list' }, + { authorization: 'Bearer sk-sim-personal', origin: 'https://attacker.example' } + ), + undefined + ) + if (response.status !== 403) console.log('BODY', await response.clone().text()) + expect(response.status).toBe(403) + }) + + it('answers GET with 405 once authenticated', async () => { + const response = await handlers.GET(rpc({ method: 'tools/list' }), undefined) + expect(response.status).toBe(405) + expect(response.headers.get('Allow')).toBe('POST') + }) +}) + +describe('Sim MCP tools', () => { + it('lists four tools with reads and writes annotated apart', async () => { + const response = await handlers.POST(rpc({ method: 'tools/list' }), undefined) + const { result } = await response.json() + const tools = Object.fromEntries( + result.tools.map((tool: { name: string; annotations: Record }) => [ + tool.name, + tool.annotations, + ]) + ) + expect(Object.keys(tools).sort()).toEqual([ + 'call_read_operation', + 'call_write_operation', + 'describe_operation', + 'search_operations', + ]) + expect(tools.call_read_operation.readOnlyHint).toBe(true) + expect(tools.call_write_operation.destructiveHint).toBe(true) + }) + + it('finds operations by keyword', async () => { + const result = await callTool('search_operations', { query: 'workspaces', limit: 5 }) + const { operations } = JSON.parse(result.content[0].text) + expect(operations.map((entry: { operation: string }) => entry.operation)).toContain( + 'listWorkspaces' + ) + }) + + it('serves a read through the v2 route with the MCP credential and audience', async () => { + const result = await callTool('call_read_operation', { operation: 'getMeta' }) + expect(result.isError).toBeFalsy() + expect(JSON.parse(result.content[0].text)).toEqual({ + data: { v2Enabled: true, keyType: 'personal', expiresAt: null }, + }) + expect(v2RouteMocks.authenticate).toHaveBeenCalledTimes(2) + expect(v2RouteMocks.authenticate).toHaveBeenLastCalledWith( + { apiKey: 'sk-sim-personal', bearer: null, malformedOAuthBearer: false }, + audience + ) + }) + + it('returns the v2 error envelope as a tool error', async () => { + v2RouteMocks.authenticate + .mockResolvedValueOnce(auth) + .mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError('Invalid API key')) + const result = await callTool('call_read_operation', { operation: 'getMeta' }) + expect(result.isError).toBe(true) + expect(JSON.parse(result.content[0].text)).toMatchObject({ + error: { code: 'UNAUTHORIZED', message: 'Invalid API key' }, + }) + }) + + it('asks an OAuth token without api:write to step up before the write tool runs', async () => { + v2RouteMocks.authenticate.mockResolvedValue({ + ...auth, + principal: { + kind: 'oauth_access_token' as const, + userId: 'user-1', + clientId: 'client-1', + tokenId: 'token-1', + scopes: ['api:read'], + expiresAt: new Date(Date.now() + 60_000), + }, + keyType: 'oauth_access_token' as const, + }) + const response = await handlers.POST( + rpc( + { + method: 'tools/call', + params: { name: 'call_write_operation', arguments: { operation: 'createTable' } }, + }, + { authorization: 'Bearer sim_oat_read_only' } + ), + undefined + ) + expect(response.status).toBe(403) + expect(response.headers.get('WWW-Authenticate')).toBe( + `Bearer error="insufficient_scope", resource_metadata="${BASE}/.well-known/oauth-protected-resource/api/mcp", scope="api:write"` + ) + expect(v2RouteMocks.authenticate).toHaveBeenCalledTimes(1) + }) + + it('checks the scope the dispatched operation declares, not its HTTP method', async () => { + v2RouteMocks.authenticate.mockResolvedValue({ + ...auth, + principal: { + kind: 'oauth_access_token' as const, + userId: 'user-1', + clientId: 'client-1', + tokenId: 'token-1', + scopes: ['api:read'], + expiresAt: new Date(Date.now() + 60_000), + }, + keyType: 'oauth_access_token' as const, + }) + const response = await handlers.POST( + rpc( + { + method: 'tools/call', + params: { + name: 'call_write_operation', + arguments: { operation: 'queryRows', params: { tableId: 'tbl_1' } }, + }, + }, + { authorization: 'Bearer sim_oat_read_only' } + ), + undefined + ) + expect(response.status).toBe(200) + }) + + it('refuses a write operation on the read tool', async () => { + const result = await callTool('call_read_operation', { operation: 'createTable' }) + expect(result.isError).toBe(true) + expect(v2RouteMocks.authenticate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/lib/api/mcp/route-handler.ts b/apps/sim/lib/api/mcp/route-handler.ts new file mode 100644 index 00000000000..9f986ec701e --- /dev/null +++ b/apps/sim/lib/api/mcp/route-handler.ts @@ -0,0 +1,114 @@ +import { isPlainRecord } from '@sim/utils/object' +import type { NextRequest } from 'next/server' +import { v2McpOperations } from '@/lib/api/application/operations' +import { simMcpContract } from '@/lib/api/contracts/sim-mcp' +import { getMcpOperation, resolveOperation, TOOL_NAMES } from '@/lib/api/mcp/catalog' +import { withSimMcpAuthChallenge } from '@/lib/api/mcp/oauth-metadata' +import { createSimMcpServer } from '@/lib/api/mcp/server' +import { getSimMcpUrl } from '@/lib/api/mcp/urls' +import { parseRequest } from '@/lib/api/server' +import { + mcpCredentialAuth, + mcpMethodNotAllowed, + readMcpCredentialHeaders, + serveStatelessMcp, +} from '@/lib/api/server/routes/mcp-server-route' +import { + admitV2Request, + v2RateLimits, + v2RouteOperation, +} from '@/lib/api/server/routes/v2-json-route' +import type { OAuthAccessTokenOptions } from '@/lib/auth/oauth-access-token' +import { type ApplicationOperation, requireOAuthOperationScope } from '@/lib/core/application' +import { isSameOrigin } from '@/lib/core/utils/validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +/** Matches the largest body an ordinary v2 JSON route accepts; each route still applies its own limit. */ +const MAX_MCP_BODY_BYTES = 10 * 1024 * 1024 + +/** + * OAuth tokens must be bound to this server, or be an existing unbound Sim API + * grant such as the CLI's — the same API either way. + */ +function simMcpAudience(): OAuthAccessTokenOptions { + return { resource: getSimMcpUrl(), allowUnboundApiTokens: true } +} + +function admit(request: NextRequest) { + return admitV2Request( + request, + v2McpOperations.connect, + mcpCredentialAuth(simMcpAudience()), + v2RateLimits.publicApi + ) +} + +/** + * The v2 operation a JSON-RPC message will run, when it calls the read or write + * tool with a known operation. Its OAuth scope is checked before the SDK runs, + * so a token without it gets the protocol's `insufficient_scope` step-up + * challenge rather than a tool error. Raw routes declare no operation and all + * change something, so they need `api:write`. + */ +async function toolCallOperation( + message: Record +): Promise { + if (message.method !== 'tools/call' || !isPlainRecord(message.params)) return null + const { name, arguments: args } = message.params + if (name !== TOOL_NAMES.read && name !== TOOL_NAMES.write) return null + if (!isPlainRecord(args) || typeof args.operation !== 'string') return null + const resolved = await resolveOperation( + args.operation, + name === TOOL_NAMES.read ? 'read' : 'write' + ) + if ('error' in resolved) return null + const route = await getMcpOperation(resolved.operation).handler() + return v2RouteOperation(route) ?? v2McpOperations.rawRoute +} + +/** Browsers may only reach the server from Sim's own origins (DNS-rebinding protection). */ +function isAllowedOrigin(origin: string | null): boolean { + return !origin || isSameOrigin(origin) || isSameOrigin(origin, getSimMcpUrl()) +} + +export function createSimMcpHandlers() { + /** JSON-RPC is a protocol boundary; every tool call is dispatched to its own v2 route. */ + const handler = withRouteHandler(async (request: NextRequest) => { + const admission = await admit(request) + if (!admission.success) return withSimMcpAuthChallenge(admission.response) + if (!isAllowedOrigin(request.headers.get('origin'))) { + return v2Error('FORBIDDEN', 'Origin is not allowed') + } + try { + const parsed = await parseRequest( + simMcpContract, + request, + {}, + { maxBodyBytes: MAX_MCP_BODY_BYTES } + ) + if (!parsed.success) return parsed.response + const operation = await toolCallOperation(parsed.data.body) + if (operation) requireOAuthOperationScope(admission.auth.principal, operation) + const server = createSimMcpServer({ + inbound: request, + credential: readMcpCredentialHeaders(request.headers), + audience: simMcpAudience(), + }) + return await serveStatelessMcp(server, request, parsed.data.body) + } catch (error) { + const response = v2CaughtOrchestrationError(error) + if (response) return withSimMcpAuthChallenge(response) + throw error + } + }) + + /** Stateless clients use POST only; authenticate unsupported methods before returning 405. */ + const unsupportedMethod = withRouteHandler(async (request: NextRequest) => { + const admission = await admit(request) + if (!admission.success) return withSimMcpAuthChallenge(admission.response) + return mcpMethodNotAllowed() + }) + + return { POST: handler, GET: unsupportedMethod, DELETE: unsupportedMethod } +} diff --git a/apps/sim/lib/api/mcp/server.ts b/apps/sim/lib/api/mcp/server.ts new file mode 100644 index 00000000000..805aebd9e7a --- /dev/null +++ b/apps/sim/lib/api/mcp/server.ts @@ -0,0 +1,169 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' +import { createLogger } from '@sim/logger' +import { z } from 'zod' +import { + describeOperation, + OPERATION_DOMAINS, + resolveOperation, + searchOperations, + TOOL_NAMES, +} from '@/lib/api/mcp/catalog' +import { + dispatchMcpOperation, + type McpDispatchContext, + type McpOperationCall, +} from '@/lib/api/mcp/dispatch' +import { jsonToolResult, toolError } from '@/lib/mcp/tool-result' + +const logger = createLogger('SimMcpServer') + +const INSTRUCTIONS = `Sim is the AI workspace where teams build, deploy, and manage AI agents. This server exposes the full Sim API: workspaces, workflows and their runs, tables, knowledge bases, files, logs, credentials, deployments, and more. + +1. Find an operation with search_operations (keywords, optionally a domain). +2. Read its input schemas with describe_operation. +3. Run it with the tool search_operations names: call_read_operation for operations that only read, call_write_operation for everything else. + +Most operations take a workspaceId; listWorkspaces returns the workspaces you can use. Put path parameters in params, query-string values in query, and the JSON request body in body. Responses use the Sim API envelope ({ "data": ... }); list operations page with limit and cursor. Streaming options are not supported over MCP.` + +const operationName = z + .string() + .trim() + .min(1) + .max(128) + .describe('Operation name from search_operations, e.g. "listTables".') + +const operationArgs = { + params: z + .record(z.string(), z.string()) + .optional() + .describe('Path parameters by name, e.g. { "tableId": "..." }.'), + query: z + .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) + .optional() + .describe('Query-string parameters by name.'), + headers: z + .record(z.string(), z.string()) + .optional() + .describe('Request headers the operation declares, such as upload-token.'), +} + +const searchInput = z + .object({ + query: z + .string() + .trim() + .max(200) + .optional() + .describe( + 'Keywords matched against operation names, summaries, and paths, e.g. "table rows".' + ), + domain: z.enum(OPERATION_DOMAINS).optional().describe('Only operations in this API area.'), + limit: z.number().int().min(1).max(100).default(25), + }) + .strict() + +const describeInput = z.object({ operation: operationName }).strict() + +const readInput = z.object({ operation: operationName, ...operationArgs }).strict() + +const writeInput = z + .object({ + operation: operationName, + ...operationArgs, + body: z.unknown().optional().describe('JSON request body, as describe_operation specifies.'), + }) + .strict() + +/** + * The Sim MCP server for one HTTP request. A request owns its server, so no + * credential outlives the request that presented it. + * + * Four tools cover the whole v2 API instead of one tool per operation: a + * catalog of 200-odd tools would overflow most clients' tool limits and spend + * the model's context on schemas it never uses. Reads and writes are separate + * tools so a client can approve reads once and still confirm every change. + */ +export function createSimMcpServer(context: Omit): McpServer { + const server = new McpServer({ name: 'Sim', version: '1.0.0' }, { instructions: INSTRUCTIONS }) + + async function call( + tool: 'read' | 'write', + { operation, ...input }: Omit & { operation: string }, + toolSignal: AbortSignal + ): Promise { + const resolved = await resolveOperation(operation, tool) + if ('error' in resolved) return toolError(resolved.error) + const signal = AbortSignal.any([context.inbound.signal, toolSignal]) + try { + return await dispatchMcpOperation( + { ...input, operation: resolved.operation }, + { ...context, signal } + ) + } catch (error) { + if (signal.aborted) return toolError('The operation was cancelled.') + logger.error('Sim MCP operation failed', { operation, error }) + return toolError('Unable to complete this operation. Please try again.') + } + } + + server.registerTool( + 'search_operations', + { + title: 'Search operations', + description: + 'Find Sim API operations by keyword or domain. Returns each operation’s name, HTTP method, path, summary, and the tool that runs it. Call without a query to list a domain.', + inputSchema: searchInput, + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, + }, + async (input) => jsonToolResult(await searchOperations(input)) + ) + + server.registerTool( + 'describe_operation', + { + title: 'Describe operation', + description: + 'Get the JSON Schema of an operation’s path parameters, query, body, and headers. Read it before calling an operation for the first time.', + inputSchema: describeInput, + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, + }, + async ({ operation }) => { + const resolved = await resolveOperation(operation, 'any') + return 'error' in resolved + ? toolError(resolved.error) + : jsonToolResult(await describeOperation(resolved.operation)) + } + ) + + server.registerTool( + TOOL_NAMES.read, + { + title: 'Read from Sim', + description: + 'Run a Sim API operation that only reads, such as listWorkspaces, listTables, queryRows, or getWorkflowRun. search_operations says which tool runs each operation.', + inputSchema: readInput, + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, + }, + async (input, extra) => call('read', input, extra.signal) + ) + + server.registerTool( + TOOL_NAMES.write, + { + title: 'Change Sim', + description: + 'Run a Sim API operation that creates, changes, runs, or deletes something, such as createTable, executeWorkflow, or deleteFile.', + inputSchema: writeInput, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + }, + async (input, extra) => call('write', input, extra.signal) + ) + + return server +} diff --git a/apps/sim/lib/api/mcp/types.ts b/apps/sim/lib/api/mcp/types.ts new file mode 100644 index 00000000000..623c1e1a6d1 --- /dev/null +++ b/apps/sim/lib/api/mcp/types.ts @@ -0,0 +1,14 @@ +import type { AnyApiRouteContract } from '@/lib/api/contracts/types' + +/** One v2 operation as the Sim MCP server exposes it; entries are generated from the contracts. */ +export interface V2McpOperation { + readonly contract: AnyApiRouteContract + /** The OpenAPI summary, when the operation has one. */ + readonly summary?: string + /** The OpenAPI description: behavior, constraints, and caveats beyond the summary. */ + readonly description?: string + /** The operation refuses workspace API keys; a personal credential is required. */ + readonly workspaceKeyUnsupported?: true + /** Loads the route handler that serves this operation over HTTP. */ + readonly handler: () => Promise +} diff --git a/apps/sim/lib/api/mcp/urls.ts b/apps/sim/lib/api/mcp/urls.ts new file mode 100644 index 00000000000..190d7196585 --- /dev/null +++ b/apps/sim/lib/api/mcp/urls.ts @@ -0,0 +1,18 @@ +import { getEnv } from '@/lib/core/config/env' +import { getBaseUrl } from '@/lib/core/utils/urls' + +/** Where the Sim MCP route lives in this app, whichever host serves it publicly. */ +export const SIM_MCP_ROUTE_PATH = '/api/mcp' + +/** + * The Sim MCP server's canonical URL. Client setup, protected-resource + * discovery, and OAuth token audience all use this one string. + * + * `SIM_MCP_URL` names a dedicated host (hosted Sim serves `https://mcp.sim.ai/mcp`), + * which `proxy.ts` maps onto {@link SIM_MCP_ROUTE_PATH}. Without it the server + * is served from the app's own origin. + */ +export function getSimMcpUrl(): string { + const configured = getEnv('SIM_MCP_URL')?.trim().replace(/\/+$/, '') + return configured || `${getBaseUrl()}${SIM_MCP_ROUTE_PATH}` +} diff --git a/apps/sim/lib/api/server/routes/mcp-server-route.ts b/apps/sim/lib/api/server/routes/mcp-server-route.ts new file mode 100644 index 00000000000..b4aadc4fe5c --- /dev/null +++ b/apps/sim/lib/api/server/routes/mcp-server-route.ts @@ -0,0 +1,65 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import type { NextRequest } from 'next/server' +import { + authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError, +} from '@/lib/api/server/routes/v2-api-key-auth' +import type { V2CredentialHeaders } from '@/lib/api/server/routes/v2-credential-headers' +import { type OAuthAccessTokenOptions, parseBearerToken } from '@/lib/auth/oauth-access-token' +import { OAUTH_ACCESS_TOKEN_PREFIX } from '@/lib/auth/oauth-provider' + +const NO_STORE = 'private, no-store' + +/** + * The credential an MCP request presents. MCP clients send whatever they hold + * as `Authorization: Bearer`, so a bearer that is not one of Sim's OAuth access + * tokens is an API key. `x-api-key` is accepted too; two different credentials + * are refused rather than one being silently chosen. + */ +export function readMcpCredentialHeaders(headers: Headers): V2CredentialHeaders { + const apiKey = headers.get('x-api-key') + const bearer = parseBearerToken(headers) + if ((headers.has('authorization') && !bearer) || (apiKey && bearer && apiKey !== bearer)) { + throw new V2ApiKeyUnauthenticatedError('Provide one valid API key') + } + const oauthBearer = bearer?.startsWith(OAUTH_ACCESS_TOKEN_PREFIX) ? bearer : null + return { apiKey: apiKey ?? (oauthBearer ? null : bearer), bearer: oauthBearer } +} + +/** Authenticates an MCP request, verifying OAuth tokens against the server's own audience. */ +export function mcpCredentialAuth(audience: OAuthAccessTokenOptions) { + return { + authenticate(request: NextRequest) { + return authenticateV2ApiKey(readMcpCredentialHeaders(request.headers), audience) + }, + } +} + +/** + * Serves one JSON-RPC message statelessly: the server exists for this request + * only, so no credential outlives the request that presented it. + */ +export async function serveStatelessMcp( + server: McpServer, + request: Request, + parsedBody: unknown +): Promise { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }) + try { + await server.connect(transport) + const response = await transport.handleRequest(request, { parsedBody }) + response.headers.set('Cache-Control', NO_STORE) + return response + } finally { + await server.close() + } +} + +/** Stateless servers take POST only; callers authenticate before answering 405. */ +export function mcpMethodNotAllowed(): Response { + return new Response(null, { status: 405, headers: { Allow: 'POST', 'Cache-Control': NO_STORE } }) +} diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index 74c8d2ee22e..b6ff4bca615 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -28,6 +28,7 @@ import { type ParseRequestOptions, parseRequest, } from '@/lib/api/server/validation' +import { getOAuthAccessTokenAudience } from '@/lib/auth/oauth-access-token' import { type ApplicationOperation, InsufficientScopeError, @@ -67,7 +68,10 @@ export class V2RouteInfrastructureError extends Error { */ export const v2ApiKeyAuth = { authenticate(request: NextRequest) { - return authenticateV2ApiKey(readV2CredentialHeaders(request.headers)) + return authenticateV2ApiKey( + readV2CredentialHeaders(request.headers), + getOAuthAccessTokenAudience() + ) }, } as const @@ -446,6 +450,18 @@ interface V2JsonRouteOptions): number } +/** + * The operation each v2 JSON route handler serves, so another transport for + * the same route (the Sim MCP server) can read its policy, such as the OAuth + * scope, without restating it. Keys are the module-level handlers. + */ +const routeOperations = new WeakMap() + +/** The operation a loaded v2 route handler serves, or `null` for a raw route. */ +export function v2RouteOperation(handler: unknown): ApplicationOperation | null { + return typeof handler === 'function' ? (routeOperations.get(handler) ?? null) : null +} + export function defineV2JsonRoute< C extends JsonApiRouteContract, O extends ApplicationOperation, @@ -561,5 +577,7 @@ export function defineV2JsonRoute< } ) - return async (request, context) => wrapped(request, context) + const route: JsonNextRouteHandler = async (request, context) => wrapped(request, context) + routeOperations.set(route, options.operation) + return route } diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 7d724bed1ec..5e7bc09be32 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -50,10 +50,10 @@ import { OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_ACCESS_TOKEN_TTL_SECONDS, OAUTH_CODE_TTL_SECONDS, + OAUTH_PUBLIC_REGISTRATION_SCOPES, OAUTH_REFRESH_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_TTL_SECONDS, OAUTH_SCOPES, - OAUTH_SEARCH_SCOPES, SIM_CLI_CLIENT_ID, } from '@/lib/auth/oauth-provider' import { bindOAuthIssuedResource, oauthResourcePlugin } from '@/lib/auth/oauth-resource' @@ -1288,7 +1288,10 @@ export const auth = betterAuth({ * earlier rotation. This is an OAuth API-authorization surface, not an * OpenID Connect identity provider; `disableJwtPlugin` keeps JWT/JWKS and * ID-token semantics out of the advertised protocol. Public registration - * is limited to read-only Search clients; other clients are operator-created. + * serves MCP clients: a registered client may request the Sim API and + * Search families, every grant is consented to, and a grant bound to an MCP + * resource is narrowed to the family that resource allows (see + * `oauth-resource.ts`). First-party clients are operator-created. */ ...(!isAuthDisabled ? [ @@ -1306,8 +1309,8 @@ export const auth = betterAuth({ allowPublicClientPrelogin: true, allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, - clientRegistrationAllowedScopes: [...OAUTH_SEARCH_SCOPES], - clientRegistrationDefaultScopes: [...OAUTH_SEARCH_SCOPES], + clientRegistrationAllowedScopes: [...OAUTH_PUBLIC_REGISTRATION_SCOPES], + clientRegistrationDefaultScopes: [...OAUTH_PUBLIC_REGISTRATION_SCOPES], customTokenResponseFields: bindOAuthIssuedResource, /** * Client-management endpoints remain operator-only. Public registration diff --git a/apps/sim/lib/auth/oauth-access-token.ts b/apps/sim/lib/auth/oauth-access-token.ts index 4204d4fb80e..f458f9a969a 100644 --- a/apps/sim/lib/auth/oauth-access-token.ts +++ b/apps/sim/lib/auth/oauth-access-token.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from 'node:async_hooks' import type { OAuthAccessTokenPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { oauthAccessToken, oauthClient, user } from '@sim/db/schema' @@ -76,6 +77,30 @@ export interface OAuthAccessTokenOptions { allowUnboundApiTokens?: boolean } +const audience = new AsyncLocalStorage() + +/** + * Runs `work` with v2 bearer tokens verified against `options` rather than the + * unbound API audience. + * + * The Sim MCP server dispatches each tool call to its v2 route handler + * in-process, and those handlers authenticate the request themselves. This is + * how they accept a token bound to the MCP server — the same audience the MCP + * endpoint already verified — without the REST API accepting MCP tokens from + * anyone else. Only server code can set it; no request input reaches it. + */ +export function withOAuthAccessTokenAudience( + options: OAuthAccessTokenOptions, + work: () => Promise +): Promise { + return audience.run(options, work) +} + +/** The audience set by {@link withOAuthAccessTokenAudience}, or the unbound API audience. */ +export function getOAuthAccessTokenAudience(): OAuthAccessTokenOptions { + return audience.getStore() ?? {} +} + /** * Resolves an opaque OAuth access token to the principal it stands for. * diff --git a/apps/sim/lib/auth/oauth-client-registration.ts b/apps/sim/lib/auth/oauth-client-registration.ts new file mode 100644 index 00000000000..db9b928c4e7 --- /dev/null +++ b/apps/sim/lib/auth/oauth-client-registration.ts @@ -0,0 +1,53 @@ +import { db } from '@sim/db' +import { oauthClient } from '@sim/db/schema' +import { isPlainRecord } from '@sim/utils/object' +import { eq } from 'drizzle-orm' + +/** + * Server-owned marker the public registration endpoint stamps on every client + * it creates. Clients cannot set metadata through registration and hold no + * management privileges, so only this server writes it. + */ +const PUBLIC_REGISTRATION_METADATA = { registration: 'public' } as const + +/** + * Stamps a just-registered client. Registration discloses the client ID only + * after this succeeds, so an unstamped client is never usable: if the write + * fails, the registration fails and nobody holds the ID. + */ +export async function markPubliclyRegisteredOAuthClient(clientId: string): Promise { + const updated = await db + .update(oauthClient) + .set({ metadata: PUBLIC_REGISTRATION_METADATA }) + .where(eq(oauthClient.clientId, clientId)) + .returning({ clientId: oauthClient.clientId }) + if (updated.length !== 1) { + throw new Error(`Registered OAuth client ${clientId} was not found to mark`) + } +} + +function readMetadata(value: unknown): unknown { + if (typeof value !== 'string') return value + try { + return JSON.parse(value) + } catch { + return null + } +} + +/** + * Whether a client was created through public registration rather than by an + * operator. Such a client may reach the Sim API only through the Sim MCP + * server, so its API grants must name that server as their resource. + */ +export async function isPubliclyRegisteredOAuthClient(clientId: string): Promise { + const [client] = await db + .select({ metadata: oauthClient.metadata }) + .from(oauthClient) + .where(eq(oauthClient.clientId, clientId)) + .limit(1) + const metadata = readMetadata(client?.metadata) + return ( + isPlainRecord(metadata) && metadata.registration === PUBLIC_REGISTRATION_METADATA.registration + ) +} diff --git a/apps/sim/lib/auth/oauth-protected-resource.ts b/apps/sim/lib/auth/oauth-protected-resource.ts new file mode 100644 index 00000000000..4774097f504 --- /dev/null +++ b/apps/sim/lib/auth/oauth-protected-resource.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server' +import { getBaseUrl } from '@/lib/core/utils/urls' + +/** An MCP endpoint protected by Sim's OAuth authorization server. */ +export interface OAuthProtectedResource { + /** The canonical resource URL tokens are bound to. */ + resource: string + /** Human-readable name clients show while connecting. */ + name: string + /** Scopes a token for this resource may carry. */ + scopes: readonly string[] +} + +/** RFC 9728 metadata location for a resource: the well-known prefix inserted before its path. */ +function getProtectedResourceMetadataUrl(resource: string): string { + const url = new URL(resource) + return `${url.origin}/.well-known/oauth-protected-resource${url.pathname}` +} + +/** Public protocol metadata describes the endpoint without looking up protected data. */ +export function protectedResourceMetadataResponse({ + resource, + name, + scopes, +}: OAuthProtectedResource) { + return NextResponse.json( + { + resource, + resource_name: name, + authorization_servers: [`${getBaseUrl()}/api/auth`], + scopes_supported: scopes, + bearer_methods_supported: ['header'], + }, + { + headers: { + 'Cache-Control': 'public, max-age=300', + 'Access-Control-Allow-Origin': '*', + }, + } + ) +} + +/** + * Points a refused request at the resource's metadata (RFC 9728 §5.1), keeping + * the route's RFC 6750 error code (`invalid_token` tells a client to refresh). A + * `401` asks for every scope the resource grants; an `insufficient_scope` `403` + * keeps the scope the request needed, so the client can step up to exactly that. + */ +export function withOAuthResourceChallenge( + response: T, + { resource, scopes }: Pick +): T { + const existing = response.headers.get('WWW-Authenticate') + const insufficientScope = response.status === 403 && existing?.includes('insufficient_scope') + if (response.status !== 401 && !insufficientScope) return response + const scope = insufficientScope + ? (existing?.match(/scope="([^"]*)"/)?.[1] ?? scopes.join(' ')) + : scopes.join(' ') + const reason = existing?.match(/error="([^"]*)"/)?.[1] + const error = reason ? `error="${reason}", ` : '' + response.headers.set( + 'WWW-Authenticate', + `Bearer ${error}resource_metadata="${getProtectedResourceMetadataUrl(resource)}", scope="${scope}"` + ) + response.headers.set('Cache-Control', 'private, no-store') + return response +} diff --git a/apps/sim/lib/auth/oauth-provider-registration.test.ts b/apps/sim/lib/auth/oauth-provider-registration.test.ts index 17fa09d785b..4fcb0fa674c 100644 --- a/apps/sim/lib/auth/oauth-provider-registration.test.ts +++ b/apps/sim/lib/auth/oauth-provider-registration.test.ts @@ -6,10 +6,10 @@ import { memoryAdapter } from 'better-auth/adapters/memory' import { symmetricEncrypt } from 'better-auth/crypto' import { beforeEach, describe, expect, it } from 'vitest' import { - registerSearchOAuthClientBodySchema, - registerSearchOAuthClientResponseSchema, + registerOAuthClientBodySchema, + registerOAuthClientResponseSchema, } from '@/lib/api/contracts/oauth-provider' -import { OAUTH_SCOPES, OAUTH_SEARCH_SCOPES } from '@/lib/auth/oauth-provider' +import { OAUTH_PUBLIC_REGISTRATION_SCOPES, OAUTH_SCOPES } from '@/lib/auth/oauth-provider' const BASE_URL = 'https://sim.test' const AUTH_SECRET = 'isolated-oauth-registration-test-secret-123456789' @@ -42,8 +42,8 @@ function createProvider(database: Record[]>) { grantTypes: ['authorization_code', 'refresh_token'], allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, - clientRegistrationAllowedScopes: [...OAUTH_SEARCH_SCOPES], - clientRegistrationDefaultScopes: [...OAUTH_SEARCH_SCOPES], + clientRegistrationAllowedScopes: [...OAUTH_PUBLIC_REGISTRATION_SCOPES], + clientRegistrationDefaultScopes: [...OAUTH_PUBLIC_REGISTRATION_SCOPES], clientPrivileges: () => false, silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true }, }), @@ -51,7 +51,7 @@ function createProvider(database: Record[]>) { }) } -describe('Search registration with the installed OAuth provider', () => { +describe('MCP client registration with the installed OAuth provider', () => { let database: Record[]> let provider: ReturnType @@ -70,7 +70,7 @@ describe('Search registration with the installed OAuth provider', () => { }) async function register(metadata: object = claudeMetadata) { - const body = registerSearchOAuthClientBodySchema.parse(metadata) + const body = registerOAuthClientBodySchema.parse(metadata) return provider.handler( new Request(`${BASE_URL}/api/auth/oauth2/register`, { method: 'POST', @@ -151,7 +151,7 @@ describe('Search registration with the installed OAuth provider', () => { }) expect(response.ok).toBe(true) const body = await response.json() - expect(registerSearchOAuthClientResponseSchema.parse(body)).toMatchObject({ + expect(registerOAuthClientResponseSchema.parse(body)).toMatchObject({ client_name: 'Claude', redirect_uris: [REDIRECT_URI], token_endpoint_auth_method: 'none', @@ -170,7 +170,7 @@ describe('Search registration with the installed OAuth provider', () => { } ) - it('narrows issuer scopes and strips privileged metadata before persistence', async () => { + it('keeps registrable issuer scopes and strips privileged metadata before persistence', async () => { const response = await register({ ...claudeMetadata, scope: 'api:read api:write search:read offline_access', @@ -183,7 +183,7 @@ describe('Search registration with the installed OAuth provider', () => { expect(response.ok).toBe(true) expect(database.oauthClient[0]).toMatchObject({ public: true, - scopes: ['search:read', 'offline_access'], + scopes: ['api:read', 'api:write', 'offline_access', 'search:read'], }) expect(database.oauthClient[0].clientSecret).toBeFalsy() expect(database.oauthClient[0].skipConsent).toBeFalsy() @@ -194,7 +194,7 @@ describe('Search registration with the installed OAuth provider', () => { 'rejects authorization without PKCE or with unregistered scope %s', async (scope) => { const registered = await register() - const client = registerSearchOAuthClientResponseSchema.parse(await registered.json()) + const client = registerOAuthClientResponseSchema.parse(await registered.json()) const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`) url.search = new URLSearchParams({ client_id: client.client_id, @@ -213,7 +213,7 @@ describe('Search registration with the installed OAuth provider', () => { it('continues negotiated public clients to sign-in with S256 PKCE', async () => { const registered = await register() - const client = registerSearchOAuthClientResponseSchema.parse(await registered.json()) + const client = registerOAuthClientResponseSchema.parse(await registered.json()) const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`) url.search = new URLSearchParams({ client_id: client.client_id, @@ -237,7 +237,7 @@ describe('Search registration with the installed OAuth provider', () => { ...claudeMetadata, token_endpoint_auth_method: authMethod, }) - const client = registerSearchOAuthClientResponseSchema.parse(await registered.json()) + const client = registerOAuthClientResponseSchema.parse(await registered.json()) const cookie = await signIn() const code = await authorize(client.client_id, cookie) const tokenResponse = await requestToken({ @@ -285,7 +285,7 @@ describe('Search registration with the installed OAuth provider', () => { 'rejects code exchange with verifier %s', async (verifier) => { const registered = await register() - const client = registerSearchOAuthClientResponseSchema.parse(await registered.json()) + const client = registerOAuthClientResponseSchema.parse(await registered.json()) const cookie = await signIn() const code = await authorize(client.client_id, cookie) const response = await requestToken({ diff --git a/apps/sim/lib/auth/oauth-provider.ts b/apps/sim/lib/auth/oauth-provider.ts index 38f313a68f1..67a1ce94ea7 100644 --- a/apps/sim/lib/auth/oauth-provider.ts +++ b/apps/sim/lib/auth/oauth-provider.ts @@ -21,6 +21,12 @@ export const OAUTH_API_WRITE_SCOPE = 'api:write' export const OAUTH_SEARCH_READ_SCOPE = 'search:read' export const OAUTH_SEARCH_SCOPES = [OAUTH_SEARCH_READ_SCOPE, 'offline_access'] as const +/** What a token bound to the Sim MCP server may carry: the Sim API, never Search. */ +export const OAUTH_API_SCOPES = [ + OAUTH_API_READ_SCOPE, + OAUTH_API_WRITE_SCOPE, + 'offline_access', +] as const export const OAUTH_SCOPES = [ 'offline_access', OAUTH_API_READ_SCOPE, @@ -30,20 +36,42 @@ export const OAUTH_SCOPES = [ export type OAuthScope = (typeof OAUTH_SCOPES)[number] +/** The scopes each kind of MCP resource may grant: the Sim API, or Search. */ +export const OAUTH_RESOURCE_SCOPES = { api: OAUTH_API_SCOPES, search: OAUTH_SEARCH_SCOPES } as const + +export type OAuthResourceKind = keyof typeof OAUTH_RESOURCE_SCOPES + /** * RFC 6749 permits granting fewer scopes than requested. Some MCP clients request - * every scope advertised by the shared issuer; a Search resource can only grant - * Search access, and the returned scope always reports that narrower grant. + * every scope advertised by the shared issuer; a resource can only grant its own + * family, and the returned scope always reports that narrower grant. `null` when + * the request names an unknown scope or nothing but `offline_access` from the family. */ -export function narrowSearchOAuthScopes(scope: string): string | null { +export function narrowResourceOAuthScopes(scope: string, kind: OAuthResourceKind): string | null { const requested = scope.split(' ').filter(Boolean) - if ( - !requested.includes(OAUTH_SEARCH_READ_SCOPE) || - requested.some((value) => !OAUTH_SCOPES.some((allowed) => allowed === value)) - ) { - return null - } - return OAUTH_SEARCH_SCOPES.filter((value) => requested.includes(value)).join(' ') + if (requested.some((value) => !OAUTH_SCOPES.some((allowed) => allowed === value))) return null + const granted = OAUTH_RESOURCE_SCOPES[kind].filter((value) => requested.includes(value)) + return granted.some((value) => value !== 'offline_access') ? granted.join(' ') : null +} + +/** + * What a publicly registered MCP client may be granted. Registration cannot know + * which server the client will connect to, so it may hold both families; each + * authorization is narrowed to the one family its resource grants. + */ +export const OAUTH_PUBLIC_REGISTRATION_SCOPES = [ + ...OAUTH_API_SCOPES, + OAUTH_SEARCH_READ_SCOPE, +] as const + +/** The registrable subset of a client's requested scopes, or `null` when none is registrable. */ +export function narrowRegistrationOAuthScopes(scope: string): string | null { + const granted = new Set( + (['api', 'search'] as const).flatMap( + (kind) => narrowResourceOAuthScopes(scope, kind)?.split(' ') ?? [] + ) + ) + return granted.size > 0 ? [...granted].join(' ') : null } /** diff --git a/apps/sim/lib/auth/oauth-resource.test.ts b/apps/sim/lib/auth/oauth-resource.test.ts index 1c6fc5a8a46..2ae67989972 100644 --- a/apps/sim/lib/auth/oauth-resource.test.ts +++ b/apps/sim/lib/auth/oauth-resource.test.ts @@ -10,18 +10,20 @@ import { getOAuthIssuedResource, InvalidOAuthResourceError, oauthResourcePlugin, - parseOAuthSearchResource, + parseOAuthResource, withOAuthResourceIssuance, } from '@/lib/auth/oauth-resource' const resource = 'https://sim.example/api/mcp/search/organizations/org-one' +const simMcpResource = 'https://sim.example/api/mcp' const otherResource = 'https://sim.example/api/mcp/search/organizations/org-two' const scopes = ['search:read', 'offline_access'] describe('OAuth resource binding', () => { - it('accepts exact organization Search endpoints and an absent API audience', () => { - expect(parseOAuthSearchResource(resource)).toBe(resource) - expect(parseOAuthSearchResource(null)).toBeNull() + it('accepts exact organization Search endpoints, the Sim MCP server, and an absent API audience', () => { + expect(parseOAuthResource(resource)).toEqual({ kind: 'search', url: resource }) + expect(parseOAuthResource(simMcpResource)).toEqual({ kind: 'api', url: simMcpResource }) + expect(parseOAuthResource(null)).toBeNull() }) it.each([ @@ -39,8 +41,11 @@ describe('OAuth resource binding', () => { 'https://sim.example/api/mcp/search/organizations', 'https://sim.example/api/v2/workspaces', 'https://sim.example:443/api/mcp/search/organizations/org-one', + 'https://sim.example/api/mcp/', + 'https://sim.example/api/mcp?workspaceId=ws-1', + 'https://attacker.example/api/mcp', ])('rejects noncanonical or unsupported resources: %s', (value) => { - expect(() => parseOAuthSearchResource(value)).toThrow(InvalidOAuthResourceError) + expect(() => parseOAuthResource(value)).toThrow(InvalidOAuthResourceError) }) it('binds only the resource from the verified authorization request before insertion', async () => { @@ -82,19 +87,32 @@ describe('OAuth resource binding', () => { [resource, ['api:read']], [resource, ['search:read', 'api:read']], [null, ['search:read']], - ])( - 'requires search scope and resource together without wider API authority', - async (target, granted) => { - await expect( - withOAuthResourceIssuance(target, async () => - bindOAuthIssuedResource({ - verificationValue: { query: { resource: target ?? undefined } }, - scopes: granted, - }) - ) - ).rejects.toMatchObject({ body: { error: 'invalid_scope' } }) - } - ) + [simMcpResource, ['search:read']], + [simMcpResource, ['api:write', 'search:read']], + [simMcpResource, ['offline_access']], + ])('grants each resource only its own scope family: %s %j', async (target, granted) => { + await expect( + withOAuthResourceIssuance(target, async () => + bindOAuthIssuedResource({ + verificationValue: { query: { resource: target ?? undefined } }, + scopes: granted, + }) + ) + ).rejects.toMatchObject({ body: { error: 'invalid_scope' } }) + }) + + it('binds Sim API grants to the Sim MCP server', async () => { + const apiScopes = ['api:write', 'offline_access'] + await withOAuthResourceIssuance(simMcpResource, async () => { + expect( + bindOAuthIssuedResource({ + verificationValue: { query: { resource: simMcpResource } }, + scopes: apiScopes, + }) + ).toEqual({}) + expect(getOAuthIssuedResource(apiScopes)).toBe(simMcpResource) + }) + }) it('preserves existing API issuance and refuses direct Search provider calls', async () => { expect(bindOAuthIssuedResource({ scopes: ['api:read'] })).toEqual({}) diff --git a/apps/sim/lib/auth/oauth-resource.ts b/apps/sim/lib/auth/oauth-resource.ts index 38545b27e2d..3ba42fb3bc7 100644 --- a/apps/sim/lib/auth/oauth-resource.ts +++ b/apps/sim/lib/auth/oauth-resource.ts @@ -1,7 +1,12 @@ import { AsyncLocalStorage } from 'node:async_hooks' import type { BetterAuthPlugin } from 'better-auth' import { APIError } from 'better-auth/api' -import { OAUTH_SEARCH_READ_SCOPE } from '@/lib/auth/oauth-provider' +import { getSimMcpUrl } from '@/lib/api/mcp/urls' +import { + narrowResourceOAuthScopes, + OAUTH_SEARCH_READ_SCOPE, + type OAuthResourceKind, +} from '@/lib/auth/oauth-provider' import { getBaseUrl } from '@/lib/core/utils/urls' interface OAuthResourceIssuance { @@ -9,37 +14,59 @@ interface OAuthResourceIssuance { verifiedResource?: string | null } +/** + * An RFC 8707 audience this deployment issues tokens for. `api` is the Sim MCP + * server, which serves the Sim API; `search` is an organization's Search server. + */ +export interface OAuthResource { + kind: OAuthResourceKind + url: string +} + const issuance = new AsyncLocalStorage() const SEARCH_RESOURCE_PATH = /^\/api\/mcp\/search\/organizations\/[A-Za-z0-9_-]{1,128}$/ export class InvalidOAuthResourceError extends Error { constructor() { - super('The resource must be a canonical Sim Search MCP URL.') + super('The resource must be a canonical Sim MCP server URL.') this.name = 'InvalidOAuthResourceError' } } -/** Accepts only organization Search MCP endpoints on this deployment's canonical origin. */ -export function parseOAuthSearchResource(value: string | null): string | null { +/** Accepts only this deployment's canonical Sim MCP URL and its organization Search endpoints. */ +export function parseOAuthResource(value: string | null): OAuthResource | null { if (value === null) return null - try { - const url = new URL(value) - if ( - value.length > 2048 || - url.href !== value || - url.origin !== new URL(getBaseUrl()).origin || - url.username || - url.password || - url.search || - url.hash || - !SEARCH_RESOURCE_PATH.test(url.pathname) - ) { - throw new InvalidOAuthResourceError() - } - return value - } catch { + if (value === getSimMcpUrl()) return { kind: 'api', url: value } + if (!URL.canParse(value)) throw new InvalidOAuthResourceError() + const url = new URL(value) + if ( + value.length > 2048 || + url.href !== value || + url.origin !== new URL(getBaseUrl()).origin || + url.username || + url.password || + url.search || + url.hash || + !SEARCH_RESOURCE_PATH.test(url.pathname) + ) { throw new InvalidOAuthResourceError() } + return { kind: 'search', url: value } +} + +/** + * Whether a grant's scopes belong to its audience: exactly the resource's own + * family. An unbound token may carry anything but Search, which is never issued + * without its resource. + */ +function oauthScopesFitResource( + resource: OAuthResource | null, + scopes: readonly string[] +): boolean { + if (!resource) return !scopes.includes(OAUTH_SEARCH_READ_SCOPE) + return ( + narrowResourceOAuthScopes(scopes.join(' '), resource.kind)?.split(' ').length === scopes.length + ) } /** Keeps the token request audience isolated while Better Auth validates the authorization code. */ @@ -66,12 +93,12 @@ export function bindOAuthIssuedResource({ const query = verificationValue?.query const resourceValue = query && typeof query === 'object' && 'resource' in query ? query.resource : undefined - let resource: string | null + let resource: OAuthResource | null try { if (resourceValue !== undefined && typeof resourceValue !== 'string') { throw new InvalidOAuthResourceError() } - resource = parseOAuthSearchResource(resourceValue ?? null) + resource = parseOAuthResource(resourceValue ?? null) } catch { throw new APIError('BAD_REQUEST', { error: 'invalid_target', @@ -79,21 +106,16 @@ export function bindOAuthIssuedResource({ }) } - if (resource !== (context?.requestedResource ?? null)) { + if ((resource?.url ?? null) !== (context?.requestedResource ?? null)) { throw new APIError('BAD_REQUEST', { error: 'invalid_target', error_description: 'The token resource must match the authorization request.', }) } - if ( - resource - ? !scopes.includes(OAUTH_SEARCH_READ_SCOPE) || - scopes.some((scope) => scope !== OAUTH_SEARCH_READ_SCOPE && scope !== 'offline_access') - : scopes.includes(OAUTH_SEARCH_READ_SCOPE) - ) { + if (!oauthScopesFitResource(resource, scopes)) { throw new APIError('BAD_REQUEST', { error: 'invalid_scope', - error_description: 'Search access requires its matching resource and search scope.', + error_description: 'The granted scopes do not match the token resource.', }) } if (resource && !context) { @@ -102,7 +124,7 @@ export function bindOAuthIssuedResource({ error_description: 'Resource-bound issuance requires a token request.', }) } - if (context) context.verifiedResource = resource + if (context) context.verifiedResource = resource?.url ?? null return {} } diff --git a/apps/sim/lib/auth/oauth-token-family.ts b/apps/sim/lib/auth/oauth-token-family.ts index b0ca9c0b365..ebf9d68ea04 100644 --- a/apps/sim/lib/auth/oauth-token-family.ts +++ b/apps/sim/lib/auth/oauth-token-family.ts @@ -22,7 +22,7 @@ import { OAUTH_REFRESH_TOKEN_PREFIX, OAUTH_TOKEN_FAMILY_MAX_GENERATION, } from '@/lib/auth/oauth-provider' -import { parseOAuthSearchResource } from '@/lib/auth/oauth-resource' +import { parseOAuthResource } from '@/lib/auth/oauth-resource' import { acquireOrganizationUserMutationLocks, getUserOrganization, @@ -282,7 +282,7 @@ export async function rotateOAuthRefreshToken( return protocolError('invalid_target', 'The resource must match the original token grant.') } try { - parseOAuthSearchResource(provisionalToken.resource) + parseOAuthResource(provisionalToken.resource) } catch { return protocolError('invalid_target', 'The original resource is no longer supported.') } diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index a9c8fc6b419..d62ef97b64e 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -371,6 +371,9 @@ export const DOCS_MANIFEST: readonly string[] = [ 'logs-debugging.mdx', 'logs-debugging/alerts.mdx', 'logs-debugging/logging.mdx', + 'mcp.mdx', + 'mcp/authentication.mdx', + 'mcp/tools.mdx', 'platform/connected-accounts.mdx', 'platform/costs.mdx', 'platform/credentials.mdx', diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 6f7e4974c84..de96c813c5e 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -485,6 +485,7 @@ export const env = createEnv({ // Real-time Communication SOCKET_SERVER_URL: z.string().url().optional(), // WebSocket server URL for real-time features PORT: z.number().optional(), // Main application port + SIM_MCP_URL: z.string().url().optional(), // Public URL of the Sim MCP server when served on its own host (e.g., https://mcp.sim.ai/mcp); defaults to /api/mcp INTERNAL_API_BASE_URL: z.string().optional(), // Optional internal base URL for server-side self-calls; must include protocol if set (e.g., http://sim-app.namespace.svc.cluster.local:3000) ALLOWED_ORIGINS: z.string().optional(), // CORS allowed origins PII_URL: z.string().optional(), // Presidio PII service base URL serving /analyze + /anonymize (standalone ECS service; default http://localhost:5001 for local dev) diff --git a/apps/sim/lib/knowledge/mcp/oauth-metadata.ts b/apps/sim/lib/knowledge/mcp/oauth-metadata.ts index e22d12caaa3..3448cf479ac 100644 --- a/apps/sim/lib/knowledge/mcp/oauth-metadata.ts +++ b/apps/sim/lib/knowledge/mcp/oauth-metadata.ts @@ -1,41 +1,17 @@ -import { NextResponse } from 'next/server' +import { + protectedResourceMetadataResponse, + withOAuthResourceChallenge, +} from '@/lib/auth/oauth-protected-resource' import { OAUTH_SEARCH_SCOPES } from '@/lib/auth/oauth-provider' -import { getBaseUrl } from '@/lib/core/utils/urls' -/** Public protocol metadata describes the endpoint without looking up protected organization data. */ export function searchMcpResourceMetadata(resource: string) { - return NextResponse.json( - { - resource, - resource_name: 'Sim Search', - authorization_servers: [`${getBaseUrl()}/api/auth`], - scopes_supported: OAUTH_SEARCH_SCOPES, - bearer_methods_supported: ['header'], - }, - { - headers: { - 'Cache-Control': 'public, max-age=300', - 'Access-Control-Allow-Origin': '*', - }, - } - ) + return protectedResourceMetadataResponse({ + resource, + name: 'Sim Search', + scopes: OAUTH_SEARCH_SCOPES, + }) } -/** Requests refresh consent so clients that follow the challenge can stay connected after expiry. */ export function withSearchMcpAuthChallenge(response: T, resource: string): T { - if ( - response.status !== 401 && - response.headers.get('WWW-Authenticate')?.includes('insufficient_scope') !== true - ) { - return response - } - const url = new URL(resource) - const metadata = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}` - const error = response.status === 403 ? 'error="insufficient_scope", ' : '' - response.headers.set( - 'WWW-Authenticate', - `Bearer ${error}resource_metadata="${metadata}", scope="${OAUTH_SEARCH_SCOPES.join(' ')}"` - ) - response.headers.set('Cache-Control', 'private, no-store') - return response + return withOAuthResourceChallenge(response, { resource, scopes: OAUTH_SEARCH_SCOPES }) } diff --git a/apps/sim/lib/knowledge/mcp/route-handler.test.ts b/apps/sim/lib/knowledge/mcp/route-handler.test.ts index 1111efb376a..07335d2f799 100644 --- a/apps/sim/lib/knowledge/mcp/route-handler.test.ts +++ b/apps/sim/lib/knowledge/mcp/route-handler.test.ts @@ -153,7 +153,7 @@ describe('organization MCP request admission', () => { const response = await post() expect(response.status).toBe(403) expect(response.headers.get('WWW-Authenticate')).toContain('error="insufficient_scope"') - expect(response.headers.get('WWW-Authenticate')).toContain('scope="search:read offline_access"') + expect(response.headers.get('WWW-Authenticate')).toContain('scope="search:read"') expect(mocks.index).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/knowledge/mcp/route-handler.ts b/apps/sim/lib/knowledge/mcp/route-handler.ts index 2b3a94b033e..006b69ad43e 100644 --- a/apps/sim/lib/knowledge/mcp/route-handler.ts +++ b/apps/sim/lib/knowledge/mcp/route-handler.ts @@ -1,13 +1,12 @@ -import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' import type { NextRequest } from 'next/server' import { organizationKnowledgeMcpContract } from '@/lib/api/contracts/knowledge/mcp' import { parseRequest } from '@/lib/api/server' import { - authenticateV2ApiKey, - V2ApiKeyUnauthenticatedError, -} from '@/lib/api/server/routes/v2-api-key-auth' + mcpCredentialAuth, + mcpMethodNotAllowed, + serveStatelessMcp, +} from '@/lib/api/server/routes/mcp-server-route' import { admitV2Request, v2RateLimits } from '@/lib/api/server/routes/v2-json-route' -import { OAUTH_ACCESS_TOKEN_PREFIX } from '@/lib/auth/oauth-provider' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { knowledgeOperations } from '@/lib/knowledge/application/operations' @@ -18,25 +17,7 @@ import { getSearchMcpUrl } from '@/lib/knowledge/mcp/urls' import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' function mcpAuth(resource: string) { - return { - authenticate(request: NextRequest) { - const apiKey = request.headers.get('x-api-key') - const authorization = request.headers.get('authorization') - const bearer = authorization?.match(/^Bearer ([^\s]+)$/i)?.[1] - if ((authorization && !bearer) || (apiKey && bearer && apiKey !== bearer)) { - throw new V2ApiKeyUnauthenticatedError('Provide one valid API key') - } - /** MCP clients also send existing Sim API keys as bearer credentials. */ - const oauthBearer = bearer?.startsWith(OAUTH_ACCESS_TOKEN_PREFIX) ? bearer : null - return authenticateV2ApiKey( - { - apiKey: apiKey ?? (oauthBearer ? null : (bearer ?? null)), - bearer: oauthBearer, - }, - { resource, allowUnboundApiTokens: true } - ) - }, - } + return mcpCredentialAuth({ resource, allowUnboundApiTokens: true }) } export function createKnowledgeMcpHandlers() { @@ -72,18 +53,7 @@ export function createKnowledgeMcpHandlers() { ...parsed.data.params, searchIndexId: index.knowledgeBaseId, }) - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true, - }) - try { - await server.connect(transport) - const response = await transport.handleRequest(request, { parsedBody: parsed.data.body }) - response.headers.set('Cache-Control', 'private, no-store') - return response - } finally { - await server.close() - } + return await serveStatelessMcp(server, request, parsed.data.body) } catch (error) { const response = v2CaughtOrchestrationError(error) if (response) return withSearchMcpAuthChallenge(response, resource) @@ -104,10 +74,7 @@ export function createKnowledgeMcpHandlers() { v2RateLimits.publicApi ) if (!admission.success) return withSearchMcpAuthChallenge(admission.response, resource) - return new Response(null, { - status: 405, - headers: { Allow: 'POST', 'Cache-Control': 'private, no-store' }, - }) + return mcpMethodNotAllowed() } ) diff --git a/apps/sim/lib/knowledge/mcp/server.ts b/apps/sim/lib/knowledge/mcp/server.ts index 226fece5474..c2d1ab67303 100644 --- a/apps/sim/lib/knowledge/mcp/server.ts +++ b/apps/sim/lib/knowledge/mcp/server.ts @@ -25,6 +25,7 @@ import { type SearchMcpActivityInput, } from '@/lib/knowledge/mcp/activity' import { createKnowledgeDocumentCitation } from '@/lib/knowledge/search/citation' +import { toolError } from '@/lib/mcp/tool-result' import { v2CaughtOrchestrationError } from '@/app/api/v2/lib/response' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -45,10 +46,6 @@ interface KnowledgeMcpContext { searchIndexId: string | null } -function toolError(message: string): CallToolResult { - return { isError: true, content: [{ type: 'text', text: message }] } -} - function projectResult(value: unknown, registry: ResolvedSecretTraceRegistry): CallToolResult { if (!registry.isComplete()) { return toolError( diff --git a/apps/sim/lib/mcp/tool-result.ts b/apps/sim/lib/mcp/tool-result.ts new file mode 100644 index 00000000000..51d4cadad65 --- /dev/null +++ b/apps/sim/lib/mcp/tool-result.ts @@ -0,0 +1,11 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' + +/** A tool result the model reads as a failure it can act on. */ +export function toolError(message: string): CallToolResult { + return { isError: true, content: [{ type: 'text', text: message }] } +} + +/** A tool result carrying a JSON value as text. */ +export function jsonToolResult(value: unknown): CallToolResult { + return { content: [{ type: 'text', text: JSON.stringify(value) }] } +} diff --git a/apps/sim/proxy.test.ts b/apps/sim/proxy.test.ts index b1dc2145a2e..ae998cd880e 100644 --- a/apps/sim/proxy.test.ts +++ b/apps/sim/proxy.test.ts @@ -2,14 +2,17 @@ * @vitest-environment node */ import { createEnvMock } from '@sim/testing' -import type { NextRequest } from 'next/server' +import { NextRequest } from 'next/server' import { describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/config/env', () => - createEnvMock({ NEXT_PUBLIC_APP_URL: 'https://app.sim.test' }) + createEnvMock({ + NEXT_PUBLIC_APP_URL: 'https://app.sim.test', + SIM_MCP_URL: 'https://mcp.sim.test/mcp', + }) ) -import { resolveApiCorsPolicy } from '@/proxy' +import { proxy, resolveApiCorsPolicy } from '@/proxy' const EXPOSED_HEADERS = 'Retry-After, WWW-Authenticate, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Request-Id, X-Run-Id' @@ -215,3 +218,37 @@ describe('resolveApiCorsPolicy', () => { } }) }) + +describe('proxy on the dedicated MCP host', () => { + function mcpRequest(pathname: string, method = 'POST') { + return new NextRequest(`https://mcp.sim.test${pathname}`, { + method, + headers: { host: 'mcp.sim.test', origin: 'https://app.sim.test' }, + }) + } + + it('answers the endpoint preflight with the API CORS policy', () => { + const response = proxy(mcpRequest('/mcp', 'OPTIONS')) + expect(response.status).toBe(204) + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://app.sim.test') + expect(response.headers.get('Access-Control-Allow-Headers')).toContain('Authorization') + }) + + it('rewrites the endpoint to the MCP route with the same CORS headers', () => { + const response = proxy(mcpRequest('/mcp')) + expect(response.headers.get('x-middleware-rewrite')).toBe('https://mcp.sim.test/api/mcp') + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://app.sim.test') + }) + + it('leaves the metadata rewrite to its own wildcard CORS', () => { + const response = proxy(mcpRequest('/.well-known/oauth-protected-resource/mcp', 'GET')) + expect(response.headers.get('x-middleware-rewrite')).toBe( + 'https://mcp.sim.test/.well-known/oauth-protected-resource/api/mcp' + ) + expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull() + }) + + it('serves nothing else on the MCP host', () => { + expect(proxy(mcpRequest('/login', 'GET')).status).toBe(404) + }) +}) diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 24d09a30419..9771768c549 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { getSessionCookie } from 'better-auth/cookies' import { type NextRequest, NextResponse } from 'next/server' +import { resolveSimMcpHostPath } from '@/lib/api/mcp/host-routing' +import { SIM_MCP_ROUTE_PATH } from '@/lib/api/mcp/urls' import { APP_ENTRY_PATH, isAppSurfacePath } from '@/lib/navigation/paths' import { isOAuthAuthorizationCallback, resolveAuthRedirect } from '@/app/(auth)/auth-redirect' import { getEnv } from './lib/core/config/env' @@ -336,6 +338,18 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null { export function proxy(request: NextRequest) { const url = request.nextUrl + const mcpPath = resolveSimMcpHostPath(request.headers.get('host'), url.pathname) + if (mcpPath === 'not_found') return new NextResponse(null, { status: 404 }) + if (mcpPath && mcpPath !== url.pathname) { + const rewrite = NextResponse.rewrite(new URL(`${mcpPath}${url.search}`, request.url)) + if (mcpPath !== SIM_MCP_ROUTE_PATH) return rewrite + /** The endpoint keeps the `/api` CORS policy it has on the app host; its metadata sets its own. */ + const policy = resolveApiCorsPolicy(request) + if (request.method === 'OPTIONS') return buildPreflightResponse(policy) + applyCorsHeaders(rewrite, policy) + return rewrite + } + if (url.pathname.startsWith('/api/')) { const policy = resolveApiCorsPolicy(request) if (request.method === 'OPTIONS') { diff --git a/package.json b/package.json index ca71919251a..3001fbfe967 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "check:openapi": "bun run scripts/check-openapi.ts", "generate:cli-api": "bun run scripts/generate-v2-cli-api.ts", "check:cli-api": "bun run scripts/generate-v2-cli-api.ts --check", + "generate:mcp-operations": "bun run scripts/generate-v2-mcp-operations.ts", + "check:mcp-operations": "bun run scripts/generate-v2-mcp-operations.ts --check", "generate:cli-docs": "bun run scripts/generate-cli-docs.ts", "check:cli-docs": "bun run scripts/generate-cli-docs.ts --check", "check:canonical-index": "bun run scripts/check-canonical-index-surface.ts", diff --git a/packages/utils/src/client-info.ts b/packages/utils/src/client-info.ts index cc990d9e5cc..e623d49d6ae 100644 --- a/packages/utils/src/client-info.ts +++ b/packages/utils/src/client-info.ts @@ -27,8 +27,11 @@ export const CLIENT_INFO_HEADER = 'x-sim-client-info' -/** The official Sim clients, as they name themselves on the wire. */ -export const SIM_SURFACES = ['web', 'desktop', 'cli', 'sdk-js', 'sdk-python'] as const +/** + * The official Sim clients, as they name themselves on the wire. `mcp` is the + * Sim MCP server, which declares itself on each v2 request it dispatches. + */ +export const SIM_SURFACES = ['web', 'desktop', 'cli', 'sdk-js', 'sdk-python', 'mcp'] as const export type SimSurface = (typeof SIM_SURFACES)[number] diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index c81e28a034d..08630a0fc1c 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -45,6 +45,7 @@ const INDIRECT_ZOD_ROUTES = new Set([ /** Shared MCP protocol factory validates the owner and JSON-RPC envelope before SDK dispatch. */ 'apps/sim/app/api/mcp/search/[workspaceId]/route.ts', 'apps/sim/app/api/mcp/search/organizations/[organizationId]/route.ts', + 'apps/sim/app/api/mcp/route.ts', // SCIM discovery documents (RFC 7644 section 4). Each serves a fixed document // describing what this server implements and accepts no params, query, or body, // so there is no input to validate and no contract to bind. They are deliberately diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index a1b2378e4e9..0a34d2409ad 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -66,6 +66,8 @@ function specFiles(): string[] { export interface OperationDoc { /** The spec's one-line summary, used as the command's `--help` description. */ summary?: string + /** The spec's longer prose, which the MCP server returns when describing the operation. */ + description?: string /** * The operation refuses a workspace API key, per its `description`. * @@ -97,6 +99,11 @@ export async function loadWorkspaceKeyDenialMarkers(): Promise description.includes(marker)) @@ -169,7 +179,7 @@ function contractModules(): string[] { .sort() } -interface RouteContract { +export interface RouteContract { method: string path: string params?: z.ZodType @@ -179,9 +189,11 @@ interface RouteContract { response: { mode: string; schema?: z.ZodType } } -interface Operation { +export interface Operation { /** `listTables` — derived from the export name. */ name: string + /** `v2ListTablesContract` — the contract module's export. */ + exportName: string domain: string contract: RouteContract } @@ -206,14 +218,15 @@ function pascal(name: string): string { return name.charAt(0).toUpperCase() + name.slice(1) } -async function collectOperations(): Promise { +/** Every v2 route contract, sorted by operation name. */ +export async function collectOperations(): Promise { const operations: Operation[] = [] for (const domain of contractModules()) { const mod: Record = await import(path.join(CONTRACTS_DIR, `${domain}.ts`)) for (const [exportName, value] of Object.entries(mod)) { if (!exportName.endsWith('Contract') || !isRouteContract(value)) continue - operations.push({ name: operationName(exportName), domain, contract: value }) + operations.push({ name: operationName(exportName), exportName, domain, contract: value }) } } @@ -593,9 +606,7 @@ function render(operations: Operation[], docs: Map): strin } out.push(` responseMode: '${op.contract.response.mode}',`) // OpenAPI writes `{id}` where the contract writes `[id]`. - const doc = docs.get( - `${op.contract.method} ${op.contract.path.replace(/\[([^\]]+)\]/g, '{$1}')}` - ) + const doc = docs.get(docPathKey(op.contract.method, op.contract.path)) if (doc?.summary) out.push(` summary: ${JSON.stringify(doc.summary)},`) if (doc?.workspaceKeyUnsupported) out.push(` workspaceKeyUnsupported: true,`) for (const slot of ['query', 'body'] as const) { diff --git a/scripts/generate-v2-mcp-operations.test.ts b/scripts/generate-v2-mcp-operations.test.ts new file mode 100644 index 00000000000..3a6e9f9cc36 --- /dev/null +++ b/scripts/generate-v2-mcp-operations.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import type { Operation } from './generate-v2-cli-api' +import { + classifyOperation, + render, + routeBuilder, + routeModulePath, +} from './generate-v2-mcp-operations' + +function operation(method: string, path: string, mode = 'json'): Operation { + return { + name: 'op', + exportName: 'v2OpContract', + domain: 'tables', + contract: { method, path, response: { mode } }, + } +} + +const routeSource = (method: string, builder: string) => + `export const dynamic = 'force-dynamic'\nexport const ${method} = ${builder}({\n contract,\n})\n` + +describe('the Sim MCP operation table', () => { + it('finds a route module by its contract path', () => { + expect(routeModulePath('/api/v2/tables/[tableId]/rows')).toBe( + 'app/api/v2/tables/[tableId]/rows/route.ts' + ) + }) + + it('reads the builder behind one method, not its neighbours', () => { + const source = `${routeSource('GET', 'defineV2BinaryRoute')}${routeSource('PATCH', 'defineV2JsonRoute')}` + expect(routeBuilder(source, 'GET')).toBe('defineV2BinaryRoute') + expect(routeBuilder(source, 'PATCH')).toBe('defineV2JsonRoute') + expect(routeBuilder(source, 'DELETE')).toBeNull() + }) + + it.each([ + ['defineV2JsonRoute', 'json'], + ['defineV2BinaryRoute', 'excluded'], + ['defineV2BodyLifecycleRoute', 'excluded'], + ])('classifies %s routes as %s', (builder, expected) => { + expect( + classifyOperation(operation('POST', '/api/v2/tables'), () => routeSource('POST', builder)) + ).toBe(expected) + }) + + it('accepts a raw route only once it has been reviewed', () => { + const raw = () => routeSource('POST', 'withRouteHandler') + expect(classifyOperation({ ...operation('POST', '/api/v2/chat'), name: 'chat' }, raw)).toBe( + 'json' + ) + expect(() => classifyOperation(operation('POST', '/api/v2/tables'), raw)).toThrow('classify it') + }) + + it('excludes binary responses without reading the route', () => { + expect( + classifyOperation(operation('GET', '/api/v2/files/[fileId]', 'binary'), () => { + throw new Error('should not read') + }) + ).toBe('excluded') + }) + + it('refuses to guess about a missing module or an unknown builder', () => { + expect(() => classifyOperation(operation('GET', '/api/v2/tables'), () => null)).toThrow( + 'no route module' + ) + expect(() => + classifyOperation(operation('GET', '/api/v2/tables'), () => + routeSource('GET', 'defineV3Route') + ) + ).toThrow('classify it') + }) + + it('pairs each contract with a lazily imported handler', () => { + const source = render([ + { + name: 'listTables', + exportName: 'v2ListTablesContract', + domain: 'tables', + method: 'GET', + modulePath: 'app/api/v2/tables/route.ts', + doc: { summary: 'List Tables' }, + }, + ]) + expect(source).toContain("import { v2ListTablesContract } from '@/lib/api/contracts/v2/tables'") + expect(source).toContain( + "handler: () => import('@/app/api/v2/tables/route').then((route) => route.GET)" + ) + expect(source).toContain('summary: "List Tables"') + }) +}) diff --git a/scripts/generate-v2-mcp-operations.ts b/scripts/generate-v2-mcp-operations.ts new file mode 100644 index 00000000000..f921ebd65e6 --- /dev/null +++ b/scripts/generate-v2-mcp-operations.ts @@ -0,0 +1,208 @@ +#!/usr/bin/env bun +/** + * Generates the Sim MCP server's operation table: every public v2 operation an + * MCP tool call can reach, paired with the route handler that serves it. + * + * The MCP server is a second transport for the v2 API, not a second API. A tool + * call is dispatched to the same route handler an HTTP request would reach, so + * authentication, OAuth scopes, rate limits, validation, the application use + * case, and the error envelope are all the route's own. This table is the one + * thing that cannot be derived at runtime: Next.js loads route modules by file + * path, so something has to name each module statically for the bundler. + * + * Operations come from {@link collectOperations} — the same contract discovery + * the CLI generator uses — so the terminal and MCP expose one operation set + * under one set of names. An operation is left out only when its transport + * cannot be expressed as a JSON tool call: a binary response, or a body that + * must be streamed as multipart. A route built by anything this script does not + * recognize fails generation rather than being guessed at. + * + * Usage: + * bun run scripts/generate-v2-mcp-operations.ts # write the generated file + * bun run scripts/generate-v2-mcp-operations.ts --check # fail if it is stale + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { + collectOperations, + docPathKey, + loadSummaries, + loadWorkspaceKeyDenialMarkers, + type Operation, + type OperationDoc, +} from './generate-v2-cli-api' +import { localBin } from './local-bin' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const APP_ROOT = path.join(ROOT, 'apps/sim') +const OUTPUT = path.join(APP_ROOT, 'lib/api/mcp/generated/v2-operations.ts') + +/** Route builders whose handlers answer a JSON request with a JSON response. */ +const JSON_BUILDERS = new Set(['defineV2JsonRoute']) + +/** + * Raw `withRouteHandler` routes reviewed to answer a JSON request with JSON: + * each streams only when the caller asks for it, which the MCP dispatcher + * refuses. A raw route is a protocol exception by definition, so any other one + * fails generation until it is reviewed and listed here. + */ +const REVIEWED_RAW_JSON_ROUTES = new Set(['chat', 'executeWorkflow', 'resumeWorkflow']) + +/** Route builders whose transport a JSON tool call cannot carry. */ +const NON_JSON_BUILDERS = new Set(['defineV2BinaryRoute', 'defineV2BodyLifecycleRoute']) + +/** `/api/v2/tables/[tableId]/rows` → `app/api/v2/tables/[tableId]/rows/route.ts`. */ +export function routeModulePath(contractPath: string): string { + return `app${contractPath}/route.ts` +} + +/** The builder a route module's `export const METHOD = builder(` uses, or `null` if none. */ +export function routeBuilder(source: string, method: string): string | null { + return source.match(new RegExp(`export const ${method} = (\\w+)\\(`))?.[1] ?? null +} + +export interface McpOperation { + name: string + exportName: string + domain: string + method: string + modulePath: string + doc?: OperationDoc +} + +/** + * Whether an operation is reachable over MCP, reading its route module to learn + * which builder serves it. Throws on a missing module or an unknown builder so a + * new transport has to be classified here before it can ship. + */ +export function classifyOperation( + operation: Operation, + readRoute: (relativePath: string) => string | null +): 'json' | 'excluded' { + if (operation.contract.response.mode !== 'json') return 'excluded' + const modulePath = routeModulePath(operation.contract.path) + const source = readRoute(modulePath) + if (source === null) { + throw new Error(`${operation.name}: no route module at apps/sim/${modulePath}`) + } + const builder = routeBuilder(source, operation.contract.method) + if (builder && JSON_BUILDERS.has(builder)) return 'json' + if (builder === 'withRouteHandler' && REVIEWED_RAW_JSON_ROUTES.has(operation.name)) return 'json' + if (builder && NON_JSON_BUILDERS.has(builder)) return 'excluded' + throw new Error( + `${operation.name}: apps/sim/${modulePath} exports ${operation.contract.method} through ${ + builder ?? 'an unrecognized form' + }; classify it in scripts/generate-v2-mcp-operations.ts` + ) +} + +export function render(operations: readonly McpOperation[]): string { + const importsByDomain = new Map() + for (const op of operations) { + const names = importsByDomain.get(op.domain) ?? [] + names.push(op.exportName) + importsByDomain.set(op.domain, names) + } + + const out: string[] = [ + '/**', + ' * GENERATED FILE — DO NOT EDIT.', + ' *', + ' * Emitted from the Zod route contracts in `apps/sim/lib/api/contracts/v2/**`', + ' * by `scripts/generate-v2-mcp-operations.ts`. Regenerate with', + ' * `bun run generate:mcp-operations`; CI fails when this file is stale.', + ' */', + '', + ] + for (const domain of [...importsByDomain.keys()].sort()) { + const names = [...(importsByDomain.get(domain) ?? [])].sort() + out.push(`import { ${names.join(', ')} } from '@/lib/api/contracts/v2/${domain}'`) + } + out.push("import type { V2McpOperation } from '@/lib/api/mcp/types'") + out.push('') + out.push('export const V2_MCP_OPERATIONS = {') + for (const op of operations) { + const specifier = `@/${op.modulePath.replace(/\.ts$/, '')}` + out.push(` ${op.name}: {`) + out.push(` contract: ${op.exportName},`) + if (op.doc?.summary) out.push(` summary: ${JSON.stringify(op.doc.summary)},`) + if (op.doc?.description) out.push(` description: ${JSON.stringify(op.doc.description)},`) + if (op.doc?.workspaceKeyUnsupported) out.push(' workspaceKeyUnsupported: true,') + out.push(` handler: () => import('${specifier}').then((route) => route.${op.method}),`) + out.push(' },') + } + out.push('} as const satisfies Record') + out.push('') + out.push('export type V2McpOperationName = keyof typeof V2_MCP_OPERATIONS') + out.push('') + return out.join('\n') +} + +/** + * Runs the emitted source through `biome check --write`, not only the + * formatter: the import list is sorted too, and lint-staged applies exactly + * that to a committed file, so anything less leaves a file the hook rewrites + * and `--check` then reports as stale. + */ +function format(source: string): string { + const result = spawnSync(localBin('biome'), ['check', '--write', `--stdin-file-path=${OUTPUT}`], { + cwd: ROOT, + encoding: 'utf8', + input: source, + }) + if (result.status !== 0 || !result.stdout) { + throw new Error(`biome failed on the generated operation table: ${result.stderr ?? ''}`) + } + return result.stdout +} + +async function main() { + const check = process.argv.includes('--check') + const docs = loadSummaries(await loadWorkspaceKeyDenialMarkers()) + const readRoute = (relativePath: string) => { + const file = path.join(APP_ROOT, relativePath) + return existsSync(file) ? readFileSync(file, 'utf8') : null + } + + const operations: McpOperation[] = [] + let excluded = 0 + for (const operation of await collectOperations()) { + if (classifyOperation(operation, readRoute) === 'excluded') { + excluded++ + continue + } + operations.push({ + name: operation.name, + exportName: operation.exportName, + domain: operation.domain, + method: operation.contract.method, + modulePath: routeModulePath(operation.contract.path), + doc: docs.get(docPathKey(operation.contract.method, operation.contract.path)), + }) + } + + const generated = format(render(operations)) + const relative = path.relative(ROOT, OUTPUT) + + if (check) { + const current = existsSync(OUTPUT) ? readFileSync(OUTPUT, 'utf8') : null + if (current !== generated) { + console.error( + `${relative} is ${current === null ? 'missing' : 'stale'}. Run: bun run generate:mcp-operations` + ) + process.exit(1) + } + console.log(`${relative} is up to date (${operations.length} operations).`) + return + } + + writeFileSync(OUTPUT, generated) + console.log( + `Wrote ${relative} — ${operations.length} operations (${excluded} excluded: binary or multipart transport).` + ) +} + +if (import.meta.main) main() From 0b7c6aea6b54251a615e2081ba346ba486c4eb8f Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:27:39 -0700 Subject: [PATCH 13/20] refactor(emcn): centralize product icon-button padding (#7987) Co-authored-by: Bill Leoutsakos --- .../components/trace-view/trace-view.tsx | 12 ++++---- .../components/log-details/log-details.tsx | 14 ++++----- .../workflow-mcp-servers.tsx | 3 +- .../column-config-sidebar.tsx | 3 +- .../enrichment-details/enrichment-details.tsx | 2 +- .../enrichments-sidebar/enrichment-config.tsx | 6 ++-- .../enrichments-sidebar.tsx | 6 ++-- .../select-field/select-options-editor.tsx | 3 +- .../components/table-filter/table-filter.tsx | 3 +- .../workflow-sidebar/workflow-sidebar.tsx | 6 ++-- .../w/[workflowId]/components/chat/chat.tsx | 6 ++-- .../deploy-modal/components/api/api.tsx | 9 ++++-- .../general/components/versions.tsx | 4 +-- .../components/output-panel/output-panel.tsx | 30 ++++++++++++------- .../toggle-button/toggle-button.tsx | 3 +- .../components/terminal/terminal.tsx | 15 ++++++---- .../[workflowId]/components/terminal/types.ts | 1 - .../components/variables/variables.tsx | 6 ++-- .../preview-editor/preview-editor.tsx | 12 ++++---- .../ui/generated-password-input.tsx | 6 ++-- .../emcn/src/components/button/button.tsx | 22 ++++++++++++-- 21 files changed, 111 insertions(+), 61 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx index 6d00dd552ce..c75ef9c2457 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx @@ -567,7 +567,7 @@ function DetailCodeSection({ -
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index 38e12407418..8e60c534209 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -586,7 +586,8 @@ function ServerDetailView({ variant='ghost' aria-label={copiedConfig ? 'Configuration copied' : 'Copy configuration'} onClick={() => handleCopyConfig(server.isPublic, server.name)} - className='-my-1.5 p-1.5!' + iconPadding='md' + className='-my-1.5' > {copiedConfig ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx index c2d80f94d36..abf3f9391a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx @@ -269,7 +269,8 @@ function ColumnConfigBody({ variant='ghost' size='sm' onClick={onClose} - className='size-7 p-1!' + iconPadding='sm' + className='size-7' aria-label='Close' > diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx index 9c6a247c30a..e2227fb5be1 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx @@ -364,7 +364,7 @@ export function EnrichmentDetails({

Enrichment Details

-
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx index 9b7d34e3e11..f411281d21e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx @@ -238,7 +238,8 @@ export function EnrichmentConfig({ variant='ghost' size='sm' onClick={onBack} - className='size-7 flex-none p-1!' + iconPadding='sm' + className='size-7 flex-none' aria-label='Back to enrichments' > @@ -249,7 +250,8 @@ export function EnrichmentConfig({ variant='ghost' size='sm' onClick={onClose} - className='size-7 flex-none p-1!' + iconPadding='sm' + className='size-7 flex-none' aria-label='Close' > diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx index 64cd19f1134..0aebd34cb4c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx @@ -77,7 +77,8 @@ function EnrichmentsSidebarBody({ variant='ghost' size='sm' onClick={onClose} - className='size-7 flex-none p-1!' + iconPadding='sm' + className='size-7 flex-none' aria-label='Close' > @@ -123,7 +124,8 @@ function EnrichmentsSidebarBody({ variant='ghost' size='sm' onClick={onClose} - className='size-7 flex-none p-1!' + iconPadding='sm' + className='size-7 flex-none' aria-label='Close' > diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx index 31b308b7309..f15e2c82ef6 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx @@ -78,7 +78,8 @@ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorPr variant='ghost' size='sm' onClick={() => remove(option.id)} - className='size-7 shrink-0 p-1!' + iconPadding='sm' + className='size-7 shrink-0' aria-label={`Remove ${option.name || 'option'}`} > diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx index 166ced1e915..8fe52025569 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx @@ -382,7 +382,8 @@ const FilterRuleRow = memo(function FilterRuleRow({ variant='ghost' size='sm' onClick={() => onRemove(rule.id)} - className='size-7 shrink-0 p-1!' + iconPadding='sm' + className='size-7 shrink-0' aria-label='Remove filter' > diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx index dc601cc599e..18b04cfd5b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx @@ -637,7 +637,8 @@ export function WorkflowSidebarBody({ variant='ghost' size='sm' onClick={onBack} - className='size-7 flex-none p-1!' + iconPadding='sm' + className='size-7 flex-none' aria-label='Back to enrichments' > @@ -651,7 +652,8 @@ export function WorkflowSidebarBody({ variant='ghost' size='sm' onClick={onClose} - className='size-7 flex-none p-1!' + iconPadding='sm' + className='size-7 flex-none' aria-label='Close' > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 804e7b570f5..d6a91f24477 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -954,7 +954,8 @@ export function Chat() { @@ -508,7 +509,8 @@ console.log(limits);` variant='ghost' onClick={() => handleCopy('stream', getStreamCommand())} aria-label='Copy command' - className='-my-1.5 p-1.5!' + iconPadding='md' + className='-my-1.5' > {copied.stream ? : } @@ -548,7 +550,8 @@ console.log(limits);` variant='ghost' onClick={() => handleCopy('async', getAsyncCommand())} aria-label='Copy command' - className='-my-1.5 p-1.5!' + iconPadding='md' + className='-my-1.5' > {copied.async ? : } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx index 262b23e208b..2245b6a3647 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx @@ -330,8 +330,8 @@ export function Versions({ @@ -357,7 +358,8 @@ export const OutputPanel = React.memo(function OutputPanel({ variant='ghost' onClick={handleSearchClick} aria-label='Search in output' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > @@ -375,7 +377,8 @@ export const OutputPanel = React.memo(function OutputPanel({ @@ -393,7 +396,8 @@ export const OutputPanel = React.memo(function OutputPanel({ variant='ghost' onClick={handleCopyClick} aria-label='Copy output' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > {showCopySuccess ? ( @@ -414,7 +418,8 @@ export const OutputPanel = React.memo(function OutputPanel({ variant='ghost' onClick={handleExportConsole} aria-label='Export console CSV' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > @@ -429,7 +434,8 @@ export const OutputPanel = React.memo(function OutputPanel({ variant='ghost' onClick={handleClearConsole} aria-label='Clear console' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > @@ -446,7 +452,8 @@ export const OutputPanel = React.memo(function OutputPanel({ variant='ghost' onClick={(e) => e.stopPropagation()} aria-label='Terminal options' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > @@ -511,7 +518,8 @@ export const OutputPanel = React.memo(function OutputPanel({ variant='ghost' onClick={goToPreviousMatch} aria-label='Previous match' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' disabled={matchCount === 0} > @@ -520,7 +528,8 @@ export const OutputPanel = React.memo(function OutputPanel({ variant='ghost' onClick={goToNextMatch} aria-label='Next match' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' disabled={matchCount === 0} > @@ -529,7 +538,8 @@ export const OutputPanel = React.memo(function OutputPanel({ variant='ghost' onClick={closeOutputSearch} aria-label='Close search' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx index 84265672062..e23a97cb40b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx @@ -17,7 +17,8 @@ export const ToggleButton = memo(function ToggleButton({ isExpanded, onClick }: return ( @@ -1290,7 +1292,8 @@ export const Terminal = memo(function Terminal() { variant='ghost' onClick={handleExportConsole} aria-label='Export console CSV' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > @@ -1305,7 +1308,8 @@ export const Terminal = memo(function Terminal() { variant='ghost' onClick={handleClearConsole} aria-label='Clear console' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > @@ -1325,7 +1329,8 @@ export const Terminal = memo(function Terminal() { e.stopPropagation() }} aria-label='Terminal options' - className='-m-1.5 p-1.5!' + iconPadding='md' + className='-m-1.5' > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts index 6285e7ac407..2bafb623824 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts @@ -51,7 +51,6 @@ export const ROW_STYLES = { status: 'shrink-0 text-sm', statusIdle: 'text-[var(--text-muted)]', nested: 'mt-0.5 ml-[3px] flex min-w-0 flex-col gap-0.5 border-[var(--border)] border-l pl-[9px]', - iconButton: 'p-1.5! -m-1.5', } as const /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx index 7505fcf6720..0baf5e457a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx @@ -458,7 +458,8 @@ export function Variables({ readOnly = false }: VariablesProps) {
-
diff --git a/apps/sim/components/ui/generated-password-input.tsx b/apps/sim/components/ui/generated-password-input.tsx index 451c9d4a6fe..1508614d35a 100644 --- a/apps/sim/components/ui/generated-password-input.tsx +++ b/apps/sim/components/ui/generated-password-input.tsx @@ -106,7 +106,7 @@ export function GeneratedPasswordInput({ onClick={handleGeneratePassword} disabled={disabled} aria-label='Generate password' - className='p-1.5!' + iconPadding='md' > @@ -124,7 +124,7 @@ export function GeneratedPasswordInput({ onClick={() => copy(displayValue)} disabled={!displayValue || disabled} aria-label='Copy password' - className='p-1.5!' + iconPadding='md' > {copied ? : } @@ -141,7 +141,7 @@ export function GeneratedPasswordInput({ onClick={toggleShowPassword} disabled={disabled || isFetchingCurrent} aria-label={showPassword ? 'Hide password' : 'Show password'} - className='p-1.5!' + iconPadding='md' > {isFetchingCurrent ? ( diff --git a/packages/emcn/src/components/button/button.tsx b/packages/emcn/src/components/button/button.tsx index 523aa73ef58..4ad21bc4a37 100644 --- a/packages/emcn/src/components/button/button.tsx +++ b/packages/emcn/src/components/button/button.tsx @@ -50,6 +50,10 @@ const buttonVariants = cva( md: 'px-2 py-1.5 text-[length:12px]', icon: 'size-[20px] rounded-sm p-0 [&_svg]:[stroke-width:1.25]', }, + iconPadding: { + sm: 'p-1', + md: 'p-1.5', + }, }, compoundVariants: [ /** @@ -70,12 +74,24 @@ const buttonVariants = cva( export interface ButtonProps extends ButtonHTMLAttributes, - VariantProps {} + VariantProps { + /** + * Symmetric padding for icon actions whose content or layout determines their size. + * Preserves the selected size's typography, corner radius and icon stroke. + * Omit for the standard size padding, including the fixed `size='icon'` treatment. + * @example + */ + iconPadding?: VariantProps['iconPadding'] +} const Button = forwardRef( - ({ className, variant, size, ...props }, ref) => { + ({ className, variant, size, iconPadding, ...props }, ref) => { return ( - + ), + ChipCombobox: ({ + options, + value, + placeholder, + }: { + options: Array<{ value: string; label: string; disabled?: boolean }> + value?: string + placeholder?: string + }) => ( +
+ {options.map((option) => ( + + {option.label} + + ))} +
+ ), + ChipDropdown: ({ + options, + value, + placeholder, + }: { + options: Array<{ value: string; label: string }> + value?: string + placeholder?: string + }) => ( +
+ {options.map((option) => ( + {option.label} + ))} +
+ ), + Label: ({ children }: { children?: React.ReactNode }) => {children}, + Tooltip: { + Root: ({ children }: { children?: React.ReactNode }) => <>{children}, + Trigger: ({ children }: { children?: React.ReactNode }) => <>{children}, + Content: () => null, + }, +})) + +vi.mock('@sim/emcn/icons', () => ({ + ChevronDown: () => null, + ChevronUp: () => null, + Plus: () => null, + Trash: () => null, +})) + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value', + () => ({ + useSubBlockValue: (_blockId: string, subBlockId: string) => [ + subBlockId === 'model' || subBlockId === 'fallbackModels' ? subBlockValues[subBlockId] : null, + mockSetValue, + ], + }) +) + +vi.mock('@/hooks/queries/environment', () => ({ + usePersonalEnvironment: () => ({ data: { PERSONAL_KEY: 'x' } }), + useWorkspaceEnvironment: () => ({ + data: { workspace: { OPENROUTER_API_KEY: 'x' }, personal: {}, conflicts: [] }, + }), +})) + +vi.mock('@/hooks/use-permission-config', () => ({ + usePermissionConfig: () => ({ isModelUsable: (model: string) => model !== 'denied-model' }), +})) + +vi.mock('@/hooks/use-settings-navigation', () => ({ + useSettingsNavigation: () => ({ navigateToSettings: vi.fn() }), +})) + +vi.mock('@/lib/credentials/client-state', () => ({ + writePendingCredentialCreateRequest: vi.fn(), +})) + +vi.mock('@/stores/providers/store', () => ({ + useProvidersStore: (selector: (state: { providers: object }) => unknown) => + selector({ providers: {} }), +})) + +vi.mock('@/blocks/utils', () => ({ + shouldRequireApiKeyForModel: (model: string) => + model.startsWith('openrouter/') || (model.startsWith('gpt') && !getDeploymentShape().hosted), + getModelOptions: () => [ + { id: 'claude-sonnet-5', label: 'claude-sonnet-5' }, + { id: 'gpt-5', label: 'gpt-5' }, + { id: 'denied-model', label: 'denied-model' }, + { id: 'openrouter/x', label: 'openrouter/x' }, + { id: 'sim-auto', label: 'Auto' }, + ], +})) + +vi.mock('@/lib/workflows/blocks/fallback-models', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isViableFallbackModel: (model: string, primary: string) => + model !== 'sim-auto' && model !== primary, + getFallbackTuningKnobsToShow: (model: string) => (model === 'gpt-5' ? ['reasoningEffort'] : []), + getTuningOptionsForModel: (model: string, knob: string) => + model === 'gpt-5' && knob === 'reasoningEffort' ? ['auto', 'low', 'high'] : null, + } +}) + +import { ModelFallbackList } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/model-fallback-list/model-fallback-list' + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + seedDeploymentShape({ ...resolveDeploymentShape(), hosted: true }) +}) + +afterEach(() => { + resetDeploymentShape() + vi.unstubAllGlobals() +}) + +function render(extra: Partial> = {}) { + return renderToStaticMarkup( + + ) +} + +describe('ModelFallbackList', () => { + beforeEach(() => { + subBlockValues.model = 'claude-sonnet-5' + subBlockValues.fallbackModels = [] + mockSetValue.mockReset() + }) + + it('renders only the add affordance when nothing is configured', () => { + const html = render() + expect(html).toContain('Add fallback model') + expect(html).not.toContain('choice') + }) + + it('labels rows as ordinal choices and offers viable, permitted models', () => { + subBlockValues.fallbackModels = [ + { id: 'r1', model: 'gpt-5' }, + { id: 'r2', model: '' }, + ] + const html = render() + expect(html).toContain('2nd choice') + expect(html).toContain('3rd choice') + expect(html).not.toContain('Auto') + expect(html).not.toContain('denied-model') + /** The primary is never offered. A model another row holds is disabled there, never in its own row. */ + expect(html).not.toContain('>claude-sonnet-5<') + expect(html.match(/data-disabled="true">gpt-5gpt-5 { + subBlockValues.fallbackModels = [ + { id: 'r1', model: 'openrouter/x', apiKey: 'sk-raw-through-socket' }, + ] + const html = render() + expect(html).not.toContain('aria-label="Move up"') + expect(html).not.toContain('sk-raw-through-socket') + expect(html).toContain('data-combobox="Select a secret" data-value=""') + }) + + it('asks for an environment variable only when the row model needs its own key', () => { + subBlockValues.fallbackModels = [{ id: 'r1', model: 'gpt-5' }] + expect(render()).not.toContain('data-combobox="Select a secret"') + + subBlockValues.fallbackModels = [ + { id: 'r1', model: 'openrouter/x', apiKey: '{{OPENROUTER_API_KEY}}' }, + ] + const html = render() + expect(html).toContain('data-combobox="Select a secret"') + expect(html).toContain('data-value="{{OPENROUTER_API_KEY}}"') + expect(html).toContain('OPENROUTER_API_KEY') + expect(html).toContain('Create Secret') + }) + + it('updates key visibility when hosted context arrives after mount, without rewriting the rows', async () => { + seedDeploymentShape({ ...resolveDeploymentShape(), hosted: false }) + subBlockValues.fallbackModels = [{ id: 'r1', model: 'gpt-5' }] + const container = document.createElement('div') + const root = createRoot(container) + try { + await act(async () => { + root.render() + }) + expect(container.querySelector('[data-combobox="Select a secret"]')).not.toBeNull() + + await act(async () => { + seedDeploymentShape({ ...resolveDeploymentShape(), hosted: true }) + }) + expect(container.querySelector('[data-combobox="Select a secret"]')).toBeNull() + expect(mockSetValue).not.toHaveBeenCalled() + } finally { + await act(async () => root.unmount()) + } + }) + + it('shows a tuning field only for the knobs the helper says need one', () => { + subBlockValues.fallbackModels = [ + { id: 'r1', model: 'gpt-5', reasoningEffort: 'low' }, + { id: 'r2', model: 'openrouter/x' }, + ] + const html = render() + expect(html).toContain('data-combobox="Select reasoning effort" data-value="low"') + expect(html.match(/Select reasoning effort/g)).toHaveLength(1) + expect(html).not.toContain('Thinking level') + }) + + it('gates a preview against the previewed primary, not the live block', () => { + /** The live block selects claude-sonnet-5; the previewed version selected gpt-5. */ + const html = render({ + isPreview: true, + previewValue: [{ id: 'r1', model: 'openrouter/x' }], + previewPrimary: { model: 'gpt-5' }, + }) + expect(html).not.toContain('>gpt-5<') + expect(html).toContain('>claude-sonnet-5<') + expect(html).not.toContain('Add fallback model') + }) + + it('disables the add affordance at the cap', () => { + subBlockValues.fallbackModels = Array.from({ length: 5 }, (_, i) => ({ + id: `r${i}`, + model: `m-${i}`, + })) + const html = render() + expect(html).toMatch(/]*disabled=""[^>]*>Add fallback model/) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/model-fallback-list/model-fallback-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/model-fallback-list/model-fallback-list.tsx new file mode 100644 index 00000000000..15b7bcefdd1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/model-fallback-list/model-fallback-list.tsx @@ -0,0 +1,424 @@ +'use client' + +import { memo, useCallback, useEffect, useMemo, useRef } from 'react' +import { Chip, ChipCombobox, ChipDropdown, type ComboboxOption, Label, Tooltip } from '@sim/emcn' +import { ChevronDown, ChevronUp, Plus, Trash } from '@sim/emcn/icons' +import { generateShortId } from '@sim/utils/id' +import { useParams } from 'next/navigation' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state' +import { + addFallbackRow, + changeFallbackRowApiKey, + changeFallbackRowModel, + changeFallbackRowTuning, + FALLBACK_TUNING_LABELS, + type FallbackModelEntry, + type FallbackTuningKnob, + fallbackRowNeedsApiKey, + getFallbackTuningKnobsToShow, + getTuningOptionsForModel, + isViableFallbackModel, + isWholeEnvVarReference, + MAX_FALLBACK_MODELS, + moveFallbackRow, + ordinalChoiceLabel, + removeFallbackRow, +} from '@/lib/workflows/blocks/fallback-models' +import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' +import { getModelOptions } from '@/blocks/utils' +import { usePersonalEnvironment, useWorkspaceEnvironment } from '@/hooks/queries/environment' +import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useSettingsNavigation } from '@/hooks/use-settings-navigation' +import { useProvidersStore } from '@/stores/providers/store' + +const CREATE_SECRET_VALUE = 'action-create-secret' + +/** The sibling values a preview renders against, since the store holds the live block's. */ +export interface FallbackListPreviewPrimary { + model?: unknown + reasoningEffort?: unknown + thinkingLevel?: unknown + verbosity?: unknown +} + +interface ModelFallbackListProps { + blockId: string + subBlockId: string + isPreview?: boolean + previewValue?: FallbackModelEntry[] | null + /** Required for a faithful preview; ignored outside preview mode. */ + previewPrimary?: FallbackListPreviewPrimary + disabled?: boolean +} + +/** A viable model before the per-row `disabled` flag is stamped on it. */ +interface ViableModelOption { + label: string + value: string + icon?: React.ComponentType<{ className?: string }> +} + +interface FallbackRowProps { + row: FallbackModelEntry + index: number + /** The row can move down only while another follows it. */ + isLast: boolean + /** Move controls render only once a second row exists. */ + canMove: boolean + primaryModel: string + primaryTuning: Partial> + viableOptions: ViableModelOption[] + /** Models any row holds; a row's own model is exempted when its options are built. */ + takenModels: ReadonlySet + envVarOptions: ComboboxOption[] + readOnly: boolean + onChangeModel: (id: string, model: string) => void + onChangeApiKey: (id: string, apiKey: string) => void + onChangeTuning: (id: string, knob: FallbackTuningKnob, value: string) => void + onMove: (id: string, direction: -1 | 1) => void + onRemove: (id: string) => void +} + +const FallbackRow = memo(function FallbackRow({ + row, + index, + isLast, + canMove, + primaryModel, + primaryTuning, + viableOptions, + takenModels, + envVarOptions, + readOnly, + onChangeModel, + onChangeApiKey, + onChangeTuning, + onMove, + onRemove, +}: FallbackRowProps) { + /** Credential visibility follows the server-resolved shape, including late hydration. */ + useDeploymentShape() + const modelOptions = useMemo( + (): ComboboxOption[] => + viableOptions.map((option) => ({ + ...option, + disabled: option.value !== row.model && takenModels.has(option.value), + })), + [viableOptions, takenModels, row.model] + ) + + const needsApiKey = fallbackRowNeedsApiKey(row.model, primaryModel) + const tuningFields = getFallbackTuningKnobsToShow(row.model, primaryModel, primaryTuning).map( + (knob) => ({ + knob, + options: (getTuningOptionsForModel(row.model, knob) ?? []).map((value) => ({ + label: value, + value, + })), + }) + ) + + /** Only a reference is ever shown; anything else that reached the store reads as unset. */ + const apiKeyValue = isWholeEnvVarReference(row.apiKey) ? row.apiKey : '' + + return ( +
+
+ {ordinalChoiceLabel(index)} +
+ {canMove && ( + <> + + + onMove(row.id, -1)} + disabled={readOnly || index === 0} + aria-label='Move up' + /> + + Move up + + + + onMove(row.id, 1)} + disabled={readOnly || isLast} + aria-label='Move down' + /> + + Move down + + + )} + + + onRemove(row.id)} + disabled={readOnly} + aria-label='Remove fallback model' + /> + + Remove + +
+
+ +
+ onChangeModel(row.id, model)} + placeholder='Select a model' + aria-label={`${ordinalChoiceLabel(index)} model`} + disabled={readOnly} + searchable + searchPlaceholder='Search models...' + maxHeight={240} + emptyMessage='No models available' + /> + {needsApiKey && ( +
+ + onChangeApiKey(row.id, apiKey)} + placeholder='Select a secret' + aria-label={`${ordinalChoiceLabel(index)} API key`} + disabled={readOnly} + searchable + searchPlaceholder='Search secrets...' + maxHeight={240} + emptyMessage='No secrets' + /> +
+ )} + {tuningFields.map(({ knob, options }) => ( +
+ + onChangeTuning(row.id, knob, value)} + placeholder={`Select ${FALLBACK_TUNING_LABELS[knob].toLowerCase()}`} + aria-label={`${ordinalChoiceLabel(index)} ${FALLBACK_TUNING_LABELS[knob].toLowerCase()}`} + disabled={readOnly} + className='w-full' + /> +
+ ))} +
+
+ ) +}) + +/** + * Ordered fallback models for a model-driven block: the 2nd, 3rd, ... choice + * tried in sequence when the request to the block's own model fails. + * + * Every write is the whole array, so a collaborator's concurrent edit and an + * undo both flow straight through the store. A row's key is stored only as a + * `{{ENV_VAR}}` reference: the picker offers the workspace's secret names and + * nothing else, which is what keeps a raw secret out of the list value (see + * `FallbackModelEntry`). The row transforms live in `fallback-models.ts`. + */ +export function ModelFallbackList({ + blockId, + subBlockId, + isPreview = false, + previewValue, + previewPrimary, + disabled = false, +}: ModelFallbackListProps) { + const params = useParams() + const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : '' + const { navigateToSettings } = useSettingsNavigation() + const { isModelUsable } = usePermissionConfig() + const deploymentShape = useDeploymentShape() + const providers = useProvidersStore((state) => state.providers) + const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) + const [primaryModelValue] = useSubBlockValue(blockId, 'model') + const [primaryReasoningEffort] = useSubBlockValue(blockId, 'reasoningEffort') + const [primaryThinkingLevel] = useSubBlockValue(blockId, 'thinkingLevel') + const [primaryVerbosity] = useSubBlockValue(blockId, 'verbosity') + const { data: personalEnv = {} } = usePersonalEnvironment() + const { data: workspaceEnv } = useWorkspaceEnvironment(workspaceId, { + enabled: Boolean(workspaceId), + }) + + const readOnly = isPreview || disabled + /** A preview shows another version's rows, so its gates read that version's primary, not the live one. */ + const primarySource = isPreview + ? { + model: previewPrimary?.model, + reasoningEffort: previewPrimary?.reasoningEffort, + thinkingLevel: previewPrimary?.thinkingLevel, + verbosity: previewPrimary?.verbosity, + } + : { + model: primaryModelValue, + reasoningEffort: primaryReasoningEffort, + thinkingLevel: primaryThinkingLevel, + verbosity: primaryVerbosity, + } + const primaryModel = typeof primarySource.model === 'string' ? primarySource.model : '' + const { + reasoningEffort: sourceReasoningEffort, + thinkingLevel: sourceThinkingLevel, + verbosity: sourceVerbosity, + } = primarySource + const primaryTuning = useMemo( + () => ({ + reasoningEffort: sourceReasoningEffort, + thinkingLevel: sourceThinkingLevel, + verbosity: sourceVerbosity, + }), + [sourceReasoningEffort, sourceThinkingLevel, sourceVerbosity] + ) + const rows: FallbackModelEntry[] = useMemo(() => { + const value = isPreview ? previewValue : storeValue + return Array.isArray(value) ? value : [] + }, [isPreview, previewValue, storeValue]) + + /** + * `getModelOptions` reads the providers store itself; subscribing to + * `providers` here is what recomputes the list when a dynamic provider's + * models finish loading. + */ + const viableOptions = useMemo( + (): ViableModelOption[] => + getModelOptions() + .filter( + (option) => isModelUsable(option.id) && isViableFallbackModel(option.id, primaryModel) + ) + .map((option) => ({ + label: option.label, + value: option.id, + ...(option.icon ? { icon: option.icon } : {}), + })), + [primaryModel, isModelUsable, providers, deploymentShape] + ) + + const takenModels = useMemo(() => new Set(rows.map((row) => row.model).filter(Boolean)), [rows]) + + const envVarOptions = useMemo((): ComboboxOption[] => { + const names = workspaceId + ? [ + ...Object.keys(workspaceEnv?.workspace ?? {}), + ...Object.keys(workspaceEnv?.personal ?? {}), + ] + : Object.keys(personalEnv) + const options: ComboboxOption[] = [...new Set(names)].map((name) => ({ + label: name, + value: `{{${name}}}`, + })) + options.push({ + label: 'Create Secret', + value: CREATE_SECRET_VALUE, + icon: Plus, + onSelect: () => { + if (workspaceId) { + writePendingCredentialCreateRequest({ + workspaceId, + type: 'env_personal', + requestedAt: Date.now(), + }) + } + navigateToSettings({ section: 'secrets' }) + }, + }) + return options + }, [workspaceId, workspaceEnv, personalEnv, navigateToSettings]) + + /** + * Handlers read the latest rows through a ref so their identity survives an + * edit; otherwise every keystroke in one row would re-render all of them. + */ + const rowsRef = useRef(rows) + useEffect(() => { + rowsRef.current = rows + }, [rows]) + + const write = useCallback( + (transform: (current: FallbackModelEntry[]) => FallbackModelEntry[]) => { + if (readOnly) return + const current = rowsRef.current + const next = transform(current) + if (next !== current) setStoreValue(next) + }, + [readOnly, setStoreValue] + ) + + const handleAdd = useCallback( + () => write((current) => addFallbackRow(current, generateShortId())), + [write] + ) + const handleRemove = useCallback( + (id: string) => write((current) => removeFallbackRow(current, id)), + [write] + ) + const handleMove = useCallback( + (id: string, direction: -1 | 1) => write((current) => moveFallbackRow(current, id, direction)), + [write] + ) + const handleChangeModel = useCallback( + (id: string, model: string) => + write((current) => changeFallbackRowModel(current, id, model, primaryModel)), + [primaryModel, write] + ) + const handleChangeTuning = useCallback( + (id: string, knob: FallbackTuningKnob, value: string) => + write((current) => changeFallbackRowTuning(current, id, knob, value)), + [write] + ) + const handleChangeApiKey = useCallback( + (id: string, apiKey: string) => { + if (apiKey === CREATE_SECRET_VALUE) return + write((current) => changeFallbackRowApiKey(current, id, apiKey)) + }, + [write] + ) + + return ( +
+ {rows.map((row, index) => ( + 1} + primaryModel={primaryModel} + primaryTuning={primaryTuning} + viableOptions={viableOptions} + takenModels={takenModels} + envVarOptions={envVarOptions} + readOnly={readOnly} + onChangeModel={handleChangeModel} + onChangeApiKey={handleChangeApiKey} + onChangeTuning={handleChangeTuning} + onMove={handleMove} + onRemove={handleRemove} + /> + ))} + {!readOnly && ( + = MAX_FALLBACK_MODELS} + > + Add fallback model + + )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 594b374808c..4795928d1c6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -11,6 +11,7 @@ import { import { isEqual } from 'es-toolkit' import { useParams } from 'next/navigation' import type { FilterRule, SortRule } from '@/lib/table/query-builder/constants' +import type { FallbackModelEntry } from '@/lib/workflows/blocks/fallback-models' import { CheckboxList, Code, @@ -32,6 +33,7 @@ import { McpServerSelector, McpToolSelector, MessagesInput, + ModelFallbackList, ResponseFormat, ScheduleInfo, SelectorInput, @@ -1197,6 +1199,27 @@ function SubBlockComponent({ } return } + case 'model-fallback-list': + return ( + + ) + case 'messages-input': return ( = { 'messages-input': MessageSquareText, 'tool-input': Wrench, 'skill-input': Sparkles, + 'model-fallback-list': ArrowLeftRight, 'oauth-input': Key, switch: ToggleLeft, 'file-upload': Paperclip, @@ -574,6 +576,11 @@ const SubBlockRow = memo(function SubBlockRow({ [subBlock, rawValue, workspaceSkills] ) + const fallbackModelsDisplayValue = useMemo( + () => resolveFallbackModelsLabel(subBlock, rawValue), + [subBlock, rawValue] + ) + /** * Hydrates the Function block's sandbox id to its name. Deliberately scoped to * the sandbox row: this row is memoized per subblock, and the shared list query @@ -605,6 +612,7 @@ const SubBlockRow = memo(function SubBlockRow({ filterDisplayValue || toolsDisplayValue || skillsDisplayValue || + fallbackModelsDisplayValue || sandboxDisplayValue || knowledgeBaseDisplayName || workflowSelectionName || diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index b3935655903..04dfea0021a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -23,6 +23,7 @@ import { getDisplayValue, hasDisplayableRowValue, resolveDropdownLabel, + resolveFallbackModelsLabel, resolveFolderPathLabel, resolveSkillsLabel, resolveToolsLabel, @@ -151,6 +152,7 @@ function resolvePreviewDisplayValue( // schema/registry fallbacks rather than the API. const toolsDisplay = resolveToolsLabel(subBlock, rawValue, []) const skillsDisplay = resolveSkillsLabel(subBlock, rawValue, []) + const fallbackModelsDisplay = resolveFallbackModelsLabel(subBlock, rawValue) const workflowName = resolveWorkflowSelectionLabel(subBlock, rawValue, workflowLookup) const workflowMultiSelectionNames = resolveWorkflowMultiSelectLabel( subBlock, @@ -165,6 +167,7 @@ function resolvePreviewDisplayValue( variablesDisplay || toolsDisplay || skillsDisplay || + fallbackModelsDisplay || workflowName || workflowMultiSelectionNames || /* diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index 693b2d00bdf..15077a66fdb 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -604,6 +604,7 @@ describe.concurrent('Blocks Module', () => { 'text', 'router-input', 'table-selector', + 'model-fallback-list', 'column-selector', 'filter-builder', 'sort-builder', diff --git a/apps/sim/blocks/blocks/agent.test.ts b/apps/sim/blocks/blocks/agent.test.ts index 23ec48fed05..9be09f82a0a 100644 --- a/apps/sim/blocks/blocks/agent.test.ts +++ b/apps/sim/blocks/blocks/agent.test.ts @@ -30,6 +30,27 @@ describe('AgentBlock', () => { } describe('tools.config.params function', () => { + it('normalizes fallback models and drops the key when none survive', () => { + const withRows = paramsFunction({ + model: 'gpt-4o', + fallbackModels: [ + { id: 'a', model: ' claude-sonnet-5 ' }, + { id: 'b', model: 'sim-auto' }, + { id: 'c', model: 'claude-sonnet-5' }, + { id: 'd', model: 'openrouter/x', apiKey: '{{OPENROUTER_API_KEY}}' }, + { id: 'e', model: 'openrouter/y', apiKey: '' }, + ], + }) + expect(withRows.fallbackModels).toEqual([ + { model: 'claude-sonnet-5' }, + { model: 'openrouter/x', apiKey: '{{OPENROUTER_API_KEY}}' }, + { model: 'openrouter/y' }, + ]) + + const empty = paramsFunction({ model: 'gpt-4o', fallbackModels: [{ id: 'a', model: '' }] }) + expect(empty).not.toHaveProperty('fallbackModels') + }) + it('should pass through params when no tools array is provided', () => { const params = { model: 'gpt-4o', diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index b09eae6e203..82464855edd 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -1,5 +1,8 @@ import { createLogger } from '@sim/logger' +import { omit } from '@sim/utils/object' import { AgentIcon } from '@/components/icons' +import { normalizeFallbackModels } from '@/lib/workflows/blocks/fallback-models' +import { getModelFallbackSubBlock, MODEL_FALLBACK_INPUTS } from '@/blocks/model-fallbacks' import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { @@ -31,6 +34,7 @@ const logger = createLogger('AgentBlock') /** Model the agent block falls back to when `model` is unset or the auto pseudo-model. */ const AGENT_FALLBACK_MODEL = 'claude-sonnet-5' + const MODELS_WITH_REASONING_EFFORT = getModelsWithReasoningEffort() const MODELS_WITH_VERBOSITY = getModelsWithVerbosity() const MODELS_WITH_THINKING = getModelsWithThinking() @@ -429,6 +433,7 @@ Return ONLY the JSON array.`, value: MODELS_WITH_DEEP_RESEARCH, }, }, + getModelFallbackSubBlock(), ], tools: { access: [ @@ -448,7 +453,12 @@ Return ONLY the JSON array.`, }, params: (params: Record) => { const normalizedFiles = normalizeFileInput(params.files) - const baseParams = normalizedFiles ? { ...params, files: normalizedFiles } : params + const withFiles = normalizedFiles ? { ...params, files: normalizedFiles } : params + const fallbackModels = normalizeFallbackModels(params.fallbackModels) + const baseParams = + fallbackModels.length > 0 + ? { ...withFiles, fallbackModels } + : omit(withFiles, ['fallbackModels']) // If tools array is provided, handle tool usage control if (params.tools && Array.isArray(params.tools)) { @@ -586,6 +596,7 @@ Return ONLY the JSON array.`, type: 'boolean', description: 'Cache the system prompt and tool definitions on models that support it', }, + ...MODEL_FALLBACK_INPUTS, tools: { type: 'json', description: 'Available tools configuration' }, skills: { type: 'json', description: 'Selected skills configuration' }, }, diff --git a/apps/sim/blocks/blocks/evaluator.ts b/apps/sim/blocks/blocks/evaluator.ts index 194183149cc..75fed5e1c06 100644 --- a/apps/sim/blocks/blocks/evaluator.ts +++ b/apps/sim/blocks/blocks/evaluator.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { ChartBarIcon } from '@/components/icons' +import { getModelFallbackSubBlock, MODEL_FALLBACK_INPUTS } from '@/blocks/model-fallbacks' import type { BlockConfig, ParamType } from '@/blocks/types' import { getModelOptions, @@ -189,6 +190,7 @@ export const EvaluatorBlock: BlockConfig = { options: getModelOptions, }, ...getProviderCredentialSubBlocks(), + getModelFallbackSubBlock(), { id: 'temperature', title: 'Temperature', @@ -294,6 +296,7 @@ export const EvaluatorBlock: BlockConfig = { }, model: { type: 'string' as ParamType, description: 'AI model to use' }, ...PROVIDER_CREDENTIAL_INPUTS, + ...MODEL_FALLBACK_INPUTS, temperature: { type: 'number' as ParamType, description: 'Response randomness level (low for consistent evaluation)', diff --git a/apps/sim/blocks/blocks/router.ts b/apps/sim/blocks/blocks/router.ts index 34ba119712e..8d9bccb9f9c 100644 --- a/apps/sim/blocks/blocks/router.ts +++ b/apps/sim/blocks/blocks/router.ts @@ -1,4 +1,5 @@ import { ConnectIcon } from '@/components/icons' +import { getModelFallbackSubBlock, MODEL_FALLBACK_INPUTS } from '@/blocks/model-fallbacks' import { AuthMode, type BlockConfig } from '@/blocks/types' import { getModelOptions, @@ -186,6 +187,7 @@ export const RouterBlock: BlockConfig = { options: getModelOptions, }, ...getProviderCredentialSubBlocks(), + getModelFallbackSubBlock(), { id: 'temperature', title: 'Temperature', @@ -221,6 +223,7 @@ export const RouterBlock: BlockConfig = { prompt: { type: 'string', description: 'Routing prompt content' }, model: { type: 'string', description: 'AI model to use' }, ...PROVIDER_CREDENTIAL_INPUTS, + ...MODEL_FALLBACK_INPUTS, temperature: { type: 'number', description: 'Response randomness level (low for consistent routing)', @@ -303,6 +306,7 @@ export const RouterV2Block: BlockConfig = { options: getModelOptions, }, ...getProviderCredentialSubBlocks(), + getModelFallbackSubBlock(), ], tools: { access: [ @@ -322,6 +326,7 @@ export const RouterV2Block: BlockConfig = { routes: { type: 'json', description: 'Route definitions with descriptions' }, model: { type: 'string', description: 'AI model to use' }, ...PROVIDER_CREDENTIAL_INPUTS, + ...MODEL_FALLBACK_INPUTS, }, outputs: { context: { type: 'string', description: 'Context used for routing' }, diff --git a/apps/sim/blocks/model-fallbacks.ts b/apps/sim/blocks/model-fallbacks.ts new file mode 100644 index 00000000000..4f449356d8d --- /dev/null +++ b/apps/sim/blocks/model-fallbacks.ts @@ -0,0 +1,19 @@ +import { MAX_FALLBACK_MODELS } from '@/lib/workflows/blocks/fallback-models' +import type { SubBlockConfig } from '@/blocks/types' + +const DESCRIPTION = `Ordered models tried once each when the selected model's request fails; with Retry on fail, after its tries run out. Each row is { model, apiKey?, reasoningEffort?, thinkingLevel?, verbosity? }. API keys must be whole {{ENV_VAR}} references and apply only when the row asks for a key. Tuning must be supported by the row model. sim-auto is not allowed. Max ${MAX_FALLBACK_MODELS}.` + +/** Shared advanced field for blocks that execute model requests. */ +export function getModelFallbackSubBlock(): SubBlockConfig { + return { + id: 'fallbackModels', + title: 'Fallback models', + type: 'model-fallback-list', + mode: 'advanced', + description: DESCRIPTION, + } +} + +export const MODEL_FALLBACK_INPUTS = { + fallbackModels: { type: 'json', description: DESCRIPTION }, +} as const diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index c260de06e87..9c1b933eff7 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -75,6 +75,8 @@ import { parseOptionalBooleanInput, parseOptionalJsonInput, parseOptionalNumberInput, + providerRequiresFamilyCredentials, + requiresProviderFamilyCredentials, } from '@/blocks/utils' import { getProviderFromModel } from '@/providers/utils' @@ -97,6 +99,45 @@ const BASE_CLOUD_MODELS: Record = { 'mistral-large-latest': 'mistral', } +describe('providerRequiresFamilyCredentials', () => { + it('answers for a provider the caller already resolved', () => { + expect(providerRequiresFamilyCredentials('vertex')).toBe(true) + expect(providerRequiresFamilyCredentials('openai')).toBe(false) + expect(providerRequiresFamilyCredentials(null)).toBe(false) + expect(providerRequiresFamilyCredentials(undefined)).toBe(false) + }) +}) + +describe('requiresProviderFamilyCredentials', () => { + beforeEach(() => { + setEnvFlags({ isHosted: false, isAzureConfigured: false, isOllamaConfigured: false }) + }) + + it('is true for Vertex, and for Bedrock until the deployment provides default credentials', () => { + expect(requiresProviderFamilyCredentials('vertex/gemini-2.5-pro')).toBe(true) + expect(requiresProviderFamilyCredentials('bedrock/my-inference-profile')).toBe(true) + vi.stubEnv('NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS', 'true') + try { + expect(requiresProviderFamilyCredentials('bedrock/my-inference-profile')).toBe(false) + } finally { + vi.unstubAllEnvs() + } + }) + + it('is true for Azure only until the deployment configures it server-side', () => { + expect(requiresProviderFamilyCredentials('azure/my-deployment')).toBe(true) + expect(requiresProviderFamilyCredentials('azure-anthropic/my-deployment')).toBe(true) + setEnvFlags({ isAzureConfigured: true }) + expect(requiresProviderFamilyCredentials('azure/my-deployment')).toBe(false) + }) + + it('is false for API-key providers, local servers, and unknown ids', () => { + expect(requiresProviderFamilyCredentials('openrouter/anthropic/claude')).toBe(false) + expect(requiresProviderFamilyCredentials('ollama/llama3')).toBe(false) + expect(requiresProviderFamilyCredentials('')).toBe(false) + }) +}) + describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { const evaluateCondition = (model: string): boolean => { const conditionFn = getApiKeyCondition() diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index bc81c39cc2a..92b43f7b483 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -1,6 +1,7 @@ import { toError } from '@sim/utils/errors' import { SimAutoIcon } from '@/components/icons' import { getDeploymentShape } from '@/lib/core/config/deployment-shape' +import { getEnv, isTruthy } from '@/lib/core/config/env' import { isOllamaConfigured } from '@/lib/core/config/env-flags' import { getScopesForService } from '@/lib/oauth/utils' import { containsReference } from '@/lib/workflows/sanitization/references' @@ -137,7 +138,12 @@ function buildModelVisibilityCondition(model: string, shouldShow: boolean) { return shouldShow ? { field: 'model', value: model } : { field: 'model', value: model, not: true } } -function shouldRequireApiKeyForModel(model: string): boolean { +/** + * Whether the block must show an API Key field for `model` on this deployment: + * false for hosted models on hosted Sim (BYOK or the platform key serve them), + * for providers with their own credential fields, and for local servers. + */ +export function shouldRequireApiKeyForModel(model: string): boolean { const normalizedModel = model.trim().toLowerCase() if (!normalizedModel) return false @@ -278,6 +284,31 @@ export function getCohereRerankerApiKeyCondition() { } } +/** + * Whether `model` can only run with credentials that live on the block beyond an + * API key: a Vertex OAuth credential, Bedrock AWS keys, or an Azure endpoint, + * unless the deployment supplies them server-side (the same env flags that hide + * those fields). The fields render only while the block's own `model` is in + * that provider family, so nothing outside the family can inherit them. + */ +export function requiresProviderFamilyCredentials(model: string): boolean { + return providerRequiresFamilyCredentials(findProviderFromModel(model.trim())) +} + +/** + * The provider-keyed half of {@link requiresProviderFamilyCredentials}, for a + * caller that has already resolved the provider and must not pay for a second + * catalog scan. + */ +export function providerRequiresFamilyCredentials(provider: string | null | undefined): boolean { + if (provider === 'vertex') return true + if (provider === 'bedrock') return !isTruthy(getEnv('NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS')) + if (provider === 'azure-openai' || provider === 'azure-anthropic') { + return !getDeploymentShape().azureConfigured + } + return false +} + function getModelProviderCondition(...providerIds: ProviderId[]) { return (values?: Record) => { const model = typeof values?.model === 'string' ? values.model : '' diff --git a/apps/sim/executor/execution/block-executor.retry.test.ts b/apps/sim/executor/execution/block-executor.retry.test.ts index e22c93104eb..d52827b0a72 100644 --- a/apps/sim/executor/execution/block-executor.retry.test.ts +++ b/apps/sim/executor/execution/block-executor.retry.test.ts @@ -119,6 +119,60 @@ describe('BlockExecutor retry', () => { await expect(executor.execute(createContext(state), createNode(block), block)).rejects.toThrow() expect(execute).toHaveBeenCalledTimes(1) + expect(execute.mock.calls[0][3]).not.toHaveProperty('retry') + }) + + it('tells each try where it sits in the policy, and a block without one nothing', async () => { + const block = createBlock({ enabled: true, maxTries: 3, waitBetweenTriesMs: 0 }) + const execute = vi + .fn() + .mockRejectedValueOnce(new Error('one')) + .mockRejectedValueOnce(new Error('two')) + .mockResolvedValueOnce({ ok: true }) + const state = new ExecutionState() + const executor = buildExecutor(block, { canHandle: () => true, execute }, state) + + await executor.execute(createContext(state), createNode(block), block) + + expect(execute.mock.calls.map(([, , , metadata]) => metadata.retry)).toEqual([ + { attempt: 1, maxTries: 3, isFinalTry: false }, + { attempt: 2, maxTries: 3, isFinalTry: false }, + { attempt: 3, maxTries: 3, isFinalTry: true }, + ]) + expect(execute.mock.calls[0][3].nodeId).toBe(block.id) + + const plain = createBlock() + const executePlain = vi.fn().mockResolvedValue({ ok: true }) + const plainState = new ExecutionState() + await buildExecutor( + plain, + { canHandle: () => true, execute: executePlain }, + plainState + ).execute(createContext(plainState), createNode(plain), plain) + expect(executePlain.mock.calls[0][3]).not.toHaveProperty('retry') + }) + + it('hands the same try position to a handler that takes the node', async () => { + const block = createBlock({ enabled: true, maxTries: 2, waitBetweenTriesMs: 0 }) + const executeWithNode = vi + .fn() + .mockRejectedValueOnce(new Error('one')) + .mockResolvedValueOnce({ ok: true }) + const execute = vi.fn() + const state = new ExecutionState() + const executor = buildExecutor( + block, + { canHandle: () => true, execute, executeWithNode }, + state + ) + + await executor.execute(createContext(state), createNode(block), block) + + expect(execute).not.toHaveBeenCalled() + expect(executeWithNode.mock.calls.map(([, , , metadata]) => metadata.retry)).toEqual([ + { attempt: 1, maxTries: 2, isFinalTry: false }, + { attempt: 2, maxTries: 2, isFinalTry: true }, + ]) }) it('replays any failure and succeeds on a later try', async () => { diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index e19b7a464ac..d19349ec2d1 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -44,6 +44,7 @@ import { import { type BlockHandler, type BlockLog, + type BlockRetryAttempt, type BlockState, type ExecutionContext, getNextExecutionOrder, @@ -277,11 +278,12 @@ export class BlockExecutor { * token is drained, so a replay cannot duplicate output the client has * already seen. */ - const output = await this.runHandlerWithRetry(blockCtx, block, blockLog, () => - handler.executeWithNode - ? handler.executeWithNode(blockCtx, block, resolvedInputs, nodeMetadata) - : handler.execute(blockCtx, block, resolvedInputs, nodeMetadata) - ) + const output = await this.runHandlerWithRetry(blockCtx, block, blockLog, (retry) => { + const invocationMetadata = retry ? { ...nodeMetadata, retry } : nodeMetadata + return handler.executeWithNode + ? handler.executeWithNode(blockCtx, block, resolvedInputs, invocationMetadata) + : handler.execute(blockCtx, block, resolvedInputs, invocationMetadata) + }) completedHandlerCost = readTrustedExecutionCost(output) @@ -546,15 +548,20 @@ export class BlockExecutor { * Rethrows the final try's error so the caller's catch — and with it the error * port — behaves exactly as it does for a block that never retried. Retrying * only ever delays the existing outcome; it never changes it. + * + * Each try is told where it sits in the policy (`BlockRetryAttempt`). The + * policy stays here: a handler cannot ask for another try or skip the wait, + * it can only hold work for the try after which no other follows, the way the + * Agent block keeps its fallback models for the final try. */ private async runHandlerWithRetry( ctx: ExecutionContext, block: SerializedBlock, blockLog: BlockLog | undefined, - invoke: () => Promise + invoke: (retry: BlockRetryAttempt | undefined) => Promise ): Promise { const policy = resolveBlockRetryPolicy(block) - if (!policy) return invoke() + if (!policy) return invoke(undefined) const shouldAccumulateFunctionCost = block.metadata?.id === BlockType.FUNCTION let accumulatedFunctionCost: TrustedExecutionCost | undefined @@ -562,8 +569,9 @@ export class BlockExecutor { try { for (;;) { tries++ + const isFinalTry = tries >= policy.maxTries try { - const output = await invoke() + const output = await invoke({ attempt: tries, maxTries: policy.maxTries, isFinalTry }) if (!shouldAccumulateFunctionCost || !accumulatedFunctionCost || !isRecordLike(output)) { return output } @@ -585,7 +593,6 @@ export class BlockExecutor { ) } - const isFinalTry = tries >= policy.maxTries if (isFinalTry || ctx.abortSignal?.aborted || !isRetryableBlockError(error)) { attachTrustedExecutionCost(error, accumulatedFunctionCost) throw error diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 85d14bd6e27..799281abcd2 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -18,17 +18,22 @@ import { type Mock, vi, } from 'vitest' +import { resetDeploymentShape } from '@/lib/core/config/deployment-shape' import type { AutoRoutingSignals } from '@/lib/model-router/resolve' import * as userFileBase64 from '@/lib/uploads/utils/user-file-base64.server' import { getAllBlocks } from '@/blocks' import { AGENT, BlockType, isMcpTool } from '@/executor/constants' +import type { DAGNode } from '@/executor/dag/builder' +import { BlockExecutor } from '@/executor/execution/block-executor' +import { ExecutionState } from '@/executor/execution/state' import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler' import type { AgentInputs, Message } from '@/executor/handlers/agent/types' import type { ExecutionContext, StreamingExecution } from '@/executor/types' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { VariableResolver } from '@/executor/variables/resolver' import { executeProviderRequest } from '@/providers' import { installStreamingCostPolicy } from '@/providers/cost-policy' -import { SIM_AUTO_MODEL_ID } from '@/providers/models' +import { getModelCapabilities, SIM_AUTO_MODEL_ID } from '@/providers/models' import { getProviderToolInputProvenance, getProviderToolModelInputRegistry, @@ -43,15 +48,23 @@ process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' const { mockDiscoverMcpServerToolsAsExecutor, mockImportWorkspaceFileSecretProvenanceForModelView, + mockValidateModelProvider, } = vi.hoisted(() => ({ mockDiscoverMcpServerToolsAsExecutor: vi.fn().mockResolvedValue([]), mockImportWorkspaceFileSecretProvenanceForModelView: vi.fn().mockResolvedValue(true), + mockValidateModelProvider: vi.fn().mockResolvedValue(undefined), })) vi.mock('@/lib/internal/mcp/discover-tools', () => ({ discoverMcpServerToolsAsExecutor: mockDiscoverMcpServerToolsAsExecutor, })) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + assertPermissionsAllowed: vi.fn().mockResolvedValue(undefined), + validateBlockType: vi.fn().mockResolvedValue(undefined), + validateModelProvider: mockValidateModelProvider, +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ importWorkspaceFileSecretProvenanceForModelView: mockImportWorkspaceFileSecretProvenanceForModelView, @@ -85,6 +98,7 @@ vi.mock('@/providers/utils', () => ({ vi.mock('@/blocks', () => ({ getAllBlocks: vi.fn().mockReturnValue([]), + getBlock: vi.fn().mockReturnValue(undefined), })) vi.mock('@/tools', () => ({ @@ -174,6 +188,7 @@ describe('AgentBlockHandler', () => { beforeEach(() => { handler = new AgentBlockHandler() vi.clearAllMocks() + mockValidateModelProvider.mockReset().mockResolvedValue(undefined) mockDiscoverMcpServerToolsAsExecutor.mockImplementation( async ({ serverId }: { serverId: string }) => [ @@ -495,6 +510,901 @@ describe('AgentBlockHandler', () => { }) }) + describe('model fallback', () => { + const baseInputs = { + model: 'gpt-4o', + userPrompt: 'Hello', + apiKey: 'primary-key', + temperature: 0.4, + } + + const providerFor = (model: string) => { + if (model.startsWith('gpt')) return 'openai' + if (model.startsWith('claude')) return 'anthropic' + if (model === 'blacklisted-model') throw new Error('provider blacklisted') + return 'openai' + } + + const providerResponse = (model: string, content = 'ok') => ({ + content, + model, + tokens: { input: 1, output: 1, total: 2 }, + toolCalls: [], + cost: 0, + timing: { total: 1 }, + }) + + const openLog = (blockId = mockBlock.id, endedAt = '') => ({ + blockId, + startedAt: '2026-01-01T00:00:00.000Z', + endedAt, + durationMs: 0, + success: false, + executionOrder: 1, + }) + + const streamingResponse = ( + chunks: string[], + options: { failBeforeFirstChunk?: Error; failAfterFirstChunk?: Error } = {} + ) => ({ + stream: new ReadableStream({ + async pull(controller) { + if (options.failBeforeFirstChunk) throw options.failBeforeFirstChunk + const chunk = chunks.shift() + if (chunk !== undefined) { + controller.enqueue(chunk) + return + } + if (options.failAfterFirstChunk) throw options.failAfterFirstChunk + controller.close() + }, + }), + execution: { output: { content: '' } }, + }) + + const drain = async (stream: ReadableStream) => { + const reader = stream.getReader() + const chunks: string[] = [] + for (;;) { + const { done, value } = await reader.read() + if (done) return chunks + chunks.push(value) + } + } + + beforeEach(() => { + setEnvFlags({ isHosted: false }) + resetDeploymentShape() + mockGetProviderFromModel.mockImplementation(providerFor) + mockValidateModelProvider.mockResolvedValue(undefined) + }) + + it('never touches the fallbacks when the primary answers', async () => { + const log = openLog() + log.modelFallbacks = ['stale-from-earlier-try'] + await handler.execute({ ...mockContext, blockLogs: [log] }, mockBlock, { + ...baseInputs, + fallbackModels: [{ model: 'claude-sonnet-5' }], + }) + + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1) + expect(mockExecuteProviderRequest.mock.calls[0][1].model).toBe('gpt-4o') + expect(mockAgentLogger.warn).not.toHaveBeenCalledWith( + 'Agent model failed; trying fallback', + expect.anything() + ) + /** A try that succeeds on the primary clears what an earlier try wrote. */ + expect(log.modelFallbacks).toBeUndefined() + }) + + it('falls through to the next model with the same request and no primary-only fields', async () => { + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('overloaded')) + .mockResolvedValueOnce(providerResponse('claude-sonnet-5', 'from fallback')) + const blockLog = openLog() + const ctx = { ...mockContext, blockLogs: [blockLog] } + + const result = await handler.execute(ctx, mockBlock, { + ...baseInputs, + fallbackModels: [{ id: 'row-1', model: 'claude-sonnet-5' }], + }) + + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2) + const [primaryProvider, primaryRequest] = mockExecuteProviderRequest.mock.calls[0] + const [fallbackProvider, fallbackRequest] = mockExecuteProviderRequest.mock.calls[1] + expect(primaryProvider).toBe('openai') + expect(fallbackProvider).toBe('anthropic') + expect(fallbackRequest.model).toBe('claude-sonnet-5') + expect(fallbackRequest.messages).toEqual(primaryRequest.messages) + expect(fallbackRequest.temperature).toBe(primaryRequest.temperature) + expect((result as { model: string }).model).toBe('claude-sonnet-5') + expect(mockAgentLogger.warn).toHaveBeenCalledWith( + 'Agent model failed; trying fallback', + expect.objectContaining({ failedModel: 'gpt-4o', nextModel: 'claude-sonnet-5' }) + ) + expect(blockLog).toMatchObject({ modelFallbacks: ['gpt-4o'] }) + }) + + it.each(['flat', 'memory-block'] as const)( + 'preserves injected %s memories when switching providers', + async (shape) => { + const history: Message[] = [ + { role: 'user', content: 'My name is Ada.' }, + { role: 'assistant', content: 'Hello Ada.' }, + ] + const inputs: AgentInputs = { + ...baseInputs, + systemPrompt: 'Use the conversation history.', + userPrompt: 'What is my name?', + memories: + shape === 'flat' ? history : { memories: [{ key: 'conversation-1', data: history }] }, + fallbackModels: [{ model: 'claude-sonnet-5' }], + } + const original = structuredClone(inputs) + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('overloaded')) + .mockResolvedValueOnce(providerResponse('claude-sonnet-5', 'Your name is Ada.')) + + await handler.execute(mockContext, mockBlock, inputs) + + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2) + const expectedMessages = [ + { role: 'system', content: 'Use the conversation history.' }, + ...history, + { role: 'user', content: 'What is my name?' }, + ] + for (const [, request] of mockExecuteProviderRequest.mock.calls) { + expect(request.messages).toEqual(expectedMessages) + } + expect(mockExecuteProviderRequest.mock.calls[1][0]).toBe('anthropic') + expect(inputs).toEqual(original) + } + ) + + it.each([ + { memoryType: 'conversation', streaming: false }, + { memoryType: 'conversation', streaming: true }, + { memoryType: 'sliding_window', streaming: false }, + { memoryType: 'sliding_window', streaming: true }, + ] as const)( + 'preserves $memoryType history through fallback and saves each turn once (streaming=$streaming)', + async ({ memoryType, streaming }) => { + dbChainMockFns.returning.mockResolvedValue([{ id: 'memory-1' }]) + const history: Message[] = [ + { role: 'user', content: 'Hello.' }, + { role: 'assistant', content: 'How can I help?' }, + { role: 'user', content: 'My name is Ada.' }, + { role: 'assistant', content: 'Hello Ada.' }, + ] + queueTableRows(schemaMock.memory, [{ secretProvenanceVersion: null, data: history }]) + const ctx = { ...mockContext, executionId: 'memory-fallback-execution' } + const inputs: AgentInputs = { + ...baseInputs, + userPrompt: undefined, + memoryType, + slidingWindowSize: '2', + conversationId: 'conversation-1', + messages: [ + { role: 'system', content: 'Use the conversation history.' }, + { role: 'user', content: 'What is my name?' }, + ], + fallbackModels: [{ model: 'claude-sonnet-5' }], + } + const original = structuredClone(inputs) + if (streaming) { + mockExecuteProviderRequest + .mockResolvedValueOnce( + streamingResponse([], { failBeforeFirstChunk: new Error('overloaded') }) + ) + .mockResolvedValueOnce(streamingResponse(['Your name is Ada.'])) + } else { + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('overloaded')) + .mockResolvedValueOnce(providerResponse('claude-sonnet-5', 'Your name is Ada.')) + } + + const result = await handler.execute(ctx, mockBlock, inputs) + + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2) + const currentUserMessage = { + role: 'user', + content: 'What is my name?', + executionId: ctx.executionId, + } + const expectedMessages = [ + { role: 'system', content: 'Use the conversation history.' }, + ...(memoryType === 'sliding_window' ? history.slice(-2) : history), + { role: 'user', content: 'What is my name?' }, + ] + for (const [, request] of mockExecuteProviderRequest.mock.calls) { + expect(request.messages).toEqual(expectedMessages) + } + expect(mockExecuteProviderRequest.mock.calls[1][0]).toBe('anthropic') + if (streaming) { + const streamedResult = result as StreamingExecution + expect(await drain(streamedResult.stream)).toEqual(['Your name is Ada.']) + expect(streamedResult.onFullContent).toBeTypeOf('function') + await streamedResult.onFullContent?.('Your name is Ada.') + } + + const memoryWrites = dbChainMockFns.values.mock.calls + .map(([row]) => row) + .filter((row) => Array.isArray(row.data)) + expect(memoryWrites.map((row) => row.data)).toEqual([ + [currentUserMessage], + [{ role: 'assistant', content: 'Your name is Ada.' }], + ]) + expect(memoryWrites.every((row) => row.key === inputs.conversationId)).toBe(true) + expect(inputs).toEqual(original) + } + ) + + it('holds the fallbacks on a try the executor will replay', async () => { + mockExecuteProviderRequest.mockRejectedValueOnce(new Error('overloaded')) + const blockLog = openLog() + blockLog.modelFallbacks = ['stale-from-earlier-run'] + + await expect( + handler.execute( + { ...mockContext, blockLogs: [blockLog] }, + mockBlock, + { ...baseInputs, fallbackModels: [{ model: 'claude-sonnet-5' }] }, + { nodeId: mockBlock.id, retry: { attempt: 1, maxTries: 3, isFinalTry: false } } + ) + ).rejects.toThrow('overloaded') + + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1) + expect(mockAgentLogger.info).toHaveBeenCalledWith('Fallback models held for the final try', { + blockId: mockBlock.id, + attempt: 1, + maxTries: 3, + }) + expect(mockAgentLogger.warn).not.toHaveBeenCalledWith( + 'Agent model failed; trying fallback', + expect.anything() + ) + expect(blockLog.modelFallbacks).toBeUndefined() + }) + + it('walks the chain on the final try, and on a block that never retries', async () => { + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('overloaded')) + .mockResolvedValueOnce(providerResponse('claude-sonnet-5')) + .mockRejectedValueOnce(new Error('overloaded')) + .mockResolvedValueOnce(providerResponse('claude-sonnet-5')) + const inputs = { ...baseInputs, fallbackModels: [{ model: 'claude-sonnet-5' }] } + + const onFinalTry = await handler.execute(mockContext, mockBlock, inputs, { + nodeId: mockBlock.id, + retry: { attempt: 3, maxTries: 3, isFinalTry: true }, + }) + const withoutPolicy = await handler.execute(mockContext, mockBlock, inputs, { + nodeId: mockBlock.id, + }) + + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(4) + expect((onFinalTry as { model: string }).model).toBe('claude-sonnet-5') + expect((withoutPolicy as { model: string }).model).toBe('claude-sonnet-5') + expect(mockAgentLogger.info).not.toHaveBeenCalledWith( + 'Fallback models held for the final try', + expect.anything() + ) + }) + + it('says nothing about held fallbacks when none are configured', async () => { + mockExecuteProviderRequest.mockRejectedValueOnce(new Error('overloaded')) + + await expect( + handler.execute(mockContext, mockBlock, baseInputs, { + nodeId: mockBlock.id, + retry: { attempt: 1, maxTries: 2, isFinalTry: false }, + }) + ).rejects.toThrow('overloaded') + expect(mockAgentLogger.info).not.toHaveBeenCalledWith( + 'Fallback models held for the final try', + expect.anything() + ) + }) + + it('never falls back on a deep-research follow-up turn', async () => { + mockExecuteProviderRequest.mockRejectedValueOnce(new Error('overloaded')) + + await expect( + handler.execute(mockContext, mockBlock, { + ...baseInputs, + previousInteractionId: 'interaction-1', + fallbackModels: [{ model: 'claude-sonnet-5' }], + }) + ).rejects.toThrow('overloaded') + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1) + expect(mockAgentLogger.info).toHaveBeenCalledWith( + 'Fallback models skipped for a deep-research follow-up turn', + expect.objectContaining({ blockId: mockBlock.id }) + ) + }) + + it('gives a fallback its own key, the block key on the same provider, and nothing otherwise', async () => { + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('one')) + .mockRejectedValueOnce(new Error('two')) + .mockRejectedValueOnce(new Error('three')) + .mockResolvedValueOnce(providerResponse('gpt-4o-mini')) + + const storedRows = [ + { model: 'claude-sonnet-5', apiKey: '{{ANTHROPIC_KEY}}' }, + { model: 'claude-haiku-5' }, + { model: 'gpt-4o-mini' }, + ] + const block = { + ...mockBlock, + config: { ...mockBlock.config, params: { fallbackModels: storedRows } }, + } + await handler.execute(mockContext, block, { + ...baseInputs, + fallbackModels: [ + { model: 'claude-sonnet-5', apiKey: 'anthropic-row-key' }, + { model: 'claude-haiku-5' }, + { model: 'gpt-4o-mini' }, + ], + }) + + const keys = mockExecuteProviderRequest.mock.calls.map(([, request]) => request.apiKey) + expect(keys).toEqual(['primary-key', 'anthropic-row-key', undefined, 'primary-key']) + }) + + it('treats a row key that was never resolved as no key and says which variable', async () => { + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('one')) + .mockResolvedValueOnce(providerResponse('claude-sonnet-5')) + + const storedRows = [{ model: 'claude-sonnet-5', apiKey: '{{MISSING_KEY}}' }] + const block = { + ...mockBlock, + config: { ...mockBlock.config, params: { fallbackModels: storedRows } }, + } + await handler.execute(mockContext, block, { + ...baseInputs, + fallbackModels: [{ model: 'claude-sonnet-5', apiKey: '{{MISSING_KEY}}' }], + }) + + expect(mockExecuteProviderRequest.mock.calls[1][1].apiKey).toBeUndefined() + expect(mockAgentLogger.warn).toHaveBeenCalledWith( + 'Fallback key variable is not set for this run', + expect.objectContaining({ model: 'claude-sonnet-5', variable: '{{MISSING_KEY}}' }) + ) + }) + + it.each([ + { hosted: false, primary: 'gpt-4o', fallback: 'gpt-4o-mini', expectedKey: 'primary-key' }, + { hosted: true, primary: 'gpt-4o', fallback: 'claude-sonnet-5', expectedKey: undefined }, + ])( + 'ignores a hidden row key for $fallback with hosted=$hosted', + async ({ hosted, primary, fallback, expectedKey }) => { + setEnvFlags({ isHosted: hosted }) + resetDeploymentShape() + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('overloaded')) + .mockResolvedValueOnce(providerResponse(fallback)) + const block = { + ...mockBlock, + config: { + ...mockBlock.config, + params: { fallbackModels: [{ model: fallback, apiKey: '{{OLD_KEY}}' }] }, + }, + } + + await handler.execute(mockContext, block, { + ...baseInputs, + model: primary, + fallbackModels: [{ model: fallback, apiKey: 'old-row-key' }], + }) + + expect(mockExecuteProviderRequest.mock.calls[1][1].apiKey).toBe(expectedKey) + } + ) + + it('lets the executor retry a stream startup failure while fallbacks are held', async () => { + mockExecuteProviderRequest.mockResolvedValueOnce( + streamingResponse([], { failBeforeFirstChunk: new Error('429 at stream start') }) + ) + + await expect( + handler.execute( + mockContext, + mockBlock, + { + ...baseInputs, + stream: true, + fallbackModels: [{ model: 'claude-sonnet-5' }], + }, + { + nodeId: mockBlock.id, + retry: { attempt: 1, maxTries: 3, isFinalTry: false }, + } + ) + ).rejects.toThrow('429 at stream start') + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1) + }) + + it('leaves provider-family credentials off a fallback on another provider', async () => { + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('one')) + .mockRejectedValueOnce(new Error('two')) + .mockResolvedValueOnce(providerResponse('gpt-4o-mini')) + + await handler.execute(mockContext, mockBlock, { + ...baseInputs, + vertexCredential: 'vertex-secret', + bedrockSecretKey: 'bedrock-secret', + azureEndpoint: 'https://azure.example.com', + fallbackModels: [{ model: 'claude-sonnet-5' }, { model: 'gpt-4o-mini' }], + }) + + const [, crossProvider] = mockExecuteProviderRequest.mock.calls[1] + const [, sameProvider] = mockExecuteProviderRequest.mock.calls[2] + expect(crossProvider.vertexCredential).toBeUndefined() + expect(crossProvider.bedrockSecretKey).toBeUndefined() + expect(crossProvider.azureEndpoint).toBeUndefined() + expect(JSON.stringify(crossProvider)).not.toMatch( + /vertex-secret|bedrock-secret|azure\.example/ + ) + expect(sameProvider.bedrockSecretKey).toBe('bedrock-secret') + expect(sameProvider.azureEndpoint).toBe('https://azure.example.com') + }) + + it('ignores a row key the block did not store as a reference', async () => { + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('one')) + .mockResolvedValueOnce(providerResponse('claude-sonnet-5')) + const block = { + ...mockBlock, + config: { + ...mockBlock.config, + params: { + fallbackModels: [{ model: 'claude-sonnet-5', apiKey: 'sk-raw-through-socket' }], + }, + }, + } + + await handler.execute(mockContext, block, { + ...baseInputs, + fallbackModels: [{ model: 'claude-sonnet-5', apiKey: 'sk-raw-through-socket' }], + }) + + expect(mockExecuteProviderRequest.mock.calls[1][1].apiKey).toBeUndefined() + expect(mockAgentLogger.warn).toHaveBeenCalledWith( + 'Fallback row key ignored; only an environment variable reference is accepted', + expect.objectContaining({ model: 'claude-sonnet-5', row: 1 }) + ) + expect(JSON.stringify(mockAgentLogger.warn.mock.calls)).not.toContain('sk-raw-through-socket') + }) + + it('re-resolves tuning for the fallback: row value wins, caps clamp, undeclared values drop', async () => { + const fallbackCap = getModelCapabilities('gpt-5.4-mini')?.maxOutputTokens + expect(fallbackCap).toEqual(expect.any(Number)) + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('down')) + .mockResolvedValueOnce(providerResponse('gpt-5.4-mini')) + + await handler.execute(mockContext, mockBlock, { + ...baseInputs, + model: 'claude-sonnet-5', + thinkingLevel: 'high', + temperature: 0.9, + maxTokens: (fallbackCap as number) + 5000, + fallbackModels: [{ model: 'gpt-5.4-mini', reasoningEffort: 'low' }], + }) + + const [, primaryRequest] = mockExecuteProviderRequest.mock.calls[0] + const [, fallbackRequest] = mockExecuteProviderRequest.mock.calls[1] + expect(primaryRequest.thinkingLevel).toBe('high') + expect(primaryRequest.maxTokens).toBe((fallbackCap as number) + 5000) + expect(fallbackRequest.reasoningEffort).toBe('low') + expect(fallbackRequest.thinkingLevel).toBeUndefined() + expect(fallbackRequest.temperature).toBe(0.9) + expect(fallbackRequest.maxTokens).toBe(fallbackCap) + expect(mockAgentLogger.info).toHaveBeenCalledWith( + 'Fallback model tuning adjusted', + expect.objectContaining({ model: 'gpt-5.4-mini' }) + ) + }) + + it('rethrows the last attempted model error unchanged when every model fails', async () => { + const first = new Error('primary down') + const last = new Error('fallback down') + mockExecuteProviderRequest.mockRejectedValueOnce(first).mockRejectedValueOnce(last) + const blockLog = openLog() + + await expect( + handler.execute({ ...mockContext, blockLogs: [blockLog] }, mockBlock, { + ...baseInputs, + fallbackModels: [{ model: 'claude-sonnet-5' }], + }) + ).rejects.toBe(last) + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2) + expect(blockLog).toMatchObject({ modelFallbacks: ['gpt-4o'] }) + }) + + it('rethrows the primary error when every fallback was skipped as unusable', async () => { + const primaryError = new Error('primary down') + mockExecuteProviderRequest.mockRejectedValueOnce(primaryError) + const blockLog = openLog() + + await expect( + handler.execute({ ...mockContext, blockLogs: [blockLog] }, mockBlock, { + ...baseInputs, + fallbackModels: [{ model: 'blacklisted-model' }], + }) + ).rejects.toBe(primaryError) + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1) + expect(blockLog.modelFallbacks).toBeUndefined() + }) + + it('skips sim-auto, duplicates, the primary itself, and unusable providers', async () => { + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('down')) + .mockResolvedValueOnce(providerResponse('claude-sonnet-5')) + + await handler.execute(mockContext, mockBlock, { + ...baseInputs, + fallbackModels: [ + { model: 'sim-auto' }, + { model: 'GPT-4o' }, + { model: 'blacklisted-model' }, + { model: 'claude-sonnet-5' }, + { model: 'claude-sonnet-5' }, + ], + }) + + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2) + expect(mockExecuteProviderRequest.mock.calls[1][1].model).toBe('claude-sonnet-5') + expect(mockAgentLogger.warn).toHaveBeenCalledWith( + 'Fallback model unusable; skipping', + expect.objectContaining({ model: 'blacklisted-model' }) + ) + }) + + it('skips a fallback the workspace does not permit', async () => { + mockValidateModelProvider.mockImplementation(async (_user, _workspace, model: string) => { + if (model === 'claude-sonnet-5') throw new Error('Model not permitted') + }) + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('down')) + .mockResolvedValueOnce(providerResponse('gpt-4o-mini')) + + await handler.execute( + { ...mockContext, userId: 'user-1', workspaceId: 'workspace-1' }, + mockBlock, + { + ...baseInputs, + fallbackModels: [{ model: 'claude-sonnet-5' }, { model: 'gpt-4o-mini' }], + } + ) + + expect(mockExecuteProviderRequest.mock.calls.map(([, request]) => request.model)).toEqual([ + 'gpt-4o', + 'gpt-4o-mini', + ]) + expect(mockAgentLogger.warn).toHaveBeenCalledWith( + 'Fallback model unusable; skipping', + expect.objectContaining({ model: 'claude-sonnet-5', error: 'Model not permitted' }) + ) + }) + + it('does not fall back after a stop', async () => { + const controller = new AbortController() + mockExecuteProviderRequest.mockImplementationOnce(async () => { + controller.abort() + throw new Error('Provider request timed out') + }) + + await expect( + handler.execute({ ...mockContext, abortSignal: controller.signal }, mockBlock, { + ...baseInputs, + fallbackModels: [{ model: 'claude-sonnet-5' }], + }) + ).rejects.toThrow('timed out') + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1) + }) + + it('does not start another candidate after a stop during a skipped one', async () => { + const controller = new AbortController() + mockExecuteProviderRequest.mockRejectedValueOnce(new Error('primary down')) + mockValidateModelProvider.mockImplementation(async (_user, _workspace, model: string) => { + if (model !== 'claude-sonnet-5') return + controller.abort() + throw new Error('not permitted') + }) + + await expect( + handler.execute({ ...mockContext, abortSignal: controller.signal }, mockBlock, { + ...baseInputs, + fallbackModels: [{ model: 'claude-sonnet-5' }, { model: 'gpt-4o-mini' }], + }) + ).rejects.toThrow('primary down') + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1) + }) + + it('skips a fallback whose provider cannot take the attachments and hydrates once per provider', async () => { + const file = { + id: 'file-1', + name: 'example.png', + key: 'execution/test-workspace/test-workflow/exec-1/example.png', + url: 'https://storage.example.com/example.png', + size: 8, + type: 'image/png', + context: 'execution', + } + const hydrate = vi + .spyOn(userFileBase64, 'hydrateUserFilesWithBase64') + .mockImplementation(async (value) => { + const files = value as Array + return files.map((attachment) => ({ + ...attachment, + base64: 'iVBORw0KGgo=', + })) as typeof value + }) + mockGetProviderFromModel.mockImplementation((model: string) => + model.startsWith('deepseek') ? 'deepseek' : providerFor(model) + ) + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('down')) + .mockRejectedValueOnce(new Error('down')) + .mockResolvedValueOnce(providerResponse('claude-haiku-5')) + const blockLog = openLog() + + try { + await handler.execute({ ...mockContext, blockLogs: [blockLog] }, mockBlock, { + ...baseInputs, + userPrompt: 'Describe this file', + files: [file], + fallbackModels: [ + { model: 'deepseek-chat' }, + { model: 'claude-sonnet-5' }, + { model: 'claude-haiku-5' }, + ], + }) + + expect(mockAgentLogger.warn).toHaveBeenCalledWith( + 'Fallback model cannot take the attached files; skipping', + expect.objectContaining({ model: 'deepseek-chat' }) + ) + expect(mockExecuteProviderRequest.mock.calls.map(([, request]) => request.model)).toEqual([ + 'gpt-4o', + 'claude-sonnet-5', + 'claude-haiku-5', + ]) + /** One hydration for openai, one for anthropic; the skipped provider never hydrates. */ + expect(hydrate).toHaveBeenCalledTimes(2) + /** A skipped candidate is not a failed try. */ + expect(blockLog.modelFallbacks).toEqual(['gpt-4o', 'claude-sonnet-5']) + } finally { + hydrate.mockRestore() + } + }) + + it('does not prime a stream when no candidate follows', async () => { + mockExecuteProviderRequest.mockResolvedValueOnce( + streamingResponse([], { failBeforeFirstChunk: new Error('429 at stream start') }) + ) + + const result = (await handler.execute( + mockContext, + mockBlock, + baseInputs + )) as StreamingExecution + + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1) + await expect(drain(result.stream as ReadableStream)).rejects.toThrow( + '429 at stream start' + ) + }) + + it('does not fall back on an explicitly non-retryable failure, on any try', async () => { + const error = Object.assign(new Error('permanent'), { retryable: false }) + mockExecuteProviderRequest.mockRejectedValue(error) + const inputs = { ...baseInputs, fallbackModels: [{ model: 'claude-sonnet-5' }] } + + await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toBe(error) + await expect( + handler.execute(mockContext, mockBlock, inputs, { + nodeId: mockBlock.id, + retry: { attempt: 1, maxTries: 3, isFinalTry: false }, + }) + ).rejects.toBe(error) + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2) + expect(mockAgentLogger.warn).not.toHaveBeenCalledWith( + 'Agent model failed; trying fallback', + expect.anything() + ) + }) + + it('retries the selected model under the executor policy, then walks the fallbacks once', async () => { + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('one')) + .mockRejectedValueOnce(new Error('two')) + .mockRejectedValueOnce(new Error('three')) + .mockRejectedValueOnce(new Error('four')) + .mockResolvedValueOnce(providerResponse('gpt-4o-mini', 'from the third choice')) + const block = { + ...mockBlock, + config: { + tool: 'mock-tool', + params: { + ...baseInputs, + fallbackModels: [{ model: 'claude-sonnet-5' }, { model: 'gpt-4o-mini' }], + }, + }, + retry: { enabled: true, maxTries: 3, waitBetweenTriesMs: 0 }, + } as SerializedBlock + const workflow = { + version: '1', + blocks: [block], + connections: [], + loops: {}, + parallels: {}, + } as SerializedWorkflow + const state = new ExecutionState() + const executor = new BlockExecutor( + [handler], + new VariableResolver(workflow, {}, state), + { + workspaceId: 'test-workspace', + executionId: 'execution-1', + userId: 'user-1', + metadata: { + requestId: 'request-1', + executionId: 'execution-1', + workflowId: 'test-workflow', + workspaceId: 'test-workspace', + userId: 'user-1', + triggerType: 'manual', + useDraftState: false, + startTime: new Date().toISOString(), + }, + }, + state + ) + const ctx = { + ...mockContext, + executionId: 'execution-1', + userId: 'user-1', + blockStates: state.getBlockStates(), + blockLogs: [], + } as ExecutionContext + const node = { + id: block.id, + block, + incomingEdges: new Set(), + outgoingEdges: new Map(), + metadata: {}, + } as unknown as DAGNode + + const output = await executor.execute(ctx, node, block) + + /** Three tries on the selected model, then each fallback exactly once. */ + expect(mockExecuteProviderRequest.mock.calls.map(([, request]) => request.model)).toEqual([ + 'gpt-4o', + 'gpt-4o', + 'gpt-4o', + 'claude-sonnet-5', + 'gpt-4o-mini', + ]) + expect(output).toMatchObject({ model: 'gpt-4o-mini' }) + expect(ctx.blockLogs[0]).toMatchObject({ + success: true, + tries: 3, + modelFallbacks: ['gpt-4o', 'claude-sonnet-5'], + }) + expect(mockAgentLogger.info).toHaveBeenCalledTimes(2) + expect(mockAgentLogger.info).toHaveBeenNthCalledWith( + 2, + 'Fallback models held for the final try', + { blockId: mockBlock.id, attempt: 2, maxTries: 3 } + ) + }) + + it('keeps the fallback name when a routed sim-auto primary fails and a fallback answers', async () => { + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('pool model down')) + .mockResolvedValueOnce(providerResponse('gpt-5.4-mini')) + const blockLog = openLog() + + const result = (await handler.execute({ ...mockContext, blockLogs: [blockLog] }, mockBlock, { + model: SIM_AUTO_MODEL_ID, + systemPrompt: 'Be brief.', + userPrompt: 'Hello!', + fallbackModels: [{ model: 'gpt-5.4-mini', reasoningEffort: 'low' }], + })) as { model: string } + + expect(result.model).toBe('gpt-5.4-mini') + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2) + /** The trace names the auto identity, never the pool model that was routed. */ + expect(blockLog.modelFallbacks).toEqual([SIM_AUTO_MODEL_ID]) + /** The row's tuning was set against the auto id in the editor, so it applies whatever was routed. */ + expect(mockExecuteProviderRequest.mock.calls[1][1].reasoningEffort).toBe('low') + /** The auto identity preamble belongs to the pool model, not a named fallback. */ + const systemText = (request: { messages?: Array<{ role: string; content: string }> }) => + (request.messages ?? []) + .filter((message) => message.role === 'system') + .map((message) => message.content) + .join('\n') + expect(systemText(mockExecuteProviderRequest.mock.calls[0][1])).toContain('Sim auto model') + expect(systemText(mockExecuteProviderRequest.mock.calls[1][1])).not.toContain( + 'Sim auto model' + ) + }) + + it('records the failed models on the open log entry, not an earlier closed one', async () => { + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('down')) + .mockResolvedValueOnce(providerResponse('claude-sonnet-5')) + const closed = openLog(mockBlock.id, '2026-01-01T00:00:01.000Z') + const open = openLog() + + await handler.execute({ ...mockContext, blockLogs: [closed, open] }, mockBlock, { + ...baseInputs, + fallbackModels: [{ model: 'claude-sonnet-5' }], + }) + + expect(closed.modelFallbacks).toBeUndefined() + expect(open.modelFallbacks).toEqual(['gpt-4o']) + }) + + it('falls back when a streaming candidate closes before its first chunk', async () => { + mockExecuteProviderRequest + .mockResolvedValueOnce(streamingResponse([])) + .mockResolvedValueOnce(streamingResponse(['answer'])) + + const result = (await handler.execute(mockContext, mockBlock, { + ...baseInputs, + fallbackModels: [{ model: 'claude-sonnet-5' }], + })) as StreamingExecution + + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2) + expect(mockAgentLogger.warn).toHaveBeenCalledWith( + 'Agent model failed; trying fallback', + expect.objectContaining({ error: 'Provider stream closed before its first chunk' }) + ) + await expect(drain(result.stream as ReadableStream)).resolves.toEqual(['answer']) + }) + + it('falls back when a streaming primary fails before its first chunk, and replays the first chunk otherwise', async () => { + mockExecuteProviderRequest + .mockResolvedValueOnce( + streamingResponse([], { failBeforeFirstChunk: new Error('429 at stream start') }) + ) + .mockResolvedValueOnce(streamingResponse(['first', 'second'])) + + const result = (await handler.execute(mockContext, mockBlock, { + ...baseInputs, + fallbackModels: [{ model: 'claude-sonnet-5' }], + })) as StreamingExecution + + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2) + expect(mockAgentLogger.warn).toHaveBeenCalledWith( + 'Agent model failed; trying fallback', + expect.objectContaining({ failedModel: 'gpt-4o', error: '429 at stream start' }) + ) + expect(await drain(result.stream as ReadableStream)).toEqual(['first', 'second']) + }) + + it('leaves a failure after the first chunk to the stream, as before', async () => { + const midStream = new Error('dropped mid-stream') + mockExecuteProviderRequest.mockResolvedValueOnce( + streamingResponse(['first'], { failAfterFirstChunk: midStream }) + ) + + const result = (await handler.execute(mockContext, mockBlock, { + ...baseInputs, + fallbackModels: [{ model: 'claude-sonnet-5' }], + })) as StreamingExecution + + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1) + await expect(drain(result.stream as ReadableStream)).rejects.toBe(midStream) + }) + }) + describe('execute', () => { it('should execute a basic agent block request', async () => { const inputs = { diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index d96aeed8f74..cbc182f271a 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' import { isPlainRecord, omit } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records' @@ -39,6 +39,10 @@ import { selectModelBoundFileInputPaths, } from '@/lib/uploads/utils/model-input' import { hydrateUserFilesWithBase64 } from '@/lib/uploads/utils/user-file-base64.server' +import { + type FallbackModelCandidate, + resolveFallbackTuning, +} from '@/lib/workflows/blocks/fallback-models' import { resolveCustomBlockToolBinding } from '@/lib/workflows/custom-blocks/operations' import { getAgentToolUsageControlMode, @@ -54,6 +58,7 @@ import { validateModelProvider, } from '@/ee/access-control/utils/permission-check' import { AGENT, BlockType, DEFAULTS, stripCustomToolPrefix } from '@/executor/constants' +import { isRetryableBlockError } from '@/executor/execution/block-retry' import { memoryService } from '@/executor/handlers/agent/memory' import { buildLoadSkillTool, @@ -68,9 +73,21 @@ import type { ToolInput, } from '@/executor/handlers/agent/types' import { parseResponseFormat } from '@/executor/handlers/shared/response-format' -import type { BlockHandler, ExecutionContext, StreamingExecution, UserFile } from '@/executor/types' +import type { + BlockHandler, + BlockNodeMetadata, + ExecutionContext, + StreamingExecution, + UserFile, +} from '@/executor/types' import { collectBlockData } from '@/executor/utils/block-data' import { stringifyJSON } from '@/executor/utils/json' +import { + getModelFallbacks, + PROVIDER_FAMILY_CREDENTIAL_FIELDS, + recordModelFallbacks, + resolveFallbackApiKey, +} from '@/executor/utils/model-fallbacks' import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection' import { prepareResolvedSecretProjectedInputs } from '@/executor/utils/resolved-secret-input-projection' import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' @@ -131,6 +148,7 @@ const AGENT_RAW_PROVIDER_ERROR_INPUT_PATHS: readonly ResolvedSecretInputPath[] = ['thinkingLevel'], ['promptCaching'], ['previousInteractionId'], + ['fallbackModels'], ] interface IndexedToolInput { @@ -138,6 +156,65 @@ interface IndexedToolInput { toolIndex: number } +/** + * Removes the sim-auto identity preamble from the system messages built for a + * routed primary. A fallback the builder named is not a pool model, so it must + * not be told to hide which model it is. Messages are built once per block run + * (building them appends to memory), which is why this strips rather than + * rebuilds. + */ +function stripAutoPreamble(messages: Message[] | undefined): Message[] | undefined { + if (!messages) return messages + const prefix = `${SIM_AUTO_SYSTEM_PREAMBLE}\n\n` + return messages.flatMap((message) => { + if (message.role !== 'system' || typeof message.content !== 'string') return [message] + if (message.content === SIM_AUTO_SYSTEM_PREAMBLE) return [] + if (!message.content.startsWith(prefix)) return [message] + return [{ ...message, content: message.content.slice(prefix.length) }] + }) +} + +/** One model in the order the block tries them; the primary carries the block's own key. */ +interface ModelCandidate extends FallbackModelCandidate { + isPrimary: boolean + /** + * What the trace calls this model when it fails. A routed sim-auto primary + * shows as the auto identity, since naming the pool model is the leak that + * `applyAutoModelLabel` exists to close. + */ + traceName?: string +} + +interface ExecuteAcrossModelsConfig { + candidates: ModelCandidate[] + retryPrimaryOnStreamStart: boolean + primaryModel: string + /** + * The model the builder configured, which is what the editor showed the + * per-row tuning fields against. Under sim-auto that is the auto id, not the + * pool model routed for this run, so a row's value applies whatever was routed. + */ + configuredModel: string + primaryProviderId: string + messages: Message[] | undefined + /** Provider id to hydrated messages; seeded with the primary, filled per fallback provider. */ + hydratedByProvider: Map + fileProjection: ReturnType + modelInputs: AgentInputs + /** + * The system prompt without the sim-auto identity preamble, present only when + * the primary was auto-routed: a fallback the builder named is not a pool + * model and must not be told to hide which model it is. + */ + fallbackSystemPrompt?: string + formattedTools: ProviderToolConfig[] + responseFormat: any + streaming: boolean + settledInputRegistry: ResolvedSecretTraceRegistry | undefined + resultRegistry: ResolvedSecretTraceRegistry | undefined + providerErrorRegistry: ResolvedSecretTraceRegistry | undefined +} + interface FormattedAgentTools { tools: ProviderToolConfig[] inputProvenance: Map> @@ -229,7 +306,8 @@ export class AgentBlockHandler implements BlockHandler { async execute( ctx: ExecutionContext, block: SerializedBlock, - inputs: AgentInputs + inputs: AgentInputs, + nodeMetadata?: BlockNodeMetadata ): Promise { ctx.mcpBlockId = block.id const providerErrorRegistry = ctx.resolvedSecretTraceRegistry?.forkForInputPaths( @@ -304,6 +382,8 @@ export class AgentBlockHandler implements BlockHandler { ...modelInputProjection.value, responseFormat: responseFormatProjection.value, } + /** The system prompt as the model may see it, before any auto-routing preamble joins it. */ + const projectedSystemPrompt = modelInputs.systemPrompt const projectedToolInputs = this.projectToolInputsForProvenance(ctx, tools) await this.validateToolPermissions(ctx, filteredInputs.tools || []) @@ -391,24 +471,24 @@ export class AgentBlockHandler implements BlockHandler { skillMetadata, fileProjection ) - const messagesWithFiles = await this.hydrateMessageFilesForProvider( - ctx, - messagesWithInputFiles, - providerId, - fileProjection.projectedNameByFile, - fileProjection.modelBoundInputPaths - ) - - const providerRequest = this.buildProviderRequest({ - ctx, - providerId, - model, - messages: messagesWithFiles, - inputs: modelInputs, - formattedTools: formatted.tools, - responseFormat, - streaming: streamingConfig.shouldUseStreaming ?? false, - }) + /** + * The primary hydrates before the registries settle and fork, as it always + * has: hydration imports file provenance into the live registry, and the + * result fork below must carry it. Fallbacks on another provider hydrate + * inside the chain and re-fork there. + */ + const hydratedByProvider = new Map([ + [ + providerId, + await this.hydrateMessageFilesForProvider( + ctx, + messagesWithInputFiles, + providerId, + fileProjection.projectedNameByFile, + fileProjection.modelBoundInputPaths + ), + ], + ]) settlePrivateAgentSelectors() @@ -427,21 +507,84 @@ export class AgentBlockHandler implements BlockHandler { }) } } - const result = await this.executeProviderRequest( + + /** + * Retry on fail retries the selected model; the fallbacks join only on the + * try after which the executor promises no other. Until then a failure of + * the primary is left to escape, so the executor's policy can replay it. + * + * A follow-up turn of a deep-research interaction lives on the primary's + * provider; another model has none of that conversation, so a green answer + * from it would be built on a fresh context. Such a request never falls back. + */ + const configuredFallbacks = getModelFallbacks( ctx, - providerRequest, block, + filteredInputs.fallbackModels, + logger + ) + const retry = nodeMetadata?.retry + const fallbacksHeld = retry !== undefined && !retry.isFinalTry + const fallbackCandidates = + modelInputs.previousInteractionId || fallbacksHeld + ? [] + : configuredFallbacks.filter( + (candidate) => candidate.model.toLowerCase() !== model.toLowerCase() + ) + if (configuredFallbacks.length > 0 && modelInputs.previousInteractionId) { + logger.info('Fallback models skipped for a deep-research follow-up turn', { + blockId: block.id, + }) + } else if (configuredFallbacks.length > 0 && fallbacksHeld) { + logger.info('Fallback models held for the final try', { + blockId: block.id, + attempt: retry.attempt, + maxTries: retry.maxTries, + }) + } + const candidates: ModelCandidate[] = [ + { + model, + apiKey: modelInputs.apiKey, + isPrimary: true, + ...(autoRouting ? { traceName: SIM_AUTO_MODEL_ID } : {}), + }, + ...fallbackCandidates.map((candidate) => ({ ...candidate, isPrimary: false })), + ] + const { + result, + servedModel, + resultRegistry: servedRegistry, + } = await this.executeAcrossModels(ctx, block, { + candidates, + retryPrimaryOnStreamStart: + fallbacksHeld && configuredFallbacks.length > 0 && !modelInputs.previousInteractionId, + primaryModel: model, + configuredModel: autoRouting ? SIM_AUTO_MODEL_ID : model, + primaryProviderId: providerId, + messages: messagesWithInputFiles, + hydratedByProvider, + fileProjection, + modelInputs, + fallbackSystemPrompt: autoRouting ? projectedSystemPrompt : undefined, + formattedTools: formatted.tools, responseFormat, + streaming: streamingConfig.shouldUseStreaming ?? false, + settledInputRegistry, resultRegistry, - providerErrorRegistry - ) - if (resultRegistry) ctx.resolvedSecretTraceRegistry = resultRegistry + providerErrorRegistry, + }) + if (servedRegistry) ctx.resolvedSecretTraceRegistry = servedRegistry if (autoRouting && autoRouting.billableRoutingCost > 0) { this.applyRoutingCost(result, autoRouting.billableRoutingCost) } - if (autoRouting) { + /** + * A fallback the builder named explicitly is not a pool model, so it keeps + * its own name; only the routed pool model hides behind the auto label. + */ + if (autoRouting && servedModel === model) { this.applyAutoModelLabel(result, model) } @@ -2302,6 +2445,297 @@ export class AgentBlockHandler implements BlockHandler { return paths } + /** + * Runs the provider request against each candidate in order until one answers. + * + * Which model serves the request is decided here, inside one handler + * invocation. How many invocations the block gets is the executor's retry + * policy, and the caller keeps the fallbacks out of the candidate list until + * the final try, so with retry on the block runs the primary alone on every + * earlier try and walks the whole chain once. + * + * Falling through is deliberately as indiscriminate as block retry + * (`isRetryableBlockError`): a provider error carries no status, so an + * overloaded upstream cannot be told from any other failure, and a builder who + * lists fallbacks wants the block to answer. Only a stop and an explicitly + * non-retryable failure end the chain early. The abort signal is checked + * before the error itself because `handleExecutionError` rewrites a timeout + * into a plain `Error` without a cause, which the predicate would then treat + * as replayable. + * + * Messages are built once by the caller, since building them appends to + * memory. Hydration runs per provider, because attachment support and the + * inline budget differ, and is cached so two candidates on one provider do + * not download the same files twice. A fallback whose provider is + * blacklisted, not permitted, or cannot take the attachments is skipped + * rather than counted as a failed try. + * + * A streaming candidate is accepted only once its first chunk has arrived + * (`primeStreamingExecution`), so a startup failure inside the stream still + * falls through; that wait is skipped when no candidate follows, which keeps + * blocks without fallbacks on today's path. + * + * When every candidate fails, the last attempted candidate's error is thrown + * exactly as it escaped `executeProviderRequest`, with the registries that + * call installed for its projection, so error ports and the block-level + * error handling see the shapes they see today. The models that failed are + * written to the block log on every exit, cleared as well as set, because + * block retry reuses one log entry across tries. + */ + private async executeAcrossModels( + ctx: ExecutionContext, + block: SerializedBlock, + config: ExecuteAcrossModelsConfig + ): Promise<{ + result: BlockOutput | StreamingExecution + servedModel: string + resultRegistry: ResolvedSecretTraceRegistry | undefined + }> { + const { hydratedByProvider } = config + let resultRegistry = config.resultRegistry + const failedModels: string[] = [] + let lastError: unknown + let lastErrorRegistries: + | { + error: ResolvedSecretTraceRegistry | undefined + resolved: ResolvedSecretTraceRegistry | undefined + } + | undefined + + for (let index = 0; index < config.candidates.length; index++) { + const candidate = config.candidates[index] + const hasNext = index < config.candidates.length - 1 + + /** A run stopped while a candidate was being skipped must not start another. */ + if (!candidate.isPrimary && ctx.abortSignal?.aborted) break + + let candidateProviderId: string + if (candidate.isPrimary) { + candidateProviderId = config.primaryProviderId + } else { + try { + candidateProviderId = getProviderFromModel(candidate.model) + await validateModelProvider(ctx.userId, ctx.workspaceId, candidate.model, ctx) + } catch (error) { + this.warnFallbackSkipped(ctx, block, candidate.model, 'unusable', error) + continue + } + } + + let messages: Message[] | undefined + if (hydratedByProvider.has(candidateProviderId)) { + messages = hydratedByProvider.get(candidateProviderId) + } else { + try { + messages = await this.hydrateMessageFilesForProvider( + ctx, + config.messages, + candidateProviderId, + config.fileProjection.projectedNameByFile, + config.fileProjection.modelBoundInputPaths + ) + } catch (error) { + if (candidate.isPrimary) throw error + this.warnFallbackSkipped( + ctx, + block, + candidate.model, + 'cannot take the attached files', + error + ) + continue + } + hydratedByProvider.set(candidateProviderId, messages) + /** Hydration imported this provider's file provenance; the result fork must carry it. */ + resultRegistry = config.settledInputRegistry?.forkForInputPaths([]) + } + + /** + * A fallback's own key applies only while its key field is visible. On the + * primary's provider it reuses the block's key; otherwise the provider layer resolves BYOK or + * the platform key, or reports that a key is required, which counts as + * this candidate failing. A previous interaction id belongs to the primary's + * provider alone. Tuning is re-resolved against the fallback's own + * capabilities so the request is one its provider accepts. + */ + let inputs: AgentInputs = config.modelInputs + if (!candidate.isPrimary) { + const { adjustments, ...tuning } = resolveFallbackTuning( + candidate, + config.configuredModel, + config.modelInputs + ) + const sameProvider = candidateProviderId === config.primaryProviderId + inputs = { + ...(sameProvider + ? config.modelInputs + : omit(config.modelInputs, [...PROVIDER_FAMILY_CREDENTIAL_FIELDS, 'vertexCredential'])), + apiKey: resolveFallbackApiKey({ + candidate, + configuredModel: config.configuredModel, + sameProvider, + primaryApiKey: config.modelInputs.apiKey, + blockId: block.id, + logger, + }), + previousInteractionId: undefined, + ...(config.fallbackSystemPrompt !== undefined + ? { systemPrompt: config.fallbackSystemPrompt } + : {}), + ...tuning, + } + if (adjustments.length > 0) { + logger.info( + 'Fallback model tuning adjusted', + projectAgentDiagnosticMetadata( + ctx, + { blockId: block.id, model: candidate.model, adjustments }, + { blockId: block.id, adjustmentCount: adjustments.length } + ) + ) + } + } + + const providerRequest = this.buildProviderRequest({ + ctx, + providerId: candidateProviderId, + model: candidate.model, + messages: + !candidate.isPrimary && config.fallbackSystemPrompt !== undefined + ? stripAutoPreamble(messages) + : messages, + inputs, + formattedTools: config.formattedTools, + responseFormat: config.responseFormat, + streaming: config.streaming, + }) + + try { + let result = await this.executeProviderRequest( + ctx, + providerRequest, + block, + config.responseFormat, + resultRegistry, + config.providerErrorRegistry + ) + if ((hasNext || config.retryPrimaryOnStreamStart) && this.isStreamingExecution(result)) { + result = await this.primeStreamingExecution(result as StreamingExecution) + } + recordModelFallbacks(ctx, block, failedModels) + return { result, servedModel: candidate.model, resultRegistry } + } catch (error) { + lastError = error + failedModels.push(candidate.traceName ?? candidate.model) + if (!hasNext || ctx.abortSignal?.aborted || !isRetryableBlockError(error)) { + recordModelFallbacks(ctx, block, failedModels.slice(0, -1)) + throw error + } + + /** + * `executeProviderRequest` installed the failed attempt's error + * registry, which is the only one that knows secrets a tool call + * activated; the warn is projected against it before the next candidate + * starts from the settled inputs again. The pair is kept so a rethrow + * after every remaining candidate was skipped projects the same way. + */ + const errorRegistry = ctx.errorResolvedSecretTraceRegistry + const diagnosticCtx = errorRegistry + ? { ...ctx, resolvedSecretTraceRegistry: errorRegistry } + : ctx + logger.warn( + 'Agent model failed; trying fallback', + projectAgentDiagnosticMetadata( + diagnosticCtx, + { + blockId: block.id, + failedModel: candidate.model, + nextModel: config.candidates[index + 1].model, + candidate: index + 1, + error: getErrorMessage(error), + }, + { blockId: block.id, candidate: index + 1 } + ) + ) + lastErrorRegistries = { error: errorRegistry, resolved: ctx.resolvedSecretTraceRegistry } + ctx.errorResolvedSecretTraceRegistry = config.providerErrorRegistry + ctx.resolvedSecretTraceRegistry = config.settledInputRegistry + } + } + + /** Reached only when every candidate after the last failure was skipped. */ + if (lastErrorRegistries) { + ctx.errorResolvedSecretTraceRegistry = lastErrorRegistries.error + ctx.resolvedSecretTraceRegistry = lastErrorRegistries.resolved + } + recordModelFallbacks(ctx, block, failedModels.slice(0, -1)) + throw lastError + } + + /** + * Warns that a fallback candidate was passed over, projected like every other + * diagnostic so a model id resolved from a reference never reaches the log. + * A skipped candidate is not a failed try: it never appears in `modelFallbacks`. + */ + private warnFallbackSkipped( + ctx: ExecutionContext, + block: SerializedBlock, + model: string, + reason: 'unusable' | 'cannot take the attached files', + error: unknown + ): void { + logger.warn( + `Fallback model ${reason}; skipping`, + projectAgentDiagnosticMetadata( + ctx, + { blockId: block.id, model, error: getErrorMessage(error) }, + { blockId: block.id } + ) + ) + } + + /** + * Waits for a streaming candidate's first chunk before accepting it. + * + * With tools attached, providers open the stream first and issue the initial + * upstream request inside it, so a 429 at startup would otherwise surface + * only when the executor drains the stream, past every fallback. Reading one + * chunk moves that failure back inside the candidate loop; the chunk is + * re-emitted at the head of the returned stream, and nothing has reached the + * client yet, so the next candidate cannot duplicate output. A stream that + * fails after its first chunk stays a stream failure, as it is today. + */ + private async primeStreamingExecution(result: StreamingExecution): Promise { + const reader = result.stream.getReader() + const first = await reader.read() + /** + * A stream that closes before its first chunk answered nothing; with another + * candidate waiting that is a startup failure to fall through from, not an + * empty answer to return. The last candidate is never primed, so a block + * without fallbacks still returns such a stream as it always has. + */ + if (first.done) { + throw new Error('Provider stream closed before its first chunk') + } + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(first.value) + }, + async pull(controller) { + const next = await reader.read() + if (next.done) { + controller.close() + return + } + controller.enqueue(next.value) + }, + cancel(reason) { + return reader.cancel(reason) + }, + }) + return { ...result, stream } + } + private buildProviderRequest(config: { ctx: ExecutionContext providerId: string diff --git a/apps/sim/executor/handlers/agent/types.ts b/apps/sim/executor/handlers/agent/types.ts index d2e30314589..f752859e34b 100644 --- a/apps/sim/executor/handlers/agent/types.ts +++ b/apps/sim/executor/handlers/agent/types.ts @@ -1,4 +1,5 @@ import type { McpOperationPolicy } from '@/lib/mcp/operation-policy' +import type { FallbackModelEntry } from '@/lib/workflows/blocks/fallback-models' import type { UserFile } from '@/executor/types' import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry' @@ -32,8 +33,8 @@ export interface AgentInputs { // Deep research multi-turn previousInteractionId?: string // Interactions API previous interaction reference // LLM parameters - temperature?: string - maxTokens?: string + temperature?: string | number + maxTokens?: string | number apiKey?: string azureEndpoint?: string azureApiVersion?: string @@ -48,6 +49,8 @@ export interface AgentInputs { thinkingLevel?: string promptCaching?: boolean files?: unknown + /** Ordered models tried when the request to `model` fails; see `normalizeFallbackModels`. */ + fallbackModels?: Array & { model: string }> } /** diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts index 22a337b3812..47932eb9470 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts @@ -160,6 +160,53 @@ describe('EvaluatorBlockHandler', () => { apiKey: 'test-api-key', } + it('preserves metric scores and Auto routing cost when a fallback answers', async () => { + mockGetProviderFromModel.mockImplementation((model: string) => + model.startsWith('claude') ? 'anthropic' : 'fireworks' + ) + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('overloaded')) + .mockResolvedValueOnce({ + content: '{"score1":7}', + model: 'claude-sonnet-5', + tokens: { input: 12, output: 3, total: 15 }, + cost: { input: 0.003, output: 0.001, total: 0.004 }, + }) + const output = await handler.execute(mockContext, mockBlock, { + ...admissionInputs, + model: 'sim-auto', + fallbackModels: [{ model: 'claude-sonnet-5' }], + }) + expect(output).toMatchObject({ + content: admissionInputs.content, + model: 'claude-sonnet-5', + score1: 7, + tokens: { total: 15 }, + cost: { total: 0.006 }, + }) + const first = mockExecuteProviderRequest.mock.calls[0][1] + const fallback = mockExecuteProviderRequest.mock.calls[1][1] + expect(fallback.responseFormat).toEqual(first.responseFormat) + expect(fallback.systemPrompt).not.toContain('Sim auto system preamble') + expect(fallback.apiKey).toBeUndefined() + }) + + it('forwards retry metadata so fallbacks wait for the final primary attempt', async () => { + mockExecuteProviderRequest.mockRejectedValueOnce(new Error('overloaded')) + await expect( + handler.execute( + mockContext, + mockBlock, + { + ...admissionInputs, + fallbackModels: [{ model: 'claude-sonnet-5' }], + }, + { nodeId: mockBlock.id, retry: { attempt: 1, maxTries: 2, isFinalTry: false } } + ) + ).rejects.toThrow('overloaded') + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1) + }) + it('refuses to reach the provider without an execution subject', async () => { mockContext.userId = undefined diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index 52f0859f88a..71bf795b96d 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -9,9 +9,9 @@ import { 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 type { BlockHandler, BlockNodeMetadata, ExecutionContext } from '@/executor/types' import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json' -import { executeBlockProviderRequest } from '@/executor/utils/provider-request' +import { executeModelRequestWithFallbacks } from '@/executor/utils/model-fallback-request' import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection' import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { @@ -38,7 +38,8 @@ export class EvaluatorBlockHandler implements BlockHandler { async execute( ctx: ExecutionContext, block: SerializedBlock, - inputs: Record + inputs: Record, + nodeMetadata?: BlockNodeMetadata ): Promise { const evaluatorConfig = { model: inputs.model || EVALUATOR.DEFAULT_MODEL, @@ -142,6 +143,7 @@ export class EvaluatorBlockHandler implements BlockHandler { 'Evaluate the content and provide scores for each metric as JSON.' } + const fallbackSystemPrompt = systemPromptObj.systemPrompt let model = evaluatorConfig.model let autoRouting: AutoRoutingResult | null = null if (isAutoModel(model)) { @@ -213,7 +215,12 @@ export class EvaluatorBlockHandler implements BlockHandler { workspaceId: ctx.workspaceId, } - const result = await executeBlockProviderRequest({ + const { result, usedFallback } = await executeModelRequestWithFallbacks({ + block, + configuredModel: evaluatorConfig.model, + fallbackModels: inputs.fallbackModels, + fallbackSystemPrompt, + retry: nodeMetadata?.retry, ctx, providerId, request: providerRequest, @@ -237,7 +244,7 @@ export class EvaluatorBlockHandler implements BlockHandler { return { content: inputs.content, - model: autoRouting ? SIM_AUTO_MODEL_ID : result.model, + model: autoRouting && !usedFallback ? SIM_AUTO_MODEL_ID : result.model, tokens: { input: inputTokens, output: outputTokens, diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index b69d6f4cf02..3f0fdbb4e28 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -187,6 +187,27 @@ describe('RouterBlockHandler', () => { expect(handler.canHandle(nonRouterBlock)).toBe(false) }) + it('selects the same legacy destination when a fallback provider answers', async () => { + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('overloaded')) + .mockResolvedValueOnce({ + content: 'target-block-1', + model: 'claude-sonnet-5', + tokens: { input: 10, output: 2, total: 12 }, + cost: 0.001, + }) + const output = await handler.execute(mockContext, mockBlock, { + prompt: 'Pick a destination', + model: 'gpt-4o', + fallbackModels: [{ model: 'claude-sonnet-5' }], + }) + expect(output).toMatchObject({ + model: 'claude-sonnet-5', + selectedPath: { blockId: 'target-block-1' }, + }) + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2) + }) + it('should execute router block correctly and select a path', async () => { const inputs = { prompt: 'Choose the best option.', @@ -788,6 +809,69 @@ describe('RouterBlockHandler V2', () => { expect(handler.canHandle(mockRouterV2Block)).toBe(true) }) + it('preserves route selection and reasoning when an Auto request falls back', async () => { + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('overloaded')) + .mockResolvedValueOnce({ + content: '{"route":"route-support","reasoning":"Needs assistance"}', + model: 'claude-sonnet-5', + tokens: { input: 10, output: 2, total: 12 }, + cost: { input: 0.0008, output: 0.0002, total: 0.001 }, + }) + const output = await handler.execute(mockContext, mockRouterV2Block, { + context: 'Help me', + model: 'sim-auto', + routes: [{ id: 'route-support', title: 'Support', value: 'Needs help' }], + fallbackModels: [{ model: 'claude-sonnet-5' }], + }) + expect(output).toMatchObject({ + model: 'claude-sonnet-5', + selectedRoute: 'route-support', + reasoning: 'Needs assistance', + cost: { total: 0.003 }, + }) + expect(mockExecuteProviderRequest.mock.calls[1][1].systemPrompt).toBe( + 'Generated V2 System Prompt' + ) + expect(mockExecuteProviderRequest.mock.calls[1][1].responseFormat).toEqual( + mockExecuteProviderRequest.mock.calls[0][1].responseFormat + ) + }) + + it('waits for the final retry before using a Router V2 fallback', async () => { + mockExecuteProviderRequest.mockRejectedValueOnce(new Error('overloaded')) + await expect( + handler.execute( + mockContext, + mockRouterV2Block, + { + context: 'Help me', + model: 'gpt-4o', + routes: [{ id: 'route-support', title: 'Support', value: 'Needs help' }], + fallbackModels: [{ model: 'claude-sonnet-5' }], + }, + { nodeId: mockRouterV2Block.id, retry: { attempt: 1, maxTries: 2, isFinalTry: false } } + ) + ).rejects.toThrow('overloaded') + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1) + }) + + it('does not ask a fallback to override a NO_MATCH decision', async () => { + mockExecuteProviderRequest.mockResolvedValueOnce({ + content: '{"route":"NO_MATCH","reasoning":"Unrelated"}', + model: 'gpt-4o', + }) + await expect( + handler.execute(mockContext, mockRouterV2Block, { + context: 'Unrelated', + model: 'gpt-4o', + routes: [{ id: 'route-support', title: 'Support', value: 'Needs help' }], + fallbackModels: [{ model: 'claude-sonnet-5' }], + }) + ).rejects.toThrow('Router could not determine a matching route') + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1) + }) + it('should execute router V2 and return reasoning', async () => { const inputs = { context: 'I need help with a billing issue', diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 613ff0ce239..1b3ad1d9711 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -16,8 +16,8 @@ import { isRouterV2BlockType, ROUTER, } from '@/executor/constants' -import type { BlockHandler, ExecutionContext } from '@/executor/types' -import { executeBlockProviderRequest } from '@/executor/utils/provider-request' +import type { BlockHandler, BlockNodeMetadata, ExecutionContext } from '@/executor/types' +import { executeModelRequestWithFallbacks } from '@/executor/utils/model-fallback-request' import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' @@ -49,15 +49,16 @@ export class RouterBlockHandler implements BlockHandler { async execute( ctx: ExecutionContext, block: SerializedBlock, - inputs: Record + inputs: Record, + nodeMetadata?: BlockNodeMetadata ): Promise { const isV2 = isRouterV2BlockType(block.metadata?.id) if (isV2) { - return this.executeV2(ctx, block, inputs) + return this.executeV2(ctx, block, inputs, nodeMetadata) } - return this.executeLegacy(ctx, block, inputs) + return this.executeLegacy(ctx, block, inputs, nodeMetadata) } /** @@ -66,7 +67,8 @@ export class RouterBlockHandler implements BlockHandler { private async executeLegacy( ctx: ExecutionContext, block: SerializedBlock, - inputs: Record + inputs: Record, + nodeMetadata?: BlockNodeMetadata ): Promise { const promptModelInputPaths: ResolvedSecretInputPath[] = [['prompt']] const modelInputProjection = projectResolvedModelInput( @@ -139,7 +141,12 @@ export class RouterBlockHandler implements BlockHandler { workspaceId: ctx.workspaceId, } - const result = await executeBlockProviderRequest({ + const { result, usedFallback } = await executeModelRequestWithFallbacks({ + block, + configuredModel: routerConfig.model, + fallbackModels: inputs.fallbackModels, + fallbackSystemPrompt: systemPrompt, + retry: nodeMetadata?.retry, ctx, providerId, request: providerRequest, @@ -172,7 +179,7 @@ export class RouterBlockHandler implements BlockHandler { return { prompt: inputs.prompt, - model: resolved.autoRouting ? SIM_AUTO_MODEL_ID : result.model, + model: resolved.autoRouting && !usedFallback ? SIM_AUTO_MODEL_ID : result.model, tokens: { input: tokens.input || DEFAULTS.TOKENS.PROMPT, output: tokens.output || DEFAULTS.TOKENS.COMPLETION, @@ -206,7 +213,8 @@ export class RouterBlockHandler implements BlockHandler { private async executeV2( ctx: ExecutionContext, block: SerializedBlock, - inputs: Record + inputs: Record, + nodeMetadata?: BlockNodeMetadata ): Promise { const routes = this.parseRoutes(inputs.routes) @@ -321,7 +329,12 @@ export class RouterBlockHandler implements BlockHandler { }, } - const result = await executeBlockProviderRequest({ + const { result, usedFallback } = await executeModelRequestWithFallbacks({ + block, + configuredModel: routerConfig.model, + fallbackModels: inputs.fallbackModels, + fallbackSystemPrompt: systemPrompt, + retry: nodeMetadata?.retry, ctx, providerId, request: providerRequest, @@ -389,7 +402,7 @@ export class RouterBlockHandler implements BlockHandler { return { context: inputs.context, - model: resolved.autoRouting ? SIM_AUTO_MODEL_ID : result.model, + model: resolved.autoRouting && !usedFallback ? SIM_AUTO_MODEL_ID : result.model, tokens: { input: tokens.input || DEFAULTS.TOKENS.PROMPT, output: tokens.output || DEFAULTS.TOKENS.COMPLETION, diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 164d3de0874..4cc1367c032 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -286,6 +286,13 @@ export interface BlockLog { errorHandled?: boolean /** Total handler tries, present only when the block retried at least once. */ tries?: number + /** + * Models that failed before the one that answered, in the order tried. + * Present only when an Agent block fell back at least once. Under retry only + * the final try walks the fallbacks, so the field reflects that try; every + * earlier try clears it. + */ + modelFallbacks?: string[] loopId?: string parallelId?: string iterationIndex?: number @@ -750,6 +757,22 @@ export interface BlockNodeMetadata { originalBlockId?: string isLoopNode?: boolean executionOrder?: number + /** Where this invocation sits in the block's retry policy; absent when the block has none. */ + retry?: BlockRetryAttempt +} + +/** + * One try of a block under its retry policy, told to the handler so it can hold + * work for the last try. `isFinalTry` is the executor's own judgment, not + * `attempt >= maxTries` recomputed by the handler: what makes a try final is the + * policy's business, and a handler that fails on a non-final try is promised + * another invocation for any retryable error. + */ +export interface BlockRetryAttempt { + /** 1-based. */ + attempt: number + maxTries: number + isFinalTry: boolean } export interface BlockHandler { diff --git a/apps/sim/executor/utils/model-fallback-request.test.ts b/apps/sim/executor/utils/model-fallback-request.test.ts new file mode 100644 index 00000000000..6bbcc93e0c7 --- /dev/null +++ b/apps/sim/executor/utils/model-fallback-request.test.ts @@ -0,0 +1,279 @@ +/** + * @vitest-environment node + */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionContext } from '@/executor/types' +import { executeModelRequestWithFallbacks } from '@/executor/utils/model-fallback-request' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import type { SerializedBlock } from '@/serializer/types' + +const { request, validateModel } = vi.hoisted(() => ({ + request: vi.fn(), + validateModel: vi.fn(), +})) + +vi.mock('@/executor/utils/provider-request', () => ({ executeBlockProviderRequest: request })) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + validateModelProvider: validateModel, +})) +vi.mock('@/providers/utils', () => ({ + getProviderFromModel: (model: string) => { + if (model.startsWith('claude')) return 'anthropic' + if (model.startsWith('vertex/')) return 'vertex' + return 'openai' + }, +})) + +function context(): ExecutionContext { + return { + workflowId: 'workflow-1', + userId: 'user-1', + workspaceId: 'workspace-1', + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), + blockStates: new Map(), + blockLogs: [ + { + blockId: 'block-1', + startedAt: '', + endedAt: '', + durationMs: 0, + success: false, + executionOrder: 1, + }, + ], + metadata: { duration: 0 }, + environmentVariables: {}, + decisions: { router: new Map(), condition: new Map() }, + loopExecutions: new Map(), + completedLoops: new Set(), + executedBlocks: new Set(), + activeExecutionPath: new Set(), + } +} + +const block: SerializedBlock = { + id: 'block-1', + metadata: { id: 'evaluator' }, + position: { x: 0, y: 0 }, + config: { tool: 'evaluator', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, +} + +function input() { + return { + ctx: context(), + block, + providerId: 'openai', + configuredModel: 'gpt-4o', + request: { + model: 'gpt-4o', + apiKey: 'primary-key', + systemPrompt: 'Score the content', + temperature: 0.3, + }, + fallbackSystemPrompt: 'Score the content', + fallbackModels: [{ model: 'claude-sonnet-5' }, { model: 'gpt-4o-mini' }], + resolvedSecretTraceRegistry: undefined, + } +} + +beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isHosted: false }) + request.mockReset().mockImplementation(async ({ request: candidate }) => ({ + content: '{}', + model: candidate.model, + })) + validateModel.mockReset().mockResolvedValue(undefined) +}) +afterEach(resetEnvFlagsMock) + +describe('executeModelRequestWithFallbacks', () => { + it('keeps a successful primary request unchanged and clears earlier fallback metadata', async () => { + const options = input() + options.ctx.blockLogs[0].modelFallbacks = ['old-model'] + const output = await executeModelRequestWithFallbacks(options) + expect(output).toMatchObject({ result: { model: 'gpt-4o' }, usedFallback: false }) + expect(request).toHaveBeenCalledTimes(1) + expect(request.mock.calls[0][0].request).toBe(options.request) + expect(options.ctx.blockLogs[0].modelFallbacks).toBeUndefined() + }) + + it('walks the chain in order, preserving the last error and recording earlier failed models', async () => { + const options = input() + const last = new Error('last provider failed') + request + .mockRejectedValueOnce(new Error('first')) + .mockRejectedValueOnce(new Error('second')) + .mockRejectedValueOnce(last) + await expect(executeModelRequestWithFallbacks(options)).rejects.toBe(last) + expect(request.mock.calls.map(([args]) => args.request.model)).toEqual([ + 'gpt-4o', + 'claude-sonnet-5', + 'gpt-4o-mini', + ]) + expect(options.ctx.blockLogs[0].modelFallbacks).toEqual(['gpt-4o', 'claude-sonnet-5']) + }) + + it.each([1, 2])('leaves primary attempt %i to the executor retry policy', async (attempt) => { + const error = new Error('overloaded') + request.mockRejectedValueOnce(error) + await expect( + executeModelRequestWithFallbacks({ + ...input(), + retry: { attempt, maxTries: 3, isFinalTry: false }, + }) + ).rejects.toBe(error) + expect(request).toHaveBeenCalledTimes(1) + }) + + it('uses fallbacks on the final primary try', async () => { + request.mockRejectedValueOnce(new Error('overloaded')) + expect( + await executeModelRequestWithFallbacks({ + ...input(), + retry: { attempt: 3, maxTries: 3, isFinalTry: true }, + }) + ).toMatchObject({ usedFallback: true, result: { model: 'claude-sonnet-5' } }) + }) + + it('skips denied and incompatible provider families without sending them a request', async () => { + request.mockRejectedValueOnce(new Error('overloaded')) + validateModel.mockImplementation(async (_user, _workspace, model) => { + if (model === 'claude-sonnet-5') throw new Error('not permitted') + }) + const options = { + ...input(), + fallbackModels: [ + { model: 'claude-sonnet-5' }, + { model: 'vertex/gemini-3.1-pro' }, + { model: 'gpt-4o-mini' }, + ], + } + const output = await executeModelRequestWithFallbacks(options) + expect(output.result.model).toBe('gpt-4o-mini') + expect(request).toHaveBeenCalledTimes(2) + expect(options.ctx.blockLogs[0].modelFallbacks).toEqual(['gpt-4o']) + }) + + it('returns the original error when all remaining candidates are skipped', async () => { + const error = new Error('overloaded') + request.mockRejectedValueOnce(error) + validateModel.mockRejectedValue(new Error('not permitted')) + await expect(executeModelRequestWithFallbacks(input())).rejects.toBe(error) + expect(request).toHaveBeenCalledTimes(1) + }) + + it.each([ + Object.assign(new Error('stopped'), { name: 'AbortError' }), + Object.assign(new Error('permanent'), { retryable: false }), + ])('does not fall back after a non-retryable error', async (error) => { + request.mockRejectedValueOnce(error) + await expect(executeModelRequestWithFallbacks(input())).rejects.toBe(error) + expect(request).toHaveBeenCalledTimes(1) + }) + + it('stops when cancelled between attempts', async () => { + const controller = new AbortController() + const error = new Error('provider failed during cancellation') + request.mockImplementationOnce(async () => { + controller.abort() + throw error + }) + const options = input() + options.ctx.abortSignal = controller.signal + await expect(executeModelRequestWithFallbacks(options)).rejects.toBe(error) + expect(request).toHaveBeenCalledTimes(1) + }) + + it('only sends a cross-provider key that was stored as a reference, and drops family credentials', async () => { + const options = input() + const rows = [{ model: 'claude-sonnet-5', apiKey: '{{ANTHROPIC_KEY}}', thinkingLevel: 'high' }] + options.block = { ...block, config: { ...block.config, params: { fallbackModels: rows } } } + request.mockRejectedValueOnce(new Error('overloaded')) + await executeModelRequestWithFallbacks({ + ...options, + request: { + ...options.request, + vertexProject: 'private-project', + bedrockSecretKey: 'private-key', + responseFormat: { name: 'scores', schema: { type: 'object' }, strict: true }, + }, + fallbackModels: [{ ...rows[0], apiKey: 'resolved-key' }], + }) + const fallback = request.mock.calls[1][0] + expect(fallback).toMatchObject({ + providerId: 'anthropic', + request: { + apiKey: 'resolved-key', + thinkingLevel: 'high', + temperature: 0.3, + responseFormat: { name: 'scores' }, + }, + }) + expect(fallback.request.vertexProject).toBeUndefined() + expect(fallback.request.bedrockSecretKey).toBeUndefined() + }) + + it.each(['raw-key', '{{MISSING_KEY}}'])( + 'does not send an unsafe or unresolved key: %s', + async (key) => { + const options = input() + const rows = [{ model: 'claude-sonnet-5', apiKey: key }] + options.block = { ...block, config: { ...block.config, params: { fallbackModels: rows } } } + request.mockRejectedValueOnce(new Error('overloaded')) + await executeModelRequestWithFallbacks({ ...options, fallbackModels: rows }) + expect(request.mock.calls[1][0].request.apiKey).toBeUndefined() + } + ) + + it('uses the primary key on the same provider even if a row retained an old key', async () => { + const options = input() + options.block = { + ...block, + config: { + ...block.config, + params: { fallbackModels: [{ model: 'gpt-4o-mini', apiKey: '{{OLD_KEY}}' }] }, + }, + } + request.mockRejectedValueOnce(new Error('overloaded')) + await executeModelRequestWithFallbacks({ + ...options, + fallbackModels: [{ model: 'gpt-4o-mini', apiKey: 'old-key' }], + }) + expect(request.mock.calls[1][0].request.apiKey).toBe('primary-key') + }) + + it('lets hosted fallbacks resolve platform or BYOK credentials instead of a stale row key', async () => { + setEnvFlags({ isHosted: true }) + const options = input() + options.block = { + ...block, + config: { + ...block.config, + params: { fallbackModels: [{ model: 'claude-sonnet-5', apiKey: '{{OLD_KEY}}' }] }, + }, + } + request.mockRejectedValueOnce(new Error('overloaded')) + await executeModelRequestWithFallbacks({ + ...options, + fallbackModels: [{ model: 'claude-sonnet-5', apiKey: 'old-key' }], + }) + expect(request.mock.calls[1][0].request.apiKey).toBeUndefined() + }) + + it('strips the Auto identity prompt from fallbacks and keeps the pool model out of the trace', async () => { + const options = input() + request.mockRejectedValueOnce(new Error('overloaded')) + await executeModelRequestWithFallbacks({ + ...options, + configuredModel: 'sim-auto', + request: { ...options.request, systemPrompt: 'Auto identity\n\nScore the content' }, + }) + expect(request.mock.calls[1][0].request.systemPrompt).toBe('Score the content') + expect(options.ctx.blockLogs[0].modelFallbacks).toEqual(['sim-auto']) + }) +}) diff --git a/apps/sim/executor/utils/model-fallback-request.ts b/apps/sim/executor/utils/model-fallback-request.ts new file mode 100644 index 00000000000..17b26763a67 --- /dev/null +++ b/apps/sim/executor/utils/model-fallback-request.ts @@ -0,0 +1,150 @@ +import { createLogger } from '@sim/logger' +import { omit } from '@sim/utils/object' +import { resolveFallbackTuning } from '@/lib/workflows/blocks/fallback-models' +import { providerRequiresFamilyCredentials } from '@/blocks/utils' +import { validateModelProvider } from '@/ee/access-control/utils/permission-check' +import { isRetryableBlockError } from '@/executor/execution/block-retry' +import type { BlockRetryAttempt, ExecutionContext } from '@/executor/types' +import { + getModelFallbacks, + PROVIDER_FAMILY_CREDENTIAL_FIELDS, + recordModelFallbacks, + resolveFallbackApiKey, +} from '@/executor/utils/model-fallbacks' +import { executeBlockProviderRequest } from '@/executor/utils/provider-request' +import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models' +import type { ProviderRequest, ProviderResponse } from '@/providers/types' +import { getProviderFromModel } from '@/providers/utils' +import type { SerializedBlock } from '@/serializer/types' + +const logger = createLogger('BlockModelFallbacks') + +interface ModelFallbackRequestInput { + ctx: ExecutionContext + block: SerializedBlock + providerId: string + request: ProviderRequest + configuredModel: string + fallbackModels: unknown + /** The original system prompt, before an Auto identity preamble was added. */ + fallbackSystemPrompt: string + retry?: BlockRetryAttempt + resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry | undefined +} + +/** + * Shared non-streaming model execution for Router and Evaluator. Block retries + * exhaust the primary first; the final try walks the ordered fallback models. + * Parsing a successful response remains the handler's job, so a routing decision + * such as NO_MATCH is never silently replaced by another model's decision. + */ +export async function executeModelRequestWithFallbacks({ + ctx, + block, + providerId, + request, + configuredModel, + fallbackModels, + fallbackSystemPrompt, + retry, + resolvedSecretTraceRegistry, +}: ModelFallbackRequestInput): Promise<{ result: ProviderResponse; usedFallback: boolean }> { + const fallbacks = getModelFallbacks(ctx, block, fallbackModels, logger) + const candidates = [ + { model: request.model }, + ...(retry && !retry.isFinalTry + ? [] + : fallbacks.filter( + (candidate) => candidate.model.toLowerCase() !== request.model.toLowerCase() + )), + ] + const failedModels: string[] = [] + let lastError: unknown + + for (const [index, candidate] of candidates.entries()) { + const isPrimary = index === 0 + if (!isPrimary && ctx.abortSignal?.aborted) break + let candidateProviderId = providerId + if (!isPrimary) { + try { + candidateProviderId = getProviderFromModel(candidate.model) + await validateModelProvider(ctx.userId, ctx.workspaceId, candidate.model, ctx) + if ( + candidateProviderId !== providerId && + providerRequiresFamilyCredentials(candidateProviderId) + ) { + throw new Error('Fallback requires credentials from a different provider family') + } + } catch (error) { + logger.warn( + 'Fallback model unusable; skipping', + projectResolvedSecretDiagnosticError(error, ctx.resolvedSecretTraceRegistry, { + blockId: block.id, + model: candidate.model, + }) + ) + continue + } + } + + let candidateRequest = request + if (!isPrimary) { + const sameProvider = candidateProviderId === providerId + const { adjustments: _adjustments, ...tuning } = resolveFallbackTuning( + candidate, + configuredModel, + request + ) + candidateRequest = { + ...(sameProvider ? request : omit(request, [...PROVIDER_FAMILY_CREDENTIAL_FIELDS])), + ...tuning, + temperature: tuning.temperature === undefined ? undefined : Number(tuning.temperature), + maxTokens: tuning.maxTokens === undefined ? undefined : Number(tuning.maxTokens), + model: candidate.model, + apiKey: resolveFallbackApiKey({ + candidate, + configuredModel, + sameProvider, + primaryApiKey: request.apiKey, + blockId: block.id, + logger, + }), + systemPrompt: fallbackSystemPrompt, + } + } + + try { + const result = await executeBlockProviderRequest({ + ctx, + providerId: candidateProviderId, + request: candidateRequest, + resolvedSecretTraceRegistry, + }) + recordModelFallbacks(ctx, block, failedModels) + return { result, usedFallback: !isPrimary } + } catch (error) { + lastError = error + failedModels.push( + isPrimary && isAutoModel(configuredModel) ? SIM_AUTO_MODEL_ID : candidate.model + ) + if ( + index === candidates.length - 1 || + ctx.abortSignal?.aborted || + !isRetryableBlockError(error) + ) + break + logger.warn( + 'Model request failed; trying fallback', + projectResolvedSecretDiagnosticError(error, ctx.resolvedSecretTraceRegistry, { + blockId: block.id, + failedModel: candidate.model, + }) + ) + } + } + + recordModelFallbacks(ctx, block, failedModels.slice(0, -1)) + throw lastError +} diff --git a/apps/sim/executor/utils/model-fallbacks.ts b/apps/sim/executor/utils/model-fallbacks.ts new file mode 100644 index 00000000000..84e29e366b6 --- /dev/null +++ b/apps/sim/executor/utils/model-fallbacks.ts @@ -0,0 +1,109 @@ +import type { createLogger } from '@sim/logger' +import { isPlainRecord } from '@sim/utils/object' +import { + type FallbackModelCandidate, + fallbackRowNeedsApiKey, + isWholeEnvVarReference, + normalizeFallbackModels, +} from '@/lib/workflows/blocks/fallback-models' +import type { ExecutionContext } from '@/executor/types' +import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection' +import type { SerializedBlock } from '@/serializer/types' + +type Logger = ReturnType + +/** Credentials scoped to one provider family, never forwarded across providers. */ +export const PROVIDER_FAMILY_CREDENTIAL_FIELDS = [ + 'azureEndpoint', + 'azureApiVersion', + 'vertexProject', + 'vertexLocation', + 'bedrockAccessKeyId', + 'bedrockSecretKey', + 'bedrockRegion', +] as const + +/** Validates stored key references before admitting their resolved values to a provider. */ +export function getModelFallbacks( + ctx: ExecutionContext, + block: SerializedBlock, + rows: unknown, + logger: Logger +): FallbackModelCandidate[] { + if (!Array.isArray(rows)) return [] + const storedRows: unknown = block.config?.params?.fallbackModels + return normalizeFallbackModels( + rows.map((row, index) => { + if (!isPlainRecord(row) || row.apiKey === undefined) return row + const stored = Array.isArray(storedRows) ? storedRows[index] : undefined + if (isPlainRecord(stored) && isWholeEnvVarReference(stored.apiKey)) return row + const projection = projectResolvedSecretDiagnosticContent( + { blockId: block.id, model: row.model, row: index + 1 }, + ctx.resolvedSecretTraceRegistry + ) + logger.warn( + 'Fallback row key ignored; only an environment variable reference is accepted', + projection.safe && isPlainRecord(projection.value) + ? projection.value + : { blockId: block.id, row: index + 1 } + ) + return { ...row, apiKey: undefined } + }) + ) +} + +/** A hidden or unresolved row key cannot override the primary, BYOK, or platform key. */ +export function resolveFallbackApiKey({ + candidate, + configuredModel, + sameProvider, + primaryApiKey, + blockId, + logger, +}: { + candidate: FallbackModelCandidate + configuredModel: string + sameProvider: boolean + primaryApiKey: string | undefined + blockId: string + logger: Logger +}): string | undefined { + let rowKey = fallbackRowNeedsApiKey(candidate.model, configuredModel) + ? candidate.apiKey + : undefined + if (rowKey && isWholeEnvVarReference(rowKey)) { + logger.warn('Fallback key variable is not set for this run', { + blockId, + model: candidate.model, + variable: rowKey, + }) + rowKey = undefined + } + return rowKey ?? (sameProvider ? primaryApiKey : undefined) +} + +/** Records failed candidates on the current attempt's log, projecting secret-derived model names. */ +export function recordModelFallbacks( + ctx: ExecutionContext, + block: SerializedBlock, + failedModels: string[] +): void { + const logs = ctx.blockLogs ?? [] + for (let index = logs.length - 1; index >= 0; index--) { + const entry = logs[index] + if (entry.blockId !== block.id || entry.endedAt !== '') continue + if (failedModels.length === 0) { + entry.modelFallbacks = undefined + return + } + const registry = ctx.errorResolvedSecretTraceRegistry ?? ctx.resolvedSecretTraceRegistry + const projection = projectResolvedSecretDiagnosticContent({ models: failedModels }, registry) + const models = + projection.safe && isPlainRecord(projection.value) ? projection.value.models : undefined + entry.modelFallbacks = + Array.isArray(models) && models.every((model) => typeof model === 'string') + ? [...models] + : undefined + return + } +} diff --git a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts index e1b4a837c8d..7abe0bcbc71 100644 --- a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts +++ b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts @@ -115,6 +115,7 @@ function createBaseSpan(log: ValidBlockLog): TraceSpan { ...(log.childTraceDisabled ? { childTraceDisabled: true } : {}), ...(log.errorHandled && { errorHandled: true }), ...(log.tries !== undefined && { tries: log.tries }), + ...(log.modelFallbacks?.length && { modelFallbacks: log.modelFallbacks }), ...(log.loopId && { loopId: log.loopId }), ...(log.parallelId && { parallelId: log.parallelId }), ...(log.iterationIndex !== undefined && { iterationIndex: log.iterationIndex }), diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts index 09a0c25858c..70444e6cb47 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts @@ -1027,6 +1027,36 @@ describe('buildTraceSpans', () => { }) }) +describe('modelFallbacks', () => { + const log = (modelFallbacks?: string[]) => ({ + blockId: 'agent-1', + blockName: 'Agent', + blockType: 'agent', + startedAt: '2024-01-01T10:00:00.000Z', + endedAt: '2024-01-01T10:00:01.000Z', + durationMs: 1000, + success: true, + output: { content: 'ok', model: 'gpt-5.4-mini' }, + executionOrder: 1, + ...(modelFallbacks ? { modelFallbacks } : {}), + }) + + it.concurrent('carries the failed models onto the span and omits the field when empty', () => { + const withFallbacks = buildTraceSpans({ + success: true, + output: {}, + logs: [log(['claude-sonnet-5'])], + }) + expect(withFallbacks.traceSpans[0].modelFallbacks).toEqual(['claude-sonnet-5']) + expect(withFallbacks.traceSpans[0].model).toBe('gpt-5.4-mini') + + const empty = buildTraceSpans({ success: true, output: {}, logs: [log([])] }) + expect(empty.traceSpans[0]).not.toHaveProperty('modelFallbacks') + const none = buildTraceSpans({ success: true, output: {}, logs: [log()] }) + expect(none.traceSpans[0]).not.toHaveProperty('modelFallbacks') + }) +}) + describe('errorHandled - handled errors should not bubble up', () => { it.concurrent('block span stays error but is marked errorHandled', () => { const result: ExecutionResult = { diff --git a/apps/sim/lib/logs/types.ts b/apps/sim/lib/logs/types.ts index 80d9f7542a1..19c9f4a5c1b 100644 --- a/apps/sim/lib/logs/types.ts +++ b/apps/sim/lib/logs/types.ts @@ -285,6 +285,8 @@ export interface TraceSpan { errorHandled?: boolean /** Total handler tries, present only when the block retried at least once. */ tries?: number + /** Models that failed before the one that answered; present only when the block fell back. */ + modelFallbacks?: string[] tokens?: TokenInfo relativeStartMs?: number blockId?: string diff --git a/apps/sim/lib/workflows/blocks/fallback-models.test.ts b/apps/sim/lib/workflows/blocks/fallback-models.test.ts new file mode 100644 index 00000000000..123b112b130 --- /dev/null +++ b/apps/sim/lib/workflows/blocks/fallback-models.test.ts @@ -0,0 +1,438 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockShouldRequireApiKey, mockRequiresFamilyCredentials } = vi.hoisted(() => ({ + mockShouldRequireApiKey: vi.fn((model: string) => false), + mockRequiresFamilyCredentials: vi.fn((provider: string | null | undefined) => false), +})) + +vi.mock('@/blocks/utils', () => ({ + shouldRequireApiKeyForModel: mockShouldRequireApiKey, + providerRequiresFamilyCredentials: mockRequiresFamilyCredentials, +})) + +vi.mock('@/providers/models', () => ({ + isAutoModel: (model: string) => model.trim().toLowerCase() === 'sim-auto', + isKnownModelId: (model: string) => model.startsWith('gpt') || model.startsWith('claude'), + findProviderFromModel: (model: string) => { + const lower = model.toLowerCase() + if (lower.startsWith('gpt')) return 'openai' + if (lower.startsWith('claude')) return 'anthropic' + if (lower.startsWith('vertex/')) return 'vertex' + if (lower.startsWith('openrouter/')) return 'openrouter' + return null + }, + getReasoningEffortValuesForModel: (model: string) => + model === 'gpt-big' + ? ['low', 'medium', 'high', 'xhigh'] + : model === 'gpt-small' + ? ['low', 'high'] + : null, + getThinkingLevelsForModel: (model: string) => + model.startsWith('claude') ? ['low', 'medium', 'high'] : null, + getVerbosityValuesForModel: (model: string) => + model.startsWith('gpt') ? ['low', 'medium', 'high'] : null, + getMaxTemperature: (model: string) => + model.startsWith('claude') ? 1 : model.startsWith('gpt') ? 2 : undefined, + getModelCapabilities: (model: string) => + model === 'gpt-small' + ? { maxOutputTokens: 4096 } + : model.startsWith('gpt') + ? { maxOutputTokens: 16000 } + : model.startsWith('claude') + ? {} + : null, +})) + +import { + addFallbackRow, + changeFallbackRowApiKey, + changeFallbackRowModel, + changeFallbackRowTuning, + fallbackRowNeedsApiKey, + getFallbackTuningKnobsToShow, + getTuningOptionsForModel, + isTuningValueValidForModel, + isViableFallbackModel, + isWholeEnvVarReference, + MAX_FALLBACK_MODELS, + moveFallbackRow, + normalizeFallbackModels, + normalizeTuningValues, + ordinalChoiceLabel, + removeFallbackRow, + resolveFallbackTuning, +} from '@/lib/workflows/blocks/fallback-models' + +beforeEach(() => { + vi.clearAllMocks() + mockShouldRequireApiKey.mockReturnValue(false) + mockRequiresFamilyCredentials.mockReturnValue(false) +}) + +describe('isWholeEnvVarReference', () => { + it('accepts exactly one braced variable name', () => { + expect(isWholeEnvVarReference('{{OPENROUTER_API_KEY}}')).toBe(true) + expect(isWholeEnvVarReference('{{ key_1 }}')).toBe(true) + }) + + it('refuses raw keys, partial references, and non-strings', () => { + expect(isWholeEnvVarReference('sk-live-abc')).toBe(false) + expect(isWholeEnvVarReference('prefix {{KEY}}')).toBe(false) + expect(isWholeEnvVarReference('{{A}}{{B}}')).toBe(false) + expect(isWholeEnvVarReference('{{1BAD}}')).toBe(false) + expect(isWholeEnvVarReference(42)).toBe(false) + expect(isWholeEnvVarReference(null)).toBe(false) + }) +}) + +describe('normalizeFallbackModels', () => { + it('returns an empty chain for anything that is not an array', () => { + expect(normalizeFallbackModels(undefined)).toEqual([]) + expect(normalizeFallbackModels('gpt-5')).toEqual([]) + expect(normalizeFallbackModels({ model: 'gpt-5' })).toEqual([]) + }) + + it('keeps order, trims, and drops rows without a model', () => { + expect( + normalizeFallbackModels([ + { id: 'a', model: ' gpt-5 ' }, + { id: 'b', model: '' }, + { id: 'c' }, + null, + { id: 'd', model: 'claude-sonnet-5' }, + ]) + ).toEqual([{ model: 'gpt-5' }, { model: 'claude-sonnet-5' }]) + }) + + it('drops sim-auto and case-insensitive duplicates, keeping the first position', () => { + expect( + normalizeFallbackModels([ + { model: 'sim-auto' }, + { model: 'gpt-5' }, + { model: 'GPT-5' }, + { model: 'claude-sonnet-5' }, + ]) + ).toEqual([{ model: 'gpt-5' }, { model: 'claude-sonnet-5' }]) + }) + + it('keeps a row tuning value, trimmed and lower-cased', () => { + expect( + normalizeFallbackModels([{ model: 'gpt-small', reasoningEffort: ' Low ', thinkingLevel: '' }]) + ).toEqual([{ model: 'gpt-small', reasoningEffort: 'low' }]) + }) + + it('keeps any non-empty key, reference or already resolved, and drops blanks', () => { + expect( + normalizeFallbackModels([ + { model: 'openrouter/a', apiKey: '{{OPENROUTER_API_KEY}}' }, + { model: 'openrouter/b', apiKey: ' sk-resolved-at-runtime ' }, + { model: 'openrouter/c', apiKey: '' }, + { model: 'openrouter/d', apiKey: 42 }, + ]) + ).toEqual([ + { model: 'openrouter/a', apiKey: '{{OPENROUTER_API_KEY}}' }, + { model: 'openrouter/b', apiKey: 'sk-resolved-at-runtime' }, + { model: 'openrouter/c' }, + { model: 'openrouter/d' }, + ]) + }) + + it('caps the chain', () => { + const rows = Array.from({ length: MAX_FALLBACK_MODELS + 3 }, (_, i) => ({ model: `m-${i}` })) + expect(normalizeFallbackModels(rows)).toHaveLength(MAX_FALLBACK_MODELS) + }) +}) + +describe('isViableFallbackModel', () => { + it('rejects empty, sim-auto, the primary itself, and unresolvable ids', () => { + expect(isViableFallbackModel('', 'gpt-5')).toBe(false) + expect(isViableFallbackModel('sim-auto', 'gpt-5')).toBe(false) + expect(isViableFallbackModel('GPT-5', 'gpt-5')).toBe(false) + expect(isViableFallbackModel('mystery-model', 'gpt-5')).toBe(false) + }) + + it('offers any resolvable model that needs no provider-family credentials', () => { + expect(isViableFallbackModel('claude-sonnet-5', 'gpt-5')).toBe(true) + mockShouldRequireApiKey.mockReturnValue(true) + expect(isViableFallbackModel('openrouter/x', 'gpt-5')).toBe(true) + }) + + it('offers a family-bound model only alongside a primary of the same family', () => { + mockRequiresFamilyCredentials.mockImplementation((provider) => provider === 'vertex') + expect(isViableFallbackModel('vertex/gemini-b', 'vertex/gemini-a')).toBe(true) + expect(isViableFallbackModel('vertex/gemini-b', 'gpt-5')).toBe(false) + }) +}) + +describe('fallbackRowNeedsApiKey', () => { + it('is false when the model needs no key at all', () => { + expect(fallbackRowNeedsApiKey('claude-sonnet-5', 'gpt-5')).toBe(false) + }) + + it('is false when the block key on the same provider can be reused', () => { + mockShouldRequireApiKey.mockReturnValue(true) + expect(fallbackRowNeedsApiKey('gpt-5-mini', 'gpt-5')).toBe(false) + }) + + it('is true for a keyed model on another provider', () => { + mockShouldRequireApiKey.mockReturnValue(true) + expect(fallbackRowNeedsApiKey('openrouter/x', 'gpt-5')).toBe(true) + }) +}) + +describe('tuning options and validity', () => { + it('offers the provider-decides entry first, then what the model declares', () => { + expect(getTuningOptionsForModel('gpt-small', 'reasoningEffort')).toEqual([ + 'auto', + 'low', + 'high', + ]) + expect(getTuningOptionsForModel('claude-sonnet-5', 'thinkingLevel')).toEqual([ + 'none', + 'low', + 'medium', + 'high', + ]) + expect(getTuningOptionsForModel('claude-sonnet-5', 'reasoningEffort')).toBeNull() + expect(getTuningOptionsForModel('', 'verbosity')).toBeNull() + }) + + it('treats unset, the sentinel, and declared values as valid, and passes uncatalogued through', () => { + expect(isTuningValueValidForModel('gpt-small', 'reasoningEffort', undefined)).toBe(true) + expect(isTuningValueValidForModel('gpt-small', 'reasoningEffort', 'auto')).toBe(true) + expect(isTuningValueValidForModel('gpt-small', 'reasoningEffort', 'High')).toBe(true) + expect(isTuningValueValidForModel('gpt-small', 'reasoningEffort', 'xhigh')).toBe(false) + expect(isTuningValueValidForModel('gpt-small', 'reasoningEffort', 42)).toBe(false) + expect(isTuningValueValidForModel('openrouter/x', 'reasoningEffort', 'anything')).toBe(true) + /** Catalogued but without the knob: nothing but unset or the sentinel is acceptable. */ + expect(isTuningValueValidForModel('claude-sonnet-5', 'reasoningEffort', 'high')).toBe(false) + expect(isTuningValueValidForModel('claude-sonnet-5', 'reasoningEffort', 'auto')).toBe(true) + }) +}) + +describe('getFallbackTuningKnobsToShow', () => { + it('shows a knob the primary lacks and one whose primary value the fallback does not declare', () => { + expect(getFallbackTuningKnobsToShow('claude-sonnet-5', 'gpt-big', {})).toEqual([ + 'thinkingLevel', + ]) + expect( + getFallbackTuningKnobsToShow('gpt-small', 'gpt-big', { reasoningEffort: 'xhigh' }) + ).toEqual(['reasoningEffort']) + }) + + it('stays bare when the primary value carries over or nothing is set', () => { + expect( + getFallbackTuningKnobsToShow('gpt-small', 'gpt-big', { reasoningEffort: 'high' }) + ).toEqual([]) + expect(getFallbackTuningKnobsToShow('gpt-small', 'gpt-big', {})).toEqual([]) + expect( + getFallbackTuningKnobsToShow('gpt-small', 'gpt-big', { reasoningEffort: 'auto' }) + ).toEqual([]) + }) +}) + +describe('resolveFallbackTuning', () => { + it('prefers the row value, inherits a declared primary value, and drops the rest', () => { + const resolved = resolveFallbackTuning( + { model: 'gpt-small', reasoningEffort: 'low' }, + 'gpt-big', + { + reasoningEffort: 'xhigh', + verbosity: 'high', + } + ) + expect(resolved.reasoningEffort).toBe('low') + expect(resolved.verbosity).toBe('high') + expect(resolved.thinkingLevel).toBeUndefined() + expect(resolved.adjustments).toEqual(['reasoningEffort: xhigh -> low']) + }) + + it('drops a primary value the fallback does not declare and says so', () => { + const resolved = resolveFallbackTuning({ model: 'gpt-small' }, 'gpt-big', { + reasoningEffort: 'xhigh', + }) + expect(resolved.reasoningEffort).toBeUndefined() + expect(resolved.adjustments).toEqual(['reasoningEffort: xhigh -> provider default']) + }) + + it('never inherits a knob the primary does not have', () => { + const resolved = resolveFallbackTuning({ model: 'claude-sonnet-5' }, 'gpt-big', { + thinkingLevel: 'high', + }) + expect(resolved.thinkingLevel).toBeUndefined() + }) + + it('treats a value stored under an uncatalogued primary as stale, and lets the row decide', () => { + /** The block never shows a graded knob for a model outside the catalog. */ + const stale = resolveFallbackTuning({ model: 'gpt-small' }, 'openrouter/custom', { + reasoningEffort: 'high', + }) + expect(stale.reasoningEffort).toBeUndefined() + expect(stale.adjustments).toEqual(['reasoningEffort: high -> provider default']) + + const own = resolveFallbackTuning( + { model: 'gpt-small', reasoningEffort: 'low' }, + 'openrouter/custom', + { reasoningEffort: 'high' } + ) + expect(own.reasoningEffort).toBe('low') + }) + + it('clamps temperature and max tokens to the fallback caps, keeping the input type', () => { + const resolved = resolveFallbackTuning({ model: 'claude-sonnet-5' }, 'gpt-big', { + temperature: '1.5', + maxTokens: 20000, + }) + expect(resolved.temperature).toBe('1') + expect(resolved.maxTokens).toBe(20000) + expect(resolved.adjustments).toEqual(['temperature: 1.5 -> 1']) + + const small = resolveFallbackTuning({ model: 'gpt-small' }, 'gpt-big', { + temperature: 0.2, + maxTokens: '20000', + }) + expect(small.temperature).toBe(0.2) + expect(small.maxTokens).toBe('4096') + + /** A value that never resolved to a number passes through untouched. */ + const unresolved = resolveFallbackTuning({ model: 'claude-sonnet-5' }, 'gpt-big', { + temperature: '{{TEMP}}', + }) + expect(unresolved.temperature).toBe('{{TEMP}}') + expect(unresolved.adjustments).toEqual([]) + }) + + it('passes everything through for an uncatalogued fallback', () => { + const resolved = resolveFallbackTuning({ model: 'openrouter/x' }, 'gpt-big', { + reasoningEffort: 'xhigh', + temperature: '1.9', + maxTokens: '99999', + }) + expect(resolved).toEqual({ + reasoningEffort: 'xhigh', + thinkingLevel: undefined, + verbosity: undefined, + temperature: '1.9', + maxTokens: '99999', + adjustments: [], + }) + }) +}) + +describe('resolveFallbackTuning hidden overrides', () => { + it('ignores a stored row value once the primary value fits and the field is no longer shown', () => { + const resolved = resolveFallbackTuning( + { model: 'gpt-small', reasoningEffort: 'low' }, + 'gpt-big', + { + reasoningEffort: 'high', + } + ) + expect(resolved.reasoningEffort).toBe('high') + expect(resolved.adjustments).toEqual([]) + }) +}) + +describe('normalizeTuningValues', () => { + it('keeps trimmed lower-cased strings and drops blanks and non-strings', () => { + expect( + normalizeTuningValues({ + reasoningEffort: ' Low ', + thinkingLevel: '', + verbosity: 3, + model: 'x', + }) + ).toEqual({ reasoningEffort: 'low' }) + }) + + it('stores the provider-decides entry as absence, as the editor does', () => { + expect( + normalizeTuningValues({ reasoningEffort: 'auto', thinkingLevel: 'NONE', verbosity: 'low' }) + ).toEqual({ verbosity: 'low' }) + }) +}) + +describe('row transforms', () => { + const rows = [ + { id: 'a', model: 'gpt-big' }, + { id: 'b', model: 'openrouter/x', apiKey: '{{OPENROUTER_API_KEY}}', reasoningEffort: 'low' }, + ] + + it('adds a blank row until the cap and never past it', () => { + expect(addFallbackRow(rows, 'c')).toEqual([...rows, { id: 'c', model: '' }]) + const full = Array.from({ length: MAX_FALLBACK_MODELS }, (_, i) => ({ + id: `r${i}`, + model: 'm', + })) + expect(addFallbackRow(full, 'extra')).toBe(full) + }) + + it('removes by id and moves within bounds', () => { + expect(removeFallbackRow(rows, 'a')).toEqual([rows[1]]) + expect(moveFallbackRow(rows, 'b', -1)).toEqual([rows[1], rows[0]]) + expect(moveFallbackRow(rows, 'a', -1)).toBe(rows) + expect(moveFallbackRow(rows, 'b', 1)).toBe(rows) + expect(moveFallbackRow(rows, 'missing', 1)).toBe(rows) + }) + + it('clears tuning on a model change and keeps the key only for the same keyed provider', () => { + mockShouldRequireApiKey.mockReturnValue(true) + expect(changeFallbackRowModel(rows, 'b', 'openrouter/y', 'claude-sonnet-5')[1]).toEqual({ + id: 'b', + model: 'openrouter/y', + apiKey: '{{OPENROUTER_API_KEY}}', + }) + /** Another provider must never receive the previous provider's credential. */ + expect(changeFallbackRowModel(rows, 'b', 'gpt-small', 'claude-sonnet-5')[1]).toEqual({ + id: 'b', + model: 'gpt-small', + }) + mockShouldRequireApiKey.mockReturnValue(false) + expect(changeFallbackRowModel(rows, 'b', 'openrouter/y', 'claude-sonnet-5')[1]).toEqual({ + id: 'b', + model: 'openrouter/y', + }) + /** A key that is not a reference never survives an edit, even on the same provider. */ + mockShouldRequireApiKey.mockReturnValue(true) + const raw = [{ id: 'r', model: 'openrouter/x', apiKey: 'sk-raw' }] + expect(changeFallbackRowModel(raw, 'r', 'openrouter/y', 'claude-sonnet-5')[0]).toEqual({ + id: 'r', + model: 'openrouter/y', + }) + }) + + it('stores a tuning value, and the provider-decides entry as absence', () => { + expect(changeFallbackRowTuning(rows, 'a', 'reasoningEffort', 'high')[0]).toEqual({ + id: 'a', + model: 'gpt-big', + reasoningEffort: 'high', + }) + expect(changeFallbackRowTuning(rows, 'b', 'reasoningEffort', 'auto')[1]).toEqual({ + id: 'b', + model: 'openrouter/x', + apiKey: '{{OPENROUTER_API_KEY}}', + }) + expect(changeFallbackRowApiKey(rows, 'a', '{{K}}')[0]).toEqual({ + id: 'a', + model: 'gpt-big', + apiKey: '{{K}}', + }) + }) +}) + +describe('ordinalChoiceLabel', () => { + it('starts at the 2nd choice and handles English ordinals', () => { + expect([0, 1, 2, 3, 9, 10, 11].map(ordinalChoiceLabel)).toEqual([ + '2nd choice', + '3rd choice', + '4th choice', + '5th choice', + '11th choice', + '12th choice', + '13th choice', + ]) + }) +}) diff --git a/apps/sim/lib/workflows/blocks/fallback-models.ts b/apps/sim/lib/workflows/blocks/fallback-models.ts new file mode 100644 index 00000000000..712cde89036 --- /dev/null +++ b/apps/sim/lib/workflows/blocks/fallback-models.ts @@ -0,0 +1,372 @@ +import { providerRequiresFamilyCredentials, shouldRequireApiKeyForModel } from '@/blocks/utils' +import { + findProviderFromModel, + getMaxTemperature, + getModelCapabilities, + getReasoningEffortValuesForModel, + getThinkingLevelsForModel, + getVerbosityValuesForModel, + isAutoModel, + isKnownModelId, +} from '@/providers/models' + +/** Upper bound on fallback rows; enough for a full provider spread without an unbounded chain. */ +export const MAX_FALLBACK_MODELS = 5 + +/** The graded tuning knobs a fallback row may set for itself. */ +export const FALLBACK_TUNING_KNOBS = ['reasoningEffort', 'thinkingLevel', 'verbosity'] as const +export type FallbackTuningKnob = (typeof FALLBACK_TUNING_KNOBS)[number] + +/** The "let the provider decide" entry each knob's field offers first, as the block's own fields do. */ +const KNOB_SENTINEL: Record = { + reasoningEffort: 'auto', + thinkingLevel: 'none', + verbosity: 'auto', +} + +export const FALLBACK_TUNING_LABELS: Record = { + reasoningEffort: 'Reasoning effort', + thinkingLevel: 'Thinking level', + verbosity: 'Verbosity', +} + +/** Per-row tuning: present only when the builder chose a value for that knob. */ +export type FallbackTuningValues = Partial> + +/** + * One stored fallback row. `id` is a React key only. `apiKey`, when present, is + * always a whole `{{ENV_VAR}}` reference: a raw secret nested inside a list + * value would bypass every redaction path that keys on a top-level + * `password: true` field, so the picker cannot produce one and the copilot + * validator refuses one. The tuning knobs hold a value only when the primary's + * setting could not be carried over (see `getFallbackTuningKnobsToShow`). + */ +export interface FallbackModelEntry extends FallbackTuningValues { + id: string + model: string + apiKey?: string +} + +/** A row the executor acts on: the React key is gone, and `apiKey` is a validated reference. */ +export interface FallbackModelCandidate extends FallbackTuningValues { + model: string + apiKey?: string +} + +const WHOLE_ENV_VAR_REFERENCE = /^\{\{\s*[A-Za-z_][A-Za-z0-9_]*\s*\}\}$/ + +/** Whether a value is exactly one `{{ENV_VAR}}` reference and nothing else. */ +export function isWholeEnvVarReference(value: unknown): value is string { + return typeof value === 'string' && WHOLE_ENV_VAR_REFERENCE.test(value.trim()) +} + +/** + * Normalizes a stored fallback list into the ordered candidates execution walks. + * + * Tolerant rather than strict because it runs on every execution: rows the + * editor could not have written (missing model, sim-auto) are dropped instead + * of failing the block, and duplicates keep their first position so the order + * the builder chose is preserved. + * + * A row's `apiKey` is kept as any non-empty string. By the time this runs the + * input resolver has already turned the stored `{{ENV_VAR}}` reference into the + * key itself, exactly as it does for the block's own API Key field; the + * reference-only rule is enforced where rows are written (the picker, the + * copilot validator) and where they leave the workspace (the export sanitizer). + */ +export function normalizeFallbackModels(raw: unknown): FallbackModelCandidate[] { + if (!Array.isArray(raw)) return [] + + const seen = new Set() + const candidates: FallbackModelCandidate[] = [] + for (const row of raw) { + if (!row || typeof row !== 'object') continue + const { model, apiKey } = row as { model?: unknown; apiKey?: unknown } + if (typeof model !== 'string') continue + const trimmed = model.trim() + if (!trimmed || isAutoModel(trimmed)) continue + const key = trimmed.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + const resolvedKey = typeof apiKey === 'string' ? apiKey.trim() : '' + candidates.push({ + model: trimmed, + ...(resolvedKey ? { apiKey: resolvedKey } : {}), + ...normalizeTuningValues(row as Record), + }) + if (candidates.length >= MAX_FALLBACK_MODELS) break + } + return candidates +} + +/** The tuning knobs a row carries, trimmed and lower-cased; blanks and non-strings are dropped. */ +export function normalizeTuningValues(row: Record): FallbackTuningValues { + const values: FallbackTuningValues = {} + for (const knob of FALLBACK_TUNING_KNOBS) { + const value = row[knob] + const level = typeof value === 'string' ? value.trim().toLowerCase() : '' + /** The provider-decides entry is stored as absence, the same way the editor stores it. */ + if (level && level !== KNOB_SENTINEL[knob]) values[knob] = level + } + return values +} + +/** + * The edits the editor makes to a fallback list, as pure transforms so the + * component stays a thin binding and the rules are unit-testable. + */ +export function addFallbackRow(rows: FallbackModelEntry[], id: string): FallbackModelEntry[] { + if (rows.length >= MAX_FALLBACK_MODELS) return rows + return [...rows, { id, model: '' }] +} + +export function removeFallbackRow(rows: FallbackModelEntry[], id: string): FallbackModelEntry[] { + return rows.filter((row) => row.id !== id) +} + +export function moveFallbackRow( + rows: FallbackModelEntry[], + id: string, + direction: -1 | 1 +): FallbackModelEntry[] { + const index = rows.findIndex((row) => row.id === id) + const target = index + direction + if (index === -1 || target < 0 || target >= rows.length) return rows + const next = [...rows] + ;[next[index], next[target]] = [next[target], next[index]] + return next +} + +/** + * A new model gets a clean row. Tuning always goes, since the new model may not + * declare it. The key survives only when the new model still needs one and sits + * on the same provider as the old one: a key reference is a credential for one + * provider, and carrying it to another would send that provider's secret to an + * unrelated service. + */ +export function changeFallbackRowModel( + rows: FallbackModelEntry[], + id: string, + model: string, + primaryModel: string +): FallbackModelEntry[] { + return rows.map((row) => { + if (row.id !== id) return row + const keepKey = + isWholeEnvVarReference(row.apiKey) && + fallbackRowNeedsApiKey(model, primaryModel) && + findProviderFromModel(model.trim()) === findProviderFromModel(row.model.trim()) + return { id: row.id, model, ...(keepKey ? { apiKey: row.apiKey } : {}) } + }) +} + +export function changeFallbackRowApiKey( + rows: FallbackModelEntry[], + id: string, + apiKey: string +): FallbackModelEntry[] { + return rows.map((row) => (row.id === id ? { ...row, apiKey } : row)) +} + +/** The provider-decides entry is the field's default, so it is stored as absence. */ +export function changeFallbackRowTuning( + rows: FallbackModelEntry[], + id: string, + knob: FallbackTuningKnob, + value: string +): FallbackModelEntry[] { + return rows.map((row) => { + if (row.id !== id) return row + const { [knob]: _previous, ...rest } = row + return value && value !== KNOB_SENTINEL[knob] ? { ...rest, [knob]: value } : rest + }) +} + +/** + * Whether a model can serve as a fallback for `primaryModel` with the + * credentials the block can actually give it. + * + * A fallback resolves its key the way the primary does, through workspace BYOK, + * the platform key, or the block's own field, with one addition: a row may name + * a workspace variable holding its key. What it can never do is inherit a Vertex + * credential, Bedrock keys, or an Azure endpoint from a primary in another + * family, because those fields only render for the primary's own provider. + */ +export function isViableFallbackModel(model: string, primaryModel: string): boolean { + const trimmed = model.trim() + if (!trimmed || isAutoModel(trimmed)) return false + if (trimmed.toLowerCase() === primaryModel.trim().toLowerCase()) return false + + const provider = findProviderFromModel(trimmed) + if (!provider) return false + + if (providerRequiresFamilyCredentials(provider)) { + return provider === findProviderFromModel(primaryModel.trim()) + } + return true +} + +/** + * Whether a fallback row must name a workspace variable for its key. + * + * A model that needs a key and shares the primary's provider reuses the + * block's own API Key field, so only a cross-provider fallback asks for one. + */ +export function fallbackRowNeedsApiKey(model: string, primaryModel: string): boolean { + const trimmed = model.trim() + if (!trimmed || !shouldRequireApiKeyForModel(trimmed)) return false + const provider = findProviderFromModel(trimmed) + return provider === null || provider !== findProviderFromModel(primaryModel.trim()) +} + +/** + * The values `model` accepts for `knob`, with the provider-decides entry first, + * exactly as the block's own field offers them. Null when the model lacks the + * knob or is not in the catalog, which is also how the block's field decides + * whether to render. + */ +export function getTuningOptionsForModel(model: string, knob: FallbackTuningKnob): string[] | null { + const trimmed = model.trim() + if (!trimmed) return null + const declared = + knob === 'reasoningEffort' + ? getReasoningEffortValuesForModel(trimmed) + : knob === 'thinkingLevel' + ? getThinkingLevelsForModel(trimmed) + : getVerbosityValuesForModel(trimmed) + if (!declared) return null + const sentinel = KNOB_SENTINEL[knob] + return [sentinel, ...declared.filter((value) => value !== sentinel)] +} + +/** + * Whether `value` can be sent to `model` for `knob`. Unset and the sentinel + * always can. Otherwise the model must declare it; a model the catalog does not + * know declares nothing, so anything passes through, as it does for the primary, + * while a catalogued model without the knob accepts nothing for it. + */ +export function isTuningValueValidForModel( + model: string, + knob: FallbackTuningKnob, + value: unknown +): boolean { + if (typeof value !== 'string') return value === undefined || value === null + const normalized = value.trim().toLowerCase() + if (!normalized || normalized === KNOB_SENTINEL[knob]) return true + const options = getTuningOptionsForModel(model, knob) + if (options === null) return !isKnownModelId(model.trim()) + return options.includes(normalized) +} + +/** + * The knobs a fallback row has to ask about: those the fallback model has that + * the primary's current setting cannot fill, either because the primary lacks + * the knob or because its value is not one the fallback declares. A value the + * fallback accepts is inherited silently, so a same-family row stays bare. + */ +export function getFallbackTuningKnobsToShow( + fallbackModel: string, + primaryModel: string, + primaryValues: Partial> +): FallbackTuningKnob[] { + return FALLBACK_TUNING_KNOBS.filter((knob) => { + if (getTuningOptionsForModel(fallbackModel, knob) === null) return false + if (getTuningOptionsForModel(primaryModel, knob) === null) return true + return !isTuningValueValidForModel(fallbackModel, knob, primaryValues[knob]) + }) +} + +export interface PrimaryTuningInputs extends Partial> { + temperature?: string | number + maxTokens?: string | number +} + +export interface ResolvedFallbackTuning extends Partial> { + temperature?: string | number + maxTokens?: string | number + /** Human-readable notes on every value that differs from the primary's, for the run log. */ + adjustments: string[] +} + +function clampToCap( + value: string | number | undefined, + cap: number | undefined +): string | number | undefined { + if (value === undefined || value === null || value === '' || cap === undefined) return value + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed <= cap) return value + return typeof value === 'number' ? cap : String(cap) +} + +/** + * The tuning a fallback candidate runs with. + * + * Graded knobs: the row's own value wins, but only for a knob the row is + * currently asked about (`getFallbackTuningKnobsToShow`), so a value stored + * while the primary was incompatible stops applying once the primary's own + * value fits and the field is no longer shown. Otherwise the primary's value is + * carried over only when the fallback declares it, and dropped to the + * provider's default otherwise, which is what the row field exists to override. + * Temperature and max output tokens are caps in the primary's terms, so they + * are clamped to what the fallback allows rather than dropped: a low + * temperature chosen for repeatability must survive, and "no more than N" still + * holds under a smaller ceiling. A fallback the catalog does not know has no + * caps and no lists, so everything passes through to it unchanged. A primary + * the catalog does not know never showed a graded knob in the editor, so a + * value stored under it is stale and is not inherited; the row shows the field + * instead, and its own value is what applies. + */ +export function resolveFallbackTuning( + candidate: FallbackModelCandidate, + primaryModel: string, + primary: PrimaryTuningInputs +): ResolvedFallbackTuning { + const adjustments: string[] = [] + const resolved: ResolvedFallbackTuning = { adjustments } + const overridable = new Set(getFallbackTuningKnobsToShow(candidate.model, primaryModel, primary)) + + for (const knob of FALLBACK_TUNING_KNOBS) { + const own = overridable.has(knob) ? candidate[knob] : undefined + if (own) { + resolved[knob] = own + if (own !== primary[knob]) adjustments.push(`${knob}: ${primary[knob] ?? 'unset'} -> ${own}`) + continue + } + const inherit = + getTuningOptionsForModel(primaryModel, knob) !== null && + isTuningValueValidForModel(candidate.model, knob, primary[knob]) + resolved[knob] = inherit ? primary[knob] : undefined + if (!inherit && primary[knob]) adjustments.push(`${knob}: ${primary[knob]} -> provider default`) + } + + resolved.temperature = clampToCap(primary.temperature, getMaxTemperature(candidate.model)) + if (resolved.temperature !== primary.temperature) { + adjustments.push(`temperature: ${primary.temperature} -> ${resolved.temperature}`) + } + resolved.maxTokens = clampToCap( + primary.maxTokens, + getModelCapabilities(candidate.model)?.maxOutputTokens + ) + if (resolved.maxTokens !== primary.maxTokens) { + adjustments.push(`maxTokens: ${primary.maxTokens} -> ${resolved.maxTokens}`) + } + + return resolved +} + +/** "2nd choice", "3rd choice", ... for the row at `index` (0-based) below the primary. */ +export function ordinalChoiceLabel(index: number): string { + const n = index + 2 + const mod100 = n % 100 + const suffix = + mod100 >= 11 && mod100 <= 13 + ? 'th' + : n % 10 === 1 + ? 'st' + : n % 10 === 2 + ? 'nd' + : n % 10 === 3 + ? 'rd' + : 'th' + return `${n}${suffix} choice` +} diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.test.ts b/apps/sim/lib/workflows/credentials/credential-extractor.test.ts index ccdd1be166d..c4f5a7c15e8 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.test.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.test.ts @@ -102,6 +102,43 @@ describe('export sanitizer resource coverage', () => { expect(sanitizedValue('oauth-input', 'cred-123')).toBeNull() }) + it('keeps fallback models and only whole env-var-referenced row keys', () => { + expect( + sanitizedValue('model-fallback-list', [ + { id: 'a', model: 'gpt-5' }, + { id: 'b', model: 'openrouter/x', apiKey: '{{OPENROUTER_API_KEY}}' }, + { id: 'c', model: 'openrouter/y', apiKey: 'sk-raw-secret' }, + { id: 'd', model: 'openrouter/z', apiKey: '{{A}} sk-raw {{B}}' }, + 'not-a-row', + ]) + ).toEqual([ + { id: 'a', model: 'gpt-5' }, + { id: 'b', model: 'openrouter/x', apiKey: '{{OPENROUTER_API_KEY}}' }, + { id: 'c', model: 'openrouter/y' }, + { id: 'd', model: 'openrouter/z' }, + 'not-a-row', + ]) + expect(sanitizedValue('model-fallback-list', 'opaque')).toBe('opaque') + }) + + it('drops even referenced fallback row keys when env vars are not preserved', () => { + vi.mocked(getBlock).mockReturnValue({ + name: 'Test', + description: '', + subBlocks: [{ id: 'field', title: 'Field', type: 'model-fallback-list' }], + outputs: {}, + } as never) + const sanitized = sanitizeWorkflowForSharing( + stateWithSubBlock('model-fallback-list', [ + { id: 'b', model: 'openrouter/x', apiKey: '{{OPENROUTER_API_KEY}}' }, + ]), + { preserveEnvVars: false, redactOpaqueCredentialInputs: true } + ) + expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([ + { id: 'b', model: 'openrouter/x' }, + ]) + }) + it('leaves an ordinary field untouched', () => { expect(sanitizedValue('short-input', 'plain text')).toBe('plain text') }) diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.ts b/apps/sim/lib/workflows/credentials/credential-extractor.ts index d9e3aa86fa7..436125ea952 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.ts @@ -1,4 +1,5 @@ import { isPlainRecord } from '@sim/utils/object' +import { isWholeEnvVarReference } from '@/lib/workflows/blocks/fallback-models' import { coerceObjectArray } from '@/lib/workflows/persistence/remap-internal-ids' import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry' @@ -170,6 +171,24 @@ function isEnvironmentVariableReference(value: unknown): value is string { return typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}') } +/** + * Keeps a fallback list's models and drops every row key that is not a whole + * environment-variable reference. The editor only ever writes references, but the + * realtime subblock-value op runs no validator, so this is what guarantees a raw + * key can never leave the workspace in an export or template. + */ +function sanitizeFallbackModelsValue( + value: unknown, + options: WorkflowSanitizationOptions +): unknown { + if (!Array.isArray(value)) return value + return value.map((row) => { + if (!row || typeof row !== 'object' || Array.isArray(row)) return row + const { apiKey, ...rest } = row as Record + return options.preserveEnvVars && isWholeEnvVarReference(apiKey) ? { ...rest, apiKey } : rest + }) +} + /** * Sanitizes nested tool parameters using the same codecs as workflow search and fork remapping. * Only parameters resolved from a registered definition retain non-sensitive values. Custom, MCP, @@ -245,6 +264,9 @@ function sanitizeConfiguredSubBlockValue( if (config.password === true) { return options.preserveEnvVars && isEnvironmentVariableReference(value) ? value : null } + if (config.type === 'model-fallback-list') { + return sanitizeFallbackModelsValue(value, options) + } if ( WORKSPACE_SPECIFIC_TYPES.has(config.type) || WORKSPACE_SPECIFIC_FIELDS.has(config.id) || diff --git a/apps/sim/lib/workflows/editing/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts index 1f192cb1721..8ee23f3b87c 100644 --- a/apps/sim/lib/workflows/editing/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -3,6 +3,8 @@ */ import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getTuningOptionsForModel } from '@/lib/workflows/blocks/fallback-models' +import { getThinkingLevelsForModel } from '@/providers/models' import { normalizeConditionRouterIds } from './builders' const { @@ -338,6 +340,77 @@ describe('validateInputsForBlock', () => { ).toBe(false) }) + describe('model-fallback-list', () => { + const config = { id: 'fallbackModels', type: 'model-fallback-list' as const } + const validate = (value: unknown) => + validateValueForSubBlockType(config, value, 'fallbackModels', 'agent', 'agent-1') + + it('accepts known models with env-var-referenced keys and fills missing row ids', () => { + const result = validate([ + { id: 'row-1', model: ' claude-sonnet-5 ' }, + { model: 'openrouter/anthropic/claude', apiKey: '{{OPENROUTER_API_KEY}}' }, + ]) + expect(result.valid).toBe(true) + const rows = (result as { value: Array<{ id: string; model: string; apiKey?: string }> }) + .value + expect(rows[0]).toEqual({ id: 'row-1', model: 'claude-sonnet-5' }) + expect(rows[1].id).toEqual(expect.any(String)) + expect(rows[1]).toMatchObject({ + model: 'openrouter/anthropic/claude', + apiKey: '{{OPENROUTER_API_KEY}}', + }) + }) + + it('refuses a raw key rather than repairing it', () => { + const result = validate([{ model: 'claude-sonnet-5', apiKey: 'sk-live-raw' }]) + expect(result.valid).toBe(false) + expect((result as { error: { error: string } }).error.error).toContain( + 'apiKey must be a whole {{ENV_VAR}} reference' + ) + }) + + it('refuses sim-auto, unknown models, missing models, and non-arrays', () => { + expect(validate([{ model: 'sim-auto' }]).valid).toBe(false) + expect(validate([{ model: 'definitely-not-a-model-9000' }]).valid).toBe(false) + expect(validate([{ apiKey: '{{KEY}}' }]).valid).toBe(false) + expect(validate({ model: 'claude-sonnet-5' }).valid).toBe(false) + }) + + it('accepts a row tuning value the model declares and refuses one it does not', () => { + const levels = getThinkingLevelsForModel('claude-sonnet-5') + expect(levels?.length).toBeGreaterThan(0) + const ok = validate([ + { model: 'claude-sonnet-5', thinkingLevel: ` ${levels![0].toUpperCase()} ` }, + ]) + expect(ok.valid).toBe(true) + expect((ok as { value: Array<{ thinkingLevel?: string }> }).value[0].thinkingLevel).toBe( + levels![0] + ) + + const bad = validate([{ model: 'claude-sonnet-5', thinkingLevel: 'bogus' }]) + expect(bad.valid).toBe(false) + expect((bad as { error: { error: string } }).error.error).toContain('thinking level option') + + const notAString = validate([{ model: 'claude-sonnet-5', thinkingLevel: 42 }]) + expect(notAString.valid).toBe(false) + expect((notAString as { error: { error: string } }).error.error).toContain('"42"') + + const undeclared = (['reasoningEffort', 'verbosity', 'thinkingLevel'] as const).find( + (knob) => getTuningOptionsForModel('claude-sonnet-5', knob) === null + ) + expect(undeclared).toBeDefined() + const missingKnob = validate([{ model: 'claude-sonnet-5', [undeclared as string]: 'low' }]) + expect(missingKnob.valid).toBe(false) + }) + + it('refuses more rows than the cap', () => { + const rows = Array.from({ length: 6 }, () => ({ model: 'claude-sonnet-5' })) + const result = validate(rows) + expect(result.valid).toBe(false) + expect((result as { error: { error: string } }).error.error).toContain('at most 5') + }) + }) + it('accepts condition-input arrays with arbitrary item ids', () => { const result = validateInputsForBlock( 'condition', diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index e02ca16ef02..6d85d8a0999 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' import { omit } from '@sim/utils/object' import { isHosted as isHostedDeployment } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' @@ -8,6 +9,15 @@ import { MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { resolveAccessControlBlockType } from '@/lib/permission-groups/integration-allowlist' +import { + FALLBACK_TUNING_KNOBS, + FALLBACK_TUNING_LABELS, + getTuningOptionsForModel, + isTuningValueValidForModel, + isWholeEnvVarReference, + MAX_FALLBACK_MODELS, + normalizeTuningValues, +} from '@/lib/workflows/blocks/fallback-models' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' import { validateSelectorIds } from '@/lib/workflows/editing/selector-validator' import { containsReference } from '@/lib/workflows/sanitization/references' @@ -377,6 +387,50 @@ function validateAgentSkillEntry(item: any, index: number): string | null { return null } +/** + * Validates one fallback-model row. Returns an error string or null when valid. + * + * Refuses rather than repairs: an unknown model, sim-auto, or a raw key is an + * authoring mistake the caller must see. A missing React-key `id` is the one + * thing filled in, since it carries no meaning. + */ +function validateFallbackModelEntry(item: any, index: number): string | null { + const where = `fallbackModels[${index}]` + if (item === null || typeof item !== 'object' || Array.isArray(item)) { + return `${where} must be an object { model, apiKey? }` + } + const model = typeof item.model === 'string' ? item.model.trim() : '' + if (model === '') { + return `${where} is missing a string "model"` + } + if (isAutoModel(model)) { + return `${where}: sim-auto cannot be a fallback model; it already routes and falls back on its own` + } + if (!isKnownModelId(model) && !isCustomModelId(model)) { + const suggestions = suggestModelIdsForUnknownModel(model) + const suggestionText = + suggestions.length > 0 ? ` Valid options include: ${suggestions.join(', ')}.` : '' + return `${where}: unknown model id "${model}".${suggestionText}` + } + if (item.apiKey !== undefined && item.apiKey !== null && item.apiKey !== '') { + if (!isWholeEnvVarReference(item.apiKey)) { + return `${where}.apiKey must be a whole {{ENV_VAR}} reference; put the key in an environment variable instead of pasting it` + } + } + for (const knob of FALLBACK_TUNING_KNOBS) { + const value = item[knob] + if (value === undefined || value === null || value === '') continue + if (typeof value !== 'string' || !isTuningValueValidForModel(model, knob, value)) { + const options = getTuningOptionsForModel(model, knob) + const hint = options + ? ` Valid options: ${options.join(', ')}.` + : ` ${model} has no such setting.` + return `${where}.${knob}: "${String(value)}" is not a ${FALLBACK_TUNING_LABELS[knob].toLowerCase()} option for ${model}.${hint}` + } + } + return null +} + /** * Validates a value against its expected subBlock type * Returns validation result with the value or an error @@ -581,6 +635,57 @@ export function validateValueForSubBlockType( return { valid: true, value } } + case 'model-fallback-list': { + if (!Array.isArray(value)) { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid model-fallback-list value for field "${fieldName}" - expected an array of { model, apiKey? } objects`, + }, + } + } + if (value.length > MAX_FALLBACK_MODELS) { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `"${fieldName}" allows at most ${MAX_FALLBACK_MODELS} fallback models`, + }, + } + } + const fallbackErrors = value + .map((item, index) => validateFallbackModelEntry(item, index)) + .filter((err): err is string => err !== null) + if (fallbackErrors.length > 0) { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid fallback ${fallbackErrors.length === 1 ? 'entry' : 'entries'} in "${fieldName}": ${fallbackErrors.join('; ')}`, + }, + } + } + return { + valid: true, + value: value.map((item: Record & { model: string }) => ({ + id: typeof item.id === 'string' && item.id ? item.id : generateShortId(), + model: item.model.trim(), + ...(isWholeEnvVarReference(item.apiKey) ? { apiKey: item.apiKey.trim() } : {}), + ...normalizeTuningValues(item), + })), + } + } + case 'skill-input': { // Should be an array of skill reference objects ({ skillId, name? }) if (!Array.isArray(value)) { diff --git a/apps/sim/lib/workflows/search-replace/indexer.test.ts b/apps/sim/lib/workflows/search-replace/indexer.test.ts index e2581a05a3c..861b4fa4ec5 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.test.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.test.ts @@ -1384,6 +1384,11 @@ describe('indexWorkflowSearchMatches', () => { type: 'input-mapping', value: { childInput: 'mapped visible value' }, }, + fallbackModels: { + id: 'fallbackModels', + type: 'model-fallback-list', + value: [{ id: 'row-1', model: 'fallback-visible-model', apiKey: '{{HIDDEN_KEY_REF}}' }], + }, }, } const blockConfigs = { @@ -1396,6 +1401,7 @@ describe('indexWorkflowSearchMatches', () => { { id: 'skills', title: 'Skills', type: 'skill-input' }, { id: 'runAt', title: 'Run At', type: 'time-input' }, { id: 'mapping', title: 'Input Mapping', type: 'input-mapping' }, + { id: 'fallbackModels', title: 'Fallback models', type: 'model-fallback-list' }, ], }, } @@ -1430,7 +1436,28 @@ describe('indexWorkflowSearchMatches', () => { mode: 'text', blockConfigs, }).filter((match) => match.blockId === 'structured-1') + const fallbackMatches = indexWorkflowSearchMatches({ + workflow, + query: 'fallback-visible', + mode: 'text', + blockConfigs, + }).filter((match) => match.blockId === 'structured-1') + expect(fallbackMatches).toEqual([ + expect.objectContaining({ + subBlockId: 'fallbackModels', + valuePath: [0, 'model'], + searchText: 'fallback-visible-model', + }), + ]) + /** A row key is a `{{VAR}}` reference; text search must never offer to rewrite it. */ + const keyMatches = indexWorkflowSearchMatches({ + workflow, + query: 'HIDDEN_KEY_REF', + mode: 'text', + blockConfigs, + }).filter((match) => match.blockId === 'structured-1') + expect(keyMatches).toEqual([]) expect(containsMatches).toEqual([ expect.objectContaining({ subBlockId: 'filters', diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index 128972236fd..c2f6880c816 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -212,6 +212,10 @@ function isSearchableLeafPath( if (mode === 'text' && subBlockType === 'messages-input' && lastSegment === 'role') { return false } + /** A fallback row's key is a `{{VAR}}` reference; rewriting it would turn it into a raw value. */ + if (mode === 'text' && subBlockType === 'model-fallback-list' && lastSegment === 'apiKey') { + return false + } if (mode === 'text' && subBlockType === 'tool-input') { if (TOOL_INPUT_TEXT_EXCLUDED_LEAF_KEYS.has(lastSegment)) return false if (lastSegment.endsWith('Id')) return false diff --git a/apps/sim/lib/workflows/subblocks/display.test.ts b/apps/sim/lib/workflows/subblocks/display.test.ts index 863765cd467..50cfbfa0095 100644 --- a/apps/sim/lib/workflows/subblocks/display.test.ts +++ b/apps/sim/lib/workflows/subblocks/display.test.ts @@ -14,6 +14,7 @@ vi.mock('@/blocks', () => ({ import { getDisplayValue, resolveDropdownLabel, + resolveFallbackModelsLabel, resolveFilterFieldLabel, resolveFolderPathLabel, resolveSandboxLabel, @@ -187,6 +188,26 @@ describe('resolveSkillsLabel', () => { }) }) +describe('resolveFallbackModelsLabel', () => { + const fallbackList = { id: 'fallbackModels', type: 'model-fallback-list' } as SubBlockConfig + + it('lists the models in order and never the row keys', () => { + expect( + resolveFallbackModelsLabel(fallbackList, [ + { id: 'a', model: 'gpt-5' }, + { id: 'b', model: 'openrouter/x', apiKey: '{{OPENROUTER_API_KEY}}' }, + { id: 'c', model: 'gemini-3.6-flash' }, + ]) + ).toBe('gpt-5, openrouter/x +1') + }) + + it('returns null for other subblocks and for an empty or model-less list', () => { + expect(resolveFallbackModelsLabel(skillInput, [{ model: 'gpt-5' }])).toBeNull() + expect(resolveFallbackModelsLabel(fallbackList, [])).toBeNull() + expect(resolveFallbackModelsLabel(fallbackList, [{ id: 'a', model: '' }])).toBeNull() + }) +}) + describe('resolveSandboxLabel', () => { const sandboxes = [{ id: '443f4934-26ab-44ab-8000-000000000000', name: 'Test' }] diff --git a/apps/sim/lib/workflows/subblocks/display.ts b/apps/sim/lib/workflows/subblocks/display.ts index b371fda035e..3d47d307a09 100644 --- a/apps/sim/lib/workflows/subblocks/display.ts +++ b/apps/sim/lib/workflows/subblocks/display.ts @@ -582,6 +582,29 @@ export function resolveSkillsLabel( return summarizeNames(names) } +/** + * Resolves a fallback-model list to its model ids, e.g. "gpt-5.6, gemini-3.6-flash +1". + * Returns null for other subblocks and for an empty list so callers fall through. + * Row keys are never shown. + */ +export function resolveFallbackModelsLabel( + subBlock: SubBlockConfig | undefined, + rawValue: unknown +): string | null { + if (subBlock?.type !== 'model-fallback-list') return null + if (!Array.isArray(rawValue) || rawValue.length === 0) return null + + const models = rawValue + .map((row: unknown) => { + if (!row || typeof row !== 'object') return null + const model = (row as { model?: unknown }).model + return typeof model === 'string' && model.trim() ? model.trim() : null + }) + .filter((model): model is string => !!model) + + return summarizeNames(models) +} + /** * Resolves the Function block's stored sandbox id to the sandbox name. * diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 7666075e19b..f1cce808250 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -659,6 +659,7 @@ const EXCLUDED_SUBBLOCK_TYPES = new Set([ 'mcp-dynamic-args', 'variables-input', 'messages-input', + 'model-fallback-list', 'router-input', 'text', ]) diff --git a/packages/workflow-types/src/blocks.ts b/packages/workflow-types/src/blocks.ts index e40cf7441d8..10e08a588b0 100644 --- a/packages/workflow-types/src/blocks.ts +++ b/packages/workflow-types/src/blocks.ts @@ -54,6 +54,7 @@ export type SubBlockType = | 'text' | 'router-input' | 'table-selector' + | 'model-fallback-list' | 'column-selector' | 'modal' diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 92853f28f38..9849117afbe 100755 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -4966,6 +4966,7 @@ const SUBBLOCK_TYPE_TO_SEMANTIC: Record = { 'oauth-input': 'string', code: 'string', 'file-upload': 'string', + 'model-fallback-list': 'json', text: 'string', } From 3f245d724ffcc044336a191cabc39e12bbecfa7c Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 18 Sep 2026 16:00:08 -0700 Subject: [PATCH 15/20] improvement(knowledge): cache admitted usage checks on the search path (#7989) * improvement(knowledge): cache admitted usage checks on the search path * fix(knowledge): keep search-path refusals out of the shared usage gate cache * fix(knowledge): key the usage gate cache by billing period source --- .../knowledge/search/route.provenance.test.ts | 5 +- .../lib/billing/core/ingestion-usage-gate.ts | 69 ----------- ...-gate.test.ts => usage-gate-cache.test.ts} | 76 ++++++++++-- apps/sim/lib/billing/core/usage-gate-cache.ts | 112 ++++++++++++++++++ .../lib/knowledge/application/search.test.ts | 5 +- apps/sim/lib/knowledge/application/search.ts | 4 +- .../documents/document-indexing-usage.test.ts | 4 +- .../document-processing-source.test.ts | 4 +- apps/sim/lib/knowledge/documents/service.ts | 2 +- 9 files changed, 196 insertions(+), 85 deletions(-) delete mode 100644 apps/sim/lib/billing/core/ingestion-usage-gate.ts rename apps/sim/lib/billing/core/{ingestion-usage-gate.test.ts => usage-gate-cache.test.ts} (53%) create mode 100644 apps/sim/lib/billing/core/usage-gate-cache.ts diff --git a/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts b/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts index f295359c749..5dd6d6c8a22 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts @@ -31,7 +31,10 @@ vi.mock('@sim/platform-authz/workspace', () => ({ vi.mock('@/lib/billing/core/billing-attribution', () => ({ resolveBillingAttribution: mocks.resolveBilling, resolveSystemBillingAttribution: mocks.resolveBilling, - checkAttributedUsageLimits: mocks.checkUsage, +})) + +vi.mock('@/lib/billing/core/usage-gate-cache', () => ({ + checkSearchUsageLimits: mocks.checkUsage, })) /** Retrieval defaults are the flag's concern; here the flag is off so the search stays as configured. */ diff --git a/apps/sim/lib/billing/core/ingestion-usage-gate.ts b/apps/sim/lib/billing/core/ingestion-usage-gate.ts deleted file mode 100644 index 2f6f1c5f5f8..00000000000 --- a/apps/sim/lib/billing/core/ingestion-usage-gate.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { LRUCache } from 'lru-cache' -import { - type AttributedUsageLimitsResult, - type BillingAttributionSnapshot, - checkAttributedUsageLimits, -} from '@/lib/billing/core/billing-attribution' -import { coalesceLocally } from '@/lib/concurrency/singleflight' - -/** - * How long a usage-gate answer stays usable on the ingestion path. - * - * The gate sums the payer's usage ledger for the billing period, which grows - * with every indexed document, so a bulk sync that re-checks per document - * reads the whole period's ledger tens of thousands of times. Staleness fails - * in the harmless direction: a payer at their limit keeps indexing for at most - * this long, and a payer whose limit was just raised waits at most this long. - * Nothing on this path has a person waiting for the answer. - */ -export const INGESTION_USAGE_GATE_TTL_MS = 60 * 1000 - -/** Recent gate answers, with `LRUCache` supplying the TTL and the size bound. */ -const gateCache = new LRUCache({ - max: 10_000, - ttl: INGESTION_USAGE_GATE_TTL_MS, -}) - -/** - * The gate depends on who pays, for which period, and which member acts: the - * payer pool and the per-member cap are both part of the answer. - */ -function gateKey(attribution: BillingAttributionSnapshot): string { - return [ - attribution.billingEntity.type, - attribution.billingEntity.id, - attribution.billingPeriod.start, - attribution.billingPeriod.end, - attribution.billedAccountUserId, - attribution.actorUserId, - ].join(':') -} - -/** - * {@link checkAttributedUsageLimits} for background ingestion, with bounded - * staleness. Interactive callers (uploads, search, the settings surfaces) keep - * reading the gate fresh so a limit change is visible at once. - * - * `coalesceLocally` collapses the concurrent misses of a batch onto one ledger - * read and bounds a hung read at its settle deadline. The cache write stays on - * the value this caller received, so a producer that timed out and later - * resolved cannot overwrite a fresher answer. - */ -export async function checkIngestionUsageLimits( - attribution: BillingAttributionSnapshot -): Promise { - const key = gateKey(attribution) - const cached = gateCache.get(key) - if (cached !== undefined) return cached - - const result = await coalesceLocally(`ingestion-usage-gate:${key}`, () => - checkAttributedUsageLimits(attribution) - ) - gateCache.set(key, result) - return result -} - -/** Drops every cached gate answer. Test seam; never called in production code. */ -export function resetIngestionUsageGateCache(): void { - gateCache.clear() -} diff --git a/apps/sim/lib/billing/core/ingestion-usage-gate.test.ts b/apps/sim/lib/billing/core/usage-gate-cache.test.ts similarity index 53% rename from apps/sim/lib/billing/core/ingestion-usage-gate.test.ts rename to apps/sim/lib/billing/core/usage-gate-cache.test.ts index dbecf11abf0..ba5d0e48f98 100644 --- a/apps/sim/lib/billing/core/ingestion-usage-gate.test.ts +++ b/apps/sim/lib/billing/core/usage-gate-cache.test.ts @@ -13,9 +13,10 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { checkIngestionUsageLimits, - INGESTION_USAGE_GATE_TTL_MS, - resetIngestionUsageGateCache, -} from '@/lib/billing/core/ingestion-usage-gate' + checkSearchUsageLimits, + resetUsageGateCache, + USAGE_GATE_TTL_MS, +} from '@/lib/billing/core/usage-gate-cache' const ATTRIBUTION: BillingAttributionSnapshot = { actorUserId: 'member-1', @@ -31,9 +32,19 @@ const ATTRIBUTION: BillingAttributionSnapshot = { payerSubscription: null, } +const SUBSCRIPTION: BillingAttributionSnapshot['payerSubscription'] = { + id: 'sub-1', + referenceId: 'org-1', + plan: 'team', + status: 'active', + seats: 5, + periodStart: '2026-09-01T00:00:00.000Z', + periodEnd: '2026-10-01T00:00:00.000Z', +} + describe('checkIngestionUsageLimits', () => { beforeEach(() => { - resetIngestionUsageGateCache() + resetUsageGateCache() mockCheck.mockReset().mockResolvedValue({ isExceeded: false }) }) afterEach(() => vi.restoreAllMocks()) @@ -68,13 +79,13 @@ describe('checkIngestionUsageLimits', () => { /** `lru-cache` reads `performance.now()` and debounces it behind a real 1 ms timer. */ const start = performance.now() - vi.spyOn(performance, 'now').mockReturnValue(start + INGESTION_USAGE_GATE_TTL_MS + 1) + vi.spyOn(performance, 'now').mockReturnValue(start + USAGE_GATE_TTL_MS + 1) await sleep(5) expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(false) expect(mockCheck).toHaveBeenCalledTimes(2) }) - it('separates answers by actor, period and payer', async () => { + it('separates answers by actor, period, period source, payer and plan', async () => { await checkIngestionUsageLimits(ATTRIBUTION) await checkIngestionUsageLimits({ ...ATTRIBUTION, actorUserId: 'member-2' }) await checkIngestionUsageLimits({ @@ -86,7 +97,16 @@ describe('checkIngestionUsageLimits', () => { billedAccountUserId: 'owner-2', billingEntity: { type: 'user', id: 'owner-2' }, }) - expect(mockCheck).toHaveBeenCalledTimes(4) + await checkIngestionUsageLimits({ + ...ATTRIBUTION, + billingPeriod: { ...ATTRIBUTION.billingPeriod, source: 'reporting' }, + }) + await checkIngestionUsageLimits({ ...ATTRIBUTION, payerSubscription: SUBSCRIPTION }) + await checkIngestionUsageLimits({ + ...ATTRIBUTION, + payerSubscription: { ...SUBSCRIPTION, plan: 'enterprise' }, + }) + expect(mockCheck).toHaveBeenCalledTimes(7) }) it('does not cache a failed read', async () => { @@ -96,3 +116,45 @@ describe('checkIngestionUsageLimits', () => { expect(mockCheck).toHaveBeenCalledTimes(2) }) }) + +describe('checkSearchUsageLimits', () => { + beforeEach(() => { + resetUsageGateCache() + mockCheck.mockReset().mockResolvedValue({ isExceeded: false }) + }) + + it('reuses an admission across workspaces of the same payer', async () => { + await checkSearchUsageLimits(ATTRIBUTION) + await checkSearchUsageLimits({ ...ATTRIBUTION, workspaceId: 'workspace-9' }) + expect(mockCheck).toHaveBeenCalledTimes(1) + }) + + it('re-reads a refusal, so a raised limit applies on the next search', async () => { + mockCheck.mockResolvedValueOnce({ isExceeded: true, scope: 'payer', message: 'over' }) + expect((await checkSearchUsageLimits(ATTRIBUTION)).isExceeded).toBe(true) + expect((await checkSearchUsageLimits(ATTRIBUTION)).isExceeded).toBe(false) + expect(mockCheck).toHaveBeenCalledTimes(2) + }) + + it('does not serve a refusal cached by ingestion', async () => { + mockCheck.mockResolvedValueOnce({ isExceeded: true, scope: 'payer', message: 'over' }) + expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(true) + expect((await checkSearchUsageLimits(ATTRIBUTION)).isExceeded).toBe(false) + expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(false) + expect(mockCheck).toHaveBeenCalledTimes(2) + }) + + it('never stores a refusal for ingestion to serve', async () => { + mockCheck.mockResolvedValueOnce({ isExceeded: true, scope: 'payer', message: 'over' }) + expect((await checkSearchUsageLimits(ATTRIBUTION)).isExceeded).toBe(true) + expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(false) + expect(mockCheck).toHaveBeenCalledTimes(2) + }) + + it('does not cache a failed read', async () => { + mockCheck.mockRejectedValueOnce(new Error('ledger unavailable')) + await expect(checkSearchUsageLimits(ATTRIBUTION)).rejects.toThrow('ledger unavailable') + await checkSearchUsageLimits(ATTRIBUTION) + expect(mockCheck).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/billing/core/usage-gate-cache.ts b/apps/sim/lib/billing/core/usage-gate-cache.ts new file mode 100644 index 00000000000..982e5a406d9 --- /dev/null +++ b/apps/sim/lib/billing/core/usage-gate-cache.ts @@ -0,0 +1,112 @@ +import { LRUCache } from 'lru-cache' +import { + type AttributedUsageLimitsResult, + type BillingAttributionSnapshot, + checkAttributedUsageLimits, +} from '@/lib/billing/core/billing-attribution' +import { coalesceLocally } from '@/lib/concurrency/singleflight' + +/** + * How long a usage-gate answer stays usable on the high-frequency paths. + * + * The gate sums the payer's usage ledger for the billing period, which grows + * with the payer's activity, so a large organization scans its whole period on + * every uncached call. Bulk ingestion re-checks per document and knowledge + * search checks per query. Staleness is bounded by this TTL and fails in the + * harmless direction: a payer who crosses their limit keeps going for at most + * this long, which charges nobody wrongly. + */ +export const USAGE_GATE_TTL_MS = 60 * 1000 + +/** + * Recent gate answers, admitted and refused, with `LRUCache` supplying the TTL + * and the size bound. Each entry point decides which of them it may serve. + */ +const gateCache = new LRUCache({ + max: 10_000, + ttl: USAGE_GATE_TTL_MS, +}) + +/** + * The gate depends on who pays, for which period (and how that period was + * derived), under which plan, and which member acts: the payer pool, its limit + * and the per-member cap are all part of the answer. The workspace is not, so + * every workspace of one payer shares an entry. + */ +function gateKey(attribution: BillingAttributionSnapshot): string { + const subscription = attribution.payerSubscription + return [ + attribution.billingEntity.type, + attribution.billingEntity.id, + attribution.billingPeriod.start, + attribution.billingPeriod.end, + attribution.billingPeriod.source ?? '', + attribution.billedAccountUserId, + attribution.actorUserId, + subscription?.id ?? '', + subscription?.plan ?? '', + subscription?.status ?? '', + subscription?.seats ?? '', + ].join(':') +} + +/** + * Serves a cached answer the caller accepts, otherwise reads the gate. + * + * `cacheRefusals` governs both directions: a caller that must re-read refusals + * also never stores one, so a refusal read on the search path never reaches + * ingestion. The usage read fails closed (a ledger error comes back as + * exceeded), which makes that the only way a search-path outage stays out of + * the cache. A read that throws writes nothing. + * + * `coalesceLocally` collapses concurrent misses onto one ledger read and bounds + * a hung read at its settle deadline. The write stays on the value this caller + * received, so a producer that timed out and later resolved cannot overwrite a + * fresher answer. + * + * There is deliberately no invalidator: usage and limit changes land in other + * processes (execution workers, Stripe webhooks), so the TTL is the real bound. + */ +async function checkUsageLimitsThroughCache( + attribution: BillingAttributionSnapshot, + cacheRefusals: boolean +): Promise { + const key = gateKey(attribution) + const cached = gateCache.get(key) + if (cached !== undefined && (cacheRefusals || !cached.isExceeded)) return cached + + const result = await coalesceLocally(`usage-gate:${key}`, () => + checkAttributedUsageLimits(attribution) + ) + if (cacheRefusals || !result.isExceeded) gateCache.set(key, result) + return result +} + +/** + * {@link checkAttributedUsageLimits} for background ingestion. Serves admitted + * and refused answers alike: nothing on this path has a person waiting for a + * raised limit to apply, so a refused payer waits at most the TTL. + */ +export function checkIngestionUsageLimits( + attribution: BillingAttributionSnapshot +): Promise { + return checkUsageLimitsThroughCache(attribution, true) +} + +/** + * {@link checkAttributedUsageLimits} for knowledge search. Serves only a cached + * admission: a refusal is always re-read, so a payer who just raised their limit + * or upgraded is never held behind a cached block while they wait on a search. + * Every other interactive caller (uploads, execution admission, the settings + * surfaces) keeps reading the gate fresh. + */ +export function checkSearchUsageLimits( + attribution: BillingAttributionSnapshot +): Promise { + return checkUsageLimitsThroughCache(attribution, false) +} + +/** Drops every cached gate answer. Test seam; never called in production code. */ +export function resetUsageGateCache(): void { + gateCache.clear() +} diff --git a/apps/sim/lib/knowledge/application/search.test.ts b/apps/sim/lib/knowledge/application/search.test.ts index e42e26018c4..7b3ddd7a709 100644 --- a/apps/sim/lib/knowledge/application/search.test.ts +++ b/apps/sim/lib/knowledge/application/search.test.ts @@ -56,7 +56,10 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ resolveBillingAttribution: mocks.resolveBilling, resolveSystemBillingAttribution: mocks.resolveBilling, resolveOrganizationBillingAttribution: mocks.resolveBilling, - checkAttributedUsageLimits: mocks.checkUsage, +})) + +vi.mock('@/lib/billing/core/usage-gate-cache', () => ({ + checkSearchUsageLimits: mocks.checkUsage, })) /** Retrieval defaults are the flag's concern; here the flag is off so the search stays as configured. */ diff --git a/apps/sim/lib/knowledge/application/search.ts b/apps/sim/lib/knowledge/application/search.ts index 5b186cc1f22..174b7009320 100644 --- a/apps/sim/lib/knowledge/application/search.ts +++ b/apps/sim/lib/knowledge/application/search.ts @@ -3,9 +3,9 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type BillingAttributionSnapshot, - checkAttributedUsageLimits, toBillingContext, } from '@/lib/billing/core/billing-attribution' +import { checkSearchUsageLimits } from '@/lib/billing/core/usage-gate-cache' import { recordUsage } from '@/lib/billing/core/usage-log' import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -297,7 +297,7 @@ const searchKnowledgeUseCase = defineAuthorizedKnowledgeUseCase({ : undefined if (shouldMeter && billingAttribution) { const usage = await measureSearchStage('usage_admission', () => - checkAttributedUsageLimits(billingAttribution) + checkSearchUsageLimits(billingAttribution) ) if (usage.isExceeded) { throw new KnowledgeUsageLimitExceededError( diff --git a/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts b/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts index 437de7bf205..a6a351c5cbf 100644 --- a/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts +++ b/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts @@ -65,13 +65,13 @@ vi.mock('@/providers/utils', () => ({ })) import * as billingAttribution from '@/lib/billing/core/billing-attribution' -import { resetIngestionUsageGateCache } from '@/lib/billing/core/ingestion-usage-gate' +import { resetUsageGateCache } from '@/lib/billing/core/usage-gate-cache' import * as embeddingClient from '@/lib/embeddings/client' import { processDocumentAsync } from '@/lib/knowledge/documents/service' const mockEmbeddingCapacity = vi.fn() beforeEach(() => { - resetIngestionUsageGateCache() + resetUsageGateCache() vi.spyOn(billingAttribution, 'checkAttributedUsageLimits').mockImplementation( mockCheckAttributedUsageLimits ) diff --git a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts index 2dc0061bc2d..92b64b9424d 100644 --- a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts +++ b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts @@ -74,7 +74,7 @@ vi.mock('@/lib/uploads/server/metadata', () => ({ })) import * as billingAttribution from '@/lib/billing/core/billing-attribution' -import { resetIngestionUsageGateCache } from '@/lib/billing/core/ingestion-usage-gate' +import { resetUsageGateCache } from '@/lib/billing/core/usage-gate-cache' import { env } from '@/lib/core/config/env' import { markInsideTriggerRun, @@ -102,7 +102,7 @@ import { MAX_PROCESSING_ATTEMPTS } from '@/lib/knowledge/documents/types' const mockEmbeddingCapacity = vi.fn() beforeEach(() => { - resetIngestionUsageGateCache() + resetUsageGateCache() vi.spyOn(billingAttribution, 'checkAttributedUsageLimits').mockImplementation( mockCheckAttributedUsageLimits ) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 16531d6fa01..7b1bbef023b 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -36,7 +36,7 @@ import { type BillingAttributionSnapshot, toBillingContext, } from '@/lib/billing/core/billing-attribution' -import { checkIngestionUsageLimits } from '@/lib/billing/core/ingestion-usage-gate' +import { checkIngestionUsageLimits } from '@/lib/billing/core/usage-gate-cache' import { recordUsage } from '@/lib/billing/core/usage-log' import { applyStorageUsageDeltasInTx, From f3590315887de380f8a0175cd20933a8da639efc Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 18 Sep 2026 17:10:11 -0700 Subject: [PATCH 16/20] fix(knowledge): preserve live document jobs during recovery (#7994) --- .../background/knowledge-processing.test.ts | 23 +++- apps/sim/background/knowledge-processing.ts | 7 +- .../stored-document-recovery.integration.ts | 123 ++++++++++++++++- .../knowledge/connectors/sync-primitives.ts | 12 +- .../document-processing-source.test.ts | 38 +++++- .../documents/processing-recovery-policy.ts | 7 +- .../processing-recovery-queue.test.ts | 118 ++++++++++++++++ .../documents/processing-recovery-queue.ts | 129 ++++++++++++++++++ .../documents/processing-recovery.ts | 20 ++- apps/sim/lib/knowledge/documents/service.ts | 39 +++--- 10 files changed, 484 insertions(+), 32 deletions(-) create mode 100644 apps/sim/lib/knowledge/documents/processing-recovery-queue.test.ts create mode 100644 apps/sim/lib/knowledge/documents/processing-recovery-queue.ts diff --git a/apps/sim/background/knowledge-processing.test.ts b/apps/sim/background/knowledge-processing.test.ts index b200f9a0dbd..e9c327d988f 100644 --- a/apps/sim/background/knowledge-processing.test.ts +++ b/apps/sim/background/knowledge-processing.test.ts @@ -119,7 +119,7 @@ describe('knowledge processing worker', () => { } return value }) - mockProcessDocumentAsync.mockResolvedValue(undefined) + mockProcessDocumentAsync.mockResolvedValue({ outcome: 'indexed' }) mockResolveTriggerRegion.mockResolvedValue('us-east-1') mockTrigger.mockResolvedValue({ id: 'quota-continuation-run' }) }) @@ -128,6 +128,27 @@ describe('knowledge processing worker', () => { vi.restoreAllMocks() }) + it('reports indexed only when the document service committed the index', async () => { + expect(await runDocumentProcessing(WORKSPACE_PAYLOAD)).toMatchObject({ + success: true, + outcome: 'indexed', + documentId: WORKSPACE_PAYLOAD.documentId, + }) + }) + + it.each(['unavailable', 'not_claimed', 'superseded'] as const)( + 'reports a harmless %s skip without turning it into a task failure or an indexed success', + async (reason) => { + mockProcessDocumentAsync.mockResolvedValue({ outcome: 'skipped', reason }) + expect(await runDocumentProcessing(WORKSPACE_PAYLOAD)).toMatchObject({ + success: false, + outcome: 'skipped', + reason, + }) + expect(mockTrigger).not.toHaveBeenCalled() + } + ) + it('rejects workspace work without attribution before document processing starts', async () => { await expect( runDocumentProcessing({ diff --git a/apps/sim/background/knowledge-processing.ts b/apps/sim/background/knowledge-processing.ts index 3e8a6c2f81e..1964e315958 100644 --- a/apps/sim/background/knowledge-processing.ts +++ b/apps/sim/background/knowledge-processing.ts @@ -58,7 +58,7 @@ export async function runDocumentProcessing( logger.info(`[${requestId}] Starting Trigger.dev processing for document: ${docData.filename}`) try { - await processDocumentAsync( + const result = await processDocumentAsync( knowledgeBaseId, documentId, docData, @@ -90,10 +90,11 @@ export async function runDocumentProcessing( } ) - logger.info(`[${requestId}] Successfully processed document: ${docData.filename}`) + logger.info(`[${requestId}] Document processing finished`, { documentId, ...result }) return { - success: true, + success: result.outcome === 'indexed', + ...result, documentId, filename: docData.filename, processingTime: Date.now() - startedAt, diff --git a/apps/sim/lib/knowledge/__integration__/stored-document-recovery.integration.ts b/apps/sim/lib/knowledge/__integration__/stored-document-recovery.integration.ts index 8d3a3f45a09..36720c4ff32 100644 --- a/apps/sim/lib/knowledge/__integration__/stored-document-recovery.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/stored-document-recovery.integration.ts @@ -17,9 +17,21 @@ import { } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { and, eq, inArray, sql } from 'drizzle-orm' -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -const fixture = vi.hoisted(() => ({ root: '', embeddingCalls: 0 })) +const fixture = vi.hoisted(() => ({ + root: '', + embeddingCalls: 0, + queueEnabled: false, + listRuns: vi.fn(), +})) +vi.mock('@/lib/core/config/trigger-runtime', () => ({ + isInsideTriggerRun: () => fixture.queueEnabled, +})) +vi.mock('@trigger.dev/sdk', async (original) => ({ + ...(await original()), + runs: { list: fixture.listRuns }, +})) vi.mock('@/lib/uploads/core/setup.server', () => ({ get UPLOAD_DIR_SERVER() { return fixture.root @@ -52,6 +64,7 @@ import { searchKnowledge } from '@/lib/knowledge/application/search' import { searchScopedKnowledge } from '@/lib/knowledge/application/workspace-search' import { createContentSyncLease } from '@/lib/knowledge/connectors/sync-lock' import { addDocument } from '@/lib/knowledge/connectors/sync-persistence' +import { sweepStuckDocuments } from '@/lib/knowledge/connectors/sync-primitives' import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler' import { DOCUMENT_RECOVERY_BATCH_SIZE, @@ -60,6 +73,7 @@ import { } from '@/lib/knowledge/documents/processing-recovery' import { processDocumentAsync } from '@/lib/knowledge/documents/service' import { MAX_PROCESSING_ATTEMPTS, QUEUED_DISPATCH_GRACE_MS } from '@/lib/knowledge/documents/types' +import type { SyncResult } from '@/connectors/types' const fixtures: ReturnType[] = [] const old = () => new Date(Date.now() - QUEUED_DISPATCH_GRACE_MS - 60_000) @@ -119,6 +133,11 @@ async function failedFile( return file } +afterEach(() => { + fixture.queueEnabled = false + fixture.listRuns.mockReset() +}) + beforeAll(() => { fixture.root = mkdtempSync(path.join(tmpdir(), 'sim-stored-recovery-')) }) @@ -137,7 +156,107 @@ afterAll(async () => { await db.$client.end() }) +async function recoverFixture( + ids: ReturnType, + mode: 'independent' | 'connector' +) { + if (mode === 'independent') return recoverKnowledgeDocumentProcessing() + const result: SyncResult = { + docsAdded: 0, + docsUpdated: 0, + docsDeleted: 0, + docsUnchanged: 0, + docsSkipped: 0, + docsFailed: 0, + processingDispatch: { requested: 0, accepted: 0, failed: 0 }, + } + await sweepStuckDocuments({ + connectorId: ids.connectorId, + knowledgeBaseId: ids.knowledgeBaseId, + syncStartedAt: new Date(), + retryCutoff: new Date(Date.now() - 7 * 24 * 60 * 60_000), + billingAttribution: await resolveSystemBillingAttribution(ids.workspaceId), + result, + lease: createContentSyncLease(ids.connectorId, ids.lockId), + }) + return result.processingDispatch.requested +} + describe('independent recovery of retained connector documents', () => { + it.each(['independent', 'connector'] as const)( + '%s recovery preserves a job queued beyond the grace period', + async (mode) => { + const ids = await seed() + const file = await failedFile(ids) + await db + .update(document) + .set({ processingStatus: 'pending' }) + .where(eq(document.id, file.documentId)) + fixture.queueEnabled = true + fixture.listRuns.mockResolvedValue({ data: [{ id: 'run-queued', status: 'QUEUED' }] }) + expect(await recoverFixture(ids, mode)).toBe(0) + const [row] = await db.select().from(document).where(eq(document.id, file.documentId)) + expect(row.processingAttempts).toBe(1) + expect(row.processingQueueToken).toBe('old-fixture-generation') + expect(row.processingRecoveryAfter).not.toBeNull() + expect(await eventsFor(ids)).toHaveLength(0) + await db + .update(knowledgeConnector) + .set({ status: 'paused' }) + .where(eq(knowledgeConnector.id, ids.connectorId)) + } + ) + + it.each(['independent', 'connector'] as const)( + '%s recovery rechecks the generation after its remote lookup', + async (mode) => { + const ids = await seed() + const file = await failedFile(ids) + fixture.queueEnabled = true + fixture.listRuns.mockImplementation(async () => { + await db + .update(document) + .set({ processingQueueToken: 'replacement-generation' }) + .where(eq(document.id, file.documentId)) + return { data: [] } + }) + expect(await recoverFixture(ids, mode)).toBe(0) + const [row] = await db.select().from(document).where(eq(document.id, file.documentId)) + expect(row.processingAttempts).toBe(1) + expect(row.processingQueueToken).toBe('replacement-generation') + expect(await eventsFor(ids)).toHaveLength(0) + await db + .update(knowledgeConnector) + .set({ status: 'paused' }) + .where(eq(knowledgeConnector.id, ids.connectorId)) + } + ) + + it.each(['pending', 'processing'])( + 'does not replace an aged %s outbox continuation', + async (status) => { + const ids = await seed() + const file = await failedFile(ids) + const token = generateId() + await db + .update(document) + .set({ processingQueueToken: token }) + .where(eq(document.id, file.documentId)) + await db.insert(outboxEvent).values({ + id: token, + eventType: 'knowledge.document.processing.resume', + payload: { knowledgeBaseId: ids.knowledgeBaseId, documentId: file.documentId }, + status, + availableAt: old(), + }) + expect(await recoverFixture(ids, 'independent')).toBe(0) + expect(await recoverFixture(ids, 'connector')).toBe(0) + const [row] = await db.select().from(document).where(eq(document.id, file.documentId)) + expect(row.processingAttempts).toBe(1) + expect(row.processingQueueToken).toBe(token) + } + ) + it('uses the organization owner and preserves Search visibility during source backoff', async () => { const ids = await seed() await db.insert(member).values({ diff --git a/apps/sim/lib/knowledge/connectors/sync-primitives.ts b/apps/sim/lib/knowledge/connectors/sync-primitives.ts index 562378d26b0..13a1dd25139 100644 --- a/apps/sim/lib/knowledge/connectors/sync-primitives.ts +++ b/apps/sim/lib/knowledge/connectors/sync-primitives.ts @@ -9,7 +9,7 @@ import { import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, asc, desc, eq, inArray, isNotNull, isNull, lt, ne, sql } from 'drizzle-orm' +import { and, asc, desc, eq, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { ProviderCapacityDeferredError } from '@/lib/core/rate-limiter/provider-capacity-error' import { withDatabaseReadRetry } from '@/lib/db/read-retry' @@ -28,6 +28,10 @@ import { updateDocument, } from '@/lib/knowledge/connectors/sync-persistence' import { documentProcessingRecoveryCondition } from '@/lib/knowledge/documents/processing-recovery-policy' +import { + documentRecoveryGenerationCondition, + filterAbandonedDocumentProcessing, +} from '@/lib/knowledge/documents/processing-recovery-queue' import { DOCUMENT_PROCESSING_STALE_THRESHOLD_MS } from '@/lib/knowledge/documents/processing-timeouts.server' import type { DocumentData } from '@/lib/knowledge/documents/service' import { isTriggerAvailable, processDocumentsWithQueue } from '@/lib/knowledge/documents/service' @@ -1336,6 +1340,7 @@ export async function sweepStuckDocuments(input: SweepStuckDocumentsInput): Prom fileSize: document.fileSize, mimeType: document.mimeType, processingStatus: document.processingStatus, + processingQueueToken: document.processingQueueToken, processingQueuedAt: document.processingQueuedAt, processingStartedAt: document.processingStartedAt, processingDeferredUntil: document.processingDeferredUntil, @@ -1361,7 +1366,8 @@ export async function sweepStuckDocuments(input: SweepStuckDocumentsInput): Prom asc(document.id) ) .limit(STUCK_RETRY_MAX_CANDIDATES_PER_SYNC) - const stuckDocs = sweepCandidates.filter( + const abandoned = await filterAbandonedDocumentProcessing(sweepCandidates) + const stuckDocs = abandoned.filter( (row): row is typeof row & { processingStatus: DocumentProcessingStatus } => isDocumentProcessingStatus(row.processingStatus) ) @@ -1402,6 +1408,7 @@ export async function sweepStuckDocuments(input: SweepStuckDocumentsInput): Prom fileSize: document.fileSize, mimeType: document.mimeType, processingStatus: document.processingStatus, + processingQueueToken: document.processingQueueToken, processingQueuedAt: document.processingQueuedAt, processingStartedAt: document.processingStartedAt, processingDeferredUntil: document.processingDeferredUntil, @@ -1412,6 +1419,7 @@ export async function sweepStuckDocuments(input: SweepStuckDocumentsInput): Prom .where( and( inArray(document.id, stuckDocIds), + or(...stuckDocs.map(documentRecoveryGenerationCondition)), eq(document.connectorId, connectorId), documentProcessingRecoveryCondition(sweepEvaluatedAt, retryCutoff), lt(document.uploadedAt, syncStartedAt) diff --git a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts index 92b64b9424d..32e7eed699e 100644 --- a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts +++ b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts @@ -1460,11 +1460,46 @@ describe('processDocumentAsync write guards', () => { expect(schedule).not.toHaveBeenCalled() }) + it('reports an unavailable document without parsing or indexing it', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + expect( + await processDocumentAsync( + 'knowledge-base-1', + 'document-1', + PERSISTED_CONTEXT, + {}, + BILLING_ATTRIBUTION + ) + ).toEqual({ outcome: 'skipped', reason: 'unavailable' }) + expect(mockProcessDocument).not.toHaveBeenCalled() + }) + + it('reports discarded output when the generation changes before the index commit', async () => { + armProviderSource() + dbChainMockFns.limit.mockReset() + dbChainMockFns.limit + .mockResolvedValueOnce([PERSISTED_CONTEXT]) + .mockResolvedValueOnce([PERSISTED_PROVENANCE_ROW]) + .mockResolvedValueOnce([]) + expect( + await processDocumentAsync( + 'knowledge-base-1', + 'document-1', + PERSISTED_CONTEXT, + {}, + BILLING_ATTRIBUTION + ) + ).toEqual({ outcome: 'skipped', reason: 'superseded' }) + expect( + dbChainMockFns.set.mock.calls.some(([value]) => value.processingStatus === 'completed') + ).toBe(false) + }) + it('does not parse or reschedule a superseded provider continuation', async () => { armProviderSource() dbChainMockFns.returning.mockResolvedValueOnce([]) const schedule = vi.fn() - await processDocumentAsync( + const result = await processDocumentAsync( 'knowledge-base-1', 'document-1', PERSISTED_CONTEXT, @@ -1477,6 +1512,7 @@ describe('processDocumentAsync write guards', () => { scheduleProviderContinuation: schedule, } ) + expect(result).toEqual({ outcome: 'skipped', reason: 'not_claimed' }) expect(mockProcessDocument).not.toHaveBeenCalled() expect(schedule).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/knowledge/documents/processing-recovery-policy.ts b/apps/sim/lib/knowledge/documents/processing-recovery-policy.ts index 4c2bb0e9053..f30246e20b6 100644 --- a/apps/sim/lib/knowledge/documents/processing-recovery-policy.ts +++ b/apps/sim/lib/knowledge/documents/processing-recovery-policy.ts @@ -1,4 +1,4 @@ -import { document } from '@sim/db/schema' +import { document, outboxEvent } from '@sim/db/schema' import { and, eq, gt, isNotNull, isNull, lt, lte, or, sql } from 'drizzle-orm' import { DOCUMENT_PROCESSING_STALE_THRESHOLD_MS } from '@/lib/knowledge/documents/processing-timeouts.server' import { MAX_PROCESSING_ATTEMPTS, QUEUED_DISPATCH_GRACE_MS } from '@/lib/knowledge/documents/types' @@ -15,6 +15,11 @@ export function documentProcessingRecoveryCondition( return and( sql`${document.processingStatus} IN ('pending', 'processing', 'failed')`, isNotNull(document.connectorId), + sql`NOT EXISTS ( + SELECT 1 FROM ${outboxEvent} + WHERE ${outboxEvent.id} = ${document.processingQueueToken} + AND ${outboxEvent.status} IN ('pending', 'processing') + )`, isNotNull(document.contentHash), isNotNull(document.storageKey), eq(document.userExcluded, false), diff --git a/apps/sim/lib/knowledge/documents/processing-recovery-queue.test.ts b/apps/sim/lib/knowledge/documents/processing-recovery-queue.test.ts new file mode 100644 index 00000000000..2a99b144b71 --- /dev/null +++ b/apps/sim/lib/knowledge/documents/processing-recovery-queue.test.ts @@ -0,0 +1,118 @@ +/** @vitest-environment node */ +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ list: vi.fn(), enabled: true, insideRun: false })) +vi.mock('@sim/db', () => dbChainMock) +vi.mock('@trigger.dev/sdk', () => ({ runs: { list: mocks.list } })) +vi.mock('@/lib/core/config/env-flags', () => ({ + get isTriggerDevEnabled() { + return mocks.enabled + }, +})) +vi.mock('@/lib/core/config/trigger-runtime', () => ({ + isInsideTriggerRun: () => mocks.insideRun, +})) + +import { filterAbandonedDocumentProcessing } from '@/lib/knowledge/documents/processing-recovery-queue' + +const candidate = { + id: 'document-1', + processingQueueToken: 'generation-1', + processingQueuedAt: new Date('2026-01-01T00:00:00Z'), + processingStartedAt: null, + uploadedAt: new Date('2026-01-01T00:00:00Z'), +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.enabled = true + mocks.insideRun = false + mocks.list.mockResolvedValue({ data: [] }) +}) +afterEach(() => vi.useRealTimers()) + +it.each(['PENDING_VERSION', 'DELAYED', 'QUEUED', 'DEQUEUED', 'EXECUTING', 'WAITING'])( + 'preserves a %s run regardless of queue age, without spending another attempt', + async (status) => { + mocks.list.mockResolvedValue({ data: [{ id: 'run-1', status }] }) + expect(await filterAbandonedDocumentProcessing([candidate])).toEqual([]) + expect(mocks.list).toHaveBeenCalledWith( + expect.objectContaining({ + taskIdentifier: 'knowledge-process-document', + tag: 'documentId:document-1', + from: new Date('2025-12-31T20:00:00Z'), + status: expect.arrayContaining([status]), + limit: 1, + }), + { retry: { maxAttempts: 1 } } + ) + expect(dbChainMockFns.set).toHaveBeenCalledExactlyOnceWith({ + processingRecoveryAfter: expect.any(Date), + }) + } +) + +it('allows the existing recovery policy after confirming no live job remains', async () => { + expect(await filterAbandonedDocumentProcessing([candidate])).toEqual([candidate]) + expect(dbChainMockFns.set).not.toHaveBeenCalled() +}) + +it('fails closed and backs off when the queue cannot be inspected', async () => { + mocks.list.mockRejectedValue(new Error('unavailable')) + expect(await filterAbandonedDocumentProcessing([candidate])).toEqual([]) + expect(dbChainMockFns.set).toHaveBeenCalledExactlyOnceWith({ + processingRecoveryAfter: expect.any(Date), + }) +}) + +it('bounds concurrency and stops scheduling further lookups after the deadline', async () => { + vi.useFakeTimers() + const candidates = Array.from({ length: 200 }, (_, index) => ({ + ...candidate, + id: `doc-${index}`, + })) + let finishLookup: (value: { data: [] }) => void = () => undefined + mocks.list.mockReturnValue( + new Promise((resolve) => { + finishLookup = resolve + }) + ) + const pending = filterAbandonedDocumentProcessing(candidates) + await vi.advanceTimersByTimeAsync(10_001) + expect(await pending).toEqual([]) + expect(mocks.list).toHaveBeenCalledTimes(4) + finishLookup({ data: [] }) + await vi.runAllTimersAsync() + expect(mocks.list).toHaveBeenCalledTimes(4) +}) + +it('keeps completed checks when another lookup fails, without recovering unknown jobs', async () => { + mocks.list.mockResolvedValueOnce({ data: [] }).mockRejectedValueOnce(new Error('unavailable')) + expect( + await filterAbandonedDocumentProcessing([candidate, { ...candidate, id: 'doc-2' }]) + ).toEqual([candidate]) +}) + +it('does not look up Trigger runs on a deployment without Trigger', async () => { + mocks.enabled = false + expect(await filterAbandonedDocumentProcessing([candidate])).toEqual([candidate]) + expect(mocks.list).not.toHaveBeenCalled() +}) + +it('still inspects the queue inside a Trigger worker with a disabled environment flag', async () => { + mocks.enabled = false + mocks.insideRun = true + mocks.list.mockResolvedValue({ data: [{ id: 'run-1' }] }) + expect(await filterAbandonedDocumentProcessing([candidate])).toEqual([]) +}) + +it('does not mutate any recovery state when the caller is canceled', async () => { + const controller = new AbortController() + controller.abort(new Error('canceled')) + await expect(filterAbandonedDocumentProcessing([candidate], controller.signal)).rejects.toThrow( + 'canceled' + ) + expect(dbChainMockFns.set).not.toHaveBeenCalled() +}) diff --git a/apps/sim/lib/knowledge/documents/processing-recovery-queue.ts b/apps/sim/lib/knowledge/documents/processing-recovery-queue.ts new file mode 100644 index 00000000000..3d8f0aa241a --- /dev/null +++ b/apps/sim/lib/knowledge/documents/processing-recovery-queue.ts @@ -0,0 +1,129 @@ +import { db } from '@sim/db' +import { document } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { runs } from '@trigger.dev/sdk' +import { and, eq, isNull, or, sql } from 'drizzle-orm' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { isInsideTriggerRun } from '@/lib/core/config/trigger-runtime' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { withinDeadline } from '@/lib/core/utils/deadline' +import { documentProcessingRecoveryCondition } from '@/lib/knowledge/documents/processing-recovery-policy' +import { QUEUED_DISPATCH_GRACE_MS } from '@/lib/knowledge/documents/types' + +const logger = createLogger('DocumentRecoveryQueue') +const LOOKUP_CONCURRENCY = 4 +const LOOKUP_BUDGET_MS = 10_000 +const RECHECK_DELAY_MS = 15 * 60_000 + +export interface DocumentRecoveryGeneration { + id: string + processingQueueToken: string | null + processingQueuedAt: Date | null + processingStartedAt: Date | null + uploadedAt: Date +} + +/** Recovery may replace only the generation whose queue state was inspected outside the transaction. */ +export function documentRecoveryGenerationCondition(candidate: DocumentRecoveryGeneration) { + return and( + eq(document.id, candidate.id), + candidate.processingQueueToken === null + ? isNull(document.processingQueueToken) + : eq(document.processingQueueToken, candidate.processingQueueToken), + candidate.processingQueuedAt === null + ? isNull(document.processingQueuedAt) + : eq(document.processingQueuedAt, candidate.processingQueuedAt), + candidate.processingStartedAt === null + ? isNull(document.processingStartedAt) + : eq(document.processingStartedAt, candidate.processingStartedAt) + ) +} + +/** + * Queue age is not evidence of abandonment. Any live run for the document protects + * it, including legacy dispatches and continuations. Lookup failures fail closed. + * Callers supply bounded candidate pages; only one metadata row is read per lookup. + */ +export async function filterAbandonedDocumentProcessing( + candidates: T[], + signal?: AbortSignal +): Promise { + if (!candidates.length || (!isTriggerDevEnabled && !isInsideTriggerRun())) return candidates + + const abandoned: T[] = [] + let lookupError: unknown + try { + await withinDeadline( + async (lookupSignal) => { + await mapWithConcurrency(candidates, LOOKUP_CONCURRENCY, async (candidate) => { + lookupSignal.throwIfAborted() + if (lookupError) return + const page = await runs + .list( + { + taskIdentifier: 'knowledge-process-document', + tag: `documentId:${candidate.id}`, + /** Include the entire eligible document lifetime, with the existing dispatch grace for clock skew. */ + from: new Date(candidate.uploadedAt.getTime() - QUEUED_DISPATCH_GRACE_MS), + status: [ + 'PENDING_VERSION', + 'DELAYED', + 'QUEUED', + 'DEQUEUED', + 'EXECUTING', + 'WAITING', + ], + limit: 1, + }, + { retry: { maxAttempts: 1 } } + ) + .catch((error: unknown) => { + lookupError = error + return null + }) + lookupSignal.throwIfAborted() + if (page?.data.length === 0) abandoned.push(candidate) + }) + if (lookupError) throw lookupError + }, + Date.now() + LOOKUP_BUDGET_MS, + signal + ) + } catch (error) { + signal?.throwIfAborted() + logger.warn('Could not verify all document jobs; leaving unverified generations unchanged', { + candidates: candidates.length, + abandoned: abandoned.length, + error: getErrorMessage(error), + }) + } + + signal?.throwIfAborted() + const abandonedIds = new Set(abandoned.map((candidate) => candidate.id)) + const retained = candidates.filter((candidate) => !abandonedIds.has(candidate.id)) + if (retained.length) { + try { + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT set_config('statement_timeout', '5000', true), set_config('lock_timeout', '1000', true)` + ) + signal?.throwIfAborted() + await tx + .update(document) + .set({ processingRecoveryAfter: new Date(Date.now() + RECHECK_DELAY_MS) }) + .where( + and( + documentProcessingRecoveryCondition(new Date()), + or(...retained.map(documentRecoveryGenerationCondition)) + ) + ) + signal?.throwIfAborted() + }) + } catch (error) { + signal?.throwIfAborted() + logger.warn('Could not postpone document queue recheck', { error: getErrorMessage(error) }) + } + } + return [...abandoned] +} diff --git a/apps/sim/lib/knowledge/documents/processing-recovery.ts b/apps/sim/lib/knowledge/documents/processing-recovery.ts index 4a0a457d2ac..369a8252cab 100644 --- a/apps/sim/lib/knowledge/documents/processing-recovery.ts +++ b/apps/sim/lib/knowledge/documents/processing-recovery.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { document, knowledgeBase, knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, asc, eq, inArray, isNull, notInArray, sql } from 'drizzle-orm' +import { and, asc, eq, inArray, isNull, notInArray, or, sql } from 'drizzle-orm' import { assertBillingAttributionOwner, resolveSystemBillingAttribution, @@ -17,6 +17,10 @@ import { createWorkspaceDocumentProcessingBillingContext, } from '@/lib/knowledge/documents/processing-payload' import { documentProcessingRecoveryCondition } from '@/lib/knowledge/documents/processing-recovery-policy' +import { + documentRecoveryGenerationCondition, + filterAbandonedDocumentProcessing, +} from '@/lib/knowledge/documents/processing-recovery-queue' const logger = createLogger('KnowledgeDocumentRecovery') @@ -29,7 +33,7 @@ const RECOVERABLE_CONNECTOR_STATUSES = ['active', 'error', 'pending', 'syncing'] /** * Re-admits bounded, abandoned connector documents from our retained bytes, independently * of source sync schedules and credentials. The generation, attempt and outbox event commit - * together; no provider call or source lease is needed. Paused/deleted sources stay paused. + * together; no source-provider call or source lease is needed. Paused/deleted sources stay paused. */ export async function recoverKnowledgeDocumentProcessing(now = new Date()): Promise { const deadlineAt = Date.now() + RECOVERY_RUNTIME_MS @@ -78,6 +82,10 @@ async function recoverStoredDocumentBatch( return tx .select({ id: document.id, + processingQueueToken: document.processingQueueToken, + processingQueuedAt: document.processingQueuedAt, + processingStartedAt: document.processingStartedAt, + uploadedAt: document.uploadedAt, knowledgeBaseId: document.knowledgeBaseId, connectorId: knowledgeConnector.id, workspaceId: knowledgeBase.workspaceId, @@ -107,9 +115,11 @@ async function recoverStoredDocumentBatch( signal.throwIfAborted() if (candidates.length === 0) return 0 + const abandoned = await filterAbandonedDocumentProcessing(candidates, signal) + for (const candidate of candidates) attemptedConnectors.add(candidate.connectorId) let recovered = 0 const groups = new Map() - for (const candidate of candidates) { + for (const candidate of abandoned) { const group = groups.get(candidate.knowledgeBaseId) ?? [] group.push(candidate) groups.set(candidate.knowledgeBaseId, group) @@ -117,7 +127,6 @@ async function recoverStoredDocumentBatch( for (const [knowledgeBaseId, group] of groups) { if (Date.now() >= deadlineAt) break const connectorIds = [...new Set(group.map((row) => row.connectorId))] - for (const connectorId of connectorIds) attemptedConnectors.add(connectorId) const owner = group[0] let ownerVerified = false try { @@ -188,7 +197,8 @@ async function recoverStoredDocumentBatch( document.connectorId, connectors.map((row) => row.id) ), - documentProcessingRecoveryCondition(now) + documentProcessingRecoveryCondition(now), + or(...group.map(documentRecoveryGenerationCondition)) ) ) .orderBy(asc(document.id)) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 7b1bbef023b..2a90e1b4038 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -1447,6 +1447,22 @@ function queueGenerationConditions( : [isNull(document.processingQueueToken)] } +/** + * Who the processor reads a document's source file as. Always the actor, not + * the payer: authorizing as the KB owner would let a writer ingest an internal + * file only the owner can read. A connector-owned row was written by the sync + * from bytes it fetched, not from a caller-supplied URL, so it is read as the + * system: in members mode the row stays hidden until the sync materializes who + * observed it, and the actor's own scope would deny the read. + */ +function sourceFileAccessFor(connectorId: string | null, actorUserId: string): SourceFileAccess { + return { userId: actorUserId, knowledgeAccess: connectorId ? SYSTEM_ACCESS_SCOPE : undefined } +} + +export type DocumentProcessingResult = + | { outcome: 'indexed' } + | { outcome: 'skipped'; reason: 'unavailable' | 'not_claimed' | 'superseded' } + /** * Parses, embeds, and indexes one document. * @@ -1461,18 +1477,6 @@ function queueGenerationConditions( * invocation against the document's retry budget. Direct callers omit it and * therefore cannot refund an attempt they never charged. */ -/** - * Who the processor reads a document's source file as. Always the actor, not - * the payer: authorizing as the KB owner would let a writer ingest an internal - * file only the owner can read. A connector-owned row was written by the sync - * from bytes it fetched, not from a caller-supplied URL, so it is read as the - * system: in members mode the row stays hidden until the sync materializes who - * observed it, and the actor's own scope would deny the read. - */ -function sourceFileAccessFor(connectorId: string | null, actorUserId: string): SourceFileAccess { - return { userId: actorUserId, knowledgeAccess: connectorId ? SYSTEM_ACCESS_SCOPE : undefined } -} - export async function processDocumentAsync( knowledgeBaseId: string, documentId: string, @@ -1486,7 +1490,7 @@ export async function processDocumentAsync( providedBillingContext?: BillingAttributionSnapshot | DocumentProcessingBillingContext, indexingPassId?: string, attemptContext?: DocumentProcessingAttemptContext -): Promise { +): Promise { const startTime = Date.now() const processingStartedAt = new Date() let processingFilename = docData.filename @@ -1570,12 +1574,12 @@ export async function processDocumentAsync( documentConnectorIsActive() ) ) - return + return { outcome: 'skipped', reason: 'unavailable' } } const ctx = contextRows[0] processingFilename = ctx.filename - await withResourceOutboundScope(ctx, async () => { + return await withResourceOutboundScope(ctx, async (): Promise => { const persistedDocData = { filename: ctx.filename, fileUrl: ctx.fileUrl, @@ -1645,7 +1649,7 @@ export async function processDocumentAsync( logger.info( `[${documentId}] Skipping document processing: superseded, already active, completed, archived, or deleted` ) - return + return { outcome: 'skipped', reason: 'not_claimed' } } attemptContext?.onClaimed?.() @@ -2003,7 +2007,7 @@ export async function processDocumentAsync( if (!processingCommitted) { logger.info(`[${documentId}] Discarded output from an obsolete processing attempt`) - return + return { outcome: 'skipped', reason: 'superseded' } } const processingTime = Date.now() - startTime @@ -2074,6 +2078,7 @@ export async function processDocumentAsync( logger.error(`[${documentId}] Failed to record embedding usage`, { error: billingError }) } } + return { outcome: 'indexed' } }) } catch (error) { const processingTime = Date.now() - startTime From 99d6a9fbffc1aae1ec9343a4437a54de652ae6f6 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 18 Sep 2026 17:37:20 -0700 Subject: [PATCH 17/20] fix(knowledge): preserve live document processing during recovery (#7993) * fix(knowledge): preserve live document processing during recovery * fix(knowledge): recover abandoned redelivery and cancel liveness requests * fix(knowledge): fence recovery against concurrent protection --- apps/sim/lib/internal/mistral/client.test.ts | 49 ++- apps/sim/lib/internal/mistral/client.ts | 12 +- .../mistral/error-diagnostics.test.ts | 49 +++ .../lib/internal/mistral/error-diagnostics.ts | 57 +++ .../stored-document-recovery.integration.ts | 377 ++++++++++++++++- .../knowledge/connectors/sync-primitives.ts | 32 +- .../knowledge/documents/document-processor.ts | 7 + .../documents/pdf-ocr-triage.test.ts | 39 ++ .../documents/processing-queue.test.ts | 177 +------- ...rocessing-recovery-queue-transport.test.ts | 102 +++++ .../processing-recovery-queue.test.ts | 378 +++++++++++++----- .../documents/processing-recovery-queue.ts | 315 ++++++++++----- .../documents/processing-recovery.ts | 26 +- .../documents/retry-processing-grace.test.ts | 15 + apps/sim/lib/knowledge/documents/service.ts | 234 +++++++---- .../google-service-account-transport.test.ts | 47 +++ .../oauth/google-service-account-transport.ts | 32 +- 17 files changed, 1445 insertions(+), 503 deletions(-) create mode 100644 apps/sim/lib/internal/mistral/error-diagnostics.test.ts create mode 100644 apps/sim/lib/internal/mistral/error-diagnostics.ts create mode 100644 apps/sim/lib/knowledge/documents/processing-recovery-queue-transport.test.ts diff --git a/apps/sim/lib/internal/mistral/client.test.ts b/apps/sim/lib/internal/mistral/client.test.ts index 774882e107d..62bf449afd5 100644 --- a/apps/sim/lib/internal/mistral/client.test.ts +++ b/apps/sim/lib/internal/mistral/client.test.ts @@ -3,6 +3,11 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const { errorLog } = vi.hoisted(() => ({ errorLog: vi.fn() })) +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ error: errorLog, info: vi.fn(), warn: vi.fn(), debug: vi.fn() }), +})) + const { fetchPinned, admit, settle, validate } = vi.hoisted(() => ({ fetchPinned: vi.fn(), admit: vi.fn(), @@ -63,9 +68,41 @@ describe('Mistral provider transport', () => { expect(settle).toHaveBeenCalledWith('success', undefined) }) + it('records cooldown without waiting for a stalled 429 error body', async () => { + const cancel = vi.fn() + fetchPinned.mockResolvedValue( + new Response(new ReadableStream({ cancel }), { + status: 429, + headers: { 'retry-after': '60' }, + }) + ) + await expect(submitMistralOcr('private-key', {})).rejects.toMatchObject({ + reason: 'rate_limit', + retryAfterMs: 60_000, + }) + expect(settle).toHaveBeenCalledWith('rate_limit', 60_000) + expect(cancel).toHaveBeenCalledOnce() + expect(fetchPinned).toHaveBeenCalledOnce() + }) + + it('preserves provider rejection when its diagnostic body exceeds the byte limit', async () => { + fetchPinned.mockResolvedValue(new Response('x'.repeat(70_000), { status: 400 })) + await expect(submitMistralOcr('private-key', {})).rejects.toMatchObject({ + status: 400, + body: { success: false, error: 'Mistral API error: HTTP 400' }, + }) + expect(errorLog).toHaveBeenCalledWith( + 'Mistral API error', + expect.objectContaining({ status: 400, bodyFormat: 'unavailable' }) + ) + }) + it('identifies provider request rejection without retaining echoed document contents', async () => { fetchPinned.mockResolvedValue( - Response.json({ message: 'Sensitive fixture document text' }, { status: 400 }) + Response.json( + { type: 'invalid_request_error', code: 400, message: 'Sensitive fixture document text' }, + { status: 400, headers: { 'x-request-id': 'ocr-request-123' } } + ) ) await expect(submitMistralOcr('key', {})).rejects.toMatchObject({ source: 'provider', @@ -74,6 +111,16 @@ describe('Mistral provider transport', () => { }) expect(fetchPinned).toHaveBeenCalledOnce() expect(settle).toHaveBeenCalledWith('failure', undefined) + expect(errorLog).toHaveBeenCalledWith( + 'Mistral API error', + expect.objectContaining({ + status: 400, + providerRequestId: 'ocr-request-123', + providerErrorCode: '400', + providerErrorType: 'invalid_request_error', + }) + ) + expect(JSON.stringify(errorLog.mock.calls)).not.toContain('Sensitive fixture document text') }) it.each([ diff --git a/apps/sim/lib/internal/mistral/client.ts b/apps/sim/lib/internal/mistral/client.ts index 31ecc0f2ef1..010cf8ade43 100644 --- a/apps/sim/lib/internal/mistral/client.ts +++ b/apps/sim/lib/internal/mistral/client.ts @@ -11,9 +11,10 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { getMistralCapacityConfig, getMistralCapacityScope } from '@/lib/internal/mistral/capacity' +import { getOcrResponseDiagnostic } from '@/lib/internal/mistral/error-diagnostics' import { MistralOperationError } from '@/lib/internal/mistral/errors' import { MISTRAL_OCR_REQUEST_POLICY } from '@/lib/knowledge/documents/ocr-request-policy' -import { readBoundedHttpErrorBody, resolveRetryDelayMs } from '@/lib/knowledge/documents/utils' +import { readBoundedHttpErrorPayload, resolveRetryDelayMs } from '@/lib/knowledge/documents/utils' const logger = createLogger('MistralClient') const MISTRAL_ENDPOINT = 'https://api.mistral.ai/v1/ocr' @@ -138,8 +139,13 @@ export async function submitMistralOcr( retryAfterMs, }) } - await readBoundedHttpErrorBody(response) - logger.error('Mistral API error', { status: response.status }) + const payload = await readBoundedHttpErrorPayload(response) + logger.error('Mistral API error', { + provider: 'mistral', + operation: 'ocr', + status: response.status, + ...getOcrResponseDiagnostic(response.headers, payload.ok ? payload.body : ''), + }) throw new MistralOperationError( response.status, { success: false, error: `Mistral API error: HTTP ${response.status}` }, diff --git a/apps/sim/lib/internal/mistral/error-diagnostics.test.ts b/apps/sim/lib/internal/mistral/error-diagnostics.test.ts new file mode 100644 index 00000000000..46001741011 --- /dev/null +++ b/apps/sim/lib/internal/mistral/error-diagnostics.test.ts @@ -0,0 +1,49 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { getOcrResponseDiagnostic } from '@/lib/internal/mistral/error-diagnostics' + +describe('OCR error diagnostics', () => { + it.each([ + { code: 400, type: 'invalid_request_error', message: 'private document' }, + { error: { code: 400, type: 'invalid_request_error', message: 'private document' } }, + ])('projects only safe fields from a provider envelope', (body) => { + expect( + getOcrResponseDiagnostic(new Headers({ 'x-request-id': 'request_123' }), JSON.stringify(body)) + ).toEqual({ + bodyFormat: 'json', + providerRequestId: 'request_123', + providerErrorCode: '400', + providerErrorType: 'invalid_request_error', + }) + }) + + it.each(['not json', 'private input', '', 'null', '[]', '42'])( + 'tolerates an unavailable or non-object error: %s', + (body) => { + expect(getOcrResponseDiagnostic(new Headers(), body)).toMatchObject({ + providerRequestId: null, + providerErrorCode: null, + providerErrorType: null, + }) + } + ) + + it('does not trust arbitrary codes, error types, echoed input, or request IDs', () => { + const diagnostic = getOcrResponseDiagnostic( + new Headers({ 'x-request-id': 'secret '.repeat(30), 'apim-request-id': 'safe-123' }), + JSON.stringify({ + code: 'private_document', + type: { input: 'private_document' }, + message: 'private_document', + param: 'secret-key', + detail: { input: 'private_document' }, + }) + ) + expect(diagnostic).toMatchObject({ + providerRequestId: 'safe-123', + providerErrorCode: 'unrecognized', + providerErrorType: 'unrecognized', + }) + expect(JSON.stringify(diagnostic)).not.toMatch(/private_document|secret-key/) + }) +}) diff --git a/apps/sim/lib/internal/mistral/error-diagnostics.ts b/apps/sim/lib/internal/mistral/error-diagnostics.ts new file mode 100644 index 00000000000..5f2df64d35c --- /dev/null +++ b/apps/sim/lib/internal/mistral/error-diagnostics.ts @@ -0,0 +1,57 @@ +const SAFE_ERROR_CODES = new Set([ + 'invalid_request_error', + 'authentication_error', + 'permission_error', + 'rate_limit_error', + 'server_error', + 'unknown_model', + 'BadRequest', + 'InvalidRequest', + 'DeploymentNotFound', + 'ResourceNotFound', + 'OperationNotSupported', + 'Unauthorized', + 'Forbidden', + 'TooManyRequests', + 'InternalServerError', + 'ServiceUnavailable', +]) + +function safeCode(value: unknown): string | null { + if (value === undefined || value === null) return null + if (typeof value === 'string' && SAFE_ERROR_CODES.has(value)) return value + if (typeof value === 'number' && Number.isInteger(value) && value >= 400 && value <= 599) + return String(value) + return 'unrecognized' +} + +function safeRequestId(value: string | null): string | null { + return value && /^[a-zA-Z0-9_-]{1,128}$/.test(value) ? value : null +} + +/** Projects bounded OCR error responses without retaining messages, document data or URLs. */ +export function getOcrResponseDiagnostic(headers: Pick, body: string) { + const diagnostic = { + providerRequestId: + safeRequestId(headers.get('x-request-id')) ?? + safeRequestId(headers.get('apim-request-id')) ?? + safeRequestId(headers.get('x-ms-request-id')), + bodyFormat: body ? 'non_json' : 'unavailable', + providerErrorCode: null as string | null, + providerErrorType: null as string | null, + } + if (!body) return diagnostic + let parsed: unknown + try { + parsed = JSON.parse(body) + } catch { + return diagnostic + } + diagnostic.bodyFormat = 'json' + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return diagnostic + const error = 'error' in parsed ? parsed.error : parsed + if (!error || typeof error !== 'object' || Array.isArray(error)) return diagnostic + diagnostic.providerErrorCode = safeCode('code' in error ? error.code : undefined) + diagnostic.providerErrorType = safeCode('type' in error ? error.type : undefined) + return diagnostic +} diff --git a/apps/sim/lib/knowledge/__integration__/stored-document-recovery.integration.ts b/apps/sim/lib/knowledge/__integration__/stored-document-recovery.integration.ts index 36720c4ff32..b7a8b45e035 100644 --- a/apps/sim/lib/knowledge/__integration__/stored-document-recovery.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/stored-document-recovery.integration.ts @@ -22,15 +22,32 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest const fixture = vi.hoisted(() => ({ root: '', embeddingCalls: 0, - queueEnabled: false, + useTrigger: false, listRuns: vi.fn(), + batchTrigger: vi.fn(), })) vi.mock('@/lib/core/config/trigger-runtime', () => ({ - isInsideTriggerRun: () => fixture.queueEnabled, + isInsideTriggerRun: () => fixture.useTrigger, })) -vi.mock('@trigger.dev/sdk', async (original) => ({ - ...(await original()), - runs: { list: fixture.listRuns }, +vi.mock('@trigger.dev/core/v3', async (importOriginal) => ({ + ...(await importOriginal()), + apiClientManager: { + clientOrThrow: () => ({ baseUrl: 'https://api.trigger.dev', getHeaders: () => ({}) }), + }, +})) +vi.mock('@trigger.dev/core/v3/zodfetch', () => ({ + zodfetchCursorPage: (_schema: unknown, _url: string, params: { query: URLSearchParams }) => + fixture.listRuns({ tag: params.query.get('filter[tag]') }), +})) +vi.mock('@trigger.dev/sdk', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + tasks: { ...original.tasks, batchTrigger: fixture.batchTrigger }, + } +}) +vi.mock('@/lib/core/async-jobs/region', () => ({ + resolveTriggerRegion: async () => 'us-east-1', })) vi.mock('@/lib/uploads/core/setup.server', () => ({ get UPLOAD_DIR_SERVER() { @@ -65,13 +82,18 @@ import { searchScopedKnowledge } from '@/lib/knowledge/application/workspace-sea import { createContentSyncLease } from '@/lib/knowledge/connectors/sync-lock' import { addDocument } from '@/lib/knowledge/connectors/sync-persistence' import { sweepStuckDocuments } from '@/lib/knowledge/connectors/sync-primitives' +import { enqueueKnowledgeDocumentProcessing } from '@/lib/knowledge/documents/processing-outbox-event' import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler' import { DOCUMENT_RECOVERY_BATCH_SIZE, KNOWLEDGE_DOCUMENT_RECOVERY_OUTBOX_EVENT, recoverKnowledgeDocumentProcessing, } from '@/lib/knowledge/documents/processing-recovery' -import { processDocumentAsync } from '@/lib/knowledge/documents/service' +import { + processDocumentAsync, + processDocumentsWithQueue, + retryDocumentProcessing, +} from '@/lib/knowledge/documents/service' import { MAX_PROCESSING_ATTEMPTS, QUEUED_DISPATCH_GRACE_MS } from '@/lib/knowledge/documents/types' import type { SyncResult } from '@/connectors/types' @@ -134,8 +156,9 @@ async function failedFile( } afterEach(() => { - fixture.queueEnabled = false + fixture.useTrigger = false fixture.listRuns.mockReset() + fixture.batchTrigger.mockReset() }) beforeAll(() => { @@ -192,8 +215,11 @@ describe('independent recovery of retained connector documents', () => { .update(document) .set({ processingStatus: 'pending' }) .where(eq(document.id, file.documentId)) - fixture.queueEnabled = true - fixture.listRuns.mockResolvedValue({ data: [{ id: 'run-queued', status: 'QUEUED' }] }) + fixture.useTrigger = true + fixture.listRuns.mockResolvedValue({ + data: [{ id: 'run-queued', status: 'QUEUED' }], + hasNextPage: () => false, + }) expect(await recoverFixture(ids, mode)).toBe(0) const [row] = await db.select().from(document).where(eq(document.id, file.documentId)) expect(row.processingAttempts).toBe(1) @@ -212,13 +238,13 @@ describe('independent recovery of retained connector documents', () => { async (mode) => { const ids = await seed() const file = await failedFile(ids) - fixture.queueEnabled = true + fixture.useTrigger = true fixture.listRuns.mockImplementation(async () => { await db .update(document) .set({ processingQueueToken: 'replacement-generation' }) .where(eq(document.id, file.documentId)) - return { data: [] } + return { data: [], hasNextPage: () => false } }) expect(await recoverFixture(ids, mode)).toBe(0) const [row] = await db.select().from(document).where(eq(document.id, file.documentId)) @@ -257,6 +283,335 @@ describe('independent recovery of retained connector documents', () => { } ) + it.each(['manual', 'redelivery'])( + 'respects a concurrent liveness cooldown before %s replacement', + async (path) => { + const ids = await seed() + const file = await failedFile(ids) + await db + .update(document) + .set({ processingStatus: 'pending' }) + .where(eq(document.id, file.documentId)) + const [original] = await db.select().from(document).where(eq(document.id, file.documentId)) + const protectedUntil = new Date(Date.now() + 60_000) + fixture.useTrigger = true + fixture.batchTrigger.mockResolvedValue({ batchId: 'fixture-batch' }) + fixture.listRuns.mockImplementation(async () => { + await db + .update(document) + .set({ processingRecoveryAfter: protectedUntil }) + .where(eq(document.id, file.documentId)) + return { data: [], hasNextPage: () => false } + }) + const billing = await resolveSystemBillingAttribution(ids.workspaceId) + const docData = { + documentId: file.documentId, + filename: original.filename, + fileUrl: original.fileUrl, + fileSize: original.fileSize, + mimeType: original.mimeType, + } + if (path === 'manual') { + const result = await retryDocumentProcessing( + ids.knowledgeBaseId, + file.documentId, + docData, + generateId(), + billing + ) + expect(result.message).toContain('already queued') + } else { + const result = await processDocumentsWithQueue( + [docData], + ids.knowledgeBaseId, + {}, + generateId(), + billing, + 'interactive' + ) + expect(result).toMatchObject({ accepted: 0, failed: 1 }) + } + expect(fixture.batchTrigger).not.toHaveBeenCalled() + const [row] = await db.select().from(document).where(eq(document.id, file.documentId)) + expect(row.processingQueueToken).toBe(original.processingQueueToken) + expect(row.processingAttempts).toBe(1) + expect(row.processingRecoveryAfter).toEqual(protectedUntil) + await db + .update(knowledgeConnector) + .set({ status: 'paused' }) + .where(eq(knowledgeConnector.id, ids.connectorId)) + } + ) + + it.each([null, 'abandoned-generation'])( + 'redelivers an abandoned upload with token %s without spending another admission', + async (processingQueueToken) => { + const ids = await seed() + const file = await failedFile(ids) + const queuedAt = old() + await db + .update(document) + .set({ + connectorId: null, + processingStatus: 'pending', + processingQueueToken, + processingQueuedAt: queuedAt, + processingCompletedAt: null, + }) + .where(eq(document.id, file.documentId)) + const eventId = await enqueueKnowledgeDocumentProcessing(db, { + knowledgeBaseId: ids.knowledgeBaseId, + documentId: file.documentId, + processingOptions: {}, + billingAttribution: await resolveSystemBillingAttribution(ids.workspaceId), + processingLane: 'interactive', + }) + fixture.useTrigger = true + fixture.listRuns.mockResolvedValue({ data: [], hasNextPage: () => false }) + fixture.batchTrigger.mockResolvedValue({ batchId: 'fixture-batch' }) + expect( + await outbox.processOutboxEventById(eventId, knowledgeDocumentProcessingOutboxHandlers) + ).toBe('completed') + expect(fixture.batchTrigger).toHaveBeenCalledOnce() + expect(fixture.batchTrigger.mock.calls[0][1][0].payload).toMatchObject({ + documentId: file.documentId, + processingQueueToken: eventId, + processingQueuedAt: queuedAt.toISOString(), + chargedAtDispatch: false, + }) + const [row] = await db.select().from(document).where(eq(document.id, file.documentId)) + expect(row.processingQueueToken).toBe(eventId) + expect(row.processingAttempts).toBe(1) + } + ) + + it.each(['live', 'unknown', 'race'])( + 'does not adopt a legacy upload when its processing is %s', + async (state) => { + const ids = await seed() + const file = await failedFile(ids) + await db + .update(document) + .set({ + connectorId: null, + processingStatus: 'pending', + processingQueueToken: null, + processingCompletedAt: null, + }) + .where(eq(document.id, file.documentId)) + const eventId = await enqueueKnowledgeDocumentProcessing(db, { + knowledgeBaseId: ids.knowledgeBaseId, + documentId: file.documentId, + processingOptions: {}, + billingAttribution: await resolveSystemBillingAttribution(ids.workspaceId), + processingLane: 'interactive', + }) + fixture.useTrigger = true + if (state === 'unknown') + fixture.listRuns.mockRejectedValue(new Error('Synthetic lookup failure')) + else if (state === 'live') + fixture.listRuns.mockResolvedValue({ + data: [{ status: 'QUEUED' }], + hasNextPage: () => false, + }) + else + fixture.listRuns.mockImplementation(async () => { + await db + .update(document) + .set({ processingQueueToken: 'winning-generation', processingQueuedAt: new Date() }) + .where(eq(document.id, file.documentId)) + return { data: [], hasNextPage: () => false } + }) + expect( + await outbox.processOutboxEventById(eventId, knowledgeDocumentProcessingOutboxHandlers) + ).toBe(state === 'live' ? 'completed' : 'pending') + expect(fixture.batchTrigger).not.toHaveBeenCalled() + const [row] = await db.select().from(document).where(eq(document.id, file.documentId)) + expect(row.processingQueueToken).toBe(state === 'race' ? 'winning-generation' : null) + expect(row.processingAttempts).toBe(1) + } + ) + + it.each(['QUEUED', 'DELAYED', 'WAITING'])( + 'preserves a seven-hour %s run and its existing attempt', + async (status) => { + const ids = await seed() + const file = await failedFile(ids) + const queuedAt = new Date(Date.now() - 7 * 60 * 60_000) + await db + .update(document) + .set({ + processingStatus: 'pending', + processingQueuedAt: queuedAt, + processingCompletedAt: null, + }) + .where(eq(document.id, file.documentId)) + fixture.useTrigger = true + fixture.listRuns.mockResolvedValue({ data: [{ status }], hasNextPage: () => false }) + expect(await recoverKnowledgeDocumentProcessing()).toBe(0) + const [row] = await db.select().from(document).where(eq(document.id, file.documentId)) + expect(row).toMatchObject({ + processingStatus: 'pending', + processingAttempts: 1, + processingQueueToken: 'old-fixture-generation', + processingQueuedAt: queuedAt, + }) + expect(row.processingRecoveryAfter!.getTime()).toBeGreaterThan(Date.now()) + expect(await eventsFor(ids)).toHaveLength(0) + await db + .update(knowledgeConnector) + .set({ status: 'paused' }) + .where(eq(knowledgeConnector.id, ids.connectorId)) + } + ) + + it.each(['pending', 'processing'])( + 'preserves a %s outbox carrier without replacing its generation', + async (status) => { + const ids = await seed() + const file = await failedFile(ids) + const token = generateId() + await db + .update(document) + .set({ processingStatus: 'pending', processingQueueToken: token }) + .where(eq(document.id, file.documentId)) + await db.insert(outboxEvent).values({ + id: token, + status, + eventType: 'knowledge.document.processing', + payload: { knowledgeBaseId: ids.knowledgeBaseId }, + }) + expect(await recoverKnowledgeDocumentProcessing()).toBe(0) + const [row] = await db.select().from(document).where(eq(document.id, file.documentId)) + expect(row.processingQueueToken).toBe(token) + expect(row.processingAttempts).toBe(1) + expect(await eventsFor(ids)).toHaveLength(0) + await db + .update(knowledgeConnector) + .set({ status: 'paused' }) + .where(eq(knowledgeConnector.id, ids.connectorId)) + } + ) + + it.each(['processing', 'completed', 'pending'])( + 'does not replace a concurrent %s transition after inspecting work', + async (status) => { + const ids = await seed() + const file = await failedFile(ids) + fixture.useTrigger = true + fixture.listRuns.mockImplementation(async () => { + await db + .update(document) + .set({ + processingStatus: status, + processingQueueToken: 'winning-generation', + processingStartedAt: new Date(), + }) + .where(eq(document.id, file.documentId)) + return { data: [], hasNextPage: () => false } + }) + expect(await recoverKnowledgeDocumentProcessing()).toBe(0) + const [row] = await db.select().from(document).where(eq(document.id, file.documentId)) + expect(row.processingQueueToken).toBe('winning-generation') + expect(row.processingAttempts).toBe(1) + expect(await eventsFor(ids)).toHaveLength(0) + await db + .update(knowledgeConnector) + .set({ status: 'paused' }) + .where(eq(knowledgeConnector.id, ids.connectorId)) + } + ) + + it.each(['manual', 'connector'] as const)( + 'preserves live work through the %s retry path', + async (path) => { + const ids = await seed() + const file = await failedFile(ids) + await db + .update(document) + .set({ processingStatus: 'pending' }) + .where(eq(document.id, file.documentId)) + const [original] = await db.select().from(document).where(eq(document.id, file.documentId)) + fixture.useTrigger = true + fixture.listRuns.mockResolvedValue({ data: [{ status: 'QUEUED' }], hasNextPage: () => false }) + const billing = await resolveSystemBillingAttribution(ids.workspaceId) + if (path === 'manual') { + const result = await retryDocumentProcessing( + ids.knowledgeBaseId, + file.documentId, + { + filename: original.filename, + fileUrl: original.fileUrl, + fileSize: original.fileSize, + mimeType: original.mimeType, + }, + generateId(), + billing + ) + expect(result.message).toContain('already queued') + } else { + const result = { + docsAdded: 0, + docsUpdated: 0, + docsDeleted: 0, + docsUnchanged: 0, + docsSkipped: 0, + docsFailed: 0, + processingDispatch: { requested: 0, accepted: 0, failed: 0 }, + } + await sweepStuckDocuments({ + connectorId: ids.connectorId, + knowledgeBaseId: ids.knowledgeBaseId, + syncStartedAt: new Date(), + retryCutoff: new Date(Date.now() - 7 * 24 * 60 * 60_000), + billingAttribution: billing, + result, + lease: createContentSyncLease(ids.connectorId, ids.lockId), + }) + expect(result.processingDispatch.requested).toBe(0) + } + const [row] = await db.select().from(document).where(eq(document.id, file.documentId)) + expect(row.processingAttempts).toBe(original.processingAttempts) + expect(row.processingQueueToken).toBe(original.processingQueueToken) + expect(await eventsFor(ids)).toHaveLength(0) + await db + .update(knowledgeConnector) + .set({ status: 'paused' }) + .where(eq(knowledgeConnector.id, ids.connectorId)) + } + ) + + it('continues past a protected batch while its cooldown is active', async () => { + const ids = await seed() + const file = await failedFile(ids) + const [original] = await db.select().from(document).where(eq(document.id, file.documentId)) + await db.insert(document).values( + Array.from({ length: DOCUMENT_RECOVERY_BATCH_SIZE - 1 }, () => ({ + ...original, + id: generateId(), + externalId: generateId(), + secretProvenanceVersion: null, + })) + ) + const abandoned = await failedFile(ids) + await db + .update(document) + .set({ uploadedAt: new Date(original.uploadedAt.getTime() + 1) }) + .where(eq(document.id, abandoned.documentId)) + fixture.useTrigger = true + fixture.listRuns.mockImplementation(async ({ tag }: { tag: string }) => ({ + data: tag === `documentId:${abandoned.documentId}` ? [] : [{ status: 'QUEUED' }], + hasNextPage: () => false, + })) + expect(await recoverKnowledgeDocumentProcessing()).toBe(0) + expect(await recoverKnowledgeDocumentProcessing()).toBe(1) + expect((await eventsFor(ids))[0].payload).toMatchObject({ documentId: abandoned.documentId }) + await db + .update(knowledgeConnector) + .set({ status: 'paused' }) + .where(eq(knowledgeConnector.id, ids.connectorId)) + }) + it('uses the organization owner and preserves Search visibility during source backoff', async () => { const ids = await seed() await db.insert(member).values({ diff --git a/apps/sim/lib/knowledge/connectors/sync-primitives.ts b/apps/sim/lib/knowledge/connectors/sync-primitives.ts index 13a1dd25139..012e03e0627 100644 --- a/apps/sim/lib/knowledge/connectors/sync-primitives.ts +++ b/apps/sim/lib/knowledge/connectors/sync-primitives.ts @@ -29,8 +29,10 @@ import { } from '@/lib/knowledge/connectors/sync-persistence' import { documentProcessingRecoveryCondition } from '@/lib/knowledge/documents/processing-recovery-policy' import { - documentRecoveryGenerationCondition, - filterAbandonedDocumentProcessing, + DOCUMENT_LIVENESS_BATCH_SIZE, + documentProcessingSnapshotCondition, + findAbandonedDocumentProcessing, + processingSnapshotColumns, } from '@/lib/knowledge/documents/processing-recovery-queue' import { DOCUMENT_PROCESSING_STALE_THRESHOLD_MS } from '@/lib/knowledge/documents/processing-timeouts.server' import type { DocumentData } from '@/lib/knowledge/documents/service' @@ -1334,18 +1336,11 @@ export async function sweepStuckDocuments(input: SweepStuckDocumentsInput): Prom const sweepEvaluatedAt = new Date() const sweepCandidates = await db .select({ - id: document.id, + ...processingSnapshotColumns, fileUrl: document.fileUrl, filename: document.filename, fileSize: document.fileSize, mimeType: document.mimeType, - processingStatus: document.processingStatus, - processingQueueToken: document.processingQueueToken, - processingQueuedAt: document.processingQueuedAt, - processingStartedAt: document.processingStartedAt, - processingDeferredUntil: document.processingDeferredUntil, - processingCompletedAt: document.processingCompletedAt, - uploadedAt: document.uploadedAt, }) .from(document) .where( @@ -1365,9 +1360,9 @@ export async function sweepStuckDocuments(input: SweepStuckDocumentsInput): Prom END`), asc(document.id) ) - .limit(STUCK_RETRY_MAX_CANDIDATES_PER_SYNC) - const abandoned = await filterAbandonedDocumentProcessing(sweepCandidates) - const stuckDocs = abandoned.filter( + .limit(Math.min(STUCK_RETRY_MAX_CANDIDATES_PER_SYNC, DOCUMENT_LIVENESS_BATCH_SIZE)) + const abandonedCandidates = await findAbandonedDocumentProcessing(sweepCandidates) + const stuckDocs = abandonedCandidates.filter( (row): row is typeof row & { processingStatus: DocumentProcessingStatus } => isDocumentProcessingStatus(row.processingStatus) ) @@ -1402,24 +1397,17 @@ export async function sweepStuckDocuments(input: SweepStuckDocumentsInput): Prom const lockedCandidates = await tx .select({ - id: document.id, + ...processingSnapshotColumns, fileUrl: document.fileUrl, filename: document.filename, fileSize: document.fileSize, mimeType: document.mimeType, - processingStatus: document.processingStatus, - processingQueueToken: document.processingQueueToken, - processingQueuedAt: document.processingQueuedAt, - processingStartedAt: document.processingStartedAt, - processingDeferredUntil: document.processingDeferredUntil, - processingCompletedAt: document.processingCompletedAt, - uploadedAt: document.uploadedAt, }) .from(document) .where( and( inArray(document.id, stuckDocIds), - or(...stuckDocs.map(documentRecoveryGenerationCondition)), + or(...stuckDocs.map(documentProcessingSnapshotCondition)), eq(document.connectorId, connectorId), documentProcessingRecoveryCondition(sweepEvaluatedAt, retryCutoff), lt(document.uploadedAt, syncStartedAt) diff --git a/apps/sim/lib/knowledge/documents/document-processor.ts b/apps/sim/lib/knowledge/documents/document-processor.ts index b285dd68d09..eb00b2807f3 100644 --- a/apps/sim/lib/knowledge/documents/document-processor.ts +++ b/apps/sim/lib/knowledge/documents/document-processor.ts @@ -38,6 +38,7 @@ import { FileParserError, isFileParserError } from '@/lib/file-parsers/errors' import { openPdfDocument } from '@/lib/file-parsers/pdfjs-server' import type { FileParseMetadata, FileParseResult } from '@/lib/file-parsers/types' import { getMistralOcrPagesPerRequest } from '@/lib/internal/mistral/capacity' +import { getOcrResponseDiagnostic } from '@/lib/internal/mistral/error-diagnostics' import { MistralOperationError } from '@/lib/internal/mistral/errors' import { mistralParseInputSchema } from '@/lib/internal/mistral/input' import { executeMistralParse } from '@/lib/internal/mistral/operations' @@ -651,6 +652,12 @@ async function makeOCRRequest( } if (!response.ok) { + logger.warn('OCR provider request failed', { + provider: 'azure-mistral', + operation: 'ocr', + status: response.status, + ...getOcrResponseDiagnostic(response.headers, responseText), + }) if ([400, 415, 422].includes(response.status)) { throw new OcrRequestRejectedError(response.status) } diff --git a/apps/sim/lib/knowledge/documents/pdf-ocr-triage.test.ts b/apps/sim/lib/knowledge/documents/pdf-ocr-triage.test.ts index 11fa8e213b8..9af849f6f21 100644 --- a/apps/sim/lib/knowledge/documents/pdf-ocr-triage.test.ts +++ b/apps/sim/lib/knowledge/documents/pdf-ocr-triage.test.ts @@ -8,6 +8,11 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const { warnLog } = vi.hoisted(() => ({ warnLog: vi.fn() })) +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ warn: warnLog, error: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})) + const { mockParseBuffer, mockDownload, mockToken, mockBaseUrl, mockExecuteMistralParse } = vi.hoisted(() => ({ mockParseBuffer: vi.fn(), @@ -528,6 +533,40 @@ describe('PDF OCR triage', () => { } ) + it.each([400, 415, 422])( + 'records safe Azure HTTP %i diagnostics without retrying a rejection', + async (status) => { + Object.assign(env, { + OCR_PROVIDER: 'azure-mistral', + OCR_AZURE_API_KEY: 'test-key', + OCR_AZURE_ENDPOINT: 'https://example.openai.azure.com', + OCR_AZURE_MODEL_NAME: 'mistral-document-ai-2512', + }) + mockParseBuffer.mockResolvedValue({ content: '', metadata: {} }) + const fetch = vi + .fn() + .mockResolvedValue( + Response.json( + { error: { code: 'BadRequest', message: 'private fixture text' } }, + { status, headers: { 'apim-request-id': 'azure-request-123' } } + ) + ) + vi.stubGlobal('fetch', fetch) + await expect(parse()).rejects.toBeInstanceOf(OcrRequestRejectedError) + expect(fetch).toHaveBeenCalledOnce() + expect(warnLog).toHaveBeenCalledWith( + 'OCR provider request failed', + expect.objectContaining({ + provider: 'azure-mistral', + status, + providerErrorCode: 'BadRequest', + providerRequestId: 'azure-request-123', + }) + ) + expect(JSON.stringify(warnLog.mock.calls)).not.toContain('private fixture text') + } + ) + it('keeps an internal request-building failure distinct from provider rejection', async () => { mockParseBuffer.mockResolvedValue({ content: '', metadata: {} }) mockExecuteMistralParse.mockRejectedValue(new MistralOperationError(400, {})) diff --git a/apps/sim/lib/knowledge/documents/processing-queue.test.ts b/apps/sim/lib/knowledge/documents/processing-queue.test.ts index 64debf56f43..85d46ee6a51 100644 --- a/apps/sim/lib/knowledge/documents/processing-queue.test.ts +++ b/apps/sim/lib/knowledge/documents/processing-queue.test.ts @@ -261,53 +261,6 @@ describe('processDocumentsWithQueue dispatch backend', () => { return dbChainMockFns.where.mock.calls[whereIndex]?.[0] } - function resumeAlternatives(guard: unknown): MockCondition[] { - const alternatives = flattenMockConditions(guard).find( - (node) => - node.type === 'or' && - (node.conditions as MockCondition[]).some((condition) => - hasMockCondition( - condition, - (nested) => - nested.type === 'eq' && - nested.left === schemaMock.document.processingQueueToken && - nested.right === 'request-1' - ) - ) && - (node.conditions as MockCondition[]).some((condition) => - hasMockCondition( - condition, - (nested) => - nested.type === 'isNull' && nested.column === schemaMock.document.processingQueueToken - ) - ) - )?.conditions - expect(alternatives).toBeDefined() - expect(alternatives).toHaveLength(2) - const conditions = alternatives as MockCondition[] - expect( - conditions.filter((condition) => - hasMockCondition( - condition, - (node) => - node.type === 'eq' && - node.left === schemaMock.document.processingQueueToken && - node.right === 'request-1' - ) - ) - ).toHaveLength(1) - expect( - conditions.filter((condition) => - hasMockCondition( - condition, - (node) => - node.type === 'isNull' && node.column === schemaMock.document.processingQueueToken - ) - ) - ).toHaveLength(1) - return conditions - } - beforeEach(() => { vi.clearAllMocks() resetDbChainMock() @@ -574,11 +527,6 @@ describe('processDocumentsWithQueue dispatch backend', () => { node.type === 'gte' && node.left === schemaMock.document.processingQueuedAt ) ).toBe(true) - const queuedFreshness = flattenMockConditions(pendingWithQueueState).find( - (node: MockCondition) => - node.type === 'gte' && node.left === schemaMock.document.processingQueuedAt - ) - expect(queuedFreshness?.right).toEqual(new Date(now.getTime() - QUEUED_DISPATCH_GRACE_MS)) const liveProcessingState = acceptedStatuses.find( (condition) => condition.type === 'and' && @@ -652,52 +600,30 @@ describe('processDocumentsWithQueue dispatch backend', () => { node.type === 'isNull' && node.column === schemaMock.document.processingDeferredUntil ) ).toBe(true) - const sameTokenBranch = resumeAlternatives(resumeGuard).find((condition) => + expect( hasMockCondition( - condition, + resumeGuard, (node: MockCondition) => node.type === 'eq' && node.left === schemaMock.document.processingQueueToken && node.right === 'request-1' ) - ) - expect(sameTokenBranch).toBeDefined() + ).toBe(true) expect( hasMockCondition( resumeGuard, (node: MockCondition) => - node.type === 'isNotNull' && node.column === schemaMock.document.processingQueuedAt + node.type === 'inArray' && + node.column === schemaMock.document.processingStatus && + JSON.stringify(node.values) === JSON.stringify(['pending', 'failed']) ) ).toBe(true) - const statusGuard = flattenMockConditions(sameTokenBranch).find( - (node: MockCondition) => node.type === 'or' - ) - expect(statusGuard).toBeDefined() - const statusConditions = statusGuard?.conditions as MockCondition[] - expect(statusConditions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'eq', - left: schemaMock.document.processingStatus, - right: 'pending', - }), - expect.objectContaining({ - type: 'eq', - left: schemaMock.document.processingStatus, - right: 'failed', - }), - ]) - ) }) - it('treats a recent legacy queued-at-only row as live without redispatching it', async () => { - vi.useFakeTimers() - const now = new Date('2026-08-24T22:00:00.000Z') - vi.setSystemTime(now) + it('preserves a recently queued generation without dispatching duplicate work', async () => { markInsideTriggerRun() dbChainMockFns.returning.mockResolvedValueOnce([]).mockResolvedValueOnce([]) queueTableRows(schemaMock.document, [{ id: 'document-1' }]) - const result = await processDocumentsWithQueue( [DOCUMENT], 'knowledge-base-1', @@ -706,98 +632,15 @@ describe('processDocumentsWithQueue dispatch backend', () => { BILLING_ATTRIBUTION, 'interactive' ) - expect(result).toEqual({ requested: 1, accepted: 1, failed: 0, failedDocumentIds: [] }) expect(mockBatchTrigger).not.toHaveBeenCalled() - const resumeWrite = dbChainMockFns.set.mock.calls.find( - (call) => - (call[0] as Record | undefined)?.processingQueueToken === 'request-1' && - !('processingQueuedAt' in ((call[0] as Record | undefined) ?? {})) - ) - expect(resumeWrite).toBeDefined() - const resumeGuard = guardForResumeWrite() - const legacyBranch = resumeAlternatives(resumeGuard).find( - (condition) => - hasMockCondition( - condition, - (node: MockCondition) => - node.type === 'isNull' && node.column === schemaMock.document.processingQueueToken - ) && - hasMockCondition( - condition, - (node: MockCondition) => - node.type === 'lt' && node.left === schemaMock.document.processingQueuedAt - ) - ) - expect(legacyBranch).toBeDefined() - const cutoff = flattenMockConditions(legacyBranch).find( - (node: MockCondition) => - node.type === 'lt' && node.left === schemaMock.document.processingQueuedAt - ) - expect(cutoff?.right).toEqual(new Date(now.getTime() - QUEUED_DISPATCH_GRACE_MS)) - }) - - it('CAS-adopts a stale legacy queued-at-only row without charging again', async () => { - markInsideTriggerRun() - const legacyQueuedAt = new Date('2020-01-01T00:00:00.000Z') - dbChainMockFns.returning - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ id: 'document-1', processingQueuedAt: legacyQueuedAt }]) - - const result = await processDocumentsWithQueue( - [DOCUMENT], - 'knowledge-base-1', - {}, - 'request-1', - BILLING_ATTRIBUTION, - 'interactive' - ) - - expect(result).toEqual({ requested: 1, accepted: 1, failed: 0, failedDocumentIds: [] }) - expect(mockBatchTrigger.mock.calls[0][1][0].payload).toMatchObject({ - processingQueueToken: 'request-1', - processingQueuedAt: legacyQueuedAt.toISOString(), - chargedAtDispatch: false, - }) - - const legacyAdoptionGuard = guardForResumeWrite() - const legacyBranch = resumeAlternatives(legacyAdoptionGuard).find( - (condition) => - hasMockCondition( - condition, - (node: MockCondition) => - node.type === 'isNull' && node.column === schemaMock.document.processingQueueToken - ) && - hasMockCondition( - condition, - (node: MockCondition) => - node.type === 'lt' && node.left === schemaMock.document.processingQueuedAt - ) - ) - expect(legacyBranch).toBeDefined() expect( hasMockCondition( - legacyAdoptionGuard, + guardForResumeWrite(), (node: MockCondition) => - node.type === 'eq' && - node.left === schemaMock.document.knowledgeBaseId && - node.right === 'knowledge-base-1' + node.type === 'isNull' && node.column === schemaMock.document.processingQueueToken ) - ).toBe(true) - for (const column of [schemaMock.document.archivedAt, schemaMock.document.deletedAt]) { - expect( - hasMockCondition( - legacyAdoptionGuard, - (node: MockCondition) => node.type === 'isNull' && node.column === column - ) - ).toBe(true) - } - const adoptionWrite = dbChainMockFns.set.mock.calls.find( - (call) => - (call[0] as Record | undefined)?.processingQueueToken === 'request-1' && - !('processingQueuedAt' in ((call[0] as Record | undefined) ?? {})) - ) - expect(adoptionWrite?.[0]).not.toHaveProperty('processingAttempts') + ).toBe(false) }) it('keeps a pre-claim same-request fallback failure retryable without clearing its stamp', async () => { diff --git a/apps/sim/lib/knowledge/documents/processing-recovery-queue-transport.test.ts b/apps/sim/lib/knowledge/documents/processing-recovery-queue-transport.test.ts new file mode 100644 index 00000000000..6cb0dfd0b01 --- /dev/null +++ b/apps/sim/lib/knowledge/documents/processing-recovery-queue-transport.test.ts @@ -0,0 +1,102 @@ +/** @vitest-environment node */ +import { resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +/** Keep the SDK transport real to verify signal forwarding and response validation. */ +vi.unmock('@trigger.dev/core/v3') + +import { apiClientManager } from '@trigger.dev/core/v3' +import { env } from '@/lib/core/config/env' +import { resetInsideTriggerRunForTests } from '@/lib/core/config/trigger-runtime' +import { + type DocumentProcessingSnapshot, + findAbandonedDocumentProcessing, +} from '@/lib/knowledge/documents/processing-recovery-queue' + +const snapshot: DocumentProcessingSnapshot = { + id: 'doc-1', + uploadedAt: new Date('2026-09-01T00:00:00Z'), + processingStatus: 'pending', + processingQueueToken: 'generation-1', + processingQueuedAt: new Date('2026-09-01T00:00:00Z'), + processingStartedAt: null, + processingDeferredUntil: null, + processingCompletedAt: null, + processingRecoveryAfter: null, +} +const originalSecret = env.TRIGGER_SECRET_KEY + +function inspect(candidates: DocumentProcessingSnapshot[]) { + return apiClientManager.runWithConfig( + { + baseURL: 'https://api.trigger.dev', + accessToken: 'fixture-key', + previewBranch: 'fixture-branch', + }, + () => findAbandonedDocumentProcessing(candidates) + ) +} + +beforeEach(() => { + resetDbChainMock() + resetInsideTriggerRunForTests() + setEnvFlags({ isTriggerDevEnabled: true }) + env.TRIGGER_SECRET_KEY = 'fixture-key' +}) +afterEach(() => { + env.TRIGGER_SECRET_KEY = originalSecret + resetEnvFlagsMock() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('document liveness SDK transport', () => { + it('cancels the actual SDK HTTP requests at the deadline without accumulating requests', async () => { + vi.useFakeTimers() + let active = 0 + const fetch = vi.fn( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + active++ + init.signal!.addEventListener( + 'abort', + () => { + active-- + reject(init.signal!.reason) + }, + { once: true } + ) + }) + ) + vi.stubGlobal('fetch', fetch) + const candidates = Array.from({ length: 20 }, (_, i) => ({ ...snapshot, id: `doc-${i}` })) + for (let attempt = 0; attempt < 2; attempt++) { + const result = inspect(candidates) + await vi.advanceTimersByTimeAsync(8_000) + expect(await result).toEqual([]) + expect(active).toBe(0) + expect(fetch).toHaveBeenCalledTimes((attempt + 1) * 4) + } + const headers = new Headers(fetch.mock.calls[0][1].headers) + expect(headers.get('Authorization')).toBe('Bearer fixture-key') + expect(headers.get('x-trigger-branch')).toBe('fixture-branch') + }) + + it.each([true, false])( + 'validates the SDK response before declaring abandonment: valid=%s', + async (valid) => { + const fetch = vi + .fn() + .mockResolvedValue(Response.json(valid ? { data: [], pagination: {} } : { data: [] })) + vi.stubGlobal('fetch', fetch) + expect(await inspect([snapshot])).toEqual(valid ? [snapshot] : []) + expect(fetch).toHaveBeenCalledOnce() + const url = new URL(fetch.mock.calls[0][0]) + expect(url.searchParams.get('page[size]')).toBe('1') + expect(url.searchParams.get('filter[createdAt][from]')).toBe( + String(new Date('2026-08-31T20:00:00Z').getTime()) + ) + expect(url.searchParams.get('filter[tag]')).toBe('documentId:doc-1') + } + ) +}) diff --git a/apps/sim/lib/knowledge/documents/processing-recovery-queue.test.ts b/apps/sim/lib/knowledge/documents/processing-recovery-queue.test.ts index 2a99b144b71..b94f195bb78 100644 --- a/apps/sim/lib/knowledge/documents/processing-recovery-queue.test.ts +++ b/apps/sim/lib/knowledge/documents/processing-recovery-queue.test.ts @@ -1,118 +1,306 @@ /** @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ list: vi.fn(), enabled: true, insideRun: false })) -vi.mock('@sim/db', () => dbChainMock) -vi.mock('@trigger.dev/sdk', () => ({ runs: { list: mocks.list } })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isTriggerDevEnabled() { - return mocks.enabled - }, +import { + dbChainMock, + dbChainMockFns, + resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { listRuns, runtime } = vi.hoisted(() => ({ + listRuns: vi.fn(), + runtime: { insideRun: false }, })) -vi.mock('@/lib/core/config/trigger-runtime', () => ({ - isInsideTriggerRun: () => mocks.insideRun, +vi.mock('@trigger.dev/core/v3', () => ({ + taskContext: { + get isInsideTask() { + return runtime.insideRun + }, + }, + ListRunResponseItem: {}, + apiClientManager: { + clientOrThrow: () => ({ + baseUrl: 'https://api.trigger.dev', + getHeaders: () => ({ + Authorization: 'Bearer fixture-key', + 'x-trigger-branch': 'fixture-branch', + }), + }), + }, })) +vi.mock('@trigger.dev/core/v3/zodfetch', () => ({ zodfetchCursorPage: listRuns })) -import { filterAbandonedDocumentProcessing } from '@/lib/knowledge/documents/processing-recovery-queue' +import { env } from '@/lib/core/config/env' +import { resetInsideTriggerRunForTests } from '@/lib/core/config/trigger-runtime' +import { + DOCUMENT_LIVENESS_BATCH_SIZE, + type DocumentProcessingSnapshot, + findAbandonedDocumentProcessing, + inspectDocumentProcessingLiveness, +} from '@/lib/knowledge/documents/processing-recovery-queue' -const candidate = { - id: 'document-1', +const snapshot: DocumentProcessingSnapshot = { + id: 'doc-1', + uploadedAt: new Date('2026-09-01T00:00:00Z'), + processingStatus: 'pending', processingQueueToken: 'generation-1', - processingQueuedAt: new Date('2026-01-01T00:00:00Z'), + processingQueuedAt: new Date('2026-09-01T00:00:00Z'), processingStartedAt: null, - uploadedAt: new Date('2026-01-01T00:00:00Z'), + processingDeferredUntil: null, + processingCompletedAt: null, + processingRecoveryAfter: null, } - +const originalSecret = env.TRIGGER_SECRET_KEY beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mocks.enabled = true - mocks.insideRun = false - mocks.list.mockResolvedValue({ data: [] }) + runtime.insideRun = false + resetInsideTriggerRunForTests() + setEnvFlags({ isTriggerDevEnabled: true }) + env.TRIGGER_SECRET_KEY = 'test-secret' + listRuns.mockReset().mockResolvedValue({ data: [], hasNextPage: () => false }) }) -afterEach(() => vi.useRealTimers()) - -it.each(['PENDING_VERSION', 'DELAYED', 'QUEUED', 'DEQUEUED', 'EXECUTING', 'WAITING'])( - 'preserves a %s run regardless of queue age, without spending another attempt', - async (status) => { - mocks.list.mockResolvedValue({ data: [{ id: 'run-1', status }] }) - expect(await filterAbandonedDocumentProcessing([candidate])).toEqual([]) - expect(mocks.list).toHaveBeenCalledWith( - expect.objectContaining({ - taskIdentifier: 'knowledge-process-document', - tag: 'documentId:document-1', - from: new Date('2025-12-31T20:00:00Z'), - status: expect.arrayContaining([status]), - limit: 1, - }), - { retry: { maxAttempts: 1 } } +afterEach(() => { + env.TRIGGER_SECRET_KEY = originalSecret + resetEnvFlagsMock() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('document processing liveness', () => { + it.each(['QUEUED', 'DELAYED', 'WAITING', 'EXECUTING', 'PENDING_VERSION', 'DEQUEUED'])( + 'preserves an old document with a %s job without spending an attempt', + async (status) => { + listRuns.mockResolvedValue({ data: [{ status }], hasNextPage: () => false }) + expect(await findAbandonedDocumentProcessing([snapshot])).toEqual([]) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ processingRecoveryAfter: expect.any(Date) }) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + expect(listRuns).toHaveBeenCalledWith( + expect.anything(), + 'https://api.trigger.dev/api/v1/runs', + expect.objectContaining({ + query: expect.any(URLSearchParams), + limit: 1, + }), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + { retry: { maxAttempts: 1 } } + ) + const params = listRuns.mock.calls[0][2].query as URLSearchParams + expect(params.get('filter[createdAt][from]')).toBe( + String(new Date('2026-08-31T20:00:00Z').getTime()) + ) + expect(params.get('filter[tag]')).toBe('documentId:doc-1') + expect(params.get('filter[taskIdentifier]')).toBe('knowledge-process-document') + expect(params.get('filter[status]')?.split(',')).toEqual( + expect.arrayContaining([ + 'QUEUED', + 'WAITING', + 'DELAYED', + 'EXECUTING', + 'DEQUEUED', + 'PENDING_VERSION', + ]) + ) + } + ) + + it('allows recovery only when no live run or outbox carrier remains', async () => { + expect(await findAbandonedDocumentProcessing([snapshot])).toEqual([snapshot]) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + }) + + it('protects a pending outbox delivery without contacting Trigger', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: snapshot.processingQueueToken }]) + expect(await findAbandonedDocumentProcessing([snapshot])).toEqual([]) + expect(listRuns).not.toHaveBeenCalled() + }) + + it('protects legacy jobs without a generation token', async () => { + listRuns.mockResolvedValue({ data: [{ status: 'QUEUED' }], hasNextPage: () => false }) + expect( + await findAbandonedDocumentProcessing([{ ...snapshot, processingQueueToken: null }]) + ).toEqual([]) + }) + + it.each(['rejected', 'incomplete'])('fails closed on %s job evidence', async (mode) => { + if (mode === 'rejected') listRuns.mockRejectedValue(new Error('provider unavailable')) + else listRuns.mockResolvedValue({ data: [], hasNextPage: () => true }) + expect(await findAbandonedDocumentProcessing([snapshot])).toEqual([]) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ processingRecoveryAfter: expect.any(Date) }) + }) + + it('does not substitute missing outbox evidence for abandonment', async () => { + dbChainMockFns.limit.mockRejectedValueOnce(new Error('database unavailable')) + expect(await findAbandonedDocumentProcessing([snapshot])).toEqual([]) + expect(listRuns).not.toHaveBeenCalled() + }) + + it('bounds stalled lookups and starts no more than four requests after the deadline', async () => { + vi.useFakeTimers() + listRuns.mockImplementation(() => new Promise(() => {})) + const candidates = Array.from({ length: 20 }, (_, i) => ({ ...snapshot, id: `doc-${i}` })) + const result = findAbandonedDocumentProcessing(candidates) + await vi.advanceTimersByTimeAsync(8_000) + expect(await result).toEqual([]) + expect(listRuns).toHaveBeenCalledTimes(4) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ processingRecoveryAfter: expect.any(Date) }) + }) + + it.each(['outbox', 'cooldown'] as const)( + 'bounds %s pool acquisition and rejects a late transaction before issuing queries', + async (phase) => { + vi.useFakeTimers() + let release: () => void = () => undefined + const acquired = new Promise((resolve) => { + release = resolve + }) + let transactionResult: Promise = Promise.resolve() + dbChainMockFns.transaction.mockImplementationOnce((callback) => { + transactionResult = acquired.then(() => callback(dbChainMock.db)) + return transactionResult + }) + listRuns.mockResolvedValue({ data: [{ status: 'QUEUED' }], hasNextPage: () => false }) + const candidate = phase === 'outbox' ? snapshot : { ...snapshot, processingQueueToken: null } + let settled = false + const result = inspectDocumentProcessingLiveness([candidate]).then((value) => { + settled = true + return value + }) + await vi.advanceTimersByTimeAsync(8_000) + try { + expect(settled).toBe(true) + expect(await result).toEqual({ abandoned: [], live: phase === 'outbox' ? [] : [candidate] }) + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) + } finally { + release() + await Promise.allSettled([transactionResult, result]) + } + await expect(transactionResult).rejects.toThrow('Operation deadline expired') + expect(dbChainMockFns.execute).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + } + ) + + it.each(['outbox', 'cooldown'] as const)( + 'does not start a %s query after transaction setup exceeded the deadline', + async (phase) => { + vi.useFakeTimers() + let release: () => void = () => undefined + dbChainMockFns.execute.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve([]) + }) + ) + listRuns.mockResolvedValue({ data: [{ status: 'QUEUED' }], hasNextPage: () => false }) + const candidate = phase === 'outbox' ? snapshot : { ...snapshot, processingQueueToken: null } + let settled = false + const result = findAbandonedDocumentProcessing([candidate]).then((value) => { + settled = true + return value + }) + await vi.advanceTimersByTimeAsync(8_000) + try { + expect(settled).toBe(true) + } finally { + release() + await result + await vi.runAllTimersAsync() + } + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + } + ) + + it('keeps completed abandonment evidence when another lookup fails', async () => { + listRuns + .mockResolvedValueOnce({ data: [], hasNextPage: () => false }) + .mockRejectedValueOnce(new Error('unavailable')) + expect(await findAbandonedDocumentProcessing([snapshot, { ...snapshot, id: 'doc-2' }])).toEqual( + [snapshot] + ) + }) + + it('rejects the cooldown transaction if its update finishes after the deadline', async () => { + vi.useFakeTimers() + let release: () => void = () => undefined + dbChainMockFns.where.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }) ) - expect(dbChainMockFns.set).toHaveBeenCalledExactlyOnceWith({ - processingRecoveryAfter: expect.any(Date), + let transactionResult: Promise = Promise.resolve() + dbChainMockFns.transaction.mockImplementationOnce((callback) => { + transactionResult = callback(dbChainMock.db) + return transactionResult }) - } -) + listRuns.mockResolvedValue({ data: [{ status: 'QUEUED' }], hasNextPage: () => false }) + const result = findAbandonedDocumentProcessing([{ ...snapshot, processingQueueToken: null }]) + await vi.advanceTimersByTimeAsync(8_000) + expect(await result).toEqual([]) + release() + await expect(transactionResult).rejects.toThrow('Operation deadline expired') + }) -it('allows the existing recovery policy after confirming no live job remains', async () => { - expect(await filterAbandonedDocumentProcessing([candidate])).toEqual([candidate]) - expect(dbChainMockFns.set).not.toHaveBeenCalled() -}) + it('retains recovery on installations using the in-process fallback', async () => { + env.TRIGGER_SECRET_KEY = undefined + expect(await findAbandonedDocumentProcessing([snapshot])).toEqual([snapshot]) + expect(listRuns).not.toHaveBeenCalled() + }) -it('fails closed and backs off when the queue cannot be inspected', async () => { - mocks.list.mockRejectedValue(new Error('unavailable')) - expect(await filterAbandonedDocumentProcessing([candidate])).toEqual([]) - expect(dbChainMockFns.set).toHaveBeenCalledExactlyOnceWith({ - processingRecoveryAfter: expect.any(Date), + it('checks live jobs inside a worker when the web Trigger flag is disabled', async () => { + runtime.insideRun = true + setEnvFlags({ isTriggerDevEnabled: false }) + env.TRIGGER_SECRET_KEY = undefined + listRuns.mockResolvedValue({ data: [{ status: 'QUEUED' }], hasNextPage: () => false }) + expect(await findAbandonedDocumentProcessing([snapshot])).toEqual([]) + expect(listRuns).toHaveBeenCalledOnce() }) -}) -it('bounds concurrency and stops scheduling further lookups after the deadline', async () => { - vi.useFakeTimers() - const candidates = Array.from({ length: 200 }, (_, index) => ({ - ...candidate, - id: `doc-${index}`, - })) - let finishLookup: (value: { data: [] }) => void = () => undefined - mocks.list.mockReturnValue( - new Promise((resolve) => { - finishLookup = resolve + it('preserves caller cancellation while waiting for a database connection', async () => { + let release: () => void = () => undefined + const acquired = new Promise((resolve) => { + release = resolve }) - ) - const pending = filterAbandonedDocumentProcessing(candidates) - await vi.advanceTimersByTimeAsync(10_001) - expect(await pending).toEqual([]) - expect(mocks.list).toHaveBeenCalledTimes(4) - finishLookup({ data: [] }) - await vi.runAllTimersAsync() - expect(mocks.list).toHaveBeenCalledTimes(4) -}) - -it('keeps completed checks when another lookup fails, without recovering unknown jobs', async () => { - mocks.list.mockResolvedValueOnce({ data: [] }).mockRejectedValueOnce(new Error('unavailable')) - expect( - await filterAbandonedDocumentProcessing([candidate, { ...candidate, id: 'doc-2' }]) - ).toEqual([candidate]) -}) + let transactionResult: Promise = Promise.resolve() + dbChainMockFns.transaction.mockImplementationOnce((callback) => { + transactionResult = acquired.then(() => callback(dbChainMock.db)) + return transactionResult + }) + const controller = new AbortController() + const result = findAbandonedDocumentProcessing([snapshot], controller.signal) + controller.abort(new Error('cancelled')) + await expect(result).rejects.toBe(controller.signal.reason) + release() + await expect(transactionResult).rejects.toBe(controller.signal.reason) + expect(dbChainMockFns.execute).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) -it('does not look up Trigger runs on a deployment without Trigger', async () => { - mocks.enabled = false - expect(await filterAbandonedDocumentProcessing([candidate])).toEqual([candidate]) - expect(mocks.list).not.toHaveBeenCalled() -}) + it('refuses an unbounded candidate batch before reading external state', async () => { + await expect( + findAbandonedDocumentProcessing( + Array.from({ length: DOCUMENT_LIVENESS_BATCH_SIZE + 1 }, () => snapshot) + ) + ).rejects.toThrow('exceeds its limit') + expect(listRuns).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) -it('still inspects the queue inside a Trigger worker with a disabled environment flag', async () => { - mocks.enabled = false - mocks.insideRun = true - mocks.list.mockResolvedValue({ data: [{ id: 'run-1' }] }) - expect(await filterAbandonedDocumentProcessing([candidate])).toEqual([]) -}) + it('keeps live work protected even when persisting its cooldown fails', async () => { + listRuns.mockResolvedValue({ data: [{ status: 'QUEUED' }], hasNextPage: () => false }) + dbChainMockFns.update.mockImplementationOnce(() => { + throw new Error('database lock timeout') + }) + expect(await findAbandonedDocumentProcessing([snapshot])).toEqual([]) + }) -it('does not mutate any recovery state when the caller is canceled', async () => { - const controller = new AbortController() - controller.abort(new Error('canceled')) - await expect(filterAbandonedDocumentProcessing([candidate], controller.signal)).rejects.toThrow( - 'canceled' - ) - expect(dbChainMockFns.set).not.toHaveBeenCalled() + it('preserves caller cancellation without resetting a generation', async () => { + const signal = AbortSignal.abort(new Error('cancelled')) + await expect(findAbandonedDocumentProcessing([snapshot], signal)).rejects.toBe(signal.reason) + expect(listRuns).not.toHaveBeenCalled() + expect(dbChainMockFns.set).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/knowledge/documents/processing-recovery-queue.ts b/apps/sim/lib/knowledge/documents/processing-recovery-queue.ts index 3d8f0aa241a..332fbc3f105 100644 --- a/apps/sim/lib/knowledge/documents/processing-recovery-queue.ts +++ b/apps/sim/lib/knowledge/documents/processing-recovery-queue.ts @@ -1,129 +1,240 @@ import { db } from '@sim/db' -import { document } from '@sim/db/schema' +import { document, outboxEvent } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { runs } from '@trigger.dev/sdk' -import { and, eq, isNull, or, sql } from 'drizzle-orm' +import { apiClientManager, ListRunResponseItem, type RunStatus } from '@trigger.dev/core/v3' +import { zodfetchCursorPage } from '@trigger.dev/core/v3/zodfetch' +import { and, eq, inArray, or, sql } from 'drizzle-orm' +import { env } from '@/lib/core/config/env' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { isInsideTriggerRun } from '@/lib/core/config/trigger-runtime' -import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { withinDeadline } from '@/lib/core/utils/deadline' -import { documentProcessingRecoveryCondition } from '@/lib/knowledge/documents/processing-recovery-policy' +import type { DbTransaction } from '@/lib/db/types' import { QUEUED_DISPATCH_GRACE_MS } from '@/lib/knowledge/documents/types' -const logger = createLogger('DocumentRecoveryQueue') +const logger = createLogger('DocumentProcessingLiveness') +export const DOCUMENT_LIVENESS_BATCH_SIZE = 200 const LOOKUP_CONCURRENCY = 4 -const LOOKUP_BUDGET_MS = 10_000 -const RECHECK_DELAY_MS = 15 * 60_000 +const INSPECTION_BUDGET_MS = 8_000 +const COOLDOWN_RESERVE_MS = 2_000 +const LIVE_RECHECK_MS = 15 * 60_000 +const UNKNOWN_RECHECK_MS = 60_000 +type TriggerRunStatus = Extract -export interface DocumentRecoveryGeneration { - id: string - processingQueueToken: string | null - processingQueuedAt: Date | null - processingStartedAt: Date | null - uploadedAt: Date +/** Exhaustive against the SDK: adding a lifecycle state requires classifying it here. */ +const RUN_STATUS_LIVENESS = { + PENDING_VERSION: true, + QUEUED: true, + DEQUEUED: true, + EXECUTING: true, + WAITING: true, + DELAYED: true, + COMPLETED: false, + CANCELED: false, + FAILED: false, + CRASHED: false, + SYSTEM_FAILURE: false, + EXPIRED: false, + TIMED_OUT: false, +} satisfies Record +const ACTIVE_RUN_STATUSES = (Object.keys(RUN_STATUS_LIVENESS) as TriggerRunStatus[]).filter( + (status) => RUN_STATUS_LIVENESS[status] +) + +export const processingSnapshotColumns = { + id: document.id, + uploadedAt: document.uploadedAt, + processingStatus: document.processingStatus, + processingQueueToken: document.processingQueueToken, + processingQueuedAt: document.processingQueuedAt, + processingStartedAt: document.processingStartedAt, + processingDeferredUntil: document.processingDeferredUntil, + processingCompletedAt: document.processingCompletedAt, + processingRecoveryAfter: document.processingRecoveryAfter, } -/** Recovery may replace only the generation whose queue state was inspected outside the transaction. */ -export function documentRecoveryGenerationCondition(candidate: DocumentRecoveryGeneration) { +export type DocumentProcessingSnapshot = Pick< + typeof document.$inferSelect, + keyof typeof processingSnapshotColumns +> + +/** A claim, continuation or protection cooldown installed during lookup must win over recovery. */ +export function documentProcessingSnapshotCondition(snapshot: DocumentProcessingSnapshot) { return and( - eq(document.id, candidate.id), - candidate.processingQueueToken === null - ? isNull(document.processingQueueToken) - : eq(document.processingQueueToken, candidate.processingQueueToken), - candidate.processingQueuedAt === null - ? isNull(document.processingQueuedAt) - : eq(document.processingQueuedAt, candidate.processingQueuedAt), - candidate.processingStartedAt === null - ? isNull(document.processingStartedAt) - : eq(document.processingStartedAt, candidate.processingStartedAt) + eq(document.id, snapshot.id), + eq(document.processingStatus, snapshot.processingStatus), + sql`${document.processingQueueToken} IS NOT DISTINCT FROM ${snapshot.processingQueueToken}`, + sql`${document.processingQueuedAt} IS NOT DISTINCT FROM ${sql.param(snapshot.processingQueuedAt, document.processingQueuedAt)}`, + sql`${document.processingStartedAt} IS NOT DISTINCT FROM ${sql.param(snapshot.processingStartedAt, document.processingStartedAt)}`, + sql`${document.processingDeferredUntil} IS NOT DISTINCT FROM ${sql.param(snapshot.processingDeferredUntil, document.processingDeferredUntil)}`, + sql`${document.processingCompletedAt} IS NOT DISTINCT FROM ${sql.param(snapshot.processingCompletedAt, document.processingCompletedAt)}`, + sql`${document.processingRecoveryAfter} IS NOT DISTINCT FROM ${sql.param(snapshot.processingRecoveryAfter, document.processingRecoveryAfter)}` + ) +} + +/** Pool acquisition has no driver cancellation; late callbacks must exit before issuing work. */ +async function withinLivenessTransaction( + operation: (tx: DbTransaction) => Promise, + deadlineAt: number, + signal?: AbortSignal +): Promise { + return withinDeadline( + (transactionSignal) => + db.transaction(async (tx) => { + transactionSignal.throwIfAborted() + await tx.execute( + sql`SELECT set_config('statement_timeout', '2000', true), set_config('lock_timeout', '500', true)` + ) + transactionSignal.throwIfAborted() + const result = await operation(tx) + transactionSignal.throwIfAborted() + return result + }), + deadlineAt, + signal + ) +} + +type ProcessingLiveness = 'live' | 'abandoned' | 'unknown' + +async function inspectTriggerWork( + candidate: DocumentProcessingSnapshot, + deadlineAt: number, + signal?: AbortSignal +) { + return withinDeadline( + async (requestSignal) => { + const client = apiClientManager.clientOrThrow() + /** The SDK's runs.list wrapper omits RequestInit.signal; its transport supports it. */ + const page = await zodfetchCursorPage( + ListRunResponseItem, + `${client.baseUrl}/api/v1/runs`, + { + query: new URLSearchParams({ + 'filter[taskIdentifier]': 'knowledge-process-document', + 'filter[tag]': `documentId:${candidate.id}`, + 'filter[status]': ACTIVE_RUN_STATUSES.join(','), + 'filter[createdAt][from]': String( + candidate.uploadedAt.getTime() - QUEUED_DISPATCH_GRACE_MS + ), + }), + limit: 1, + }, + { method: 'GET', headers: client.getHeaders(), signal: requestSignal }, + { retry: { maxAttempts: 1 } } + ) + requestSignal.throwIfAborted() + if (page.data.length > 0) return 'live' as const + return page.hasNextPage() ? ('unknown' as const) : ('abandoned' as const) + }, + deadlineAt, + signal ) } /** - * Queue age is not evidence of abandonment. Any live run for the document protects - * it, including legacy dispatches and continuations. Lookup failures fail closed. - * Callers supply bounded candidate pages; only one metadata row is read per lookup. + * Age only nominates candidates. Inspect durable work before replacing its generation, + * outside row locks. Any live document run protects continuation handoffs and legacy jobs. + * Failed or incomplete lookups defer recovery; they never authorize another admission. */ -export async function filterAbandonedDocumentProcessing( - candidates: T[], +export async function inspectDocumentProcessingLiveness( + candidates: readonly T[], signal?: AbortSignal -): Promise { - if (!candidates.length || (!isTriggerDevEnabled && !isInsideTriggerRun())) return candidates - - const abandoned: T[] = [] - let lookupError: unknown +): Promise<{ abandoned: T[]; live: T[] }> { + if (candidates.length === 0) return { abandoned: [], live: [] } + if (candidates.length > DOCUMENT_LIVENESS_BATCH_SIZE) { + throw new Error('Document liveness batch exceeds its limit') + } + signal?.throwIfAborted() + const deadlineAt = Date.now() + INSPECTION_BUDGET_MS + const lookupDeadlineAt = deadlineAt - COOLDOWN_RESERVE_MS + const states = new Map() try { - await withinDeadline( - async (lookupSignal) => { - await mapWithConcurrency(candidates, LOOKUP_CONCURRENCY, async (candidate) => { - lookupSignal.throwIfAborted() - if (lookupError) return - const page = await runs - .list( - { - taskIdentifier: 'knowledge-process-document', - tag: `documentId:${candidate.id}`, - /** Include the entire eligible document lifetime, with the existing dispatch grace for clock skew. */ - from: new Date(candidate.uploadedAt.getTime() - QUEUED_DISPATCH_GRACE_MS), - status: [ - 'PENDING_VERSION', - 'DELAYED', - 'QUEUED', - 'DEQUEUED', - 'EXECUTING', - 'WAITING', - ], - limit: 1, - }, - { retry: { maxAttempts: 1 } } - ) - .catch((error: unknown) => { - lookupError = error - return null - }) - lookupSignal.throwIfAborted() - if (page?.data.length === 0) abandoned.push(candidate) - }) - if (lookupError) throw lookupError - }, - Date.now() + LOOKUP_BUDGET_MS, - signal + const tokens = candidates.flatMap((row) => + row.processingQueueToken ? [row.processingQueueToken] : [] ) - } catch (error) { - signal?.throwIfAborted() - logger.warn('Could not verify all document jobs; leaving unverified generations unchanged', { - candidates: candidates.length, - abandoned: abandoned.length, - error: getErrorMessage(error), - }) + const carriers = + tokens.length === 0 + ? [] + : await withinLivenessTransaction( + async (tx) => + tx + .select({ id: outboxEvent.id }) + .from(outboxEvent) + .where( + and( + inArray(outboxEvent.id, tokens), + inArray(outboxEvent.status, ['pending', 'processing']) + ) + ) + .limit(DOCUMENT_LIVENESS_BATCH_SIZE), + deadlineAt, + signal + ) + const liveTokens = new Set(carriers.map((row) => row.id)) + let next = 0 + await Promise.all( + Array.from({ length: Math.min(LOOKUP_CONCURRENCY, candidates.length) }, async () => { + while (next < candidates.length && Date.now() < lookupDeadlineAt && !signal?.aborted) { + const candidate = candidates[next++]! + if (candidate.processingQueueToken && liveTokens.has(candidate.processingQueueToken)) { + states.set(candidate.id, 'live') + continue + } + if (!(isInsideTriggerRun() || (isTriggerDevEnabled && env.TRIGGER_SECRET_KEY))) { + states.set(candidate.id, 'abandoned') + continue + } + try { + states.set(candidate.id, await inspectTriggerWork(candidate, lookupDeadlineAt, signal)) + } catch { + states.set(candidate.id, 'unknown') + } + } + }) + ) + } catch { + /** Missing outbox evidence cannot establish abandonment either. */ } - signal?.throwIfAborted() - const abandonedIds = new Set(abandoned.map((candidate) => candidate.id)) - const retained = candidates.filter((candidate) => !abandonedIds.has(candidate.id)) - if (retained.length) { + + for (const state of ['live', 'unknown'] as const) { + const protectedRows = candidates.filter((row) => (states.get(row.id) ?? 'unknown') === state) + if (protectedRows.length === 0) continue try { - await db.transaction(async (tx) => { - await tx.execute( - sql`SELECT set_config('statement_timeout', '5000', true), set_config('lock_timeout', '1000', true)` - ) - signal?.throwIfAborted() - await tx - .update(document) - .set({ processingRecoveryAfter: new Date(Date.now() + RECHECK_DELAY_MS) }) - .where( - and( - documentProcessingRecoveryCondition(new Date()), - or(...retained.map(documentRecoveryGenerationCondition)) - ) - ) - signal?.throwIfAborted() - }) - } catch (error) { + await withinLivenessTransaction( + async (tx) => { + await tx + .update(document) + .set({ + processingRecoveryAfter: new Date( + Date.now() + (state === 'live' ? LIVE_RECHECK_MS : UNKNOWN_RECHECK_MS) + ), + }) + .where(or(...protectedRows.map(documentProcessingSnapshotCondition))) + }, + deadlineAt, + signal + ) + } catch { signal?.throwIfAborted() - logger.warn('Could not postpone document queue recheck', { error: getErrorMessage(error) }) + logger.warn('Document recovery cooldown could not be persisted', { + count: protectedRows.length, + }) } + if (state === 'unknown') + logger.warn('Document recovery deferred: work status could not be established', { + count: protectedRows.length, + }) + } + return { + abandoned: candidates.filter((row) => states.get(row.id) === 'abandoned'), + live: candidates.filter((row) => states.get(row.id) === 'live'), } - return [...abandoned] +} + +export async function findAbandonedDocumentProcessing( + candidates: readonly T[], + signal?: AbortSignal +): Promise { + return (await inspectDocumentProcessingLiveness(candidates, signal)).abandoned } diff --git a/apps/sim/lib/knowledge/documents/processing-recovery.ts b/apps/sim/lib/knowledge/documents/processing-recovery.ts index 369a8252cab..08c9a18e087 100644 --- a/apps/sim/lib/knowledge/documents/processing-recovery.ts +++ b/apps/sim/lib/knowledge/documents/processing-recovery.ts @@ -18,8 +18,10 @@ import { } from '@/lib/knowledge/documents/processing-payload' import { documentProcessingRecoveryCondition } from '@/lib/knowledge/documents/processing-recovery-policy' import { - documentRecoveryGenerationCondition, - filterAbandonedDocumentProcessing, + DOCUMENT_LIVENESS_BATCH_SIZE, + documentProcessingSnapshotCondition, + findAbandonedDocumentProcessing, + processingSnapshotColumns, } from '@/lib/knowledge/documents/processing-recovery-queue' const logger = createLogger('KnowledgeDocumentRecovery') @@ -73,7 +75,7 @@ async function recoverStoredDocumentBatch( limit: number ): Promise { /** Discovery does not claim work. Ownership is rechecked under lifecycle locks below. */ - const candidates = await db.transaction(async (tx) => { + const observedCandidates = await db.transaction(async (tx) => { signal.throwIfAborted() await tx.execute( sql`SELECT set_config('statement_timeout', '5000', true), set_config('lock_timeout', '1000', true)` @@ -81,11 +83,7 @@ async function recoverStoredDocumentBatch( signal.throwIfAborted() return tx .select({ - id: document.id, - processingQueueToken: document.processingQueueToken, - processingQueuedAt: document.processingQueuedAt, - processingStartedAt: document.processingStartedAt, - uploadedAt: document.uploadedAt, + ...processingSnapshotColumns, knowledgeBaseId: document.knowledgeBaseId, connectorId: knowledgeConnector.id, workspaceId: knowledgeBase.workspaceId, @@ -110,16 +108,16 @@ async function recoverStoredDocumentBatch( ) ) .orderBy(asc(document.uploadedAt), asc(document.id)) - .limit(limit) + .limit(Math.min(limit, DOCUMENT_LIVENESS_BATCH_SIZE)) }) signal.throwIfAborted() + for (const candidate of observedCandidates) attemptedConnectors.add(candidate.connectorId) + const candidates = await findAbandonedDocumentProcessing(observedCandidates, signal) if (candidates.length === 0) return 0 - const abandoned = await filterAbandonedDocumentProcessing(candidates, signal) - for (const candidate of candidates) attemptedConnectors.add(candidate.connectorId) let recovered = 0 const groups = new Map() - for (const candidate of abandoned) { + for (const candidate of candidates) { const group = groups.get(candidate.knowledgeBaseId) ?? [] group.push(candidate) groups.set(candidate.knowledgeBaseId, group) @@ -192,13 +190,13 @@ async function recoverStoredDocumentBatch( document.id, group.map((row) => row.id) ), + or(...group.map(documentProcessingSnapshotCondition)), eq(document.knowledgeBaseId, knowledgeBaseId), inArray( document.connectorId, connectors.map((row) => row.id) ), - documentProcessingRecoveryCondition(now), - or(...group.map(documentRecoveryGenerationCondition)) + documentProcessingRecoveryCondition(now) ) ) .orderBy(asc(document.id)) diff --git a/apps/sim/lib/knowledge/documents/retry-processing-grace.test.ts b/apps/sim/lib/knowledge/documents/retry-processing-grace.test.ts index 4c9db45ba91..16532b9b567 100644 --- a/apps/sim/lib/knowledge/documents/retry-processing-grace.test.ts +++ b/apps/sim/lib/knowledge/documents/retry-processing-grace.test.ts @@ -26,6 +26,18 @@ import { } from '@/lib/knowledge/documents/service' import { QUEUED_DISPATCH_GRACE_MS } from '@/lib/knowledge/documents/types' +const OBSERVED_DOCUMENT = { + uploadedAt: new Date(0), + id: 'doc-1', + processingStatus: 'completed', + processingQueueToken: 'old-token', + processingQueuedAt: new Date(0), + processingStartedAt: null, + processingDeferredUntil: null, + processingCompletedAt: new Date(0), + processingRecoveryAfter: null, +} + const DOC_DATA = { filename: 'report.pdf', fileUrl: 'https://example.com/report.pdf', @@ -66,6 +78,7 @@ describe('retryDocumentProcessing requeue stamp', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + dbChainMockFns.limit.mockResolvedValueOnce([OBSERVED_DOCUMENT]) }) it('clears the previous attempt terminal state', async () => { @@ -171,6 +184,7 @@ describe('retryDocumentProcessing requeue guard', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + dbChainMockFns.limit.mockResolvedValueOnce([OBSERVED_DOCUMENT]) }) /** @@ -393,6 +407,7 @@ describe('retryDocumentProcessing dispatch unwind', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + dbChainMockFns.limit.mockResolvedValueOnce([OBSERVED_DOCUMENT]) }) /** diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 2a90e1b4038..3d6820369cf 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -121,6 +121,13 @@ import { ProviderCapacityContinuationExhaustedError, } from '@/lib/knowledge/documents/processing-provider-deferral' import { scheduleDocumentProcessingQuotaContinuation } from '@/lib/knowledge/documents/processing-quota-continuation' +import { + DOCUMENT_LIVENESS_BATCH_SIZE, + documentProcessingSnapshotCondition, + findAbandonedDocumentProcessing, + inspectDocumentProcessingLiveness, + processingSnapshotColumns, +} from '@/lib/knowledge/documents/processing-recovery-queue' import { documentProcessingOutcomeSelection, getDocumentProcessingOutcome, @@ -879,10 +886,10 @@ async function markDocumentsQueued( knowledgeBaseId: string, queueToken: string, queuedAt: Date, - lease: ProcessingDispatchLease | undefined + lease: ProcessingDispatchLease | undefined, + signal?: AbortSignal ): Promise { - const legacyAdoptionCutoff = new Date(queuedAt.getTime() - QUEUED_DISPATCH_GRACE_MS) - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { if (lease) await assertSyncLeaseHeldInTx(tx, lease.connectorId, lease) const claimed = await tx .update(document) @@ -925,20 +932,8 @@ async function markDocumentsQueued( and( inArray(document.id, unclaimedIds), eq(document.knowledgeBaseId, knowledgeBaseId), - or( - and( - or( - eq(document.processingStatus, 'pending'), - eq(document.processingStatus, 'failed') - ), - eq(document.processingQueueToken, queueToken) - ), - and( - eq(document.processingStatus, 'pending'), - isNull(document.processingQueueToken), - lt(document.processingQueuedAt, legacyAdoptionCutoff) - ) - ), + inArray(document.processingStatus, ['pending', 'failed']), + eq(document.processingQueueToken, queueToken), isNotNull(document.processingQueuedAt), isNull(document.processingDeferredUntil), eq(document.userExcluded, false), @@ -996,6 +991,68 @@ async function markDocumentsQueued( ), } }) + + if (result.unresolvedIds.length === 0) return result + const candidates = await db + .select(processingSnapshotColumns) + .from(document) + .where( + and( + inArray(document.id, result.unresolvedIds), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.processingStatus, 'pending'), + lt(document.processingQueuedAt, new Date(queuedAt.getTime() - QUEUED_DISPATCH_GRACE_MS)), + isNull(document.processingDeferredUntil), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(DOCUMENT_LIVENESS_BATCH_SIZE) + const { abandoned, live } = await inspectDocumentProcessingLiveness(candidates, signal) + /** Redelivery resumes an abandoned admission; it does not spend another attempt. */ + const adopted = + abandoned.length === 0 + ? [] + : await db.transaction(async (tx) => { + signal?.throwIfAborted() + if (lease) await assertSyncLeaseHeldInTx(tx, lease.connectorId, lease) + return tx + .update(document) + .set({ processingQueueToken: queueToken }) + .where( + and( + eq(document.knowledgeBaseId, knowledgeBaseId), + or(...abandoned.map(documentProcessingSnapshotCondition)), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .returning({ id: document.id, processingQueuedAt: document.processingQueuedAt }) + }) + const acceptedIds = new Set([...live, ...adopted].map((row) => row.id)) + return { + generations: [ + ...result.generations, + ...adopted.flatMap((row) => + row.processingQueuedAt + ? [ + { + documentId: row.id, + processingQueuedAt: row.processingQueuedAt, + chargedAtDispatch: false, + }, + ] + : [] + ), + ], + acceptedWithoutDispatchIds: [ + ...result.acceptedWithoutDispatchIds, + ...live.map((row) => row.id), + ], + unresolvedIds: result.unresolvedIds.filter((id) => !acceptedIds.has(id)), + } } /** @@ -1108,7 +1165,14 @@ export async function processDocumentsWithQueue( generations: queuedGenerations, acceptedWithoutDispatchIds, unresolvedIds, - } = await markDocumentsQueued(documentIds, knowledgeBaseId, requestId, queuedAt, lease) + } = await markDocumentsQueued( + documentIds, + knowledgeBaseId, + requestId, + queuedAt, + lease, + executionContext?.signal + ) const generationByDocumentId = new Map( queuedGenerations.map((generation) => [generation.documentId, generation]) ) @@ -3441,78 +3505,76 @@ export async function retryDocumentProcessing( requestId: string, billingAttribution: BillingAttributionSnapshot | undefined ): Promise<{ success: boolean; status: string; message: string }> { - /** - * A document may be retried from a terminal state, or from a `pending` state - * old enough that its dispatch is certainly lost. - * - * Unguarded, a double-click issued two full passes: the second reset a - * document that the first had already queued, so both dispatches ran, both - * indexed, and both billed. A terminal-only guard closes that, but it also - * strands a document that never left `pending` — a worker killed before its - * claim UPDATE burns an attempt without changing status, and once the - * processing-attempt budget is spent the connector sweep drops it too. The row - * then matches nothing anywhere. - * - * The `pending` arm is admitted only past {@link QUEUED_DISPATCH_GRACE_MS}, - * which is the same grace the connector sweep waits out, so a second click - * still lands inside a live dispatch's window and still matches no rows. - * - * Age is measured from `COALESCE(processingQueuedAt, uploadedAt)`, exactly as - * `isStuckDocumentSweepEligible` measures it. `processingQueuedAt` is NULL - * only for a document no dispatch has ever stamped, and falling back to - * `uploadedAt` — rather than treating NULL as retryable — keeps the grace - * window closed for a document created moments ago whose first dispatch is - * still in flight. - */ + const [observed] = await db + .select(processingSnapshotColumns) + .from(document) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + isNull(document.deletedAt), + isNull(document.archivedAt) + ) + ) + .limit(1) + const mayReplace = + observed && + (observed.processingStatus === 'completed' || + (['pending', 'failed'].includes(observed.processingStatus) && + (await findAbandonedDocumentProcessing([observed])).length === 1)) + /** Age alone does not prove a queued generation was lost. */ const queuedGraceCutoff = new Date(Date.now() - QUEUED_DISPATCH_GRACE_MS) - const requeued = await db.transaction(async (tx) => { - const reset = await tx - .update(document) - .set({ - processingStatus: 'pending', - /** - * Invalidates the prior dispatch generation in the same write that - * reopens the row. The dispatch below installs its fresh generation. - */ - processingQueuedAt: null, - processingQueueToken: null, - processingStartedAt: null, - processingDeferredUntil: null, - processingCompletedAt: null, - processingError: null, - chunkCount: 0, - tokenCount: 0, - characterCount: 0, - }) - .where( - and( - eq(document.id, documentId), - or(isNull(document.connectorId), isNotNull(document.contentHash)), - not(skippedDocumentCondition()), - or( - inArray(document.processingStatus, ['completed', 'failed']), - and( - eq(document.processingStatus, 'pending'), - sql`COALESCE(${document.processingQueuedAt}, ${document.uploadedAt}) < ${sql.param(queuedGraceCutoff, document.processingQueuedAt)}`, - or( - isNull(document.processingDeferredUntil), - lt(document.processingDeferredUntil, queuedGraceCutoff) + const requeued = + mayReplace && + (await db.transaction(async (tx) => { + const reset = await tx + .update(document) + .set({ + processingStatus: 'pending', + /** + * Invalidates the prior dispatch generation in the same write that + * reopens the row. The dispatch below installs its fresh generation. + */ + processingQueuedAt: null, + processingQueueToken: null, + processingStartedAt: null, + processingDeferredUntil: null, + processingCompletedAt: null, + processingError: null, + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + }) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + documentProcessingSnapshotCondition(observed), + or(isNull(document.connectorId), isNotNull(document.contentHash)), + not(skippedDocumentCondition()), + or( + inArray(document.processingStatus, ['completed', 'failed']), + and( + eq(document.processingStatus, 'pending'), + sql`COALESCE(${document.processingQueuedAt}, ${document.uploadedAt}) < ${sql.param(queuedGraceCutoff, document.processingQueuedAt)}`, + or( + isNull(document.processingDeferredUntil), + lt(document.processingDeferredUntil, queuedGraceCutoff) + ) ) - ) - ), - isNull(document.archivedAt), - isNull(document.deletedAt) + ), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) ) - ) - .returning({ id: document.id }) + .returning({ id: document.id }) - // Embeddings are dropped only for a document this call actually claimed, - // so a losing double-click cannot wipe the winner's in-flight work. - if (reset.length > 0) { - await tx.delete(embedding).where(eq(embedding.documentId, documentId)) - } - return reset.length > 0 - }) + /** Only the winning reset may remove embeddings. */ + if (reset.length > 0) { + await tx.delete(embedding).where(eq(embedding.documentId, documentId)) + } + return reset.length > 0 + })) if (!requeued) { const [skipped] = await db diff --git a/apps/sim/lib/oauth/google-service-account-transport.test.ts b/apps/sim/lib/oauth/google-service-account-transport.test.ts index b62c7adc713..36cac725adf 100644 --- a/apps/sim/lib/oauth/google-service-account-transport.test.ts +++ b/apps/sim/lib/oauth/google-service-account-transport.test.ts @@ -2,6 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { exchangeGoogleServiceAccountJwt } from '@/lib/oauth/google-service-account-transport' +const { warn } = vi.hoisted(() => ({ warn: vi.fn() })) +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ warn, info: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + const TOKEN_URI = 'https://oauth2.googleapis.com/token' const ASSERTION = 'private-jwt-assertion' const fetchMock = vi.fn() @@ -9,6 +14,7 @@ const fetchMock = vi.fn() beforeEach(() => { vi.useFakeTimers() fetchMock.mockReset() + warn.mockClear() vi.stubGlobal('fetch', fetchMock) }) @@ -158,6 +164,18 @@ describe('Google service-account token transport', () => { const checked = expect(request).rejects.toMatchObject({ name: 'TimeoutError' }) await vi.advanceTimersByTimeAsync(30_000) await checked + expect(warn).toHaveBeenCalledWith( + 'Google service account token transport failed', + expect.objectContaining({ + operation: 'google.oauth.token_exchange', + stage: 'reading_response', + currentStatus: status, + lastHttpStatus: status, + attempts: 1, + elapsedMs: 30_000, + timedOut: true, + }) + ) expect(requestSignal?.aborted).toBe(true) expect(fetchMock).toHaveBeenCalledTimes(1) } @@ -187,5 +205,34 @@ describe('Google service-account token transport', () => { caller.abort(reason) await checked expect(fetchMock).toHaveBeenCalledTimes(1) + expect(warn).not.toHaveBeenCalled() }) }) + +it('distinguishes a prior HTTP response from a stalled next request without logging secrets', async () => { + fetchMock + .mockResolvedValueOnce(Response.json({ error: 'private-provider-detail' }, { status: 503 })) + .mockImplementationOnce( + (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }) + }) + ) + const result = exchangeGoogleServiceAccountJwt(TOKEN_URI, ASSERTION) + const checked = expect(result).rejects.toMatchObject({ name: 'TimeoutError' }) + await vi.advanceTimersByTimeAsync(30_000) + await checked + expect(warn).toHaveBeenCalledWith( + 'Google service account token transport failed', + expect.objectContaining({ + stage: 'awaiting_response', + attempts: 2, + lastHttpStatus: 503, + timedOut: true, + }) + ) + expect(warn.mock.calls[0][1]).not.toHaveProperty('currentStatus') + expect(JSON.stringify(warn.mock.calls)).not.toMatch( + /private-provider-detail|private-jwt-assertion|oauth2.googleapis.com/ + ) +}) diff --git a/apps/sim/lib/oauth/google-service-account-transport.ts b/apps/sim/lib/oauth/google-service-account-transport.ts index 8cf93a77844..f3e3b58df0d 100644 --- a/apps/sim/lib/oauth/google-service-account-transport.ts +++ b/apps/sim/lib/oauth/google-service-account-transport.ts @@ -1,3 +1,4 @@ +import { createLogger } from '@sim/logger' import { parseRetryAfter } from '@sim/utils/retry' import { isRetryableError, @@ -7,6 +8,7 @@ import { const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]) const TOKEN_EXCHANGE_BUDGET_MS = 30_000 +const logger = createLogger('GoogleServiceAccountTransport') interface GoogleTokenExchangeResponse { ok: boolean @@ -33,10 +35,18 @@ export async function exchangeGoogleServiceAccountJwt( jwt: string, signal?: AbortSignal ): Promise { + const startedAt = Date.now() + let attempts = 0 + let stage: 'awaiting_response' | 'reading_response' | 'between_attempts' = 'awaiting_response' + let currentStatus: number | undefined + let lastStatus: number | undefined let lastResponse: GoogleTokenExchangeResponse | undefined try { return await retryWithExponentialBackoff( async (attemptSignal) => { + attempts++ + stage = 'awaiting_response' + currentStatus = undefined lastResponse = undefined const response = await fetch(tokenUri, { method: 'POST', @@ -48,6 +58,9 @@ export async function exchangeGoogleServiceAccountJwt( }), signal: attemptSignal, }) + stage = 'reading_response' + currentStatus = response.status + lastStatus = response.status const payload = await readBoundedHttpErrorPayload(response) attemptSignal.throwIfAborted() if (response.ok && !payload.ok) @@ -68,12 +81,27 @@ export async function exchangeGoogleServiceAccountJwt( maxDelayMs: 2000, retryBudgetMs: TOKEN_EXCHANGE_BUDGET_MS, signal, - retryCondition: (error) => - error instanceof TokenExchangeRetryError || isRetryableError(error), + retryCondition: (error) => { + const retryable = error instanceof TokenExchangeRetryError || isRetryableError(error) + if (retryable) stage = 'between_attempts' + return retryable + }, } ) } catch (error) { if (error instanceof TokenExchangeRetryError && lastResponse) return lastResponse + if (!signal?.aborted) { + logger.warn('Google service account token transport failed', { + operation: 'google.oauth.token_exchange', + stage, + attempts, + elapsedMs: Date.now() - startedAt, + budgetMs: TOKEN_EXCHANGE_BUDGET_MS, + ...(currentStatus !== undefined ? { currentStatus } : {}), + ...(lastStatus !== undefined ? { lastHttpStatus: lastStatus } : {}), + timedOut: error instanceof Error && error.name === 'TimeoutError', + }) + } throw error } } From 1f16b034b8935c101cb60d3312a990e981519cab Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 18 Sep 2026 17:58:30 -0700 Subject: [PATCH 18/20] fix(file-search): avoid foreground GIN cleanup stalls (#7995) * fix(file-search): avoid foreground GIN cleanup stalls * fix(file-search): create chunk GIN index concurrently --- apps/sim/lib/workspace-files/search/README.md | 18 +- .../search/chunks.integration.ts | 135 +- .../lib/workspace-files/search/constants.ts | 10 +- .../lib/workspace-files/search/index-state.ts | 9 +- .../0364_workspace_file_search_direct_gin.sql | 12 + .../db/migrations/meta/0364_snapshot.json | 27765 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 10 +- 8 files changed, 27942 insertions(+), 24 deletions(-) create mode 100644 packages/db/migrations/0364_workspace_file_search_direct_gin.sql create mode 100644 packages/db/migrations/meta/0364_snapshot.json diff --git a/apps/sim/lib/workspace-files/search/README.md b/apps/sim/lib/workspace-files/search/README.md index e00b0318be8..8f03dac94c8 100644 --- a/apps/sim/lib/workspace-files/search/README.md +++ b/apps/sim/lib/workspace-files/search/README.md @@ -10,7 +10,11 @@ PostgreSQL stores complete extracted text in bounded chunks. Object storage rema 8 KiB values may still use PostgreSQL TOAST. The bound controls the size of each logical value and detoast operation; avoiding TOAST entirely is not the objective. Tiny lines share rows, so row count scales with bytes instead of newline count. Worst-case line packing can leave roughly half a block unused; long-line overlap adds at most eight bytes per fragment. -Workers download and extract outside database transactions, then insert batches of at most 250 rows / 1 MiB. Each batch checks the build token and lease. Publication locks the canonical file, build, and revision in that order, verifies the stored chunk count, and changes the visible pointer only after every batch succeeds. Old dispatch failure callbacks cannot overwrite newer dispatches or successful builds. +Workers download and extract outside database transactions, then insert batches of at most 250 rows / 128 KiB. Each batch checks the build token and lease. Publication locks the canonical file, build, and revision in that order, verifies the stored chunk count, and changes the visible pointer only after every batch succeeds. Old dispatch failure callbacks cannot overwrite newer dispatches or successful builds. + +The chunk GIN index uses `fastupdate = off`. Each bounded insert updates the main index directly instead of appending to a shared pending list. With deferred updates enabled, even a small insert can cross the pending-list threshold and synchronously merge accumulated work from other files. Direct updates trade some bulk-write throughput for avoiding that foreground cleanup cliff. They do not eliminate normal index I/O, vacuum, or storage contention; the row and worker limits still apply. The 128 KiB batch budget bounds direct index work per transaction without changing the 25 MiB file coverage limit. Dense text with many distinct trigrams and an index working set larger than the available cache can still exceed the statement deadline. Capacity validation must include that cache pressure, not only a small corpus or a row count. + +Indexing transactions have separate limits from search: ten seconds per statement, five seconds waiting for a lock, and thirty seconds total on PostgreSQL 17. The outer limit leaves time for ordinary statement cancellation and rollback instead of terminating the connection at the same ten-second deadline. PostgreSQL 16 uses the compatible idle-transaction guard. A canceled batch remains unpublished; the existing task retry starts a fresh fenced build, and cleanup retires the previous attempt. This does not automatically retry revisions already marked failed. The indexing task uses an isolated `medium-2x` Trigger worker (4 GB RAM). Document parsers can materialize expanded content before chunking, so source and extracted-text byte limits do not bound parser memory. Parser complexity guards and the worker's memory budget remain separate protections. @@ -41,16 +45,22 @@ Search results retain `fileId`, 1-based `lineNumber`, and bounded `text` preview 3. Before retiring legacy storage, verify the new app and Trigger workers are fully deployed, old runs/retries have drained, the backfill cursor has completed, and scoped coverage is ready or explicitly excluded. Investigate failed or stale pending revisions. Check cleanup backlog and run representative exact/regex searches, including long lines and folder scopes. 4. After the rollback window, ship a separate contract PR removing the legacy schema and dropping `workspace_file_search_segment` / `workspace_file_search_index` with a short lock timeout. Do not delete the entire old index row-by-row or backfill it inside the schema migration. Dropping obsolete tables reclaims their heap, indexes, and TOAST together. The `contract-pending` marker in `packages/db/schema.ts` tracks this step. -Until that contract deploy, legacy foreign-key cascades can still make a hard file/workspace deletion expensive. New-index cleanup is bounded; retaining the old schema cannot erase that legacy cost. The earlier timestamp-repair script detects the chunk schema and leaves obsolete legacy text for this contract step instead of deleting it in bulk. No production cleanup is part of this PR. +Until that contract deploy, legacy foreign-key cascades can still make a hard file/workspace deletion expensive. New-index cleanup is bounded; retaining the old schema cannot erase that legacy cost. The earlier timestamp-repair script detects the chunk schema and leaves obsolete legacy text for this contract step instead of deleting it in bulk. Legacy-table retirement remains a separate contract migration. Rollback before retirement requires restoring the old trigger function as well as the old app/worker version, and reconciling legacy revisions written during the cutover. Do not assume retained tables are automatically up to date. Canonical revision joins prevent stale content from being returned. +### Direct GIN writes + +Migration `0364_workspace_file_search_direct_gin.sql` changes the existing index's storage option without rebuilding it or rewriting chunks. It commits that change before calling PostgreSQL's `gin_clean_pending_list` with a five-minute statement budget. Turning off deferred updates alone leaves the previous pending list in place, so this one-time drain is required. Searches continue to see both existing pending entries and main-index entries throughout the transition, and old workers use the same schema. Deploy the smaller-batch worker policy in the same release; running older workers retain their previous batch size until replaced. + +If maintenance times out, the storage option remains off and migration replay safely resumes the drain; no content is discarded and no new pending entries accumulate. The migration uses the runner's direct connection and short DDL lock timeout. It adds no maintenance scheduler. After deployment, check that the index is valid, `reloptions` includes `fastupdate=off`, the migration completed, and chunk insert latency and timeout rates remain healthy under the existing worker concurrency. Previously failed revisions require an explicitly scoped retry after their failure reason and current content version are checked; no blanket backfill or deletion is part of this change. + ## Verification -Run unit tests in `apps/sim` with `bunx vitest run lib/workspace-files/search lib/file-parsers`. Run the PostgreSQL suites on both PostgreSQL 16 and 17 against a disposable local database through `KNOWLEDGE_ACL_TEST_DATABASE_URL` and `--mode integration`. `chunks.integration.ts` applies the actual trigger migrations in an isolated schema. It covers build fencing, revision changes, deletion, cleanup bounds, complete-line matching, UTF-8 boundaries, scope, and admission limits. +Run unit tests in `apps/sim` with `bunx vitest run lib/workspace-files/search lib/file-parsers`. Run the PostgreSQL suites on both PostgreSQL 16 and 17 against a disposable local database through `KNOWLEDGE_ACL_TEST_DATABASE_URL` and `--mode integration`. `chunks.integration.ts` applies the actual trigger and index migrations in an isolated schema. It covers build fencing, revision changes, deletion, cleanup bounds, complete-line matching, UTF-8 boundaries, scope, admission limits, GIN migration replay with a populated pending list, and statement cancellation followed by a successful retry on an intact connection. Its local database role must be able to install the `pgstattuple` diagnostic extension. Set `FILE_SEARCH_BENCHMARK_FILES` to change the synthetic file count (default 1,000, maximum 10,000). Set `FILE_SEARCH_BENCHMARK_OUTPUT` to an output path when running the chunk integration suite to record repeated end-to-end searches and `EXPLAIN (ANALYZE, BUFFERS)` plans on a synthetic multi-file corpus. The fixture is synthetic; it contains no production content. ## PostgreSQL references -The design uses PostgreSQL's documented [TOAST behavior](https://www.postgresql.org/docs/17/storage-toast.html), [trigram index support for LIKE and regex](https://www.postgresql.org/docs/17/pgtrgm.html), and [EXPLAIN guidance](https://www.postgresql.org/docs/17/using-explain.html). The chunk size and query paths are application choices validated by the synthetic fixture, not PostgreSQL hard limits. +The design uses PostgreSQL's documented [TOAST behavior](https://www.postgresql.org/docs/17/storage-toast.html), [trigram index support for LIKE and regex](https://www.postgresql.org/docs/17/pgtrgm.html), [GIN pending-list tradeoffs](https://www.postgresql.org/docs/17/gin.html#GIN-FAST-UPDATE), [index storage parameters](https://www.postgresql.org/docs/17/sql-createindex.html), and [EXPLAIN guidance](https://www.postgresql.org/docs/17/using-explain.html). The chunk size and query paths are application choices validated by the synthetic fixture, not PostgreSQL hard limits. diff --git a/apps/sim/lib/workspace-files/search/chunks.integration.ts b/apps/sim/lib/workspace-files/search/chunks.integration.ts index c5759de539a..192252c5011 100644 --- a/apps/sim/lib/workspace-files/search/chunks.integration.ts +++ b/apps/sim/lib/workspace-files/search/chunks.integration.ts @@ -32,9 +32,12 @@ vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({ resolveServable vi.mock('@/lib/file-parsers', () => ({ parseBuffer: vi.fn(), isSupportedFileType: vi.fn() })) import { + FILE_SEARCH_CHUNK_BYTES, FILE_SEARCH_CLEANUP_BATCH_ROWS, FILE_SEARCH_CLEANUP_BUDGET_MS, FILE_SEARCH_CLEANUP_MAX_BATCHES, + FILE_SEARCH_INSERT_BATCH_BYTES, + FILE_SEARCH_INSERT_BATCH_ROWS, FILE_SEARCH_QUERY_GLOBAL_CONCURRENCY, FILE_SEARCH_QUERY_WORKSPACE_CONCURRENCY, } from '@/lib/workspace-files/search/constants' @@ -93,6 +96,35 @@ describe('chunked workspace file search on PostgreSQL', () => { onnotice: () => {}, }) ) + const ginWriteMigration = '0364_workspace_file_search_direct_gin.sql' + + async function applyMigration(migration: string) { + const source = readFileSync( + resolve(process.cwd(), '../../packages/db/migrations', migration), + 'utf8' + ).replaceAll('"public".', `"${schema}".`) + const session = await connection.reserve() + try { + await session`BEGIN` + for (const statement of source.split('--> statement-breakpoint')) + if (statement.trim()) await session.unsafe(statement) + await session`COMMIT` + } finally { + await session`ROLLBACK` + await session`RESET statement_timeout` + session.release() + } + } + + async function ginState() { + const [state] = + await connection`SELECT c.oid, 'fastupdate=off' = ANY(c.reloptions) AS direct_writes, + i.indisvalid, pending.pending_pages + FROM pg_class c JOIN pg_index i ON i.indexrelid = c.oid + CROSS JOIN LATERAL pgstatginindex(c.oid) pending + WHERE c.oid = 'workspace_file_search_chunk_content_idx'::regclass` + return state + } async function addFile( fileId: string, @@ -110,10 +142,14 @@ describe('chunked workspace file search on PostgreSQL', () => { expect(build).not.toBeNull() const plan = planFileSearchIndex({ text, partial: false }, signal) const chunks = [...iterateFileSearchChunks(plan, signal)] - for (let offset = 0; offset < chunks.length; offset += 100) - expect(await appendFileSearchChunks(build!, chunks.slice(offset, offset + 100), signal)).toBe( - true - ) + const batchRows = Math.min( + FILE_SEARCH_INSERT_BATCH_ROWS, + Math.floor(FILE_SEARCH_INSERT_BATCH_BYTES / FILE_SEARCH_CHUNK_BYTES) + ) + for (let offset = 0; offset < chunks.length; offset += batchRows) + expect( + await appendFileSearchChunks(build!, chunks.slice(offset, offset + batchRows), signal) + ).toBe(true) expect( await publishFileSearchBuild( build!, @@ -140,6 +176,7 @@ describe('chunked workspace file search on PostgreSQL', () => { let captureQuery = false beforeAll(async () => { + await connection`CREATE EXTENSION IF NOT EXISTS pgstattuple` await connection`CREATE SCHEMA ${connection(schema)}` await connection`CREATE TABLE workspace (id text PRIMARY KEY)` await connection`CREATE TABLE workspace_files (id text PRIMARY KEY, workspace_id text REFERENCES workspace(id) ON DELETE CASCADE, @@ -149,13 +186,9 @@ describe('chunked workspace file search on PostgreSQL', () => { '0313_puzzling_zodiak.sql', '0358_workspace_file_content_version_precision.sql', '0359_workspace_file_search_chunks.sql', + ginWriteMigration, ]) { - const source = readFileSync( - resolve(process.cwd(), '../../packages/db/migrations', migration), - 'utf8' - ).replaceAll('"public".', `"${schema}".`) - for (const statement of source.split('--> statement-breakpoint')) - if (statement.trim()) await connection.unsafe(statement) + await applyMigration(migration) } database.current = drizzle(connection) database.search = drizzle(searchConnection, { @@ -189,6 +222,88 @@ describe('chunked workspace file search on PostgreSQL', () => { } }) + it('preserves search through disabling, draining, and replaying GIN pending-list maintenance', async () => { + await connection`ALTER INDEX workspace_file_search_chunk_content_idx SET (fastupdate = on)` + try { + await index('heading\nold needle αβγ\ntail') + const before = await ginState() + expect(before.pending_pages).toBeGreaterThan(0) + + /** Simulate an interrupted rollout after the storage option commits but before the drain. */ + await connection`ALTER INDEX workspace_file_search_chunk_content_idx SET (fastupdate = off)` + await index('heading\nnew needle αβγ\ntail', await addFile('file-2')) + expect((await ginState()).pending_pages).toBe(before.pending_pages) + const expected = [ + { fileId: 'file-1', lineNumber: 2 }, + { fileId: 'file-2', lineNumber: 2 }, + ] + expect((await search('^(old|new) needle αβγ$', 'regex')).results).toMatchObject(expected) + + for (let attempt = 0; attempt < 2; attempt++) { + await applyMigration(ginWriteMigration) + expect(await ginState()).toMatchObject({ + oid: before.oid, + indisvalid: true, + direct_writes: true, + pending_pages: 0, + }) + expect((await search('needle αβγ')).results).toMatchObject(expected) + } + await index('heading\nnew needle αβγ\ntail', await addFile('file-3')) + expect((await ginState()).pending_pages).toBe(0) + expect((await search('^(old|new) needle αβγ$', 'regex')).results).toHaveLength(3) + } finally { + await applyMigration(ginWriteMigration) + } + }) + + it('cancels a slow chunk statement without losing the connection or publishing partial content', async () => { + const build = (await beginFileSearchBuild(revision))! + const plan = planFileSearchIndex({ text: 'needle', partial: false }, signal) + const chunks = [...iterateFileSearchChunks(plan, signal)] + const writer = postgres(databaseUrl, { + max: 1, + prepare: false, + connection: { search_path: `${schema},public` }, + }) + const original = database.current + await connection`CREATE FUNCTION slow_chunk_insert() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN PERFORM pg_sleep(11); RETURN NEW; END $$` + await connection`CREATE TRIGGER slow_chunk_insert BEFORE INSERT ON workspace_file_search_chunk + FOR EACH STATEMENT EXECUTE FUNCTION slow_chunk_insert()` + try { + database.current = drizzle(writer) + const [before] = await writer`SELECT pg_backend_pid() AS pid` + await expect(appendFileSearchChunks(build, chunks, signal)).rejects.toMatchObject({ + cause: { code: '57014' }, + }) + expect((await writer`SELECT pg_backend_pid() AS pid`)[0].pid).toBe(before.pid) + expect( + (await connection`SELECT count(*)::int AS count FROM workspace_file_search_chunk`)[0].count + ).toBe(0) + expect((await search('needle')).results).toEqual([]) + } finally { + database.current = original + await writer.end() + await connection`DROP TRIGGER slow_chunk_insert ON workspace_file_search_chunk` + await connection`DROP FUNCTION slow_chunk_insert()` + } + expect(await appendFileSearchChunks(build, chunks, signal)).toBe(true) + expect( + await publishFileSearchBuild( + build, + { + status: 'ready', + chunkCount: chunks.length, + lineCount: plan.lineCount, + indexedBytes: plan.indexedBytes, + }, + signal + ) + ).toBe(true) + expect((await search('needle')).results).toMatchObject([{ fileId: 'file-1', lineNumber: 1 }]) + }) + it('packs a million short lines without a million rows and bounds every stored value', async () => { await index('abc\n'.repeat(1_000_000)) const [row] = diff --git a/apps/sim/lib/workspace-files/search/constants.ts b/apps/sim/lib/workspace-files/search/constants.ts index ed26fd31a70..f69aeec1419 100644 --- a/apps/sim/lib/workspace-files/search/constants.ts +++ b/apps/sim/lib/workspace-files/search/constants.ts @@ -46,7 +46,15 @@ export const FILE_SEARCH_CLEANUP_MIN_BATCH_MS = FILE_SEARCH_CLEANUP_BUDGET_MS / FILE_SEARCH_CLEANUP_MAX_BATCHES export const FILE_SEARCH_RECONCILE_INTERVAL_MS = 60 * 60 * 1000 export const FILE_SEARCH_INSERT_BATCH_ROWS = 250 -export const FILE_SEARCH_INSERT_BATCH_BYTES = 1024 * 1024 +/** Direct GIN writes perform index work in each insert, so transactions use smaller byte batches. */ +export const FILE_SEARCH_INSERT_BATCH_BYTES = 128 * 1024 + +/** Index writes allow statement cancellation before the outer transaction terminates its session. */ +export const FILE_SEARCH_INDEX_TRANSACTION_LIMITS = { + statementTimeout: 10 * 1000, + lockTimeout: 5 * 1000, + transactionTimeout: 30 * 1000, +} as const export const FILE_SEARCH_INDEX_GLOBAL_CONCURRENCY = 10 export const FILE_SEARCH_INDEX_WORKSPACE_OUTSTANDING = 2 diff --git a/apps/sim/lib/workspace-files/search/index-state.ts b/apps/sim/lib/workspace-files/search/index-state.ts index 9e04446f495..0b5fea4e8b5 100644 --- a/apps/sim/lib/workspace-files/search/index-state.ts +++ b/apps/sim/lib/workspace-files/search/index-state.ts @@ -15,6 +15,7 @@ import { FILE_SEARCH_CLEANUP_BUDGET_MS, FILE_SEARCH_CLEANUP_MAX_BATCHES, FILE_SEARCH_CLEANUP_MIN_BATCH_MS, + FILE_SEARCH_INDEX_TRANSACTION_LIMITS, FILE_SEARCH_INSERT_BATCH_BYTES, FILE_SEARCH_INSERT_BATCH_ROWS, } from '@/lib/workspace-files/search/constants' @@ -90,7 +91,7 @@ export async function beginFileSearchBuild( dispatchToken?: string ): Promise { return db.transaction(async (tx) => { - await configureFileSearchTransaction(tx) + await configureFileSearchTransaction(tx, FILE_SEARCH_INDEX_TRANSACTION_LIMITS) if (!(await lockCurrentFile(tx, revision))) return null const [observed] = await tx .select({ @@ -154,7 +155,7 @@ export async function appendFileSearchChunks( throw new Error('File search insert batch exceeds its budget') } return db.transaction(async (tx) => { - await configureFileSearchTransaction(tx) + await configureFileSearchTransaction(tx, FILE_SEARCH_INDEX_TRANSACTION_LIMITS) if (!(await lockBuild(tx, build))) return false signal.throwIfAborted() await tx @@ -177,7 +178,7 @@ export async function publishFileSearchBuild( signal: AbortSignal ): Promise { return db.transaction(async (tx) => { - await configureFileSearchTransaction(tx) + await configureFileSearchTransaction(tx, FILE_SEARCH_INDEX_TRANSACTION_LIMITS) signal.throwIfAborted() if (!(await lockCurrentFile(tx, build)) || !(await lockBuild(tx, build))) return false if (publication.status === 'ready') { @@ -216,7 +217,7 @@ export async function failFileSearchRevision( dispatchToken?: string ): Promise { await db.transaction(async (tx) => { - await configureFileSearchTransaction(tx) + await configureFileSearchTransaction(tx, FILE_SEARCH_INDEX_TRANSACTION_LIMITS) if (!(await lockCurrentFile(tx, revision))) return const [state] = await tx .select() diff --git a/packages/db/migrations/0364_workspace_file_search_direct_gin.sql b/packages/db/migrations/0364_workspace_file_search_direct_gin.sql new file mode 100644 index 00000000000..bd489c56c7f --- /dev/null +++ b/packages/db/migrations/0364_workspace_file_search_direct_gin.sql @@ -0,0 +1,12 @@ +-- migration-safe: Metadata-only storage option; existing and new workers retain the same index and search semantics. +ALTER INDEX "public"."workspace_file_search_chunk_content_idx" SET (fastupdate = off); +--> statement-breakpoint +-- Release the DDL lock before maintenance. Both operations are safe to replay after a partial migration. +COMMIT; +--> statement-breakpoint +-- Disabling fastupdate does not flush existing pending entries. Drain them once without rebuilding the index. +SET statement_timeout = '5min'; +--> statement-breakpoint +SELECT pg_catalog.gin_clean_pending_list('"public"."workspace_file_search_chunk_content_idx"'::regclass); +--> statement-breakpoint +SET statement_timeout = 0; diff --git a/packages/db/migrations/meta/0364_snapshot.json b/packages/db/migrations/meta/0364_snapshot.json new file mode 100644 index 00000000000..62318e9019a --- /dev/null +++ b/packages/db/migrations/meta/0364_snapshot.json @@ -0,0 +1,27765 @@ +{ + "id": "8715613a-2152-4f34-95b1-876050ac4aec", + "prevId": "49d8ea2d-8e1d-43b1-969b-c490364cb943", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config": { + "name": "oauth_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_conversation_key": { + "name": "external_conversation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_conversation_metadata": { + "name": "external_conversation_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_organization_id_idx": { + "name": "copilot_chats_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_external_conversation_unique": { + "name": "copilot_chats_external_conversation_unique", + "columns": [ + { + "expression": "external_conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"copilot_chats\".\"external_conversation_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_org_created_idx": { + "name": "copilot_chats_user_org_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_organization_id_organization_id_fk": { + "name": "copilot_chats_organization_id_organization_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "copilot_chats_owner_check": { + "name": "copilot_chats_owner_check", + "value": "num_nonnulls(\"copilot_chats\".\"workspace_id\", \"copilot_chats\".\"organization_id\") <= 1" + }, + "copilot_chats_organization_workflow_check": { + "name": "copilot_chats_organization_workflow_check", + "value": "\"copilot_chats\".\"organization_id\" IS NULL OR \"copilot_chats\".\"workflow_id\" IS NULL" + } + }, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_started_at_idx": { + "name": "copilot_runs_chat_started_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_app_id": { + "name": "slack_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_personal_token": { + "name": "encrypted_personal_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_oauth_config_version": { + "name": "mcp_oauth_config_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_tools": { + "name": "mcp_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mcp_tools_refreshed_at": { + "name": "mcp_tools_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_organization_id_idx": { + "name": "credential_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_organization_account_unique": { + "name": "credential_organization_account_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"account_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_org_personal_token_unique": { + "name": "credential_org_personal_token_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'personal_token'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_mcp_server_idx": { + "name": "credential_mcp_server_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_managed_mcp_enrollment_server_unique": { + "name": "credential_managed_mcp_enrollment_server_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_mcp'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_personal_token_identity_unique": { + "name": "credential_personal_token_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'personal_token'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_organization_id_organization_id_fk": { + "name": "credential_organization_id_organization_id_fk", + "tableFrom": "credential", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_slack_app_id_slack_app_id_fk": { + "name": "credential_slack_app_id_slack_app_id_fk", + "tableFrom": "credential", + "tableTo": "slack_app", + "columnsFrom": ["slack_app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_mcp_server_id_mcp_servers_id_fk": { + "name": "credential_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "credential", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_owner_check": { + "name": "credential_owner_check", + "value": "num_nonnulls(\"credential\".\"workspace_id\", \"credential\".\"organization_id\") = 1" + }, + "credential_organization_type_check": { + "name": "credential_organization_type_check", + "value": "\"credential\".\"organization_id\" IS NULL OR \"credential\".\"type\" IN ('oauth', 'managed_oauth', 'managed_mcp', 'service_account', 'personal_token')" + }, + "credential_personal_token_source_check": { + "name": "credential_personal_token_source_check", + "value": "(type::text <> 'personal_token') OR (\n created_by IS NOT NULL\n AND provider_id IS NOT NULL\n AND provider_id = 'gitlab'\n AND provider_subject_id IS NOT NULL\n AND provider_tenant_id IS NOT NULL\n AND encrypted_personal_token IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND account_id IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND authorization_app_id IS NULL\n AND encrypted_oauth_token_set IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_managed_mcp_source_check": { + "name": "credential_managed_mcp_source_check", + "value": "(type::text <> 'managed_mcp') OR (\n id LIKE 'mcp-cg-%'\n AND account_id IS NULL\n AND provider_id IS NULL\n AND authorization_app_id IS NULL\n AND credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NULL\n AND mcp_server_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND (managed_oauth_status <> 'active' OR (\n encrypted_oauth_token_set IS NOT NULL\n AND mcp_tools IS NOT NULL\n ))\n AND granted_at IS NOT NULL\n AND managed_oauth_scope_version IS NULL\n AND provider_subject_id IS NULL\n AND provider_tenant_id IS NULL\n AND granted_scopes IS NULL\n AND provider_metadata IS NULL\n AND created_by IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_creator_source_check": { + "name": "credential_creator_source_check", + "value": "(type::text = 'managed_mcp') OR created_by IS NOT NULL" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_organization_id_idx": { + "name": "credential_group_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_organization_unique": { + "name": "credential_group_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_unique": { + "name": "credential_group_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_organization_id_organization_id_fk": { + "name": "credential_group_organization_id_organization_id_fk", + "tableFrom": "credential_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_owner_check": { + "name": "credential_group_owner_check", + "value": "num_nonnulls(\"credential_group\".\"workspace_id\", \"credential_group\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_user_unique": { + "name": "credential_group_enrollment_group_user_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential_group_enrollment\".\"user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_user_id_idx": { + "name": "credential_group_enrollment_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_user_id_user_id_fk": { + "name": "credential_group_enrollment_user_id_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_recovery_after": { + "name": "processing_recovery_after", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{ws}'::text[]" + }, + "acl_requirements": { + "name": "acl_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "acl_verified_at": { + "name": "acl_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_modified_at": { + "name": "source_modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_seen_at": { + "name": "source_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_acl_gin_idx": { + "name": "doc_acl_gin_idx", + "columns": [ + { + "expression": "acl", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "array_ops" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_recovery_idx": { + "name": "doc_processing_recovery_idx", + "columns": [ + { + "expression": "uploaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"processing_status\" IN ('pending', 'processing', 'failed') AND \"document\".\"connector_id\" IS NOT NULL AND \"document\".\"content_hash\" IS NOT NULL AND \"document\".\"storage_key\" IS NOT NULL AND \"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_processing_status_idx": { + "name": "doc_connector_processing_status_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"processing_status\" IN ('pending', 'processing', 'failed') AND \"document\".\"connector_id\" IS NOT NULL AND \"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_source_lookup_idx": { + "name": "doc_connector_source_lookup_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_reconciliation_idx": { + "name": "doc_connector_reconciliation_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "COALESCE(\"source_seen_at\", '-infinity'::timestamp)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag1_lower_idx": { + "name": "doc_kb_tag1_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag1\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag2_lower_idx": { + "name": "doc_kb_tag2_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag2\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag3_lower_idx": { + "name": "doc_kb_tag3_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag3\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag4_lower_idx": { + "name": "doc_kb_tag4_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag4\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag5_lower_idx": { + "name": "doc_kb_tag5_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag5\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag6_lower_idx": { + "name": "doc_kb_tag6_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag6\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag7_lower_idx": { + "name": "doc_kb_tag7_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag7\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "doc_acl_token_shape_check": { + "name": "doc_acl_token_shape_check", + "value": "array_position(\"document\".\"acl\", NULL) IS NULL AND (cardinality(\"document\".\"acl\") = 0 OR (cardinality(\"document\".\"acl\") = array_length(string_to_array(array_to_string(\"document\".\"acl\", E'\\n'), E'\\n'), 1) AND array_to_string(\"document\".\"acl\", E'\\n') ~ '^((ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+)(\\n(ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+))*)$'))" + } + }, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_384": { + "name": "embedding_384", + "type": "vector(384)", + "primaryKey": false, + "notNull": false + }, + "embedding_768": { + "name": "embedding_768", + "type": "vector(768)", + "primaryKey": false, + "notNull": false + }, + "embedding_1024": { + "name": "embedding_1024", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "embedding_3072": { + "name": "embedding_3072", + "type": "vector(3072)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag1_lower_idx": { + "name": "emb_kb_tag1_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag1\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag2_lower_idx": { + "name": "emb_kb_tag2_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag2\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag3_lower_idx": { + "name": "emb_kb_tag3_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag3\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag4_lower_idx": { + "name": "emb_kb_tag4_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag4\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag5_lower_idx": { + "name": "emb_kb_tag5_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag5\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag6_lower_idx": { + "name": "emb_kb_tag6_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag6\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag7_lower_idx": { + "name": "emb_kb_tag7_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag7\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_width_check": { + "name": "embedding_width_check", + "value": "num_nonnulls(\"embedding\", \"embedding_384\", \"embedding_768\", \"embedding_1024\", \"embedding_3072\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.embedding_keyword_search": { + "name": "embedding_keyword_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "embedding_keyword_search_kb_idx": { + "name": "embedding_keyword_search_kb_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_keyword_search_document_idx": { + "name": "embedding_keyword_search_document_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_keyword_search_content_idx": { + "name": "embedding_keyword_search_content_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_keyword_search_id_embedding_id_fk": { + "name": "embedding_keyword_search_id_embedding_id_fk", + "tableFrom": "embedding_keyword_search", + "tableTo": "embedding", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding_search": { + "name": "embedding_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "binary": { + "name": "binary", + "type": "bit(1536)", + "primaryKey": false, + "notNull": false + }, + "binary_384": { + "name": "binary_384", + "type": "bit(384)", + "primaryKey": false, + "notNull": false + }, + "binary_768": { + "name": "binary_768", + "type": "bit(768)", + "primaryKey": false, + "notNull": false + }, + "binary_1024": { + "name": "binary_1024", + "type": "bit(1024)", + "primaryKey": false, + "notNull": false + }, + "binary_3072": { + "name": "binary_3072", + "type": "bit(3072)", + "primaryKey": false, + "notNull": false + }, + "vector": { + "name": "vector", + "type": "halfvec(1536)", + "primaryKey": false, + "notNull": false + }, + "vector_384": { + "name": "vector_384", + "type": "halfvec(384)", + "primaryKey": false, + "notNull": false + }, + "vector_512": { + "name": "vector_512", + "type": "halfvec(512)", + "primaryKey": false, + "notNull": false + }, + "vector_768": { + "name": "vector_768", + "type": "halfvec(768)", + "primaryKey": false, + "notNull": false + }, + "vector_1024": { + "name": "vector_1024", + "type": "halfvec(1024)", + "primaryKey": false, + "notNull": false + }, + "vector_3072": { + "name": "vector_3072", + "type": "halfvec(3072)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "embedding_search_kb_idx": { + "name": "embedding_search_kb_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_search_document_lookup_idx": { + "name": "embedding_search_document_lookup_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"embedding_search\".\"enabled\"", + "concurrently": true, + "method": "btree", + "with": {} + }, + "embedding_search_binary_hnsw_idx": { + "name": "embedding_search_binary_hnsw_idx", + "columns": [ + { + "expression": "binary", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bit_hamming_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_384_binary_hnsw_idx": { + "name": "embedding_search_384_binary_hnsw_idx", + "columns": [ + { + "expression": "binary_384", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bit_hamming_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_768_binary_hnsw_idx": { + "name": "embedding_search_768_binary_hnsw_idx", + "columns": [ + { + "expression": "binary_768", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bit_hamming_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_1024_binary_hnsw_idx": { + "name": "embedding_search_1024_binary_hnsw_idx", + "columns": [ + { + "expression": "binary_1024", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bit_hamming_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_3072_binary_hnsw_idx": { + "name": "embedding_search_3072_binary_hnsw_idx", + "columns": [ + { + "expression": "binary_3072", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bit_hamming_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_cosine_hnsw_idx": { + "name": "embedding_search_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_512_cosine_hnsw_idx": { + "name": "embedding_search_512_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_512", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_384_cosine_hnsw_idx": { + "name": "embedding_search_384_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_384", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_768_cosine_hnsw_idx": { + "name": "embedding_search_768_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_768", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_1024_cosine_hnsw_idx": { + "name": "embedding_search_1024_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_1024", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_3072_cosine_hnsw_idx": { + "name": "embedding_search_3072_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_3072", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + } + }, + "foreignKeys": { + "embedding_search_id_embedding_id_fk": { + "name": "embedding_search_id_embedding_id_fk", + "tableFrom": "embedding_search", + "tableTo": "embedding", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_search_width_check": { + "name": "embedding_search_width_check", + "value": "num_nonnulls(\"binary\", \"binary_384\", \"binary_768\", \"binary_1024\", \"binary_3072\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_search_index": { + "name": "is_search_index", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_organization_id_idx": { + "name": "kb_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_organization_search_index_unique": { + "name": "kb_organization_search_index_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"is_search_index\" = true AND \"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_organization_name_active_unique": { + "name": "kb_organization_name_active_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_search_index_unique": { + "name": "kb_workspace_search_index_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"is_search_index\" = true AND \"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_organization_id_organization_id_fk": { + "name": "knowledge_base_organization_id_organization_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kb_owner_check": { + "name": "kb_owner_check", + "value": "num_nonnulls(\"knowledge_base\".\"workspace_id\", \"knowledge_base\".\"organization_id\") = 1" + }, + "kb_organization_search_index_check": { + "name": "kb_organization_search_index_check", + "value": "\"knowledge_base\".\"organization_id\" IS NULL OR \"knowledge_base\".\"is_search_index\"" + }, + "kb_organization_folder_check": { + "name": "kb_organization_folder_check", + "value": "\"knowledge_base\".\"organization_id\" IS NULL OR \"knowledge_base\".\"folder_id\" IS NULL" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "access_mode": { + "name": "access_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_status": { + "name": "member_sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "member_sync_lock_token": { + "name": "member_sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_lock_lease_at": { + "name": "member_sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_member_sync_at": { + "name": "next_member_sync_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_at": { + "name": "last_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_error": { + "name": "last_member_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_consecutive_failures": { + "name": "member_sync_consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "access_rewrite_pending": { + "name": "access_rewrite_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "listing_checkpoint": { + "name": "listing_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "directory_checkpoint": { + "name": "directory_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "next_directory_sync_at": { + "name": "next_directory_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_member_sync_due_idx": { + "name": "kc_member_sync_due_idx", + "columns": [ + { + "expression": "member_sync_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_member_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'members' AND \"knowledge_connector\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_directory_sync_due_idx": { + "name": "kc_directory_sync_due_idx", + "columns": [ + { + "expression": "next_directory_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'admin' AND \"knowledge_connector\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_credential_group_id_credential_group_id_fk": { + "name": "knowledge_connector_credential_group_id_credential_group_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kc_access_mode_check": { + "name": "kc_access_mode_check", + "value": "\"knowledge_connector\".\"access_mode\" IN ('workspace', 'members', 'admin')" + }, + "kc_member_sync_status_check": { + "name": "kc_member_sync_status_check", + "value": "\"knowledge_connector\".\"member_sync_status\" IN ('idle', 'pending', 'running', 'error', 'disabled')" + }, + "kc_sync_lock_exclusive_check": { + "name": "kc_sync_lock_exclusive_check", + "value": "NOT (\"knowledge_connector\".\"sync_lock_token\" IS NOT NULL AND \"knowledge_connector\".\"member_sync_lock_token\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member": { + "name": "knowledge_connector_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_listing_at": { + "name": "last_complete_listing_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_listed_count": { + "name": "last_listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_synced_through": { + "name": "member_synced_through", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "change_cursor": { + "name": "change_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "listing_checkpoint": { + "name": "listing_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kcm_organization_id_idx": { + "name": "kcm_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_credential_unique": { + "name": "kcm_connector_credential_unique", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_queue_idx": { + "name": "kcm_connector_queue_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "last_started_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_credential_idx": { + "name": "kcm_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_workspace_id_workspace_id_fk": { + "name": "knowledge_connector_member_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_organization_id_organization_id_fk": { + "name": "knowledge_connector_member_organization_id_organization_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_credential_id_credential_id_fk": { + "name": "knowledge_connector_member_credential_id_credential_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcm_owner_check": { + "name": "kcm_owner_check", + "value": "num_nonnulls(\"knowledge_connector_member\".\"workspace_id\", \"knowledge_connector_member\".\"organization_id\") = 1" + }, + "kcm_status_check": { + "name": "kcm_status_check", + "value": "\"knowledge_connector_member\".\"status\" IN ('active', 'suspended', 'disabled')" + }, + "kcm_subject_token_shape_check": { + "name": "kcm_subject_token_shape_check", + "value": "\"knowledge_connector_member\".\"subject_token\" ~ '^s:[^:]+:[^:]+:.+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member_sync_log": { + "name": "knowledge_connector_member_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "members_claimed": { + "name": "members_claimed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_completed": { + "name": "members_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_incomplete": { + "name": "members_incomplete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_failed": { + "name": "members_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "processing_dispatch_failed": { + "name": "processing_dispatch_failed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "docs_listed": { + "name": "docs_listed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_hydrated_once": { + "name": "docs_hydrated_once", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_added": { + "name": "observations_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_removed": { + "name": "observations_removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_tombstoned": { + "name": "docs_tombstoned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_resurrected": { + "name": "docs_resurrected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_purged": { + "name": "docs_purged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "credentials_audited": { + "name": "credentials_audited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcmsl_connector_started_at_idx": { + "name": "kcmsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcmsl_started_at_partial_idx": { + "name": "kcmsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_member_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcmsl_status_check": { + "name": "kcmsl_status_check", + "value": "\"knowledge_connector_member_sync_log\".\"status\" IN ('started', 'partial', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_partition": { + "name": "knowledge_connector_partition", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "partition_key": { + "name": "partition_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation_id": { + "name": "generation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "retry_at": { + "name": "retry_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_served_at": { + "name": "last_served_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure": { + "name": "failure", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "permission_cursor": { + "name": "permission_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_attempts": { + "name": "permission_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "permission_retry_at": { + "name": "permission_retry_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permission_last_served_at": { + "name": "permission_last_served_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "permission_started_at": { + "name": "permission_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "permission_failure": { + "name": "permission_failure", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcp_content_due_idx": { + "name": "kcp_content_due_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcp_permission_due_idx": { + "name": "kcp_permission_due_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_last_served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_partition_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_partition_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_partition", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "kcp_pk": { + "name": "kcp_pk", + "columns": ["connector_id", "partition_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcp_partition_key_check": { + "name": "kcp_partition_key_check", + "value": "octet_length(\"knowledge_connector_partition\".\"partition_key\") BETWEEN 1 AND 1024" + }, + "kcp_context_check": { + "name": "kcp_context_check", + "value": "jsonb_typeof(\"knowledge_connector_partition\".\"context\") = 'object' AND octet_length(\"knowledge_connector_partition\".\"context\"::text) <= 16384" + }, + "kcp_status_check": { + "name": "kcp_status_check", + "value": "\"knowledge_connector_partition\".\"status\" IN ('pending', 'complete', 'blocked')" + }, + "kcp_cursor_check": { + "name": "kcp_cursor_check", + "value": "(\"knowledge_connector_partition\".\"cursor\" IS NULL OR octet_length(\"knowledge_connector_partition\".\"cursor\") <= 393216) AND (\"knowledge_connector_partition\".\"permission_cursor\" IS NULL OR octet_length(\"knowledge_connector_partition\".\"permission_cursor\") <= 393216)" + }, + "kcp_attempts_check": { + "name": "kcp_attempts_check", + "value": "\"knowledge_connector_partition\".\"attempts\" >= 0 AND \"knowledge_connector_partition\".\"permission_attempts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_permission_grant": { + "name": "knowledge_connector_permission_grant", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kcpg_subject_idx": { + "name": "kcpg_subject_idx", + "columns": [ + { + "expression": "subject_token", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kcpg_snapshot_fk": { + "name": "kcpg_snapshot_fk", + "tableFrom": "knowledge_connector_permission_grant", + "tableTo": "knowledge_connector_permission_snapshot", + "columnsFrom": ["connector_id"], + "columnsTo": ["connector_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "kcpg_pk": { + "name": "kcpg_pk", + "columns": ["connector_id", "group_key", "subject_token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcpg_group_check": { + "name": "kcpg_group_check", + "value": "length(\"knowledge_connector_permission_grant\".\"group_key\") BETWEEN 1 AND 255" + }, + "kcpg_subject_check": { + "name": "kcpg_subject_check", + "value": "\"knowledge_connector_permission_grant\".\"subject_token\" ~ '^u:[^[:space:]A-Z]+@[^[:space:]A-Z]+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_permission_snapshot": { + "name": "knowledge_connector_permission_snapshot", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "kcps_connector_fk": { + "name": "kcps_connector_fk", + "tableFrom": "knowledge_connector_permission_snapshot", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcps_revision_check": { + "name": "kcps_revision_check", + "value": "\"knowledge_connector_permission_snapshot\".\"revision\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "listed_count": { + "name": "listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_document_observation": { + "name": "knowledge_document_observation", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kdo_member_idx": { + "name": "kdo_member_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_document_observation_document_id_document_id_fk": { + "name": "knowledge_document_observation_document_id_document_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_document_observation_member_id_knowledge_connector_member_id_fk": { + "name": "knowledge_document_observation_member_id_knowledge_connector_member_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "knowledge_connector_member", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_document_observation_document_id_member_id_pk": { + "name": "knowledge_document_observation_document_id_member_id_pk", + "columns": ["document_id", "member_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_external_directory": { + "name": "knowledge_external_directory", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_sync_at": { + "name": "last_complete_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ked_organization_id_idx": { + "name": "ked_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ked_workspace_identity_unique": { + "name": "ked_workspace_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ked_organization_identity_unique": { + "name": "ked_organization_identity_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_external_directory_workspace_id_workspace_id_fk": { + "name": "knowledge_external_directory_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_external_directory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_external_directory_organization_id_organization_id_fk": { + "name": "knowledge_external_directory_organization_id_organization_id_fk", + "tableFrom": "knowledge_external_directory", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ked_owner_check": { + "name": "ked_owner_check", + "value": "num_nonnulls(\"knowledge_external_directory\".\"workspace_id\", \"knowledge_external_directory\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_external_group": { + "name": "knowledge_external_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_group_id": { + "name": "external_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "keg_organization_id_idx": { + "name": "keg_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_organization_identity_unique": { + "name": "keg_organization_identity_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_organization_synced_idx": { + "name": "keg_organization_synced_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_identity_unique": { + "name": "keg_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_workspace_synced_idx": { + "name": "keg_workspace_synced_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_external_group_organization_id_organization_id_fk": { + "name": "knowledge_external_group_organization_id_organization_id_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "keg_workspace_fk": { + "name": "keg_workspace_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "keg_owner_check": { + "name": "keg_owner_check", + "value": "num_nonnulls(\"knowledge_external_group\".\"workspace_id\", \"knowledge_external_group\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_external_group_member": { + "name": "knowledge_external_group_member", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kegm_subject_token_idx": { + "name": "kegm_subject_token_idx", + "columns": [ + { + "expression": "subject_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kegm_group_fk": { + "name": "kegm_group_fk", + "tableFrom": "knowledge_external_group_member", + "tableTo": "knowledge_external_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_external_group_member_group_id_subject_token_pk": { + "name": "knowledge_external_group_member_group_id_subject_token_pk", + "columns": ["group_id", "subject_token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_organization_id_organization_id_fk": { + "name": "mcp_server_oauth_organization_id_organization_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_server_oauth_owner_check": { + "name": "mcp_server_oauth_owner_check", + "value": "num_nonnulls(\"mcp_server_oauth\".\"workspace_id\", \"mcp_server_oauth\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_connector_id": { + "name": "managed_connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config_version": { + "name": "oauth_config_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_organization_id_idx": { + "name": "mcp_servers_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_idx": { + "name": "mcp_servers_credential_group_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_managed_connector_unique": { + "name": "mcp_servers_credential_group_managed_connector_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "managed_connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_servers\".\"credential_group_id\" IS NOT NULL AND \"mcp_servers\".\"managed_connector_id\" IS NOT NULL AND \"mcp_servers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_organization_id_organization_id_fk": { + "name": "mcp_servers_organization_id_organization_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_credential_group_id_credential_group_id_fk": { + "name": "mcp_servers_credential_group_id_credential_group_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_owner_check": { + "name": "mcp_servers_owner_check", + "value": "num_nonnulls(\"mcp_servers\".\"workspace_id\", \"mcp_servers\".\"organization_id\") = 1" + }, + "mcp_servers_organization_managed_check": { + "name": "mcp_servers_organization_managed_check", + "value": "\"mcp_servers\".\"organization_id\" IS NULL OR \"mcp_servers\".\"credential_group_id\" IS NOT NULL" + }, + "mcp_servers_credential_group_managed_connector_check": { + "name": "mcp_servers_credential_group_managed_connector_check", + "value": "\"mcp_servers\".\"credential_group_id\" IS NULL OR \"mcp_servers\".\"managed_connector_id\" IS NOT NULL" + }, + "mcp_servers_managed_connector_oauth_check": { + "name": "mcp_servers_managed_connector_oauth_check", + "value": "\"mcp_servers\".\"managed_connector_id\" IS NULL OR \"mcp_servers\".\"auth_type\" = 'oauth'" + } + }, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_access_token_client_id_idx": { + "name": "oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_session_id_idx": { + "name": "oauth_access_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_id_idx": { + "name": "oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_user_client_idx": { + "name": "oauth_access_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_expires_at_idx": { + "name": "oauth_access_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_access_token_search_resource_check": { + "name": "oauth_access_token_search_resource_check", + "value": "NOT ('search:read' = ANY(\"oauth_access_token\".\"scopes\")) OR \"oauth_access_token\".\"resource\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_client_user_id_idx": { + "name": "oauth_client_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_client_id_idx": { + "name": "oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_consent_user_client_reference_unique": { + "name": "oauth_consent_user_client_reference_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "client_id", "reference_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_user_client_idx": { + "name": "oauth_refresh_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_expires_at_idx": { + "name": "oauth_refresh_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_family_id_oauth_token_family_id_fk": { + "name": "oauth_refresh_token_family_id_oauth_token_family_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_token_family", + "columnsFrom": ["family_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + }, + "oauth_refresh_token_family_generation_unique": { + "name": "oauth_refresh_token_family_generation_unique", + "nullsNotDistinct": false, + "columns": ["family_id", "generation"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_refresh_token_generation_check": { + "name": "oauth_refresh_token_generation_check", + "value": "\"oauth_refresh_token\".\"generation\" BETWEEN 0 AND 1000" + }, + "oauth_refresh_token_search_resource_check": { + "name": "oauth_refresh_token_search_resource_check", + "value": "NOT ('search:read' = ANY(\"oauth_refresh_token\".\"scopes\")) OR \"oauth_refresh_token\".\"resource\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.oauth_token_family": { + "name": "oauth_token_family", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_generation": { + "name": "current_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_token_family_client_id_idx": { + "name": "oauth_token_family_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_session_id_idx": { + "name": "oauth_token_family_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_user_client_idx": { + "name": "oauth_token_family_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_consent_id_idx": { + "name": "oauth_token_family_consent_id_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_expires_at_idx": { + "name": "oauth_token_family_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_token_family_client_id_oauth_client_client_id_fk": { + "name": "oauth_token_family_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_session_id_session_id_fk": { + "name": "oauth_token_family_session_id_session_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_token_family_user_id_user_id_fk": { + "name": "oauth_token_family_user_id_user_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_consent_id_oauth_consent_id_fk": { + "name": "oauth_token_family_consent_id_oauth_consent_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_consent", + "columnsFrom": ["consent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "oauth_token_family_generation_check": { + "name": "oauth_token_family_generation_check", + "value": "\"oauth_token_family\".\"current_generation\" BETWEEN 0 AND 1000" + } + }, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "require_sso": { + "name": "require_sso", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_access_request_settings": { + "name": "organization_access_request_settings", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "allow_requests": { + "name": "allow_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_access_request_settings_organization_id_organization_id_fk": { + "name": "organization_access_request_settings_organization_id_organization_id_fk", + "tableFrom": "organization_access_request_settings", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_access_request_settings_updated_by_user_id_fk": { + "name": "organization_access_request_settings_updated_by_user_id_fk", + "tableFrom": "organization_access_request_settings", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_integration": { + "name": "organization_search_integration", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved": { + "name": "approved", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_search_integration_organization_id_organization_id_fk": { + "name": "organization_search_integration_organization_id_organization_id_fk", + "tableFrom": "organization_search_integration", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_search_integration_organization_id_connector_type_pk": { + "name": "organization_search_integration_organization_id_connector_type_pk", + "columns": ["organization_id", "connector_type"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_invocation": { + "name": "organization_search_invocation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_types": { + "name": "source_types", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_search_invocation_org_created_idx": { + "name": "organization_search_invocation_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_search_invocation_user_idx": { + "name": "organization_search_invocation_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_search_invocation_organization_id_organization_id_fk": { + "name": "organization_search_invocation_organization_id_organization_id_fk", + "tableFrom": "organization_search_invocation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_search_invocation_user_id_user_id_fk": { + "name": "organization_search_invocation_user_id_user_id_fk", + "tableFrom": "organization_search_invocation", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_search_invocation_result_count_bounds": { + "name": "organization_search_invocation_result_count_bounds", + "value": "\"organization_search_invocation\".\"result_count\" BETWEEN 0 AND 100" + }, + "organization_search_invocation_source_types_bounds": { + "name": "organization_search_invocation_source_types_bounds", + "value": "cardinality(\"organization_search_invocation\".\"source_types\") <= 100" + } + }, + "isRLSEnabled": false + }, + "public.organization_search_mcp_invocation": { + "name": "organization_search_mcp_invocation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_kind": { + "name": "auth_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_search_mcp_invocation_org_created_idx": { + "name": "organization_search_mcp_invocation_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_search_mcp_invocation_user_idx": { + "name": "organization_search_mcp_invocation_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_search_mcp_invocation_org_fk": { + "name": "org_search_mcp_invocation_org_fk", + "tableFrom": "organization_search_mcp_invocation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_search_mcp_invocation_user_fk": { + "name": "org_search_mcp_invocation_user_fk", + "tableFrom": "organization_search_mcp_invocation", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_search_mcp_invocation_tool_check": { + "name": "organization_search_mcp_invocation_tool_check", + "value": "\"organization_search_mcp_invocation\".\"tool_name\" IN ('search', 'read_document', 'chat')" + }, + "organization_search_mcp_invocation_outcome_check": { + "name": "organization_search_mcp_invocation_outcome_check", + "value": "\"organization_search_mcp_invocation\".\"outcome\" IN ('success', 'error', 'cancelled', 'rate_limited')" + }, + "organization_search_mcp_invocation_duration_check": { + "name": "organization_search_mcp_invocation_duration_check", + "value": "\"organization_search_mcp_invocation\".\"duration_ms\" >= 0" + }, + "organization_search_mcp_invocation_client_name_check": { + "name": "organization_search_mcp_invocation_client_name_check", + "value": "length(\"organization_search_mcp_invocation\".\"client_name\") <= 256" + }, + "organization_search_mcp_invocation_auth_check": { + "name": "organization_search_mcp_invocation_auth_check", + "value": "(\"organization_search_mcp_invocation\".\"auth_kind\" = 'oauth_access_token' AND \"organization_search_mcp_invocation\".\"oauth_client_id\" IS NOT NULL)\n OR (\"organization_search_mcp_invocation\".\"auth_kind\" IN ('personal_api_key', 'workspace_api_key') AND \"organization_search_mcp_invocation\".\"oauth_client_id\" IS NULL AND \"organization_search_mcp_invocation\".\"client_name\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_pending_type_available_idx": { + "name": "outbox_event_pending_type_available_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox_event\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config": { + "name": "oauth_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_organization_id_idx": { + "name": "pending_draft_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_draft_user_provider_org": { + "name": "pending_draft_user_provider_org", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_organization_id_organization_id_fk": { + "name": "pending_credential_draft_organization_id_organization_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_draft_owner_check": { + "name": "pending_draft_owner_check", + "value": "num_nonnulls(\"pending_credential_draft\".\"workspace_id\", \"pending_credential_draft\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.permission_access_request": { + "name": "permission_access_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requester_id": { + "name": "requester_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_key": { + "name": "target_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision_reason": { + "name": "decision_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decision": { + "name": "decision", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "permission_access_request_pending_unique": { + "name": "permission_access_request_pending_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requester_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"permission_access_request\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_access_request_org_queue_idx": { + "name": "permission_access_request_org_queue_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_access_request_requester_idx": { + "name": "permission_access_request_requester_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requester_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_access_request_organization_id_organization_id_fk": { + "name": "permission_access_request_organization_id_organization_id_fk", + "tableFrom": "permission_access_request", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_access_request_requester_id_user_id_fk": { + "name": "permission_access_request_requester_id_user_id_fk", + "tableFrom": "permission_access_request", + "tableTo": "user", + "columnsFrom": ["requester_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_access_request_decided_by_user_id_fk": { + "name": "permission_access_request_decided_by_user_id_fk", + "tableFrom": "permission_access_request", + "tableTo": "user", + "columnsFrom": ["decided_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "permission_access_request_status_check": { + "name": "permission_access_request_status_check", + "value": "\"permission_access_request\".\"status\" in ('pending', 'fulfilled', 'declined', 'cancelled', 'closed')" + } + }, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "membership_mode": { + "name": "membership_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "blocked_until": { + "name": "blocked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capacity_state": { + "name": "capacity_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_policy_organization_id_idx": { + "name": "resource_policy_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_resource_unique": { + "name": "resource_policy_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_organization_id_organization_id_fk": { + "name": "resource_policy_organization_id_organization_id_fk", + "tableFrom": "resource_policy", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "resource_policy_owner_check": { + "name": "resource_policy_owner_check", + "value": "num_nonnulls(\"resource_policy\".\"workspace_id\", \"resource_policy\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_connection": { + "name": "scim_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconcile_lock_token": { + "name": "reconcile_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconcile_lease_at": { + "name": "reconcile_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_connection_organization_unique": { + "name": "scim_connection_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_connection_reconcile_due_idx": { + "name": "scim_connection_reconcile_due_idx", + "columns": [ + { + "expression": "reconciled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_connection_organization_id_organization_id_fk": { + "name": "scim_connection_organization_id_organization_id_fk", + "tableFrom": "scim_connection", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_connection_created_by_user_id_fk": { + "name": "scim_connection_created_by_user_id_fk", + "tableFrom": "scim_connection", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_credential": { + "name": "scim_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_credential_token_hash_unique": { + "name": "scim_credential_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_credential_connection_idx": { + "name": "scim_credential_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_credential_connection_id_scim_connection_id_fk": { + "name": "scim_credential_connection_id_scim_connection_id_fk", + "tableFrom": "scim_credential", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_credential_revoked_by_user_id_fk": { + "name": "scim_credential_revoked_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["revoked_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "scim_credential_created_by_user_id_fk": { + "name": "scim_credential_created_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group": { + "name": "scim_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_connection_display_name_unique": { + "name": "scim_group_connection_display_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_external_id_unique": { + "name": "scim_group_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_order_idx": { + "name": "scim_group_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_connection_id_scim_connection_id_fk": { + "name": "scim_group_connection_id_scim_connection_id_fk", + "tableFrom": "scim_group", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_mapping": { + "name": "scim_group_mapping", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_mapping_group_idx": { + "name": "scim_group_mapping_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_permission_group_idx": { + "name": "scim_group_mapping_permission_group_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_workspace_idx": { + "name": "scim_group_mapping_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_group_target_unique": { + "name": "scim_group_mapping_group_target_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"permission_group_id\", \"workspace_id\", \"role\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_mapping_group_id_scim_group_id_fk": { + "name": "scim_group_mapping_group_id_scim_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_permission_group_id_permission_group_id_fk": { + "name": "scim_group_mapping_permission_group_id_permission_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_workspace_id_workspace_id_fk": { + "name": "scim_group_mapping_workspace_id_workspace_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_created_by_user_id_fk": { + "name": "scim_group_mapping_created_by_user_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scim_group_mapping_target_shape": { + "name": "scim_group_mapping_target_shape", + "value": "(\n (\"scim_group_mapping\".\"target_kind\" = 'permission_group' AND \"scim_group_mapping\".\"permission_group_id\" IS NOT NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'workspace' AND \"scim_group_mapping\".\"workspace_id\" IS NOT NULL AND \"scim_group_mapping\".\"permission_type\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'org_role' AND \"scim_group_mapping\".\"role\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.scim_group_member": { + "name": "scim_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_member_group_user_unique": { + "name": "scim_group_member_group_user_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_scim_user_idx": { + "name": "scim_group_member_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_member_group_id_scim_group_id_fk": { + "name": "scim_group_member_group_id_scim_group_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_member_scim_user_id_scim_user_id_fk": { + "name": "scim_group_member_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grant": { + "name": "scim_projection_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "baseline_permission": { + "name": "baseline_permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'directory'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_projection_grant_user_target_unique": { + "name": "scim_projection_grant_user_target_unique", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_connection_idx": { + "name": "scim_projection_grant_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grant_connection_id_scim_connection_id_fk": { + "name": "scim_projection_grant_connection_id_scim_connection_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grant_scim_user_id_scim_user_id_fk": { + "name": "scim_projection_grant_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_request_log": { + "name": "scim_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scim_type": { + "name": "scim_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_request_log_connection_created_idx": { + "name": "scim_request_log_connection_created_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_request_log_connection_id_scim_connection_id_fk": { + "name": "scim_request_log_connection_id_scim_connection_id_fk", + "tableFrom": "scim_request_log", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user": { + "name": "scim_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "attributes": { + "name": "attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_user_connection_user_unique": { + "name": "scim_user_connection_user_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_user_name_unique": { + "name": "scim_user_connection_user_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_external_id_unique": { + "name": "scim_user_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_order_idx": { + "name": "scim_user_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_user_idx": { + "name": "scim_user_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_connection_id_scim_connection_id_fk": { + "name": "scim_user_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_user_id_user_id_fk": { + "name": "scim_user_user_id_user_id_fk", + "tableFrom": "scim_user", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user_tombstone": { + "name": "scim_user_tombstone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_user_tombstone_connection_external_id_unique": { + "name": "scim_user_tombstone_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_tombstone_user_idx": { + "name": "scim_user_tombstone_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_tombstone_connection_id_scim_connection_id_fk": { + "name": "scim_user_tombstone_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_tombstone_user_id_user_id_fk": { + "name": "scim_user_tombstone_user_id_user_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_app": { + "name": "slack_app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_client_secret": { + "name": "encrypted_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_signing_secret": { + "name": "encrypted_signing_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "slack_app_organization_id_organization_id_fk": { + "name": "slack_app_organization_id_organization_id_fk", + "tableFrom": "slack_app", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "slack_app_owner_check": { + "name": "slack_app_owner_check", + "value": "(\"slack_app\".\"kind\" = 'custom' AND \"slack_app\".\"organization_id\" IS NOT NULL) OR (\"slack_app\".\"kind\" = 'shared' AND \"slack_app\".\"organization_id\" IS NULL)" + }, + "slack_app_custom_credentials_check": { + "name": "slack_app_custom_credentials_check", + "value": "\"slack_app\".\"kind\" = 'shared' OR (\"slack_app\".\"client_id\" IS NOT NULL AND \"slack_app\".\"encrypted_client_secret\" IS NOT NULL AND \"slack_app\".\"encrypted_signing_secret\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.slack_search_installation": { + "name": "slack_search_installation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_app_id": { + "name": "slack_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enterprise_id": { + "name": "enterprise_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "credential_version": { + "name": "credential_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_outcome": { + "name": "last_outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_event_at": { + "name": "last_event_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_search_installation_organization_idx": { + "name": "slack_search_installation_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_credential_unique": { + "name": "slack_search_installation_credential_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_app_team_unique": { + "name": "slack_search_installation_app_team_unique", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_active_team_unique": { + "name": "slack_search_installation_active_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"slack_search_installation\".\"enabled\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_search_installation_organization_id_organization_id_fk": { + "name": "slack_search_installation_organization_id_organization_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_search_installation_credential_id_credential_id_fk": { + "name": "slack_search_installation_credential_id_credential_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_search_installation_slack_app_id_slack_app_id_fk": { + "name": "slack_search_installation_slack_app_id_slack_app_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "slack_app", + "columnsFrom": ["slack_app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_search_turn": { + "name": "slack_search_turn", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "always", + "name": "slack_search_turn_ordinal_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_key": { + "name": "conversation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "lease_id": { + "name": "lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_search_turn_event_unique": { + "name": "slack_search_turn_event_unique", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_pending_idx": { + "name": "slack_search_turn_pending_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_thread_idx": { + "name": "slack_search_turn_thread_idx", + "columns": [ + { + "expression": "conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_active_thread_unique": { + "name": "slack_search_turn_active_thread_unique", + "columns": [ + { + "expression": "conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"slack_search_turn\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_search_turn_installation_id_slack_search_installation_id_fk": { + "name": "slack_search_turn_installation_id_slack_search_installation_id_fk", + "tableFrom": "slack_search_turn", + "tableTo": "slack_search_installation", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "primary_provider_id": { + "name": "primary_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_capability_governed_user_id_user_id_fk": { + "name": "table_row_executions_capability_governed_user_id_user_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_governed_active_idx": { + "name": "table_run_dispatches_governed_active_idx", + "columns": [ + { + "expression": "capability_governed_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_run_dispatches\".\"status\" IN ('pending', 'dispatching')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspension_source": { + "name": "suspension_source", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_lower_idx": { + "name": "user_email_lower_idx", + "columns": [ + { + "expression": "lower(btrim(\"email\"))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_activity_idx": { + "name": "workflow_execution_logs_workspace_activity_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "total_duration_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_build": { + "name": "workspace_file_search_build", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_file_search_build_file_idx": { + "name": "workspace_file_search_build_file_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_build_cleanup_idx": { + "name": "workspace_file_search_build_cleanup_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_build\".\"expires_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_chunk": { + "name": "workspace_file_search_chunk", + "schema": "", + "columns": { + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_start": { + "name": "line_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fragment": { + "name": "fragment", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "overlap": { + "name": "overlap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_chunk_line_idx": { + "name": "workspace_file_search_chunk_line_idx", + "columns": [ + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "line_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_chunk_content_idx": { + "name": "workspace_file_search_chunk_content_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": true, + "method": "gin", + "with": { + "fastupdate": "off" + } + } + }, + "foreignKeys": { + "workspace_file_search_chunk_build_id_workspace_file_search_build_id_fk": { + "name": "workspace_file_search_chunk_build_id_workspace_file_search_build_id_fk", + "tableFrom": "workspace_file_search_chunk", + "tableTo": "workspace_file_search_build", + "columnsFrom": ["build_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_chunk_pk": { + "name": "workspace_file_search_chunk_pk", + "columns": ["build_id", "ordinal"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_search_chunk_content_size": { + "name": "workspace_file_search_chunk_content_size", + "value": "octet_length(\"workspace_file_search_chunk\".\"content\") <= 8192" + }, + "workspace_file_search_chunk_position": { + "name": "workspace_file_search_chunk_position", + "value": "\"workspace_file_search_chunk\".\"ordinal\" >= 0 AND \"workspace_file_search_chunk\".\"line_start\" > 0 AND \"workspace_file_search_chunk\".\"overlap\" BETWEEN 0 AND 2" + } + }, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_revision": { + "name": "workspace_file_search_revision", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_revision_workspace_status_idx": { + "name": "workspace_file_search_revision_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_revision_build_idx": { + "name": "workspace_file_search_revision_build_idx", + "columns": [ + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_revision_pending_idx": { + "name": "workspace_file_search_revision_pending_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_revision\".\"status\" = 'pending' AND \"workspace_file_search_revision\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_revision_active_idx": { + "name": "workspace_file_search_revision_active_idx", + "columns": [ + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_revision\".\"status\" = 'pending' AND \"workspace_file_search_revision\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_revision_file_id_workspace_files_id_fk": { + "name": "workspace_file_search_revision_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_search_revision", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_revision_build_id_workspace_file_search_build_id_fk": { + "name": "workspace_file_search_revision_build_id_workspace_file_search_build_id_fk", + "tableFrom": "workspace_file_search_revision", + "tableTo": "workspace_file_search_build", + "columnsFrom": ["build_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "date_trunc('milliseconds', now())" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_active_keyset_idx": { + "name": "workspace_files_workspace_active_keyset_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_organization_id_idx": { + "name": "workspace_files_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_organization_id_organization_id_fk": { + "name": "workspace_files_organization_id_organization_id_fk", + "tableFrom": "workspace_files", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_files_organization_binding_check": { + "name": "workspace_files_organization_binding_check", + "value": "\"workspace_files\".\"organization_id\" IS NULL OR (\"workspace_files\".\"workspace_id\" IS NULL AND \"workspace_files\".\"context\" = 'knowledge-base' AND \"workspace_files\".\"folder_id\" IS NULL AND \"workspace_files\".\"chat_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_operation_receipt": { + "name": "workspace_operation_receipt", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_operation_receipt_request_unique": { + "name": "workspace_operation_receipt_request_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_operation_receipt_workspace_created_idx": { + "name": "workspace_operation_receipt_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_operation_receipt_workspace_id_workspace_id_fk": { + "name": "workspace_operation_receipt_workspace_id_workspace_id_fk", + "tableFrom": "workspace_operation_receipt", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": [ + "oauth", + "managed_oauth", + "managed_mcp", + "env_workspace", + "env_personal", + "service_account", + "personal_token" + ] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "organization_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output", + "api-tool" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "file_folder", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index d564602c17e..77500307995 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2542,6 +2542,13 @@ "when": 1789713467804, "tag": "0363_connector_sync_schedule_precision", "breakpoints": true + }, + { + "idx": 364, + "version": "7", + "when": 1789773032501, + "tag": "0364_workspace_file_search_direct_gin", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 3771500eb70..08f0db6bf82 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -2547,11 +2547,11 @@ export const workspaceFileSearchChunk = pgTable( table.lineStart, table.ordinal ), - contentIdx: index('workspace_file_search_chunk_content_idx').using( - 'gin', - table.workspaceId.asc().op('text_ops'), - table.content.asc().op('gin_trgm_ops') - ), + /** Bounded chunk writes must not inherit accumulated pending-list cleanup from other files. */ + contentIdx: index('workspace_file_search_chunk_content_idx') + .using('gin', table.workspaceId.asc().op('text_ops'), table.content.asc().op('gin_trgm_ops')) + .with({ fastupdate: 'off' }) + .concurrently(), contentSize: check( 'workspace_file_search_chunk_content_size', sql`octet_length(${table.content}) <= 8192` From 305da026664f0ea4e687746edabbdec0e1739c3a Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 18 Sep 2026 18:06:13 -0700 Subject: [PATCH 19/20] improvement(knowledge): rank inside the permitted set for organization search (#7996) * improvement(knowledge): rank inside the permitted set for organization search * fix(knowledge): count a caller's reach inside the requested bases --- .../kb-block-search.integration.ts | 44 +- apps/sim/lib/knowledge/access/predicate.ts | 50 ++- apps/sim/lib/knowledge/search/diagnostics.ts | 9 + apps/sim/lib/knowledge/search/queries.test.ts | 230 +++++++++- apps/sim/lib/knowledge/search/queries.ts | 392 ++++++++++++++---- 5 files changed, 623 insertions(+), 102 deletions(-) diff --git a/apps/sim/lib/knowledge/__integration__/kb-block-search.integration.ts b/apps/sim/lib/knowledge/__integration__/kb-block-search.integration.ts index 5abf70817cd..6d4ebc08920 100644 --- a/apps/sim/lib/knowledge/__integration__/kb-block-search.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/kb-block-search.integration.ts @@ -3,14 +3,18 @@ import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' import { document, embedding, knowledgeBase, organization, user, workspace } from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { eq, inArray } from 'drizzle-orm' +import { eq, inArray, sql } from 'drizzle-orm' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { createKnowledgeAclFixtureIds, seedKnowledgeAclFixture, } from '@/lib/knowledge/__integration__/seed-source-access-fixture' import { createKnowledgeAccessProvider } from '@/lib/knowledge/access/scope' -import { retrieveKnowledgeSearch } from '@/lib/knowledge/search/queries' +import { + resolvePermittedDocuments, + retrieveKnowledgeSearch, + VECTOR_PROBE_DOCUMENT_LIMIT, +} from '@/lib/knowledge/search/queries' import { embeddingVectorValues } from '@/lib/knowledge/vector-columns' describe('API-key KB block fan-out', () => { @@ -140,13 +144,45 @@ describe('API-key KB block fan-out', () => { expect(matching('AS visible')).toHaveLength(bases.length) expect(matching(') + 0 LIMIT')).toHaveLength(bases.length) expect(matching('scored_search_candidates')).toHaveLength(bases.length) - /** The probe enumerates visible documents; it never ranks them. */ + /** The probe enumerates visible documents and reports saturation; it never ranks them. */ expect( - statements.filter((query) => query.includes('AS id FROM') && !query.includes('ORDER BY')) + statements.filter( + (query) => query.includes('AS saturated') && !query.includes('ORDER BY') + ) ).toHaveLength(bases.length) } finally { db.$client.options.debug = previousDebug } } ) + + it('bounds the permitted set by the requested bases, not by what the tokens reach elsewhere', async () => { + const crowded = generateId() + await db.insert(knowledgeBase).values({ + id: crowded, + userId: ids.aliceId, + workspaceId: ids.workspaceId, + name: 'Crowded neighbour', + }) + try { + /** Baseline tokens are shared by every tenant, so another base can hold more than the limit. */ + await db.execute(sql` + INSERT INTO ${document} (id, knowledge_base_id, filename, file_url, file_size, mime_type, + processing_status, acl) + SELECT 'crowded-' || n, ${crowded}, 'crowded', 'https://fixture.invalid/crowded', 1, + 'text/plain', 'completed', ARRAY['ws']::text[] + FROM generate_series(1, ${VECTOR_PROBE_DOCUMENT_LIMIT + 1}) AS n + `) + const permitted = await resolvePermittedDocuments({ + knowledgeBaseIds: [bases[0].id], + access: { kind: 'user', userId: ids.bobId, tokens: ['pub', 'ws'] }, + }) + expect(permitted).toEqual({ + kind: 'bounded', + documents: [{ id: bases[0].visible, connectorId: null }], + }) + } finally { + await db.delete(knowledgeBase).where(eq(knowledgeBase.id, crowded)) + } + }) }) diff --git a/apps/sim/lib/knowledge/access/predicate.ts b/apps/sim/lib/knowledge/access/predicate.ts index d3077014937..e18da618334 100644 --- a/apps/sim/lib/knowledge/access/predicate.ts +++ b/apps/sim/lib/knowledge/access/predicate.ts @@ -193,6 +193,26 @@ export function knowledgeMetadataCandidateAccessCondition( return storedKnowledgeAccessCondition(scope, sql`true`) } +/** + * A members-mode document is readable while one of the caller's active member identities on its + * connector still observes it, freshly. Correlated on the document so each check is a lookup on + * the observation primary key, which leads with `document_id`: phrased as a row-value `IN` + * inside the access predicate's `OR`, PostgreSQL instead hashes every observation in the table + * once per statement, a fixed cost paid by every query that carries the predicate. + */ +function memberObservationCondition(tokens: SQL, cutoff: SQL): SQL { + return sql`EXISTS ( + SELECT 1 FROM ${knowledgeDocumentObservation} + JOIN ${knowledgeConnectorMember} + ON ${knowledgeConnectorMember.id} = ${knowledgeDocumentObservation.memberId} + WHERE ${knowledgeDocumentObservation.documentId} = ${document.id} + AND ${knowledgeConnectorMember.connectorId} = ${document.connectorId} + AND ${knowledgeConnectorMember.status} = 'active' + AND ${knowledgeConnectorMember.subjectToken} = ANY(${tokens}) + AND GREATEST(${knowledgeDocumentObservation.lastSeenAt}, ${knowledgeConnectorMember.memberSyncedThrough}) > ${cutoff} + )` +} + function storedKnowledgeAccessCondition( scope: KnowledgeAccessScope | SystemAccessScope, liveSourceAccess: SQL @@ -202,7 +222,7 @@ function storedKnowledgeAccessCondition( const tokens = textArrayLiteral(scope.tokens) const cutoff = sql`statement_timestamp() - (${SOURCE_ACL_MAX_AGE_MS} * interval '1 millisecond')` return sql`( - ${document.acl} && ${tokens} + ${aclOverlap(tokens)} AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements(${document.aclRequirements}) AS required_clause(tokens) WHERE NOT (required_clause.tokens ?| ${tokens}) @@ -221,14 +241,7 @@ function storedKnowledgeAccessCondition( (${knowledgeConnector.accessMode} = 'workspace' AND ${document.acl} = ARRAY['ws']::text[]) OR (${document.acl} <> ARRAY['ws']::text[] AND ( (${knowledgeConnector.accessMode} = 'admin' AND ${document.aclVerifiedAt} > ${cutoff}) - OR (${knowledgeConnector.accessMode} = 'members' AND (${document.id}, ${document.connectorId}) IN ( - SELECT ${knowledgeDocumentObservation.documentId}, ${knowledgeConnectorMember.connectorId} FROM ${knowledgeDocumentObservation} - JOIN ${knowledgeConnectorMember} - ON ${knowledgeConnectorMember.id} = ${knowledgeDocumentObservation.memberId} - WHERE ${knowledgeConnectorMember.status} = 'active' - AND ${knowledgeConnectorMember.subjectToken} = ANY(${tokens}) - AND GREATEST(${knowledgeDocumentObservation.lastSeenAt}, ${knowledgeConnectorMember.memberSyncedThrough}) > ${cutoff} - )) + OR (${knowledgeConnector.accessMode} = 'members' AND ${memberObservationCondition(tokens, cutoff)}) )) ) ) @@ -236,6 +249,25 @@ function storedKnowledgeAccessCondition( )` } +/** + * The token half of the stored access predicate: the documents a caller's tokens reach before + * any source, freshness, or requirement check narrows them. It is a necessary condition of + * {@link knowledgeAccessCondition}, never a substitute for it. + * + * Paired with `deleted_at IS NULL` it matches `doc_acl_gin_idx` exactly, so a query can enumerate + * a member's reachable documents from that index alone. PostgreSQL cannot estimate array-overlap + * selectivity, so left to itself it intersects this highly selective bitmap with base-wide ones. + */ +export function knowledgeAclOverlapCondition(scope: KnowledgeAccessScope): SQL { + if (scope.tokens.length === 0) return sql`false` + return aclOverlap(textArrayLiteral(scope.tokens)) +} + +/** One spelling of the token overlap, so the probe's reach and the full predicate cannot drift. */ +function aclOverlap(tokens: SQL): SQL { + return sql`${document.acl} && ${tokens}` +} + /** * The pool uses fetch_types: false, so arrays must be constructed from scalar * parameters. A JSON scalar keeps large sets below PostgreSQL's bind limit. diff --git a/apps/sim/lib/knowledge/search/diagnostics.ts b/apps/sim/lib/knowledge/search/diagnostics.ts index 8406c131027..534801c106f 100644 --- a/apps/sim/lib/knowledge/search/diagnostics.ts +++ b/apps/sim/lib/knowledge/search/diagnostics.ts @@ -28,6 +28,7 @@ export type SearchStage = | 'access_scope' | 'defaults' | 'retrieval' + | 'permitted_documents' | 'result_provenance' | 'reranking' | 'usage_recording' @@ -89,6 +90,14 @@ export interface SearchDiagnosticMetadata { vectorCandidateLimit?: number /** Visible documents the tractability probe enumerated, capped at its own document limit. */ vectorProbeDocumentCount?: number + /** + * Whether a user-scoped search resolved its permitted documents before retrieval: `bounded` + * ranks inside that set, `unbounded` means it exceeded the probe's limit and both legs search + * the index with the access predicate applied per candidate. + */ + permittedDocuments?: 'bounded' | 'unbounded' + /** Documents in a bounded permitted set. */ + permittedDocumentCount?: number vectorCandidateCount?: number vectorCandidateDimensions?: number resultCount?: number diff --git a/apps/sim/lib/knowledge/search/queries.test.ts b/apps/sim/lib/knowledge/search/queries.test.ts index 984829a43dc..7d5cbfbab88 100644 --- a/apps/sim/lib/knowledge/search/queries.test.ts +++ b/apps/sim/lib/knowledge/search/queries.test.ts @@ -28,9 +28,12 @@ import { handleTagAndVectorSearch, handleTagOnlySearch, handleVectorOnlySearch, + type PermittedDocuments, + resolvePermittedDocuments, retrieveKnowledgeSearch, type SearchParams, VECTOR_PROBE_DOCUMENT_LIMIT, + visibleDocumentsQuery, } from '@/lib/knowledge/search/queries' import type { StructuredFilter } from '@/lib/knowledge/types' @@ -58,7 +61,8 @@ describe('retrieval leg budgets', () => { vi.spyOn(SearchBudget.prototype, 'remaining').mockImplementation(function ( this: SearchBudget ) { - if (!deadlines.has(this.leg)) deadlines.set(this.leg, this.deadline) + /** Capped steps like the permitted-document probe carry the leg with a shorter deadline. */ + deadlines.set(this.leg, Math.max(deadlines.get(this.leg) ?? 0, this.deadline)) return remaining.call(this) }) const access: UserAccessScope = { @@ -107,9 +111,9 @@ function render(condition: unknown) { return (condition as { toSQL: () => { sql: string; params: unknown[] } }).toSQL() } -/** The document probe is the only vector statement that selects ids without ordering them. */ +/** The permitted-document probe is the only statement that reports whether it saturated. */ function isProbeStatement(sql: string) { - return sql.includes('AS id FROM') && !sql.includes('ORDER BY') + return sql.includes('AS saturated') } /** `+ 0` is what keeps the exact ranking off the ANN index, so it also identifies the statement. */ @@ -392,7 +396,7 @@ describe('workspace-scoped vector retrieval', () => { } if (statement.includes('WITH scored_search_candidates')) return ranked if (isExactRanking(statement)) return exactRows - if (statement.includes('AS id FROM')) return probeRows + if (isProbeStatement(statement)) return probeRows return [] }) }) @@ -886,7 +890,7 @@ describe('live repository authorization follows ranked candidates', () => { if (statement.includes('WITH scored_search_candidates')) return rerankPages.shift() ?? [] if (statement.includes('WITH matched_keyword_chunks')) return keywordPages.shift() ?? [] if (isExactRanking(statement)) return exactPages.shift() ?? [] - if (statement.includes('AS id FROM')) return probePages.shift() ?? [] + if (isProbeStatement(statement)) return probePages.shift() ?? [] return [] }) getForConnectors.mockReset().mockResolvedValue(allowed) @@ -1256,6 +1260,36 @@ describe('live repository authorization follows ranked candidates', () => { expect(fragments).toContain('= ANY (ARRAY(SELECT document_id FROM matched_keyword_chunks))') }) + it('drops an excluded source from the bounded permitted set on refill', async () => { + getForConnectors.mockResolvedValueOnce(identity) + keywordPages.push( + [candidate('denied', 'revoked-source')], + [candidate('selected', 'allowed-source')] + ) + queueTableRows(schemaMock.embedding, []) + queueTableRows(schemaMock.embedding, [{ id: 'selected', content: 'verified result' }]) + await executeKeywordSearch({ + ...params, + query: 'release', + queryVector: params.queryVector!, + permitted: { + kind: 'bounded', + documents: [ + { id: 'doc-denied', connectorId: 'revoked-source' }, + { id: 'doc-selected', connectorId: 'allowed-source' }, + { id: 'doc-uploaded', connectorId: null }, + ], + }, + }) + const [first, refill] = dbChainMockFns.execute.mock.calls.map(([query]) => + JSON.stringify(query) + ) + expect(first).toContain('doc-denied') + expect(refill).not.toContain('doc-denied') + expect(refill).toContain('doc-selected') + expect(refill).toContain('doc-uploaded') + }) + it('recomputes keyword candidates after excluding a revoked source and rechecks content access', async () => { getForConnectors.mockResolvedValueOnce(identity) keywordPages.push( @@ -1354,3 +1388,189 @@ describe('live repository authorization follows ranked candidates', () => { } ) }) + +describe('permitted-document planner', () => { + const reader: UserAccessScope = { + kind: 'user', + userId: 'reader', + tokens: ['u:reader@example.com'], + } + const workspace = { kind: 'workspace' as const, tokens: WORKSPACE_ACCESS_TOKENS } + const provider: KnowledgeAccessProvider = { + get: async () => reader, + getForConnectors: async () => reader, + getForDocuments: async () => reader, + liveSourceConnectorCondition: async () => null, + } + const params: SearchParams = { + knowledgeBaseIds: ['org-index'], + topK: 1, + access: reader, + accessProvider: provider, + queryVector: { vector: '[0.1,0.2]', dimensions: 1536, model: 'text-embedding-3-small' }, + distanceThreshold: 1, + } + const hit = (id: string, connectorId: string | null) => ({ + id, + documentId: `doc-${id}`, + connectorId, + liveAuthorizationSource: false, + distance: 0.1, + }) + const bounded = ( + ...documents: Array<{ id: string; connectorId: string | null }> + ): PermittedDocuments => ({ kind: 'bounded', documents }) + + let probeRows: Array<{ id: string | null; connectorId: string | null; saturated: boolean }> + let exactRows: Array<{ id: string }> + let traversedRows: Array<{ id: string }> + let rerankRows: Array> + + beforeEach(() => { + resetDbChainMock() + probeRows = [] + exactRows = [] + traversedRows = [] + rerankRows = [] + dbChainMockFns.execute.mockImplementation(async (query) => { + const statement = render(query).sql + if (statement.includes('AS visible')) return traversedRows + if (statement.includes('WITH scored_search_candidates')) return rerankRows + if (isExactRanking(statement)) return exactRows + if (isProbeStatement(statement)) return probeRows + return [] + }) + }) + + it('ranks a bounded permitted set exactly without walking the graph', async () => { + exactRows = [{ id: 'a' }] + rerankRows = [hit('a', null)] + queueTableRows(schemaMock.embedding, [hit('a', null)]) + const results = await handleVectorOnlySearch({ + ...params, + permitted: bounded({ id: 'doc-a', connectorId: null }, { id: 'doc-b', connectorId: 'src' }), + }) + expect(results.map((row) => row.id)).toEqual(['a']) + const sqls = statements().map((query) => query.sql) + expect(sqls.some((sql) => sql.includes('hnsw.iterative_scan'))).toBe(false) + expect(sqls.some((sql) => sql.includes('AS visible'))).toBe(false) + expect(sqls.some(isProbeStatement)).toBe(false) + const exact = JSON.stringify(statements().find((query) => isExactRanking(query.sql))) + expect(exact).toContain('doc-a') + expect(exact).toContain('doc-b') + }) + + it('ranks nothing when the bounded permitted set is empty', async () => { + expect(await handleVectorOnlySearch({ ...params, permitted: bounded() })).toEqual([]) + expect(dbChainMockFns.execute).not.toHaveBeenCalled() + }) + + it('does not probe again after an underfilled walk of an unbounded permitted set', async () => { + traversedRows = [{ id: 'a' }] + rerankRows = [hit('a', null)] + queueTableRows(schemaMock.embedding, [hit('a', null)]) + await handleVectorOnlySearch({ ...params, permitted: { kind: 'unbounded' } }) + const sqls = statements().map((query) => query.sql) + expect(sqls.some((sql) => sql.includes('AS visible'))).toBe(true) + expect(sqls.some(isProbeStatement)).toBe(false) + expect(sqls.some(isExactRanking)).toBe(false) + }) + + it('confines keyword matching to the bounded permitted set', async () => { + await executeKeywordSearch({ + ...params, + topK: 1, + query: 'release', + queryVector: params.queryVector!, + permitted: bounded({ id: 'doc-a', connectorId: null }), + }) + const keyword = statements().find((query) => query.sql.includes('WITH matched_keyword_chunks'))! + /** The mock renders the whole WHERE as one parameter, so the restriction shows up in it. */ + expect(JSON.stringify(keyword)).toContain('doc-a') + }) + + it('skips keyword SQL entirely when nothing is permitted', async () => { + expect( + await executeKeywordSearch({ + ...params, + query: 'release', + queryVector: params.queryVector!, + permitted: bounded(), + }) + ).toEqual([]) + expect(dbChainMockFns.execute).not.toHaveBeenCalled() + }) + + it('reads a user scope through its reachable documents and reports saturation', () => { + const user = render(visibleDocumentsQuery(['org-index'], [], reader)) + const userSql = user.sql + expect(userSql).toContain('WITH reach AS MATERIALIZED') + expect(userSql).toContain('reachable AS MATERIALIZED') + expect(userSql).toContain('FROM reachable AS') + expect(userSql).toContain('AS saturated') + /** + * Baseline tokens reach every tenant's org-wide, public, and uploaded documents, so both the + * count and the rows are confined to the requested bases, outside the fence around the index. + */ + const [reach, reachable] = userSql.split('reachable AS MATERIALIZED') + for (const cte of [reach, reachable.split('FROM reachable AS')[0]]) { + expect(cte).toMatch(/OFFSET 0\s*\) AS \?\s*WHERE \?/) + } + expect(JSON.stringify(user.params)).toContain('org-index') + const workspaceSql = render(visibleDocumentsQuery(['org-index'], [], workspace)).sql + expect(workspaceSql).not.toContain('reachable') + expect(workspaceSql).toContain('AS saturated') + }) + + it.each([ + [[{ id: null, connectorId: null, saturated: true }], 'unbounded'], + [[{ id: 'doc-a', connectorId: null, saturated: false }], 'bounded'], + ] as const)('resolves %j as %s', async (rows, kind) => { + probeRows = [...rows] + const permitted = await resolvePermittedDocuments({ + knowledgeBaseIds: ['org-index'], + access: reader, + }) + expect(permitted.kind).toBe(kind) + if (permitted.kind === 'bounded') + expect(permitted.documents).toEqual([{ id: 'doc-a', connectorId: null }]) + }) + + it('reports an exhausted vector budget as unbounded instead of failing both legs', async () => { + const budget = new SearchBudget('vector', performance.now() - 1) + const permitted = await resolvePermittedDocuments({ + knowledgeBaseIds: ['org-index'], + access: reader, + budget, + }) + expect(permitted.kind).toBe('unbounded') + expect(budget.timedOut).toBe(true) + }) + + it('resolves the permitted set once, before both legs, for live user scopes only', async () => { + const search = { + knowledgeBaseIds: ['org-index'], + topK: 1, + searchMode: 'hybrid' as const, + query: 'release', + queryVector: params.queryVector!, + } + await retrieveKnowledgeSearch({ ...search, access: reader, accessProvider: provider }) + /** Budgeted statements open with their transaction's `set_config`; the SQL that follows is ordered. */ + const userSqls = statements() + .map((query) => query.sql) + .filter((sql) => !sql.includes('set_config')) + expect(isProbeStatement(userSqls[0])).toBe(true) + expect(userSqls.filter(isProbeStatement)).toHaveLength(1) + + resetDbChainMock() + dbChainMockFns.execute.mockImplementation(async () => []) + await retrieveKnowledgeSearch({ + ...search, + access: reader, + accessProvider: provider, + filters: { documentIds: ['doc-a'] }, + }) + expect(statements().some((query) => isProbeStatement(query.sql))).toBe(false) + }) +}) diff --git a/apps/sim/lib/knowledge/search/queries.ts b/apps/sim/lib/knowledge/search/queries.ts index 1792f735f72..9152057475c 100644 --- a/apps/sim/lib/knowledge/search/queries.ts +++ b/apps/sim/lib/knowledge/search/queries.ts @@ -11,6 +11,7 @@ import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { and, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm' import { knowledgeAccessCondition, + knowledgeAclOverlapCondition, knowledgeMetadataCandidateAccessCondition, textArrayLiteral, } from '@/lib/knowledge/access/predicate' @@ -270,6 +271,8 @@ export interface SearchParams { filters?: WorkspaceSearchFilters queryVector?: KnowledgeQueryVector distanceThreshold?: number + /** Resolved once per user-scoped search; absent for resolved scopes and explicit documents. */ + permitted?: PermittedDocuments } /** All valid tag slot keys */ @@ -521,6 +524,22 @@ function getDocumentVisibilityConditions( ] } +/** + * The document-level candidate predicate every ranked leg applies. The permitted set is resolved + * with the same list, which is what lets a leg rank inside it without admitting anything more. + */ +function candidateDocumentConditions( + knowledgeBaseIds: string[], + access: KnowledgeAccessScope, + filters: WorkspaceSearchFilters | undefined, + accessCondition: SQL +) { + return [ + inArray(document.knowledgeBaseId, knowledgeBaseIds), + ...getDocumentVisibilityConditions(access, filters, accessCondition), + ] +} + interface SearchReadCandidatePage { candidates: SearchReadCandidate[] nextOffset: number @@ -820,25 +839,27 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise { + access: KnowledgeAccessScope, + budget: SearchBudget | undefined, + stage: 'vector.probe' | 'permitted_documents' +): Promise { const probeBudget = budget?.capped(VECTOR_PROBE_BUDGET_MS) try { - const probed = await runSearchQuery(probeBudget, 'vector.probe', (executor) => - executor.execute<{ id: string }>(sql` - SELECT ${document.id} AS id FROM ${document} - WHERE ${and(...conditions)} - LIMIT ${VECTOR_PROBE_DOCUMENT_LIMIT + 1} - `) + const probed = await runSearchQuery(probeBudget, stage, (executor) => + executor.execute( + visibleDocumentsQuery(knowledgeBaseIds, conditions, access) + ) ) - if (probed.length > VECTOR_PROBE_DOCUMENT_LIMIT) return null - return probed.map(({ id }) => id) + /** The saturation sentinel is only ever emitted alone. */ + if (probed.length > VECTOR_PROBE_DOCUMENT_LIMIT || probed[0]?.saturated) return null + return probed.map(({ id, connectorId }) => ({ id, connectorId })) } catch (error) { if (!budget || !probeBudget?.isTimeout(error)) throw error /** Only the probe's share was spent; the leg's own deadline still governs. */ @@ -847,6 +868,141 @@ async function probeVisibleDocuments( } } +/** + * The probe's SQL, returning at most one row past the document limit. + * + * A user scope first materializes the documents its tokens reach in these bases, read through + * `doc_acl_gin_idx` alone, then applies the state and full access conditions to those rows in + * memory; the set is aliased as `document` so the shared conditions bind to it unchanged. Handed + * the combined predicate instead, PostgreSQL misjudges the token overlap as unselective and + * intersects it with base-wide indexes that read the whole search index. + * + * The index is global and every caller holds the baseline tokens every tenant's org-wide, public, + * and uploaded documents carry, so the reach must be counted inside these bases or those + * documents alone would saturate it. The base check is applied outside an `OFFSET 0` fence so it + * filters the index's rows instead of replacing the index with a base-wide scan. The reach is + * counted before any row is materialized, so a caller whose tokens reach past the limit pays only + * for the count, and a `saturated` sentinel row then reports the set as unbounded. Resolved scopes + * hold base-wide tokens, so they filter directly. + */ +export function visibleDocumentsQuery( + knowledgeBaseIds: string[], + conditions: (SQL | undefined)[], + access: KnowledgeAccessScope +): SQL { + const limit = VECTOR_PROBE_DOCUMENT_LIMIT + 1 + if (access.kind !== 'user') { + return sql` + SELECT ${document.id} AS id, ${document.connectorId} AS "connectorId", false AS saturated + FROM ${document} + WHERE ${and(...conditions)} + LIMIT ${limit} + ` + } + /** Exactly `doc_acl_gin_idx`'s predicate, so both the count and the rows read that index alone. */ + const reached = sql`${document.deletedAt} IS NULL AND ${knowledgeAclOverlapCondition(access)}` + const underLimit = sql`(SELECT n FROM reach) < ${limit}` + const inBases = inArray(document.knowledgeBaseId, knowledgeBaseIds) + return sql` + WITH reach AS MATERIALIZED ( + SELECT count(*) AS n FROM ( + SELECT 1 FROM ( + SELECT ${document.knowledgeBaseId} FROM ${document} WHERE ${reached} OFFSET 0 + ) AS ${document} + WHERE ${inBases} + LIMIT ${limit} + ) AS reached + ), reachable AS MATERIALIZED ( + SELECT * FROM ( + SELECT * FROM ${document} WHERE ${underLimit} AND ${reached} OFFSET 0 + ) AS ${document} + WHERE ${inBases} + ) + ( + SELECT ${document.id} AS id, ${document.connectorId} AS "connectorId", false AS saturated + FROM reachable AS ${document} + WHERE ${underLimit} AND ${and(...conditions)} + LIMIT ${limit} + ) + UNION ALL + SELECT NULL, NULL, true WHERE (SELECT n FROM reach) >= ${limit} + ` +} + +/** A document a caller may rank, with the source a live authorization pass may later exclude. */ +type PermittedDocument = { + id: string + connectorId: string | null +} + +/** + * The documents a user-scoped search may rank, resolved once before either leg runs. + * + * Organization search indexes grant most documents to a single mailbox, channel, or file owner, + * so a member typically reads a vanishing share of the index. Ranking the whole index and + * checking access afterwards then scans thousands of candidates to find none; ranking inside the + * permitted set finds every eligible chunk at a cost proportional to what the member can read. + * `unbounded` means the set exceeded the probe's limit, where post-filtered index search fills + * quickly because most candidates are readable. + */ +export type PermittedDocuments = + | { kind: 'bounded'; documents: readonly PermittedDocument[] } + | { kind: 'unbounded' } + +/** + * Resolve the permitted set with the candidate predicate both legs apply, so restricting a leg + * to it never admits a document the leg would otherwise refuse. Tag filters stay chunk-level in + * each leg; the set is the document-level superset they narrow. + * + * It runs ahead of both legs on the vector leg's budget, so exhausting that budget here reports + * `unbounded` and marks the vector leg timed out rather than failing the keyword leg with it. + */ +export async function resolvePermittedDocuments(params: { + knowledgeBaseIds: string[] + access: KnowledgeAccessScope + filters?: WorkspaceSearchFilters + budget?: SearchBudget +}): Promise { + let documents: PermittedDocument[] | null + try { + documents = await probeVisibleDocuments( + params.knowledgeBaseIds, + candidateDocumentConditions( + params.knowledgeBaseIds, + params.access, + params.filters, + knowledgeMetadataCandidateAccessCondition(params.access) + ), + params.access, + params.budget, + 'permitted_documents' + ) + } catch (error) { + if (!params.budget?.isTimeout(error)) throw error + documents = null + } + const permitted: PermittedDocuments = documents + ? { kind: 'bounded', documents } + : { kind: 'unbounded' } + annotateSearchDiagnostics({ + permittedDocuments: permitted.kind, + ...(documents ? { permittedDocumentCount: documents.length } : {}), + }) + return permitted +} + +/** The permitted documents still eligible after live authorization excluded some sources. */ +function permittedDocumentIds( + documents: readonly PermittedDocument[], + excludedSources: readonly string[] +): string[] { + if (!excludedSources.length) return documents.map((entry) => entry.id) + const excluded = new Set(excludedSources) + return documents + .filter((entry) => entry.connectorId === null || !excluded.has(entry.connectorId)) + .map((entry) => entry.id) +} + /** * Tags live on chunks, so a row qualifies when a chunk it joins to carries them — and only a * chunk the search can actually return counts, or a document whose sole match is disabled would @@ -922,8 +1078,12 @@ async function selectVectorResults(params: SearchParams): Promise + const rankPermittedExactly = async (documentIds: string[]) => { + annotateSearchDiagnostics({ vectorRanking: 'exact-candidates' }) + if (!documentIds.length) return [] + return runSearchQuery(params.budget, 'vector.exact_candidates', (executor) => executor.execute<{ id: string }>(sql` - SELECT ${embeddingSearch.id} AS id FROM ${embeddingSearch} - CROSS JOIN LATERAL ( - SELECT 1 FROM ${document} - WHERE ${and(eq(document.id, embeddingSearch.documentId), ...candidateDocumentVisibility, candidateTagCondition)} - LIMIT 1 - ) AS visible - WHERE ${and( - inArray(embeddingSearch.knowledgeBaseId, params.knowledgeBaseIds), - eq(embeddingSearch.enabled, true) - )} - ORDER BY ${candidateDistance} LIMIT ${candidateLimit} - `), - params.budget - ) - /** - * A full traversal is already the nearest permitted chunks, so nothing else is worth - * running. An underfilled one is the signal that visibility removed neighbours the graph - * had already chosen: pgvector's HNSW post-filters by construction — it declares no scan - * strategies and never reads the scan keys — so a permitted set that is a small share of - * the index is discarded after the graph has committed to its neighbours, and widening - * the traversal cannot recover them. - * - * Ranking the permitted set exactly does recover them, while that set is small enough to - * afford. - */ - let selected: Array<{ id: string }> = traversed - if (traversed.length < candidateLimit) { - const visibleDocumentIds = await probeVisibleDocuments( - [...candidateDocumentVisibility, documentTagCondition], - params.budget - ) - if (visibleDocumentIds) { - annotateSearchDiagnostics({ - vectorRanking: 'exact-candidates', - vectorProbeDocumentCount: visibleDocumentIds.length, - }) - /** - * `+ 0` keeps the planner off the ANN index, and the probed identities keep the scan - * on `embedding_search_document_lookup_idx`, so this reads what the permitted set - * costs rather than re-deriving permission across the whole index. Exact ranking also - * honours `statement_timeout`, which a traversal cannot. - */ - selected = visibleDocumentIds.length - ? await runSearchQuery(params.budget, 'vector.exact_candidates', (executor) => - executor.execute<{ id: string }>(sql` SELECT ${embeddingSearch.id} AS id FROM ${embeddingSearch} WHERE ${and( inArray(embeddingSearch.knowledgeBaseId, params.knowledgeBaseIds), eq(embeddingSearch.enabled, true), - sql`${embeddingSearch.documentId} = ANY(${textArrayLiteral(visibleDocumentIds)})`, + sql`${embeddingSearch.documentId} = ANY(${textArrayLiteral(documentIds)})`, candidateTagCondition )} ORDER BY (${candidateDistance}) + 0 LIMIT ${candidateLimit} `) - ) - : [] + ) + } + let selected: Array<{ id: string }> + if (params.permitted?.kind === 'bounded') { + /** + * A bounded permitted set is ranked exactly without walking the graph first: the walk + * post-filters, so when the caller reads a small share of the index it spends its whole + * uninterruptible tuple budget and still returns almost none of their neighbours. + */ + selected = await rankPermittedExactly( + permittedDocumentIds(params.permitted.documents, excludedSources) + ) + } else { + /** + * The bounded ANN traversal is the whole candidate set. LIMIT keeps document + * authorization downstream of the traversal, with a primary-key lookup per candidate. + */ + selected = await withVectorScanSettings( + (executor) => + executor.execute<{ id: string }>(sql` + SELECT ${embeddingSearch.id} AS id FROM ${embeddingSearch} + CROSS JOIN LATERAL ( + SELECT 1 FROM ${document} + WHERE ${and(eq(document.id, embeddingSearch.documentId), ...candidateDocumentVisibility, candidateTagCondition)} + LIMIT 1 + ) AS visible + WHERE ${and( + inArray(embeddingSearch.knowledgeBaseId, params.knowledgeBaseIds), + eq(embeddingSearch.enabled, true) + )} + ORDER BY ${candidateDistance} LIMIT ${candidateLimit} + `), + params.budget + ) + /** + * A full traversal is already the nearest permitted chunks, so nothing else is worth + * running. An underfilled one is the signal that visibility removed neighbours the graph + * had already chosen: pgvector's HNSW post-filters by construction — it declares no scan + * strategies and never reads the scan keys — so a permitted set that is a small share of + * the index is discarded after the graph has committed to its neighbours, and widening + * the traversal cannot recover them. + * + * Ranking the permitted set exactly does recover them, while that set is small enough to + * afford. An `unbounded` permitted set already proved it is not, so the probe is skipped. + */ + if (selected.length < candidateLimit && params.permitted?.kind !== 'unbounded') { + const visibleDocuments = await probeVisibleDocuments( + params.knowledgeBaseIds, + [...candidateDocumentVisibility, documentTagCondition], + params.access, + params.budget, + 'vector.probe' + ) + if (visibleDocuments) { + annotateSearchDiagnostics({ vectorProbeDocumentCount: visibleDocuments.length }) + selected = await rankPermittedExactly(visibleDocuments.map(({ id }) => id)) + } } } candidatePool = { excludedKey, identities: selected } @@ -1077,6 +1251,8 @@ export interface KeywordSearchParams { queryVector: KnowledgeQueryVector structuredFilters?: StructuredFilter[] filters?: WorkspaceSearchFilters + /** Resolved once per user-scoped search; absent for resolved scopes and explicit documents. */ + permitted?: PermittedDocuments } /** @@ -1144,29 +1320,61 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise budget: params.budget, topK, selectPage: async (limit, offset, excludedSources) => { + /** + * A bounded permitted set confines matching to the chunks the caller may read, so a term + * common across the index is ranked only where it can surface. The visibility CTE below + * still re-applies the candidate predicate, so the restriction can only narrow. + */ + const permittedIds = + params.permitted?.kind === 'bounded' + ? permittedDocumentIds(params.permitted.documents, excludedSources) + : undefined + if (permittedIds?.length === 0) return { candidates: [], nextOffset: offset } + const baseScope = and( + inArray(embeddingKeywordSearch.knowledgeBaseId, knowledgeBaseIds), + eq(embeddingKeywordSearch.enabled, true) + ) + const chunkMatch = and( + sql`${embeddingKeywordSearch.contentTsv} @@ ${tsQuery}`, + tagFilterConditions.length + ? sql`EXISTS ( + SELECT 1 FROM ${embedding} WHERE ${embedding.id} = ${embeddingKeywordSearch.id} + AND ${and(...tagFilterConditions)} + )` + : undefined + ) + /** + * A bounded permitted set is read through its documents alone and matched row by row, at a + * cost linear in the permitted chunks. Offered the text or base indexes alongside, + * PostgreSQL may intersect the permitted chunks with every chunk in the base that holds the + * term or sits in the base; measured on an organization index that plan cost several + * times the direct read, and the direct read is never materially slower. The permitted + * documents were resolved inside these bases; the base check still applies to the rows + * read, so the read can never widen the scope. `OFFSET 0` keeps the read from being + * flattened back into an intersection; the alias lets the shared conditions bind to it. + */ + const matchedChunks = permittedIds + ? sql` + SELECT ${embeddingKeywordSearch.id} AS id, ${embeddingKeywordSearch.documentId} AS document_id + FROM ( + SELECT * FROM ${embeddingKeywordSearch} + WHERE ${embeddingKeywordSearch.documentId} = ANY(${textArrayLiteral(permittedIds)}) + OFFSET 0 + ) AS ${embeddingKeywordSearch} + WHERE ${and(baseScope, chunkMatch)}` + : sql` + SELECT ${embeddingKeywordSearch.id} AS id, ${embeddingKeywordSearch.documentId} AS document_id + FROM ${embeddingKeywordSearch} + WHERE ${and(baseScope, chunkMatch)}` const candidates = await runSearchQuery(params.budget, 'keyword.sql', (executor) => executor.execute(sql` - WITH matched_keyword_chunks AS MATERIALIZED ( - SELECT ${embeddingKeywordSearch.id} AS id, - ${embeddingKeywordSearch.documentId} AS document_id - FROM ${embeddingKeywordSearch} - WHERE ${and( - inArray(embeddingKeywordSearch.knowledgeBaseId, knowledgeBaseIds), - eq(embeddingKeywordSearch.enabled, true), - sql`${embeddingKeywordSearch.contentTsv} @@ ${tsQuery}`, - tagFilterConditions.length - ? sql`EXISTS ( - SELECT 1 FROM ${embedding} WHERE ${embedding.id} = ${embeddingKeywordSearch.id} - AND ${and(...tagFilterConditions)} - )` - : undefined - )} + WITH matched_keyword_chunks AS MATERIALIZED (${matchedChunks} ), visible_keyword_documents AS MATERIALIZED ( SELECT ${document.id} AS id FROM ${document} WHERE ${and( - inArray(document.knowledgeBaseId, knowledgeBaseIds), sql`${document.id} = ANY (ARRAY(SELECT document_id FROM matched_keyword_chunks))`, - ...getDocumentVisibilityConditions( + ...candidateDocumentConditions( + knowledgeBaseIds, access, params.filters, knowledgeMetadataCandidateAccessCondition(access) @@ -1449,12 +1657,27 @@ export async function retrieveKnowledgeSearch( if (!queryVector) throw new Error('Query vector is required when searching with a query') const { distanceThreshold } = getQueryStrategy(knowledgeBaseIds.length, topK) const legTopK = searchMode === 'hybrid' ? hybridCandidateCount(topK) : topK + /** + * Live user scopes resolve what they may read once, before either leg, so both rank inside it + * when it is small. Resolved scopes read whole bases, and explicit documents are already a + * bounded scope with their own exhaustive ordering. + */ + const permitted = + access.kind === 'user' && params.accessProvider && !params.filters?.documentIds?.length + ? await resolvePermittedDocuments({ + knowledgeBaseIds, + access, + filters: params.filters, + budget: budgets.vector, + }) + : undefined const vectorParams = { ...common, topK: legTopK, queryVector, distanceThreshold, budget: budgets.vector, + permitted, } const vectorSearch = measureSearchStage('vector', () => hasFilters ? handleTagAndVectorSearch(vectorParams) : handleVectorOnlySearch(vectorParams) @@ -1467,6 +1690,7 @@ export async function retrieveKnowledgeSearch( query: query!, queryVector, budget: budgets.keyword, + permitted, }) ) const legs = await Promise.allSettled([vectorSearch, keywordSearch]) From b01617ec9702147c668db83d9407e85c82f9664b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 18 Sep 2026 18:55:21 -0700 Subject: [PATCH 20/20] feat(library): Best AI Agent Builders with MCP Support (#7999) Co-authored-by: Sim Pi Agent --- .../index.mdx | 184 ++++++++++++++++++ .../cover.jpg | Bin 0 -> 24312 bytes 2 files changed, 184 insertions(+) create mode 100644 apps/sim/content/library/best-ai-agent-builders-with-mcp-support/index.mdx create mode 100644 apps/sim/public/library/best-ai-agent-builders-with-mcp-support/cover.jpg diff --git a/apps/sim/content/library/best-ai-agent-builders-with-mcp-support/index.mdx b/apps/sim/content/library/best-ai-agent-builders-with-mcp-support/index.mdx new file mode 100644 index 00000000000..8c511cddaac --- /dev/null +++ b/apps/sim/content/library/best-ai-agent-builders-with-mcp-support/index.mdx @@ -0,0 +1,184 @@ +--- +slug: best-ai-agent-builders-with-mcp-support +title: 'Best AI Agent Builders with MCP Support' +description: 'Compare the best AI agent builders with MCP support, including Sim, n8n, Zapier, Make, and Gumloop, across client and server roles, authentication, deployment, and enterprise controls.' +date: 2026-09-19 +updated: 2026-09-19 +authors: + - andrew +readingTime: 14 +tags: [AI Agents, MCP, Automation, Open Source, Sim] +ogImage: /library/best-ai-agent-builders-with-mcp-support/cover.jpg +canonical: https://www.sim.ai/library/best-ai-agent-builders-with-mcp-support +draft: false +faq: + - q: "What is Model Context Protocol?" + a: "Model Context Protocol, or MCP, gives AI applications a standard way to discover and call external tools. An MCP server publishes tools and their input requirements, while an MCP client lets an agent use them." + - q: "What is the difference between MCP client and MCP server support?" + a: "MCP client support lets a platform connect its agents to tools published by external MCP servers. MCP server support lets the platform publish its own workflows as callable tools for clients such as Claude Desktop, Cursor, or VS Code." + - q: "Can no-code platforms build MCP agents?" + a: "Yes. No-code platforms can build agents that call MCP tools when they provide native client support and visual steps for mapping inputs and outputs. Some platforms limit custom logic, deployment choices, or server creation, so buyers should check both sides of MCP support." + - q: "How do MCP tool calling and authentication work?" + a: "An agent reads the tools published by an MCP server, selects an appropriate tool, and sends arguments that match its input definition. Authentication depends on the server and platform. Common methods include access tokens, OAuth connections, and platform-managed credentials." + - q: "What does a custom remote MCP server require?" + a: "A remote MCP server needs a reachable endpoint that publishes valid tool definitions and handles tool requests. You also need hosting, authentication, and operational monitoring. Sim can publish deployed workflows as remote MCP tools and provides connection configurations for supported clients." + - q: "Which platform fits enterprise MCP use?" + a: "Sim and n8n fit enterprises that require source access, self-hosting, or custom workflow control. Zapier, Make, and Gumloop fit enterprises that prefer vendor-managed visual automation, but their governance features and deployment options differ by plan. Before purchasing any platform, verify its current identity controls and audit logging, along with its data residency and governance options." +--- + +## TL;DR + +- **Sim** consumes MCP tools and exposes deployed workflows as MCP tools. Its open-source codebase and multi-model agent workspace suit buyers building custom agents. +- **n8n** [supports both MCP patterns](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp). It fits technical users who want [low-code workflow control and self-hosting](https://n8n.io/). +- **Zapier** [connects AI clients to app actions through managed MCP servers](https://docs.zapier.com/mcp/home). It fits no-code users already working within Zapier's integration catalog. +- **Make** [supports consuming MCP tools](https://apps.make.com/c5ZhYquO55e90hoCJ0rzt) and [exposing scenarios to AI clients](https://help.make.com/make-mcp-server). It fits visual builders who want granular automation control. +- **Gumloop** [supports both MCP patterns](https://docs.gumloop.com/nodes/mcp/custom_mcp_servers) for AI-focused workflows. It fits operations users who want [agent automation with limited coding](https://docs.gumloop.com/). + +## What Model Context Protocol support actually means for agent builders + +[Model Context Protocol](https://modelcontextprotocol.io/) lets an AI application discover and call tools through a shared interface. An MCP tool might search a database, update a CRM record, or run an automation. The protocol standardizes how the AI application finds that tool, describes its inputs, and receives its output. + +An MCP client consumes tools published by external MCP servers. For example, an agent builder may connect to a remote server and let its agents call the server's tools. [Sim](https://docs.sim.ai/agents/mcp), [n8n](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp), [Make](https://apps.make.com/c5ZhYquO55e90hoCJ0rzt), and [Gumloop](https://docs.gumloop.com/nodes/mcp/custom_mcp_servers) provide ways to connect workflows or agents to external MCP tools. Supported transports and authentication methods vary by platform. + +An MCP server publishes tools that other applications can call. Sim can expose deployed workflows as MCP tools and connect them to supported clients such as Claude Desktop, Cursor, and VS Code. The [Sim MCP deployment documentation](https://docs.sim.ai/workflows/deployment/mcp) covers its supported connection configurations. [n8n can expose selected workflows through its MCP Server Trigger](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.mcptrigger). [Make](https://help.make.com/get-started-with-make-mcp-server) and [Gumloop](https://docs.gumloop.com/mcp-server/overview) can make workflows available to compatible clients. [Zapier's MCP offering gives AI clients access to configured app actions](https://help.zapier.com/hc/en-us/articles/36265551472781-Manage-tools-for-your-Zapier-MCP-server). That approach differs from publishing any existing Zap as a custom MCP tool. + +Verify whether each platform consumes external MCP tools, publishes its own tools, or supports both roles before comparing products. A builder that consumes MCP tools can add external capabilities to its own agents, but other AI clients cannot necessarily call the builder's workflows. A builder that publishes workflows may serve Claude or Cursor without supporting external MCP tools inside its own agents. + +Buyers should also compare authentication, model choice, deployment, enterprise controls, and coding requirements. Authentication may rely on static credentials, OAuth, or platform-managed access. Deployment determines whether you can use a hosted endpoint or run the platform on your own infrastructure. When MCP tools can change business data, access policies and execution logs help administrators control and review those actions. Visual platforms reduce routine configuration work, but advanced tools and authentication may still require code. The broader [AI workflow automation buyer's checklist](https://www.sim.ai/library/ai-workflow-automation-platform-buyers-checklist) explains how to evaluate those operational requirements. + +## Comparison table: AI agent builders with MCP support + +The table separates platforms that consume external MCP tools from those that make workflows callable by MCP clients. + +| Platform | MCP client support | Exposes workflows as MCP tools | Authentication | Model support | Deployment options | Enterprise controls | Coding required | Best-fit buyer | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| **Sim** | [Yes](https://docs.sim.ai/agents/mcp) | [Yes, deployed workflows](https://docs.sim.ai/workflows/deployment/mcp) | API keys and provider credentials | Multi-model and BYOK | [Cloud or self-hosted](https://docs.sim.ai/platform/self-hosting) | [Enterprise access controls](https://docs.sim.ai/platform/enterprise) and self-hosting | Low-code, code optional | Teams building custom, portable agents | +| **n8n** | [Yes](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp) | [Yes](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.mcptrigger) | [Bearer, header, or OAuth authentication](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp) | [Multiple model providers](https://n8n.io/) | [Cloud or self-hosted](https://n8n.io/) | [SSO, role controls, and audit features vary by plan](https://n8n.io/pricing/) | [Low-code](https://n8n.io/) | Technical teams needing workflow control | +| **Zapier** | [Yes, through its MCP Client integration](https://help.zapier.com/hc/en-us/articles/38777069364109-Connect-remote-MCP-servers-to-Zapier-using-MCP-Client) | [Exposes selected app actions rather than existing Zaps](https://help.zapier.com/hc/en-us/articles/36265551472781-Manage-tools-for-your-Zapier-MCP-server) | [OAuth and managed connections](https://docs.zapier.com/mcp/home) | Managed within Zapier products | [Managed cloud](https://docs.zapier.com/mcp/home) | [Admin and governance features vary by plan](https://zapier.com/pricing) | [No-code](https://zapier.com/mcp) | Buyers using Zapier's app ecosystem | +| **Make** | [Yes](https://apps.make.com/c5ZhYquO55e90hoCJ0rzt) | [Yes, scenarios](https://help.make.com/get-started-with-make-mcp-server) | [Tokens, OAuth, and managed connections](https://help.make.com/mcp-toolboxes) | [Multiple providers through integrations](https://www.make.com/) | [Managed cloud](https://www.make.com/) | [Administration features vary by plan](https://www.make.com/en/pricing) | [No-code to low-code](https://www.make.com/en/pricing) | Visual automation users needing granular scenarios | +| **Gumloop** | [Yes](https://docs.gumloop.com/nodes/mcp/custom_mcp_servers) | [Yes](https://docs.gumloop.com/mcp-server/overview) | [API keys, OAuth, and managed connections](https://docs.gumloop.com/mcp-server/overview) | [Multi-model](https://docs.gumloop.com/core-concepts/ai_models) | [Managed cloud](https://docs.gumloop.com/) | [Enterprise identity controls](https://docs.gumloop.com/enterprise-features/sso_saml_scim) | [No-code](https://docs.gumloop.com/) | Operations teams building AI-focused automations | + +Authentication methods and enterprise controls can vary by plan and MCP connection type. Buyers should confirm current limits before choosing a production deployment. + +### Sim + +Sim is an open-source, multi-model workspace for building custom agents with specific tools, models, and data sources. You construct and deploy your own workflows rather than start with a single ready-made assistant. The open-source code lets technical buyers inspect and modify the software and operate a [self-hosted deployment](https://docs.sim.ai/platform/self-hosting). Teams comparing source access and deployment rights can also read this guide to [open-source AI agent frameworks](https://www.sim.ai/library/best-open-source-ai-agent-frameworks). + +Sim can turn a deployed workflow into a tool that other applications call through an MCP server. After you create a server and add the workflow as a tool, Sim provides [connection configurations for supported MCP clients](https://docs.sim.ai/workflows/deployment/mcp). Supported clients include Cursor, Codex, Claude Code, Claude Desktop, VS Code, and Sim itself. Each client can then invoke the workflow through the tool interface instead of reproducing its logic locally. + +Sim supports hosted models and bring-your-own-key access for connecting a provider account. Model and deployment availability can vary by plan and environment, so buyers should verify current terms for their intended setup. The [BYOK and multi-model agent builder guide](https://www.sim.ai/library/byok-multi-model-ai-agent-builder) covers the tradeoffs behind provider choice. + +**Best for.** Sim fits technical buyers who want to build custom, multi-model agents and expose their workflows to several MCP clients. It also suits buyers who value access to source code and deployment flexibility. + +**Pros.** Sim combines agent construction, workflow deployment, and MCP tool exposure in one workspace. Multi-model access reduces dependence on one model provider, and client-specific configurations simplify connections to common coding and assistant applications. + +**Cons.** Buyers seeking a ready-made assistant may find Sim broader than necessary. Complex custom integrations and self-hosted deployments still require technical ownership. + +**Pricing.** Sim offers hosted and enterprise options alongside its self-hosted codebase. Check Sim's current plan details for feature limits and deployment terms. + +### n8n + +[n8n supports both MCP roles](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp) through separate nodes for calling external tools and exposing automations. Its MCP Client Tool node lets an AI Agent node access tools from an external MCP server. The [MCP Server Trigger](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.mcptrigger) takes the opposite role by making connected n8n tools and workflows available to compatible MCP clients. + +Builders can combine MCP nodes with [n8n's visual automation library, branching logic, and API requests](https://n8n.io/). Most integrations use the visual editor, but uncommon APIs and complex data transformations may require [JavaScript or Python](https://docs.n8n.io/build/code-in-n8n/using-the-code-node). A self-hosted deployment also requires you to manage deployment, updates, security, and availability. + +Authentication depends on the MCP node and server configuration. The MCP Client Tool supports [bearer, header, and OAuth2 methods](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp). You should confirm that the selected transport and authentication method work with the intended client before building production workflows. Self-hosting gives you more infrastructure control, while n8n Cloud reduces operational work. + +**Best for:** Technical teams that want low-code agent workflows, extensive automation controls, and a self-hosting option. + +**Pros** + +- [MCP client](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolmcp) and [server](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.mcptrigger) patterns support both consuming external tools and exposing n8n capabilities. +- The [workflow editor](https://n8n.io/) provides granular control over branching, data transformations, and error handling. +- n8n provides built-in integrations and [community nodes](https://docs.n8n.io/integrations/community-nodes/installation-and-management/environment-variable-installation) for business applications. +- [Self-hosting](https://n8n.io/) supports buyers with specific infrastructure or data residency requirements. + +**Cons** + +- Complex agents can become difficult to test and maintain as node counts grow. +- Self-hosting requires operational knowledge. +- Custom nodes, unsupported APIs, and advanced transformations may require code. +- [Enterprise governance features depend on the selected plan](https://n8n.io/pricing/). + +**Pricing:** [n8n offers self-hosted and paid cloud options, with cloud pricing based on workflow executions](https://n8n.io/pricing/). Buyers should compare execution limits because agent loops and tool calls can increase usage quickly. + +### Zapier + +Zapier offers a [managed MCP option](https://docs.zapier.com/mcp/home) for users who already automate work through its app catalog. Zapier MCP acts as a hosted server that lets supported AI clients call selected Zapier app actions. You choose which accounts and actions the client can access rather than giving it unrestricted access to every Zapier connection. + +Zapier also supports consuming remote MCP tools through its [MCP Client integration](https://help.zapier.com/hc/en-us/articles/38777069364109-Connect-remote-MCP-servers-to-Zapier-using-MCP-Client). Existing Zaps do not automatically become MCP tools; instead, users configure [app actions as tools](https://help.zapier.com/hc/en-us/articles/36265551472781-Manage-tools-for-your-Zapier-MCP-server) or use another supported entry point. + +**Best for:** Operations and business users who want no-code MCP access to apps they already manage through Zapier. + +**Pros:** [Zapier handles MCP hosting, credentials, and connection setup](https://docs.zapier.com/mcp/home). Its integration catalog lets agents use configured actions in supported business applications. [Action-level configuration](https://help.zapier.com/hc/en-us/articles/36265551472781-Manage-tools-for-your-Zapier-MCP-server) also limits which capabilities an MCP client can invoke. + +**Cons:** Zapier offers less control over server behavior, deployment, and custom tool logic than open-source or developer-focused platforms. Zapier MCP is vendor-hosted, and advanced workflows remain subject to Zapier's product limits. + +**Pricing:** [Zapier uses plan-based pricing](https://zapier.com/pricing), and MCP-triggered actions may count toward applicable usage limits. Buyers should confirm current MCP access and task allowances for their chosen plan. + +### Make + +Make suits users who want visual control over how an AI agent moves data and calls tools. Its [visual scenario editor](https://www.make.com/) displays each step and its associated filters or data mappings. Buyers can use that detail to configure branching and transformations within a scenario. + +Make supports both MCP directions. Its [MCP Client app](https://apps.make.com/c5ZhYquO55e90hoCJ0rzt) can connect scenarios to external MCP servers and call their available tools. Make can also [expose eligible scenarios through its MCP server](https://help.make.com/get-started-with-make-mcp-server) so supported AI clients can run them as tools. Authentication relies on configured connections and access controls, while each scenario defines the actions an AI client can reach. + +**Best for:** Operations and technical users who want detailed visual workflows without building an automation service in code. + +**Pros:** The visual canvas makes branching logic and data transformations easier to inspect. Make also provides an integration catalog and supports both [consuming MCP tools](https://apps.make.com/c5ZhYquO55e90hoCJ0rzt) and [making scenarios callable through MCP](https://help.make.com/mcp-toolboxes). + +**Cons:** Complex scenarios can become difficult to maintain as routes and mappings multiply. Standard integrations require little coding, but custom APIs, JSON payloads, and unsupported authentication methods may require technical knowledge. Make is a managed cloud platform rather than a self-hosted open-source platform such as Sim or n8n. + +**Pricing:** [Make offers a free plan and paid tiers based on usage credits](https://www.make.com/en/pricing). Scenario module actions count toward credits, so buyers should estimate costs using expected agent call volume. + +### Gumloop + +Gumloop gives operations and AI teams a [no-code environment for building agent-driven automations](https://docs.gumloop.com/). Its visual workflows center on AI tasks and actions across connected business applications. + +Gumloop supports both sides of MCP. Workflows can [call tools from external MCP servers](https://docs.gumloop.com/nodes/mcp/custom_mcp_servers), and the [Gumloop MCP server](https://docs.gumloop.com/mcp-server/overview) lets compatible clients manage and trigger workflows and agents. The [visual builder](https://docs.gumloop.com/core-concepts/workbooks) removes most coding requirements, although custom APIs and unusual authentication flows may still need technical work. + +**Best for:** Operations and AI teams that want to create MCP-connected agents without managing application code or infrastructure. + +**Pros:** Gumloop combines [no-code workflow design](https://docs.gumloop.com/core-concepts/workbooks) with AI-focused nodes and [reusable subflows](https://docs.gumloop.com/core-concepts/subflows). Its MCP client and server capabilities support agents that consume external tools or provide automations to other MCP clients. + +**Cons:** Gumloop focuses more narrowly on AI workflows than broad automation platforms such as n8n, Zapier, and Make. Buyers with large libraries of conventional business automations should compare connector coverage before migrating. Deployment and infrastructure requirements may also matter more to developer-led or regulated organizations. + +**Pricing:** [Gumloop bills agent chats and workflow runs with credits](https://docs.gumloop.com/core-concepts/credits). Buyers should check its [current pricing page](https://www.gumloop.com/pricing) for workflow, collaboration, and enterprise terms before estimating production costs. + +## How to expose a Sim workflow as an MCP tool + +Sim exposes a workflow through MCP in four steps. + +1. Create an MCP server in Sim. The server groups the workflow tools that external clients can call. +2. Deploy the workflow you want to expose. Deployment creates a callable version of the workflow rather than exposing an unpublished draft. +3. Add the deployed workflow to the MCP server as a tool. Give the tool a clear name and description so the connected model can determine when to call it. +4. Copy the connection configuration that Sim provides for Cursor, Codex, Claude Code, Claude Desktop, VS Code, or Sim. The client can then discover the tool and invoke the workflow with the required inputs. + +Authentication settings control who can connect to the MCP server. Use the configuration and credentials Sim provides, and avoid placing sensitive credentials directly inside workflow prompts. A remote client must also have network access to the deployed MCP endpoint. A self-hosted environment may require network routing and firewall configuration so the client can reach the MCP endpoint. + +Sim's [MCP deployment guide](https://docs.sim.ai/workflows/deployment/mcp) provides the current client-specific configuration fields, authentication instructions, and deployment details. + +## Choosing between MCP client tools and MCP-exposed workflows + +Choose MCP client support when your agent needs to call tools hosted elsewhere. For example, you might connect an agent to an existing CRM or internal service without publishing your own workflow. No-code builders fit this scenario when they provide guided connections, credential storage, and ready-made tool selection. + +Choose MCP server support when external assistants need to call your workflow. For example, you might package an approval process or data lookup as a reusable tool for Cursor, Claude Desktop, or another MCP client. Buyers should verify that the platform can deploy remote endpoints, define tool inputs, and authenticate incoming requests. + +Choose a platform that supports both patterns when agents must consume external tools and provide capabilities to other clients. For example, an internal research agent could query third-party data through MCP and expose its completed report workflow as another MCP tool. A platform that handles both roles can keep tool consumption and workflow publishing in the same workspace. + +Enterprise requirements can narrow the choice. Regulated buyers should verify identity controls such as SSO and role-based access. They should also examine audit logs and policies for secrets and data retention. Self-hosting matters when company policy prevents workflows or credentials from running in a vendor-managed cloud. + +Coding requirements determine who can maintain the deployment. Ops users benefit from visual tool configuration and managed authentication. Developers may prefer low-code or open-source platforms when they need custom server logic, private network access, or control over deployment. Before committing, test authentication and logging with a representative workflow. A feature checklist cannot show how much configuration a specific connection requires. The guide to the [best no-code and low-code AI agent builders](https://www.sim.ai/library/best-no-code-ai-agent-builders-2026) provides another view of this maintenance tradeoff. + +## Why Sim fits teams building custom agents beyond a single assistant + +Sim fits buyers who need custom agents with tailored access to company tools and data. Its open-source codebase supports [self-hosting and modification](https://docs.sim.ai/platform/self-hosting). Multi-model support lets buyers configure workflows with more than one model provider. + +Sim provides blocks and logs for reviewing outputs, approving runs, and inspecting execution. Human-in-the-loop blocks can pause a run for approval, and guardrails can restrict inputs or outputs. Evaluator blocks assess results against defined criteria. Wait blocks suspend execution, while run logs support debugging and review. + +Sim makes the most sense when developers or technical operators will extend, deploy, and govern the agent workspace. Buyers seeking pure no-code automation with minimal engineering involvement may find Zapier or Gumloop easier to adopt. Teams centered on general workflow automation and self-hosting should also compare n8n before deciding. + +## Conclusion + +Choose an MCP agent builder by confirming whether it consumes external tools, exposes workflows to MCP clients, or supports both roles. Then compare authentication, deployment, governance, and coding requirements. + +Sim fits buyers who want an open-source, multi-model workspace for building custom agents and exposing deployed workflows as MCP tools. Buyers focused on familiar no-code automation may prefer another platform. Use the comparison table to identify suitable platforms. If Sim matches your requirements, follow [Sim's MCP deployment guide](https://docs.sim.ai/workflows/deployment/mcp) for setup instructions. diff --git a/apps/sim/public/library/best-ai-agent-builders-with-mcp-support/cover.jpg b/apps/sim/public/library/best-ai-agent-builders-with-mcp-support/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2c99c7f1841e03b784f932b115a4a6a0e2cd3fd9 GIT binary patch literal 24312 zcmeFYbyS?o(l0!?2X}XO3&GtX_~7pD5FijNxa*+7-6dEE?(R--C%A^(VV|@2d*1z? zb=NxUTi^ZX-WeY0o~OI2eqCKvT~G6S@%IWq902=|4;&mU0wVZ}f{cWOjDqzB75v2_ z#l*n`|B(`s6XJspb{Yx_8uquW%&c!E1O+8DRWzNPQeHcO`2XBVHvkpQKte%4Kmh*wz`{Ypz(Ya4{)+Xl_5XO__c8z(3fu+_3JqM*`2DYD z|M3lsuqk2gf80vaNOHUgX1twk2xGjgsdm^swTkOGvC3qI8w<}J0{5dpAtb28qU_E0KPINb#oT z*!+)J=H@rXOAPprn*6_q{~rSXcOU>`SUGbz6r{wf>%~%Y5OwC$i5&1MITR947BfmJ zuUi-ZK%u(Kws>+JJ5#7LmInQfxVaMP&J{lq0GK&r=z%)Icqczi%>EZ#q{2Yj6lK1} zzTCg6{(eKJXP0T3`@O>M09{QVt7BPCk?-rm^fKj_tA`>9gDB*Shr(i{=HqNQc{)a` z_?Z&z<7t8EciMW3-vKsONCaPbTN$^~Xg#!qt`Qy)ryJ^y#eODAnSBTNx{fs}5LQZ7 zV+pZT*&e6~z$HuQybwLyyH%f}2fI;x>ih(t{qRVLckmnV(Y_YxEgaYR$bzn%&70V} zy(cG-QOhcDZt&2O>pJ}C?>zrmM#R->m`-dEk=-Ks8r-yBd^#nvjP;|r{@R6-p!lni zbn?=eK91_+XePzh&z^*&_UWEmN)7i@rMP2uK`3i~ zD0?AyFHF_J4F+-2047y14-ias2>iZ7hIMRG8&5uOI1xN{7BXVW(pL(|Lc5q22vr}! zzA!!(7cx-V3+}6U%y_L#o0=J0WX^Dfj3I?#>-!jM6-)X4GRvG}oj-`YvmRTx`1+R+ zf4hOrAMxU{-<-B{2>_V%?{-sCjsUpZ$WfnCqV#g1R4S5DG-pY3@}Kzy zSJyf!suPsTej5OAaZ4c35f*SHY&%ovPjCOj%yoW1NzS2UHVI0xC>?(1^8rNu&{6>R zy-(NGHHi)aS>)g^k-u^9zb}%q5du{17Y2)&@Lyx8|NV#Y1aeYiYf_Ux(C*?E{3r@C zR|A3lpr)>g0{}qwb~ox@n_DSfR%c|y0l4~iwZ_H<0pNJAh#AJm7=R+%1n$JhGHCEd zB;UNd=&mrPcLW+bhvCAC;Wr>}Ty%F&=3hGk01$EITl?9w@c5c13d$0MKU}%b%Y6p` zM2|?KzjI?e`vzqcs%WdY20-l}?5CS2`20sw002);6ItOCou&xqX<``hYI_pFtBv0g^g{B&uHj!F4C8ke(2j) zu9buGq8H+ZNkiw3SDz(w+?d^~^W z;>K*eSHm16txf$TI2#$6#UwTj!0NS;C*UP@d2Be8g7wbLW3r;}jL^}r_WR&{5D}q| z-ZZobsPd>s%d6I*i*;+}x~>sW{$|%;v!(1gd;9%Q2>x+>-zvCAU+EUMw=XTCz$gtm zbx{`Cm_;yOaxYAwd+Ltm`h`CJ3r*~BB_fMuNL^Fi)vb(?f#u_egEtO53pbLEv82Z1 z-=2t_C*8)zMAC4h5n0bc$UgEvR5`BpvQzdF-#wL{sgy6ju}<0TMmlyK^Ok(5DLLh& zg6G>wT|?_Q!3hhe1? zYIWutanjTUAwwEX!@Wpc=mj5xmH!aQh>tx3n;k0N{f9C-DZCcCa78Tilul@HWh)+30bce_;eN1*W z(KA`76^#7;SJw=p_5ooFg_~1nsFI|e%-Es?@6aWdpufV1Fy9!Wyh9M3kiCbd%Am`{ z{DM`P<`|&X(pOSV-ggMs6A1F@j$Ox{E^hkw^!`?h*CL+T&MMbFlr$$y{m{CXwdB`8 zco_n74Ues8H{17vJXS`@fmgaAbozHu0K7W$ z?;Ipn%mP`+*iGc;I8~2-WbGIecO-fU$_ns3wSRyBgseNcd`Xscgyb^|_!|%Zwg|QP zBwj>AWn;vECoMN^k(2vSo7?ijR4cPWLz`c0J$=Yu%^e zz8ls!`s2_v3PC6lH}>&)6hD6E8ou9vT)v&rZzbik`=cks>0S_UnWDp?Kl9Ha75**l zzgJ>QR#)L`PV0rBah|jlLS|T!B(r-*gR3M0W(R|s#bamuzT1iPPdr;4bV-{ zx7aqze5jwd8~F||u`Z;-E6INAmJrw}@^^0jZTUG;E<``pF5=8K8z8n9SiAxh7bU6* zZR1=R+&kjaHUV*-F=B&wJ6 z{F^=fx8(k;L}gVOca=8+0I-w`cmAyW|MiLrPEox@13*ASLO{Sm!$3nqLP7xm&=8PN zFaTIIZa8#IEHWMpY#dHpF3I;sT*4f=sh28DvWfA=B`f3|n4INd72E?;n#9l&J z#@-PWL~A6U?&9*$O!(k%nh*_iqP^O({nnS{n0rn9P4aLmJjFsMhkIF)^@?IjvKJqB zd6-pnQQ;rIEbP>0XDek)#we?$k5?UO%5lj1i}Kz*TKm%T3#L+q)>*deZ>6J|KPQVeOK2S&&Le!=+?H4b4Ltt5aQ?rBnQkj;;eSUfdfoWM z#9pU$rzlBM@%{ByN&ZQP#{{>bSc~it#FjB9UNmRUpL&}xA)56F2_x<|)kA8(8 zn$S!xj0QI5cLf6Om1o?SD&HrjmIRKQ={Au&YsBTv4uk!FI|~ak>+2do>Rg6MX2#STN5$$ zt*vd?e)6&+m}o+3>4QD($aD3Ar?-gjtve*XaM|s|E_ZO!M%CFx##*;v#C@UJlr)q7 zZRTEWmCuX2a!@9E#5LhP&B%h?C0zjsxD|U?zWQi$2Vc~LU3OSkH3~|kq=7&P>&m5J zy*u}_Pe)z<{EJ`X^C(M%t24Gv$xg8#)3G?~6d0su>zCE4oAY@Ra*BX)t_)OOmEE=9 z0Go-TMdzjS^@bOux|Ij_jUENkQ7hWZ;F5FgLPn+6vs0Jkq;{C0flOx3Hhv$5ROw*$7H zdDUhXEQ%m;_OA?O&EEjy3mIx3PQywoth}fvBYj;HV5Gu_dF@aGio86AS8e+TkPay% zN?DW5T2IfD)8Z|C;ve}%Yizj0VxcjZugKJd@LE`lAGR3ov;XhIcIhh-6o)Y5n zcyzuTR_M@OG?G#hbc(3$a-${LXXSqYTiq(xN+tK?aPz7b!z;#sA9@?dPubLwoT@cf z*ZI$S9^sF|1pdv89lC{|Gr#&W2jU}S<5405yV0EU*BGBq`S%>Ga7eMJ7doTQmc~Kv zzShOc#T}T^-AvOZ=e#jI;+*Z@3LSpatkJ*Wa@2YqL9}qaza-H;rpT`^mB-Rm#3s7K z(Z_|bwlBNk{Z@07Vd^JiI$`od zDAvLvYTG(dwmLcAn+)14qiwVIYIB(CCQ`J`0*Em+F3nkXoLJ4Ix&)|th$hcrO0I{H zT!9^VB^7pRe%96d^Pm^|d5x5UFcF!uh=_oRy+Gus#ayqulqh%SD@{U(IGAsm3ejSH zZ6&-dWNBXTGSd#c-7p`QlErQ} zC{Nwb{A$rZtYc5!#L?Y}zZ`rZ&0lyZ*TOpS!Dl2TJPGu3-VJ*sL`~6!mAkN$OSSMl zu7VsPjLS{@T$@mP@oiL@(#Wb5tax8nM^qgxEntI6#lI-7HqJK^Y*cbT>rWV{tE&pI zvI~F8kE>bdVR1)WG~XPeCEP)YSjX4>UEl4l5n?-6cwy*c3#`0c zJ{v(wFe(jj0?ZC=JQR*^C?55 zzxmp$_?_pvZF;tPyJwC;JYJlZuP2LgqS4MO&v&%C{(`wLE}a3LG&I`RD{uG(pXPJA zJiw}u)L~LQ={6OE?kWo+?Yk=VY5tKS*Hx@A7iFDfROTcq4&UFN(o${1*TPgQHW8!6 zO%$A2QU+?~G!80-QF%*b8}BVt@8(mFzdyH8GBrfd(jc!j6dI|0cjm#t;A;Pa0phrViJ}Ce58K_> zes%+E`Q>0S-zX(0jowZ=NEcy8*evw8CuOzQW zgL-xw`i5608DHq-Yy9&qsvq2koXLU*@x>GIuKE?wCU9;P4t0qO@MW8bRR~0OPoafB zKkAJ^5O_8GbiR#AG6<2JF$;;8Lik$l>0gkoH`daY{(2jN$%d20N@rodvy|>Ij1NI z7(w4aV2ZxvBP15eF!5O;_YCT$uI=fn&Z}HAL^UAVkjO~f>z)aOoLV37pa zy*WZN?-&OkISq#&b%vV-fBYIDj3ld$HJ0fp8vtiaXv%t6#)OOx2sgo6o@DYB z<^}(b%mvXs2)Flvz{MiLdZkLGcFf0*vkL(X^KXEnijh0_+_`U{&N({*Q%|Jg*9_(c zfA;>yN~OI72?|kqOpsifu|%5!dhfb8ZSLB~?5^QUT*-Wmb5Zwq=z1|yjTN>!<3gak zF8X6YvITAsLwNhC~;aZTf1qAWx&A;u@v!Eyj#T%D3-dFfS18p2aeR(Te(SLtlOa&QU*#oXwgCCmep2%tLTjK4&6 z)|~LNOy*c{p1;rv(ojf0Mrf}$FyXfRDi&t$d>snjWVtK|<5 zQHH(r^v<%J!zLke3e4F`C z;vdNsbHdatx3Cu1CwIRb4`@I+c}JOBW2XVNXmAC-xX+xUdar+dK8mJowAtWV@`%JyRDh!E+=Gj$tR(I zdd({;mC-)4wwQK7&tNC-!tuicBiqi&_a+$xQd)!bY{t@WsKV?r&J{u6HR1FE_u=~L zpWIJZ*)VYX&sW9qQF(i0iLE)}y(;h#cEZCMgQ27~S+xFC< z3l<7*K<1Vm9Fdf?BCOL%LZ3N|KSY-n6o=kKn{DeHXk^+iFj|`2n~u3Ehs5`rXW)UO zMhiQgxB+o;vX-(r%{H}0ck>Db4|{{nx@%b?mqu-xkDu+I9|)gAqZ zKIMF0nzu*BpIqfWO-^?2>km<>->OPql}AmXuuXs-vU$q+$31i-d}F41`vwkIg6DxHfy2xj_{a$|nXxfBE;SIRQDebr z$X~g_Ua9du0p}3&zq{WwMkZT!F*Av{yw&${Ha<5>A@V)i=H?Gf>G#aBZv$f8)xa$+ z$#tBUxaO#7-klJ#U02Gaee7{G+0{2J2{7CleJT#H&_u$1GyRF_15Pjk6@Z& z@?ya_{|$eAmOC`HX=b#fYh-6*W}bPhVhfM1IbJ(RQxv&gNPZ=pD*mBR*}^aCGQKTI zCRx1QF-y;)P+(^&n&}CqvVd2Uy^pDGjItnw-aJ%l2zR`SQ<=i0RGXZzQ!Fu}Bbz}7 zJ~#N-mJqk4k9&AdnkV0`q0)ztPAb-ji5m4_?eQcHcOQDJfRx-rm#{Qz$D!|h+k$`h zx@a)n2sL4ze*I*=72Cp)sBsPsV|B)6#n^gl5e!?)1Bdk_gmOFSEplS?>|S&QH#|?TI-FfjiqjXVvCOu>ZZDUhuNdy1L^C0 zi_YHPnL9dI-4`79IcQXK&`E6JLd*#fVmuXyDA9ZWP`c7O--zLWK5oN@#)d4Uj4UyI z3*-19YF|?&W($$V6d^6W@d&}uGM=4FPTkRE=|ELtKvw6#!fpieNPTbiGc9P}18GFB z)eOfoLS~199-qs3cr2-tTE;g#xv+!>A~~$2BqGE6qiR^y$u#8U?E1U&UqlYC{HN$h zk1Er(*CFi=B++PwTw(TM3ctCGDc||drLgvhmH9?sSCI$}C6|~Iy;twZ0~?*5$O^8d z00*jr5Im(IpEuJxgSHUK$83|UY%_qSc8opDoc{b3loMPk+TUv9wdVRqUkAr= zhLJ%koKxfzzNY%d!Rur`NQ)nkbX(zB<%p)!So#8ah)Om0D+g?mK9-@(j1-6gxyO8T z_BO8N4fy$gnP&B@dhlt_mfx>GQof!3^^FzN@33Cxxc0(9C&$8>QQGiIUKGX^Z*h5^ zlQxjzOA0~-v=bZ-V<~u!5Z1=f9RA#NmgW=hW8zgulO$sIeMuzs7N;Z`i0hY4?e zrH0J`?R_8?QnCuVhJ(^W@|Qx3VANzH|9af<>bk%xnQ3q2jZ_!UE#4lz7||xWE|xSc!tqc`EBO(CVyO{ec|J6mx z&olVJzT<&cD;Pu3OI6J?d{s&T_48&WkA3bZl!&JKQmu=QYboc#Ctm}arx}a*1=9g; zq6Kb?@JU4`<>_Iog__3IlVGm$5qd&!XI{%jD(4kCt=Bb@ZjkYp!>bSG4!(8G7)_NL zDwF^`gN1?q@cE5H9#BPkph~lW*K7bKeTW%NWPB-v40x5B7M#D>=}De#O4$ zfvR(X8{I?wktTleQ%u`T+szUi>o`LHGin4{QR8dwY`JQB)W(COffq_L+XOjMoyJJ_QBuXkTER}^aG0x0Xy|{o z!@{E`A_AxK1RNV3Tu?1ZY<+|UEm_3qvW?Chc` zzetbesMd@Luez^iZBz|?`sk#@6l5O&6SmJi>Y8>kDRQAmw_s@{oLK!wVB#?A5D-!y z#C_VN)6}5aw@4pp@{azjOxmVZKhotYUWVAin(IirlP&cxtc5jb>il{ZpOIw%1n8f~ zT0`Zos5stGMqhTs_dBUAU|h)jo4I}8RuZ~{8%@56ts~#hD8&0L{6-SFet0mCi!Xpi z)bbTeWoU^WCQQR)b2YjBW;_-4MuI+YjnIYmNPV{e6dKX6oO!+!=Cm4VHi z6e&~`p z5-KLvTy!O6EG;qwjZ>~vcF8d@;#@X?2Gt>1*--b%3<)rE`*rk25S?b*u`iBvP=X?`;TbL z>Z)#BD9$l3A*PiM%p6K)LsI@2vjtT;FPoFcXf^r>l6F2K<*AG9xNXC;sqmnGeWZwF zbZ;F_z>4?IG#-vNA*-Z_3(=D5$nM(s1v^=mr>@-tEkYq9alo&@j0^i7*N?!sQ+OC+&H)nqbN+I8#O0<-9?`G{}3 z#;Px2UwS2MDAkzhyT8WJjez-u!a= zo0U*P`3D3qVR^p+H>j4i>T?V=;ZI^F47sPL@YTNo{;>8 ztE#~or}0Pj$E%=!&h@A25TeC6Ph$he8k&i}#o92H+?RD;aCE5wGNBVm@!x3*^+w!A z9O#%9ocP!%eO0fw1pSIt$cUdo-8^rZwic$QUSML`u{pi~4#vya>yqg;#rT|IFU`_n z`g1S9jD6HAcCR}ZNg8=;3cVyO-MfAr&d%$n%E?`*E5dIoZ>3{!AECh@Y)5m_K5(Taf1I3tapW@1m ztBPhs7JBC#E$5+Sx^3Zenle0$U70cFB&X=gMz?xvXC@0R+o76MAJmfdQgKdQS|C=> zf?d3l+TeK=)FUGW%kF&`;VI^C<@3*i@X9NH#L3(=vWh~`^Q zEy3?p6_H+ZWJ|nD84-xRO$~UK14K^!gWDq?2AMylX`4k~$lPxf5ajDM!Hm2#+Xlv5 z4iQ;>heezs$3dommkR{aG(ya!P{ST^Vj&s9ORC#2RK{1=2-Iy1qB;q~?6-Jt@C%@@c6Sm`<8*SP>0(Rtn z>b}0+I>|U0>kr}7wv&(_+c~9KAGWZKXIf~q`-6{)(M8sBk~2>iLc#5{DANKTycxI) z+hKf8(X5Jk2JF3gk3>sjes^Gs?coLyKYZ&rJ+TtH`(Cgh4lE}`OznfEqQIkm; zeQiO$dEu)Ecjl3nw{V7)mp6FgXNOT({^7=Z3!kraAxji?Tp`(j!yyIol8&ApYc$~4 z*`oqSH-D(D6GXih9-q^kZioZF1tE`L1Y%bq0rF(|8hLQ`2^hDp3w$xQ8P#@_dNKWN zEDpUz_|pm!m&pP%BV~Z?vyg4GN_X=X6?5n5c@UqomNtSFCp<1sWgtNpH38A2t^P%_ z+4EmQd~4JM!EDRX(3j+fbPm}w^fqJnV7gWT)dYv1V`M2@gt+VLTs&W2ik0NW*^LI+ zI~v5qxx1yeO}Ce%n+Zt@O!9AsnuJrUSYFoWu3Nh*-|l)S-db#-7n9QfO;Ug)2@+3j z;rKrV!RAo2^=r9UKAuhM0YhN0jLUC83Q^hswcDM_t^N$4nr!oQH6?7 zVu$F4Prj&Q5fS_~FVIw~P+33G_$0z9WcY9Sm+&fFGxuBcj^sGb!ezGp@RYeDRkiki z#MgvxQEQ7B=q~xpLI&^4C*J82+zve_tALaIq{XVBDj&;}TQ|ddOp&_tmpqS;{MbZB z*a7p>KR%CDCF;4Zv5lX+^x;0)UX$nf82Ekr#++*koAbGASHp)PAb!ZZK@;v;04Hjw5!)T!f(&GMdSa?nBq&zm7w`DOFp4{)a+>77(y=!|0fe4N~ zs#Erc^S&)}8JmM!X-FtdoV5K0l)Qs~-$G)v{hdl7_#@I(FjHN)yJjZ0%1?p`$)YbB z=9GKVw_Yzd_mGp%wQS0?SF+zmSFMPRa`eG_UK+=2tLFVThdL`b_7ga{ME4_BQ)Q1j z(27@=T3RaZ4ZosXj#Uw2 zu3my2j8hT8Z@?&Qku2JYnNiOvzG-fFyQUN2Nfla88*v1Y;mM;PQy7LKo9asC1aMXP zcAmxCSlEcvTX=@j;n;aUoObvGbWNNsbK{H z4ok=KnQ=)(?{I_#uZ9amHTpMKslmklQBS~kP>g06W}iqv2FeMXpp#uIr9*VaW+S~O z@#Q@eFIzGD5Z4DawsY`SaO{BO_A-}nf5#ctp6WFg zmQsg=19DSUg^a9=Awb_i=(B^lGfjYC3L~-DXYyx?qwD=*0X3jjF&!HF=F_!O4e(?L z!X9!e+uey9U8r2QP@zGND%;Y}@c$s30r&j^QsH ztErjP^m?Hb=;U57%uo z1M<$POpgZK4-kw`;Rzw`{@19auLW`}o!a|0f@2Dc8^_Av~ z;JH3wxG|X$VpS5BDIq>Igt+y$OFhIGP~4i|{00Pm%fI-a@cku1XSrC{d#WRz03Jb! zdXcdLQ`0=r^`fuL4Zi^qiuy@~yFOITxr@AX<<8^%vVN3f|3EM`tRvmef3v#e*%mlEzZ8Jgqsi{{e=QAF6pKwI@;B@Cevd+ zUQQgz_3_slzV_|Ia<~Es_6^8J~8%c-adFg3o?+4BChO^oF)7v-FEV_P^;ql?}Z` z-By#t>Sz%`Y+M??H-vXhJMQpvnNiI5nYe6q=HyRr~b~R@C#u<8SE3f23AWVyB(-#f*$S#h=6G8)VL=J8pZ_I^LRL|``{^%vgSW~US+dRB z@wKp<^9xbdkEUuaLY+U&Z#unJo8~YsVh?#nm(N4}P!H^Fe*<`Trk{&hF<54&kXok(k4P3+_U-tGsN>GjI{;opFt!>yG4`;Ikc8~UWCf6*_3K$mA1_^!ER#svFg zR8TCfEG%oO83^>XmT@zwKxN0WC!undo_@EG934p6DvRx78(dT2iJn_ivNUW2SXOkZLL=R0Hla-R%!egKeg+vP?QTgfe+<0tZ?8kmbiu>{N4KEp8_Hj3P2C zc83vdnP`gEC1@_2J_4my*zNPIm9tkcu2=Yd)rEmVR&o4x-=eo-sWdUI_G}B;wA)M) ziASO~TwRkiIihFjd{L}R4Adh^X~`V5}%i1!4JSXg(dWwJ)gH)%`D^){UNq@e6%JKoO~U?FX^q| z*OKfVTAXyO&L4UO45#)xb4-eg;o(Rq_|W&3!JUTeh;n9!+{buL1unQZBJIG7ou$A~ zQ%%5yrC-fd0$G*0#}0#a-$0Rd*jtF=)(`41nvNN{lnl<8&JoDOd#8zn@@qED%Rl^m z;;-J>N~$RLdcs7N1}_m&CU<;+5jn^b07MOhVqW|g5B|W&sb#y!(J&rnZ4Jqt_$IQF zoveqY%<4t72-MGsX`w9NYG#`fcxqQY(f7DEa(mGUV~hqm>|$M!oauO>S1yXCICz>a z)R7W_;*75CF|yo*KQ_|i!%1ppp(qU_lkA-6+Rbc3nS)pDZ8FfR(5UmxA$uwn1)@T+ zeFjB&sm>sqa3E{nPS^|Il+qZ}tF9Wub*vfjQNaKbl-aO-WXT!#R7$0n##7LHZwEE++Cqv*llI=VG*`0+{B=eSi@%2kuHdtJA zy0*tVO(9V-g5X?iVRLeAN|TCWuY6OiPpt!Q^W3PTlj563nagVN>ZZESAlw>fKk&(N zge42)u`p2TP-4I)#CZ>>qXAm zh+MRWY7)ZdS9vF+=JDqzozQx+GUuD_LDnjtfnJXSrfwctmO>B0s%0LEPFqOfhTO@O zFwYwqx6k&vN}Nt_y5VbKb5mgV$*c>Vf{~T_nDaJFfwOdvZ;0JOPI`>-A2>Wf7YofP z^NDC)g-3$7@>QB!GgFE!Zw?a<536d$`ZK=v7F8>9NBucFvf`zHDEnz2#@Ja-w8Qsl zQVv5ES&!|-saxxkjoS%Uu!5mFJc5p~>^@zaT@$K|-d(j2Yt6}fU5|X-V+fKP;6|<} z1QG`mEMbk7&%P9EFc(wY~?r)Hp}>qanQGU8t*N0^k57<|6#vUXNKvT)btH z=o^(eE|wf4u*<-&oF}GlzkM7nCq=) z+`$Qg9>|ijH*9m+?A@qKwbRNq)9@EE&1QSX9WIwYUs^GilG%jjh9 zm6FGdYBtthXQjGLs5Fa)2juct$oP7bj#SK?GV~l!LjnFc?0H$1FB}q0>dONu*Rk) z%dW`Dt}^^L_Vboa4{(l&Jf+ zKy92^6q-g~vH~NsZ9zt&xX`w6^w>C?*6aYiNoeBS1vm+WebK(0(?*lsurnuOE0nhr zJl$_YuiQVfKW_Wlp9gXf*&#x)U9|z@Gl2ZMoTaC!i+*l;CP=O z&e`Yh3tq97*LYSSqv7vpQZ=GO-2((E76RYj*QRLtIJ7JosF6B|t4C28f;BKu5tl!? zKU^S6!S_Ref{wVnK}TjR&Bv)6v9%UA`lVO1>T*GGDlGcJRB8x^hSs%3;}iEkRc?-} z%9F|BH-J#xf{43|ETjHW8wtUl$WOj9+AbL$&UqQNWZG(oFxhw?t8g=+Tm8g%A3HX3 z(gJ>a);5@o#HcIn2EtWN4F&xjqDQR|dWAy7<=giGYY5;Mc6~Tb0Z_TEOR?sM4JK-< zV#O_nqf@qYbtXKI*|qG$ZBs#-3~vKSFl`0JKA|50&zmJBQ4$%O^FGV$PK1aF7FFg3 z0S~rJ_zMzS7;N#0s>%z*2p3%lThp&MCKXa9!tFT^P~-=3WlhZ-im;e2_$l2>8wsV^ zw+u^feO>-SonZRyR{nJk^=wc?Y{jvIOoM~h;xF8)7kcc0lBrgih$G)oR#}(GFK+VC7g*rg59r zoD^ru6sQk{<#fr6_5B8fhwyO>?xwfjM0$2hq}1O@v@AjqJ*PiO`5}4iP8OSc0lndt zi)rYI24Sv-k2FMS zr-3DIQV3utME6xmz)b;&=PnPrUOM*jp?FPRs65i^n#XHCeOk7*3;Uw(006pZflXki zA~#X=YEe`+=!hE}iU+Y)KK1YLxI z*_LrHgqj3zQ*$JM+4U2H-}@-N=IedL`V-u)m~yg?@XfAwghS^-W2pDxa}BHGyDm71 zDrxI@j!otnk0D$pHT4C0uqzGz6Zkt$7J2!8!3fU`(gvYQ_k;?6MC zD787)B}eF;6u-#uz81`PK4nq(I?+o$lB@SEcD zOiiRH!l~H>SVcWeV(;rT{ttnZ922(qIX`0@pF%dToiPs%@!>n1bbMwWv|x%mgp>by zOHX25d2hp)OPd+Z{Uj-uAl0Kr|5dmwKu*V*B;203O!>YFN%@06L$H*5-9b=cJqxm; zg7j1IWd&kCBBwPRbA4*p(H0Gq%%J2ML=}o8rc(T&bMT5{z}iy;1if|d2kHioQsJ$y z%z}r{W$5@UBYAI9#?>jrh_xiLh#R}C&y{!IhYfk?qq#MGEQYKDL@Y}VpZDJUUo`$`rV`9iloH0<Di?Tk@r_KBdmUIp0rxX4wgf9dI;w{p@T6JZo_E;zrS z>h5CM_xEy+PsK%C2vmy@4S%_f=(sZ@DFq!^%IL`S&-)7aR3gKkK@}g1M=*EnTCF)Q zR+?Jz`98jT&U;;WeB8S1eIR53DYTmEvrQDmvVfFlui1C(M89%B0Y3>@EJ~MLzA1#Y zJbsrd6l^vxu}28Z3Q#-fMLqbTwpzXC(yh#TIh9dYgBP~o(>p9gNe@r?%@C%454+?f zo^Ptze7ky>Bm7sQig2A3nkP{qgD>?@JqKf*SRV@4{OJ5UX$9L6vWI@>^3vrB2-rk1 zdQ#DCLhP6tDXBer65;6I0JYDu?|I_1Z~^C4h&?{F?+avI+};EAcDI}D-8fu;=->>* z@nyv2ppf&Bv1`r8+5lQ3+860J3Ay%URV)x2df1_NCSPO{b`ElDT1JYvXOJJQE)NTa zKGoLlUb9cOu>(-~29HCoXQn=iwl)OQ=C-D!wjg8W@Qr-I7k(BZN>UtMJx%)H!iF5j z(|3IbdS3<;0Qubf)V%X=>wj^L{s8+O`41U_xC5w_bayr^%?@%D4f)$AUPG0%U+J?R$XAq-e>wy7zdF-LMKfNhKB04Rl zB8VT~{<`122>ut3ZnXyVA7&og<`ya@e9@%WKd*R}2)>GA^xrKo5}1rG%E(f!CcIY- zcUEA*OqaT?unjqwJfK~qTb*aI>VRXEp@}Vg4;A|p9OZv)tr?9kD9|HB_v`ZjM-R1b_lA?P=#31xmA87#!zN znnmL;J|yWaAet=5hS!a5BFzh9Q@xk0G|)6>dJO5Rkf>?tD@%zU#^54!cWUC?=uo(9 z@n$*eOIvCN{a{k17#1rV{f-!)wYZiZZlatCijSXN)0S05O^f(p2i{VjEo&ITlYC!27 z1?kO`c-D8$U3cAe|K!)s&fYV#pFQ)wE8}x{0D6-e`33BC9c_#U8xQ03f~-#tLjC0e z?L-v6nW+vw&s+8`wU%s`h&HzDcqAMidzylt6_;YxyH-hCCA)z2jtt(UQpFAfb+VBICkJ^Iwm~Q7d!sj4K|H8N`AgXPuQZI@fKMR6C8f75NE! zF=i-QSyvi!_N!4si+=-Fcga&3fGrh)LXE2!b8dGZ#QHji{!ip^jclmZeh*;@Cz76Y zM{$*4Sfgvka#7hrmb{H+yzv{LeJId`y@lw*XD&n99&vfB-S<;8+h4nyY7iI34ZA;! z8Ctljp+99-#ZK(je*f^{(O3uamcpR=Zgr9Wv8iIgsuS8niWSOP$Sg;X1sB zC^<3@8VtTkKIW!u3INCfWR!QhODqi(WW62J(WkA73Bk_13f+h7L0~c%>{+aLs!n z7sO);wo6?sX5=Z)sKkvU2ZxR0QALxAe=h1-|3X+xgvPsgo>EW5@mZ1d8UT_^dRaJ?a}U_v3){ zv)(_acq^1zBUyG9d~QBd8LWF(s;}3$dTF8W7gtljR@GDO<~C{8^==OvP|B9@q7UjE zTS-bpU#~N-!&1<|tx*vCG95oJS$A~bQ%8#;s&|hoC+AC^6MMFF4Eyur2>_04;tx#o zHMSgGcpMSMsvVpR8uu-Ym)zwoSR8pO@6Ee;V>>|H56m}9TzGtGi#3{%>5p;^i;)`h znZE(}H!))XSNNB*P<=a#0+0*o%KpjR7ns5$l&TUH(cTCYsE5;RXZ-i0n%z2~IL_*mGEt%IDnt>-GY#-xOxURd z(pXTM&osvU7$W0ns3Hh3yy(wLliul-r&rC_#Ay=Tk+*7gitq0ipNNZnL?Z$=ocvWk z!`{8B4kf2uVW0<(=r0a$Q!|!_<*3aI&%_GmEe?%~KVciz{RckKhg}U01$sQTvf0PF zEz)l*Pv0-@NssI1eW3CE7#d_baso3Q_a9b-KacYQr}SZGt(rCQCB-xI4@@IK}guk9B3Y2py%{UtyiH2_eNS)yq~Qg3=lRX_YUS%4jT) zZycZoUTjKof!u!OwUus<%sKtMlvnParU6%y@SLqY)_zmSc?pRnyu1EDue?bY&bAS7 z`R6X8)`=>=*(15Jdp%BR^AA(h*A3l)KX)U;*tYc@`%`G(dbM>OOaG-zr6H!*Vw)u{ zcJxpat!(hak$X!rXI&rbwx|CMnWa;U-lQA;yoQBpB79N(2%R^~Lk3#A zYq>QN(ar@$X-}khsD7+N#dMx>uW_G+S$F!Pc;c%Or@(q0TEGk+c#{Q#m3x)7?#h|w zPo27_;7{;&gCFPvEBB$rH?U-; z>OJ7Bs{rk|m;-#-z*r$Bx%b3G7c0%SGa$LW=2#14zYI%JEFy%HrVE z4Ss0`LMQmhN4kuIGmGexzFz5z-f?ftxOb@}n6oQD{4wN8Al#m_XPH?Is%X@L!lNbH zWY*Te$mE7rpzFRnFHJO3p=`o)u(o5UDhJmy2{s*Q-ZAV7=;W9>l zCqi(jKaa#-S`Ww#^5Ti%=O3cb<|PgJrn1soPW)B0@tU6Z8Vj~t_*qh7vMp>F^dz`a zv6Ny5z&R1^ZA!8-$!irZ{+K;)EzmLMQQ5g>4YQ z1dxBlH{scq}qTfOe5{n>lor7#yldV+@*-w|0Tk;$t9*8@8uMJkA zjRm7wYbK0zUNOgQ1g;qSV%ufyNwSGq6nc4{eIIDRspyCZ0r5LYyb zmv6!(Lsj#>5G;22h(n{q%NX$??%`(q#CR1`GTl;?@t}Op9BcI&qqkvQ#joA@G@DI2 z%{dG}+I3{~M%o(6-OXUr$ig_PYVRn{HzFFTO;@Btw%Vm7FxA)yzq2OENFKD5S4USbhaR0 zZGKyE?riwUvyRz2@F^9{{wEaT5d956#kNfsOb)X%$~wxi@i84#_l@JvkEhqw#YTI%y<>Qb&kQ#MuQ@}y0uyCEqX{B{D{~p zk72%~AAJ_8W~)htT%BT0u>le7arn6h4=s<9m;yONNaeLlQ+$jb7eF2-duK>n7B2k7 z*Ce#c0PZ50_kEgL-11%WbFQ5!X*}$WBO$EX*J3Q z9DB4my}UjpB8(OA+vY?P{wTcAXH-9y@L$fK>Z;Nv2igr)a1C>>swM5%@Ux~& zR6XR}^mw31Qc8Nmuk>v^-YM_!?q4u@R+2nvV0JP`N#!l7Tz2(avqPnI*%$iONXk(a zW)0peS0oGkAMNgD!yY`H3pw=CY<8_zrJs&Oi}DRKIf!zW8U*uOci{8nAK}nnH0#p4 zM%DV2R<5(#!vw>rhFRL)So^D*gmx8G-(0AA_qf=IIR&`h7QslnY|2B0Y9c1*OS%`U zb;7{af+r>aR*Ani={4=7s5s<^Je+ZDn4ppFr#;D~?H+mFnvj*7Y@oo2E1<^ql-O^6 zYRnMpFMZXP78`4i>a2}~j)n;}`Iq5zo1^{X+Bp`MgF@cc28<*lhLBu4g7`tELoK!V zJivkg_}&X2jz1ks+65EI!!Un7lO0}HxwyxgEGkS!R2(UkZQ{EKt7lyq{V3C?^TLPh z&nM}u68JOIdZum9aH5jdoJNxjVa~r&(U%iO%SBcwVwJ2X_4gG=hpQPQF5&})5(|ZM z`-rOmn%YN&d4z43_RP}A0RW0ZKsQx&gM8eA!%J5q70&0y1V@k@Z&%LlZqJ3kw&hG@ z8P{SindaMA&|WG}C*cN3;38?&C&tlnclA_cajoB4$BETgqN|Zc2}+pEl;ip4L909s_3>$VD;gVC`rghM?B#!>4i~*E_mPGD zR99w7$_ReIlsr}~daBpW%Dav^%7M93ZMpaLfsa1}JU3_CG8Tg!Lla0r3KOz_Q<&lC z31GfX6(|NBrMj?5rpN)+VGy>5%$Xat#DF3>wiF^Ccm7jcNvBy(-nD$Yka9=f+dlvk zKu)mv0j#2_RDVj2!j{gHWsUUTsCQJHOa1h;GL+T5)4hP5-Y=UK!4>&3vg&r>qX zYW-o*byKjUc)ByNy`JNee+gK1ZzP`%&$4_V%A?xgoQRV@_EEXZwaY?m_c9!q~kB!G$AGjvofhRp* zu*EEz+xcNSpZSb9*H2Zz7QN<^d&p0-)7WgP8HbmPeInb43NWF=s$SK{*#x!0lb*E>)`};= zA(-iGL)RC_?JV5Dv^pkFjrfHTi-!1D=p5w~n(#18^#`UFkkBAiA~g`u#|y_mH+cV| z+2jaUoMZnQh=_?j^)j?m6(9Lnr-V{?02>G9JxcIRta3?*GZN2sdEe~%$^|)>)u;}a z2iC;mUShiLNK-U9Fg|xo#{B+XMTzodE$v-3l^dKj8ra0$(t1;`mH4|}zWJGK0w3c# zJyvtKz-wn9mP8XAsDw%`D*g8%0T;vV@wM@;?;(Y8>w literal 0 HcmV?d00001